Compare commits

..

19 Commits

Author SHA1 Message Date
f1d8a757fd docs(16.2-04): update state and roadmap after plan 04 2026-10-10 13:07:31 +02:00
2786faade3 docs(16.2-04): complete Journal embeds layer release plan 2026-10-10 13:07:11 +02:00
ffa6d18474 docs(16.2-03): update state and roadmap after plan 03 2026-10-10 12:13:59 +02:00
2a7e9761a4 docs(16.2-03): complete Journal Blog Editor integration plan 2026-10-10 12:13:26 +02:00
332993c909 docs(16.2): record the editor-output policy extension 2026-10-10 12:11:30 +02:00
685cfc21cd docs(16.2-02): complete blog editor element plan
- SUMMARY with the package report, the human reply, installed versions,
  sm-journal-plugin commits and bundle size
- STATE, ROADMAP and the broken-windows ledger updated
2026-10-10 11:55:28 +02:00
5600c02a73 docs(16.2-01): update state and roadmap after plan 01 2026-10-10 11:28:33 +02:00
8237b7a19f docs(16.2-01): complete plugin field type framework plan 2026-10-10 11:27:58 +02:00
4adb48a76e feat(16.2-01): translatable plugin fields, element uploads and save notices in the admin
- MLPluginField wraps PluginField in the MLField locale switcher; the
  element gets the active content locale and that locale's string
- isMLField treats a translatable plugin field as a locale map in the form
  state, FormView adopt and the relation child modal
- summer-upload posts the file with the admin session to the field upload
  route and answers summer-upload-result {id, url} or {id, error}
- noticeLines shows meta.notices under each field as a warning status line
  (text only) and in the saved toast; the stored value is reloaded
- docs/backend/field-types.md documents the full element contract;
  modules/boardwalk/dist rebuilt
2026-10-10 11:25:26 +02:00
8fa92db96d feat(16.2-01): translatable plugin field types, untouched skip, writes, list values and uploads
- translatable types lift, store and hydrate through the ML path
  (isMLField); Decode and Load run per locale and notices carry the locale
- on update a value equal to the served value (Load of the stored host
  value or exact translation) is neither decoded nor written
- FieldWrite, FieldWritesFromContext and WithFieldWrites expose what a save
  writes to the Form hooks
- list columns may use a plugin alias and show the type's ListValue
- POST .../{id}/fields/{field}/upload hands the file to the type's Upload
  (guard, X-Requested-With, permissions, field context, record scope, body
  cap); OpenAPI, route inventories, README and docs updated
2026-10-10 11:19:24 +02:00
83fa2f7b2c feat(16.2-01): host plugin field type elements in the admin SPA
- loadFieldTypes fetches GET /field-types after sign-in, loads each script
  once and adds the styles as global links activateStyles never disables
- PluginField creates the type's element, sets the value property and the
  contract attributes, takes summer-change as the form value and never
  writes back a value the element just emitted
- rendererForField and isRegisteredField route any field with an element
  to PluginField in forms, previews and the save payload
- rebuilt modules/boardwalk/dist
2026-10-10 11:10:41 +02:00
717d46ec3d feat(16.2-01): compile, decode and load plugin field types in cabana
- compileFieldTypes builds a per-boot field type registry (alias, core and
  cross-plugin collisions, element prefix, assets, option keys) passed down
  the compile functions; settings and relation forms refuse plugin aliases
- fields.yaml accepts a plugin alias with the general keys plus the type's
  options (typeOptions, element, translatable in the schema); CheckOptions
  runs at boot; CompileFormWithTypes for plugin tests
- save decodes plugin values inside the transaction (FormFieldError is a
  422, other errors opaque 500) and answers meta.notices (FieldNotice) only
  when a type reported something; show and save serve values through Load
- GET /field-types lists the types with versioned asset URLs; OpenAPI,
  schema.d.ts, README and docs/backend/field-types.md updated
2026-10-10 11:06:12 +02:00
1e20ace15c feat(16.2-01): add the pact plugin field type API
- pact.HasFormFieldTypes and pact.FormFieldType: a plugin registers admin
  form field types under a short alias, with element, assets, options and
  CheckOptions/Decode/Load/ListValue/Upload funcs
- FormFieldValue, FormFieldDecoded (value plus notices), FormFieldNotice,
  FormFieldUpload, FormFieldUploadResult and FormFieldError
- ListValue takes the request context: func(ctx, value) string
2026-10-10 11:03:01 +02:00
0754a6e5ab docs(16.2): revise phase plans after phase 16.1 2026-10-10 02:27:05 +02:00
94900f233b docs(16.2): amend D-10 to strip on every admin save, categories included 2026-10-10 01:57:55 +02:00
9656bab214 docs(phase-16.1): complete phase — UAT passed, verification passed 2026-10-10 01:49:19 +02:00
433a543f85 test(16.1): UAT item 1 passed — hardened vhost installed on UAT 2026-10-10 01:42:52 +02:00
f734d25d05 test(16.1): persist human verification items as UAT 2026-10-10 00:58:42 +02:00
967449bf62 docs(16.1): add phase verification; record Docker-host suites green 2026-10-10 00:58:28 +02:00
77 changed files with 5824 additions and 388 deletions

View File

@@ -982,7 +982,7 @@ 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:** 3/3 plans executed
**Plans:** 3/3 plans complete
Plans:
**Wave 1**
@@ -999,22 +999,22 @@ Plans:
**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:** 6 plans
**Plans:** 4/6 plans executed
Plans:
**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
- [x] 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, Decode notices as meta.notices (D-10 strip report), untouched skip, FieldWritesFromContext, field-types and field upload routes, OpenAPI and TS types, SPA PluginField/MLPluginField, global loader and notice display, dist, READMEs and docs
- [x] 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, policy-safe links, uploads and YouTube embeds, the committed editor-output corpus, JS build, vitest, check-dist, embed
**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
- [x] 16.2-03-PLAN.md — Journal Go integration: blogeditor and mlblogeditor types, strip-and-report Decode per locale (SanitizeHTMLReport), D-07 Load, post and category fields (category 422 gate retired), content_html companion writes without goldmark, bluemonday policy extended for the editor's own output (corpus test), editor uploads through the extracted media storage
- [x] 16.2-04-PLAN.md — vue-journal-layer: sanitize-html mirror extended for the editor's own output (corpus copy), expandEmbeds as the last step after sanitizeJournalHtml, SSR proof, tag v0.2.0 (human push gate), pin in vue-grzybyfunkcjonalne-app
**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
- [ ] 16.2-05-PLAN.md — Release gate (human), summercms tag v0.1.18 (next free), journal and host bumps, UAT redeploy and on-server smoke, UAT acceptance: untouched re-saves byte-identical, editor edit with upload and embed, unsafe markup stripped and reported
**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
- [ ] 16.2-06-PLAN.md — Unit tests: resave contract at every layer, strip-and-report and notices, policy cross-check corpus (both policies, identical copies), category strip, full Go and JS coverage, Postgres tests for a Docker host, phase gate scripts/check-phase16.2.sh, validation map
### Phase 17: Blog companions

View File

@@ -2,18 +2,18 @@
msd_state_version: "1.0"
milestone: v1.0
current_phase: "16.2"
current_phase_name: plugin-provided-field-types-and-the-blog-editor-formwidget
current_phase_name: Plugin-provided field types and the Blog Editor formwidget
status: executing
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: cd25af08782e69da40515350c21a30aaa229e8c2
stopped_at: Completed 16.2-04-PLAN.md
last_updated: "2026-10-10T11:07:23.353Z"
last_activity: 2026-10-10
last_activity_desc: Phase 16.2 execution started
state_head: 2786faade3761c2121d5dbf780a48fcf211b4abf
progress:
total_phases: 34
completed_phases: 13
total_plans: 157
completed_plans: 147
completed_plans: 151
gsd_state_version: "1.0"
milestone_name: milestone
---
@@ -25,14 +25,14 @@ milestone_name: milestone
See: .planning/PROJECT.md (updated 2026-09-16)
**Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test.
**Current focus:** Phase 16.1 — Journal admin urgent fixes: relation search, list state, post form content and excerpt (INSERTED)
**Current focus:** Phase 16.2 — Plugin-provided field types and the Blog Editor formwidget
## Current Position
Phase: 16.2 (plugin-provided-field-types-and-the-blog-editor-formwidget) — READY TO EXECUTE
Plan: 3 of 3
Phase: 16.2 (Plugin-provided field types and the Blog Editor formwidget) — EXECUTING
Plan: 5 of 6
Status: Ready to execute
Last activity: 2026-10-09 — Phase 16.1 execution started
Last activity: 2026-10-10 — Phase 16.2 execution started
Progress: [██████████] 100%
@@ -202,6 +202,10 @@ Progress: [██████████] 100%
| 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 |
| Phase 16.2 P01 | 34min | 4 tasks | 51 files |
| Phase 16.2 P02 | 25min | 3 tasks | 24 files |
| Phase 16.2 P03 | 13min | 3 tasks | 21 files |
| Phase 16.2 P04 | 50 min | 3 tasks | 15 files |
## Accumulated Context
@@ -655,6 +659,19 @@ Recent decisions affecting current work:
- [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
- [Phase 16.2]: pact.FormFieldType.ListValue is func(ctx context.Context, value any) string (human adjust at the 16.2-01 checkpoint); plans 03, 05 and 06 use the ctx form
- [Phase 16.2]: Plugin field type registry is built per Activate by compileFieldTypes and passed down the compile functions; existing compile functions stay nil-registry wrappers
- [Phase 16.2]: Only pact.FormFieldError from a plugin field type's Decode or Upload is a 422; every other plugin error, Load included, is the opaque 500
- [Phase 16.2]: Untouched plugin field values are skipped on update by canonical encoding/json comparison with the served value (stored value through Load), per field and locale
- [Phase 16.2]: TipTap 3.31.4 pinned exactly for all five editor packages after the human package check (packages approved)
- [Phase 16.2]: Blog Editor bundle built through the Vite application pipeline (single ES entry, fixed names, exports kept) because library mode never minifies ES whitespace
- [Phase 16.2]: Blog Editor link parsing, rendering and commands use isPolicyUrl (http, https, mailto, relative); on* attributes are never captured; external values load outside the undo history
- [Phase 16.2]: Uncaptioned editor images are inline (<p><img></p>); captioned ones are figure with figureImage and figcaption
- [Phase 16.2]: Plan 03: the Journal HTML policy needed exactly the four editor-output rules (figure data-embed/data-id, img alt, ol start); the corpus showed no fifth
- [Phase 16.2]: Plan 03: editor uploads must sniff as a JPEG/PNG/GIF/WebP type matching the extension; uniqueMediaKey errors instead of overwriting when all numbered names are taken
- [Phase 16.2]: Plan 03: admin saves of post content write the Decode output to content and content_html per written locale (FormBefore*/FormAfter* hooks), no goldmark; category 422 gate retired
- [Phase 16.2]: 16.2-04: vue-journal-layer mirrors exactly the four Go editor-output rules; embeds expand only at the v-html sinks, after sanitizeJournalHtml
- [Phase 16.2]: 16.2-04: vue-journal-layer v0.2.0 published (tag 042b65d0 -> 759e684e, human reply "push"); vue-grzybyfunkcjonalne-app main 465286a pins it; a defect gets v0.2.1
### Pending Todos
@@ -724,6 +741,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-10-09T20:52:41.534Z
Stopped at: Completed 16.1-03-PLAN.md
Last session: 2026-10-10T11:07:22.612Z
Stopped at: Completed 16.2-04-PLAN.md
Resume file: None

View File

@@ -1,10 +1,10 @@
---
schema_version: 1
open_count: 6
open_count: 10
waived_count: 0
fixed_count: 6
total_count: 12
last_updated: 2026-10-09T20:51:27.671Z
total_count: 16
last_updated: 2026-10-10T10:12:10.986Z
---
# Broken Windows Ledger
@@ -27,6 +27,10 @@ last_updated: 2026-10-09T20:51:27.671Z
| 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 | |
| 13 | 16.2 | deviation | tests/blogeditor/fixtures/imported-post.html | | sm-journal-plugin: 16.2-02 imported-post fixture is synthetic (same structure as the export post) because reading the journal export was refused by the environment's PII classifier; swap in the real export content if wanted | open | | 2026-10-10T09:54:57.288Z | |
| 14 | 16.2 | unrun-verify | plugin_test.go | | sm-journal-plugin: TestJournalSVGEmbed now asserts AdminFS embeds assets/dist/blogeditor.js and .css; the root package TestMain needs Docker, so it was not run here; run go -C ../sm-journal-plugin test -count=1 . on a Docker host (plan 06) | open | | 2026-10-10T09:54:57.583Z | |
| 15 | 16.2 | unrun-verify | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md | | sm-journal-plugin posts_admin_smoke_test.go: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML compile under go vet but need a Docker host (plan 06) | open | | 2026-10-10T10:12:10.725Z | |
| 16 | 16.2 | unrun-verify | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md | | sm-journal-plugin journal_api_categories_test.go TestCategoriesAdminContent (strip assertions) and journal_api_task3_test.go media upload route tests after the StoreMedia refactor need a Docker host (plan 06) | open | | 2026-10-10T10:12:10.986Z | |
````json
[
@@ -185,6 +189,58 @@ last_updated: 2026-10-09T20:51:27.671Z
"recorded_at": "2026-10-09T20:51:27.671Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 13,
"kind": "deviation",
"phase": "16.2",
"file": "tests/blogeditor/fixtures/imported-post.html",
"line": null,
"description": "sm-journal-plugin: 16.2-02 imported-post fixture is synthetic (same structure as the export post) because reading the journal export was refused by the environment's PII classifier; swap in the real export content if wanted",
"status": "open",
"reason": "",
"recorded_at": "2026-10-10T09:54:57.288Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 14,
"kind": "unrun-verify",
"phase": "16.2",
"file": "plugin_test.go",
"line": null,
"description": "sm-journal-plugin: TestJournalSVGEmbed now asserts AdminFS embeds assets/dist/blogeditor.js and .css; the root package TestMain needs Docker, so it was not run here; run go -C ../sm-journal-plugin test -count=1 . on a Docker host (plan 06)",
"status": "open",
"reason": "",
"recorded_at": "2026-10-10T09:54:57.583Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 15,
"kind": "unrun-verify",
"phase": "16.2",
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md",
"line": null,
"description": "sm-journal-plugin posts_admin_smoke_test.go: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML compile under go vet but need a Docker host (plan 06)",
"status": "open",
"reason": "",
"recorded_at": "2026-10-10T10:12:10.725Z",
"resolved_at": null,
"milestone": "v1.0"
},
{
"id": 16,
"kind": "unrun-verify",
"phase": "16.2",
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md",
"line": null,
"description": "sm-journal-plugin journal_api_categories_test.go TestCategoriesAdminContent (strip assertions) and journal_api_task3_test.go media upload route tests after the StoreMedia refactor need a Docker host (plan 06)",
"status": "open",
"reason": "",
"recorded_at": "2026-10-10T10:12:10.986Z",
"resolved_at": null,
"milestone": "v1.0"
}
]
````

View File

@@ -29,6 +29,17 @@ Everything else that UGC refuses is refused: `script`, `style`, `iframe`, `objec
- **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 `&#34;` and `'` as `&#39;`, 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.
- **Amended 2026-10-10 (Phase 16.2 D-10): category content strips too, and admin saves report what was stripped.** The user chose one rule for both: post and category admin writes store the sanitized output and never fail. `CheckHTML`'s 422 contract for categories and the `TestCategoriesAdminContent` 422 assertion are retired in Phase 16.2. Winter stored category content unsanitized; that was a gap, not a contract. When the policy removes anything, the admin save response names what was removed. The policy (and the layer's sanitize-html mirror) is extended only for the Blog Editor's own output, such as the `figure data-embed`/`data-id` embed placeholder.
Phase 16.2 plan 03 implements this. The Blog Editor field types decode every locale's value with `classes.SanitizeHTMLReport`, store its output (the submitted bytes when nothing is removed) and return what was removed as admin notices; an admin save of post content writes the same HTML to `content` and `content_html` without goldmark. `newHTMLPolicy` gains exactly four rules for the editor's own output, and nothing else (no iframe, no other `data-*` attribute, no `target`, `rel`, style property or URL scheme). Plan 02's `tests/blogeditor/fixtures/editor-corpus.json` pins them: `TestEditorOutputPassesPolicy` requires every corpus entry to come back from `SanitizeHTML` byte for byte with an empty report, and checks that other providers, malformed ids and other attributes are still stripped. Plan 04 adds the same four rules to the layer's sanitize-html mirror and checks the same corpus. A fifth rule is added only to both policies together.
| Change (Phase 16.2) | Why |
|---|---|
| `data-embed` matching `^youtube$` on `figure` | The editor's embed placeholder `<figure data-embed="youtube" data-id="..."></figure>`; the layer expands it, no iframe is stored. |
| `data-id` matching `^[A-Za-z0-9_-]{11}$` on `figure` | The YouTube video id of the same placeholder. |
| `alt` with any text on `img` | The editor's alt field takes free text; UGC's `Paragraph` pattern drops `:`, `?`, `"` and more. The value is escaped on output and carries no URL. |
| `start` matching `^[0-9]+$` on `ol` | TipTap's ordered-list input rule writes it when a list does not start at 1. |
- **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.

View File

@@ -0,0 +1,54 @@
---
status: complete
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
source: [16.1-VERIFICATION.md]
started: 2026-10-10T00:00:00Z
updated: 2026-10-10T01:00:00Z
---
## Current Test
[testing complete]
## Tests
### 1. Install the hardened staging vhost on UAT (root)
expected: Security headers present exactly once on public pages, /gfadmin unchanged in access, `nginx -t` passes
result: pass
evidence: |
2026-10-09 23:05 UTC via debian@ sudo. Backup /etc/nginx/sites-available/uat.grzybyfunkcjonalne.pl.bak-20261009T230555Z;
installed sha256 bce4d7f9…bd7c; `nginx -t` ok; reloaded. curl: /, a post, /_journal, a /storage 404 and /gfadmin each carry
HSTS, nosniff, X-Frame-Options DENY, Referrer-Policy, Permissions-Policy and CSP-Report-Only exactly once; /gfadmin keeps its
enforced CSP and Referrer-Policy same-origin, answers 200 without basic auth, locale cookie Secure; public paths still 401
off-server. On-server smoke (loopback): smoke: ok (98 urls); 200 pages show all six headers. Winter vhost untouched.
### 2. Posts list search on UAT
expected: Searching "cordyceps" at https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts returns rows (no "The list could not be loaded")
result: pass
evidence: user confirmed on UAT 2026-10-10 ("Confirmed")
### 3. List state round trip on UAT (D-01, v0.1.17 build)
expected: Sort plus a category filter survive opening a post and back, "Zapisz i zamknij", and leaving via the plugin rail; the URL carries them; a pasted URL in a new tab shows the same list; after logout and login the list opens unfiltered; signing in as a different admin on the same browser shows no earlier admin's search
result: pass
evidence: user confirmed all three sub-checks on UAT 2026-10-10 (sort/filter survive navigation; URL round trip; cleared after logout)
### 4. Post form locales and fields on UAT (D-03)
expected: An imported post's form shows pl first with title, slug, content and excerpt filled
result: pass
evidence: user confirmed on UAT 2026-10-10
### 5. Review the prohibition verdicts
expected: The nine prohibition verdicts in 16.1-VERIFICATION.md (all "not violated") are acceptable
result: pass
evidence: user reviewed the nine verdicts and replied "approved" 2026-10-10 (notes: #6 UAT vhost installed, Winter vhost untouched; #3 translated content_html derived from content when both are sent, shapes unchanged)
## Summary
total: 5
passed: 5
issues: 0
pending: 0
skipped: 0
blocked: 0
## Gaps

View File

@@ -53,8 +53,8 @@ Pre-existing red, not caused by this phase: `scripts/check-phase10.sh --hygiene`
| 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 |
| 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 green on Docker 2026-10-09/10 |
| 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 green on Docker 2026-10-09/10 |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
@@ -94,7 +94,7 @@ Pre-existing red, not caused by this phase: `scripts/check-phase10.sh --hygiene`
- 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).
- Docker-host suites: still pending at plan 03 time (`TestListRelationSearchPostgres` and `TestPostsAdminResaveKeepsHTML` failed with "failed to create Docker provider", as designed). **Update 2026-10-10:** after the golem user joined the docker group, the full `go test -count=1 ./...` passed with Docker in summercms.go (37 packages, before and after cd25af0, and again for the v0.1.17 release) and in sm-journal-plugin, including `TestListQueryContract`, `TestListRelationSearchPostgres`, `TestPostsAdminResaveKeepsHTML` and the translation harness tests; the verifier re-ran them.
- 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)
**Approval:** pending (manual UAT rows only; Docker-host suites green 2026-10-10)

View File

@@ -0,0 +1,274 @@
---
phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
verified: 2026-10-09T23:05:00Z
status: passed
score: 32/33 must-haves verified
covered_files:
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-PLAN.md
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-01-SUMMARY.md
- .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.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-03-PLAN.md
- .planning/phases/16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f/16.1-03-SUMMARY.md
- admin/src/main.ts
- 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
- modules/phrasebook/backend/lang/en/lang.yaml
- modules/phrasebook/backend/lang/pl/lang.yaml
- scripts/check-phase10.sh
covered_digest: "v2:sha256:7e043180d992853a3d04039e3304ee81d631fc8574eeec0e96264b3b6ad7873d"
behavior_unverified: 0
overrides_applied: 0
human_verification:
- test: "Install the hardened staging vhost as root: copy /srv/grzybyfunkcjonalne/deploy/grzybyfunkcjonalne-staging.conf (host commits fc10094, 387d0f4) over /etc/nginx/sites-available/uat.grzybyfunkcjonalne.pl, run sudo nginx -t, reload nginx, and compare sha256 of the installed and uploaded copies"
expected: "nginx -t passes; curl -sI https://uat.grzybyfunkcjonalne.pl/ (on the server, resolved to loopback) shows Content-Security-Policy-Report-Only, X-Content-Type-Options, X-Frame-Options, Referrer-Policy and Permissions-Policy; /gfadmin keeps its auth_basic exemption; smoke.sh --url still ends 'smoke: ok ('"
why_human: "Needs root on the UAT server. Read-only check on 2026-10-09 23:00 UTC: the uploaded file has the CSP Report-Only header (count 1), the enabled vhost does not (count 0)"
- test: "UAT posts list search: in /gfadmin/golem15/journal/posts search 'cordyceps' (a category name)"
expected: "Rows appear; no 'The list could not be loaded.'; the Go log shows no SQLSTATE 42703 after 2026/10/09 22:47:29"
why_human: "Needs UAT basic auth and an admin login. Automated proof covers the SQL on real Postgres (TestListRelationSearchPostgres) and a clean log since the restart, but the Go log does not record successful searches, so it cannot show that a search ran on the real categories pivot"
- test: "UAT list state (D-01): set a sort and a category filter, open a post, press back; repeat with save-and-close; leave through the navigation rail and return; paste the URL in a new tab; log out and in"
expected: "Sort and filter survive every return and are in the URL; the pasted URL shows the same list; after logout and login the list opens unfiltered"
why_human: "Interactive flow behind UAT basic auth; the human report ('all works') predates the re-saves and the v0.1.17 deploy (which changed the storage key to be per admin), so it is not independent confirmation of the deployed build"
- test: "UAT post form (D-03): open any imported post"
expected: "The locale selector shows pl first; title, slug, content and excerpt are filled with the stored values"
why_human: "Visual check on real data. Supporting evidence: the UAT locales table has en at sort_order 1 and the default pl at sort_order 2, the exact order the fix reorders, and enabledContentLocales is covered by 12 unit cases"
- test: "Review the nine judgment-tier prohibitions in the Prohibitions section below"
expected: "Each LLM-judge verdict (all 'not violated') is accepted or overturned"
why_human: "unverified-prohibition: judgment tier, the verifier's verdict is non-authoritative"
---
# Phase 16.1: Journal admin urgent fixes Verification Report
**Phase Goal:** The Journal posts list and post form in the admin work on real imported data found on UAT. The fixes are released as a summercms tag, the host is bumped, UAT is redeployed and the staging smoke re-runs.
**Verified:** 2026-10-09T23:05:00Z
**Status:** human_needed
**Re-verification:** No, initial verification
All code, tests, tags, pushes and the deployed binary were checked against the repositories and the UAT server (read-only). Every automated must-have holds. What remains: the nginx vhost install, which needs root, and the browser checks on UAT that only a human can run.
## Goal Achievement
### Observable Truths
**Roadmap contract (success criteria derived from the goal)**
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | List search no longer fails with "Server error" on a belongsToMany relation | ✓ VERIFIED | `modules/cabana/query.go:369-449` builds `EXISTS (SELECT 1 FROM pivot AS cabana_p0 JOIN related AS cabana_r0 ...)` from `stmt.Schema.Relationships.Relations[field]`. `TestListRelationSearchPostgres` passed against real Postgres (run here through the Docker sudo path). The UAT Go log has 0 `SQLSTATE 42703` lines in the 32 lines since the 22:47:29 restart. |
| 2 | List sort and filters are remembered after opening a record and going back | ✓ VERIFIED | `ListView.vue:200-221` restores the stored query before the first request. `FormView.test.ts` "returning to the list keeps its query (D-01)" covers back, cancel, save-and-close and delete. 4 files / 144 tests pass; the full suite has 76 files / 1171 tests. |
| 3 | The post form shows the stored content | ✓ VERIFIED (code) | `http.go:986-993` moves `DefaultLocale` to index 0. `TestEnabledLocalesDefaultFirst` (12 cases) passes. The UAT `golem15_translate_locales` table has `en` at sort 1 and the default `pl` at sort 2, which confirms the root cause the fix targets. The on-UAT visual check is a human item. |
| 4 | The post form shows the stored excerpt | ✓ VERIFIED (code) | Same mechanism as #3: the excerpt lives in the host column, under the default locale. |
| 5 | Released as a summercms tag | ✓ VERIFIED | `git ls-remote`: v0.1.16 is d8e3536 on d77393b; v0.1.17 is c37de45 on c21fe36. v0.1.17 contains 06d75cb, bd8626e, 9cac45c, a8bce9c, 837922c and cd25af0. |
| 6 | Host is bumped | ✓ VERIFIED | Host HEAD 0b93ac2 equals origin/master. The journal gitlink is c60abc6, equal to sm-journal-plugin origin/main. The translate gitlink is 54cf93b, the vue app gitlink is ada03b2, and go.mod requires summercms v0.1.17. |
| 7 | UAT is redeployed | ✓ VERIFIED | Server sha256 is `e5b0ad28…3af232` and `.prev` is `e1ba3587…148ceb` (the CR-01 hotfix). The Go and Nuxt processes started at 22:47:28 and 22:47:32 UTC. |
| 8 | The staging smoke re-runs | ✓ VERIFIED | I re-ran it on the server myself (loopback resolve, GETs only): `smoke: ok (98 urls)` |
**16.1-01 must_haves**
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 9 | belongsToMany search goes through EXISTS, with names taken from GORM's parsed relationship | ✓ VERIFIED | `relationSubquery` uses `gorm.Statement.Parse` and `Relationships.Relations`. TestListRelationSQL covers custom `joinForeignKey`/`joinReferences` pivot names. |
| 10 | hasMany, hasOne and belongsTo searches are correlated EXISTS; there is no outer JOIN, so a record is counted once | ✓ VERIFIED | `applyListSearch` (query.go:476-490). TestListRelationSQL checks there is no JOIN outside the subqueries next to `user_id = 7` and `id IN (3)`. The Postgres test gives total 1 for a post matching through 2 tags. |
| 11 | A to-many sort uses `string_agg` plus the PK tiebreak; a to-one sort keeps the join | ✓ VERIFIED | query.go:313-336; TestListRelationSQL covers both directions; the Postgres test gives [2 1] and [1 2] |
| 12 | Polymorphic relations and unknown columns give 422, never 500 | ✓ VERIFIED | query.go:378-390 refuses via `rel.Polymorphic` and `PrimaryValue`; TestListRelationSQL has 5 refusal cases |
| 13 | meta.enabledLocales lists the default locale first | ✓ VERIFIED | http.go:986-993, TestEnabledLocalesDefaultFirst(Smoke) |
| 14 | FormatHTML keeps imported raw HTML and dangerous markup is not stored on any write path | ✓ VERIFIED (superseded mechanism) | The plan's "refuses" became "strips" under the accepted decision note `notes/journal-html-sanitizing-bluemonday.md`, the CR-01 fix in a3551f2, which is stronger than the planned regex gate. All three write paths call FormatHTML: posts.go:213, import_posts.go:185, posts_helpers.go:511/567. Translations go through FormatTranslation (0d00ec8). Duplicate attributes are handled in 5f47083. TestFormatHTMLKeepsRawHTML and TestFormatHTMLSanitizesUnsafeHTML pass. |
| 15 | List params stay in the URL; stored per list; an empty query is never stored | ✓ VERIFIED | `listState.ts`. The key is now `summer-admin.list:<adminId>:<controller>` (the WR-03 fix in cd25af0 is stricter than the plan's key). `writeListState` removes the key on an empty query. |
| 16 | Entry without params restores before the first request; URL params win; logout removes every stored query | ✓ VERIFIED | ListView.vue watch; `clearUser()` calls `setListStateOwner(null)`, which removes all list keys. `main.ts:27` sends `onUnauthorized` to `expireSession`. Covered by vitest. |
| 17 | Commits staged by path, holding only this plan's files; the journal commit in its own repo | ✓ VERIFIED | `git show --name-only` for 06d75cb, bd8626e and 9cac45c matches the plan. The one extra file in 9cac45c (`list.smoke.test.ts`) is a documented deviation. 528d2d8 is in sm-journal-plugin. |
| 18 | go vet clean; smoke tests, vitest, TestDocsTree and the dist check green | ✓ VERIFIED | Re-run here: `go vet ./...` ok, both repos' vet ok, TestDocsTree ok, `check-admin-dist.sh` printed "matches a fresh build", vitest 1171/1171 |
**16.1-02 must_haves**
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 19 | An annotated tag containing every 16.1-01 commit is on origin | ✓ VERIFIED | v0.1.16 and v0.1.17 are both on origin (see #5) |
| 20 | sm-journal-plugin main with the require is on origin; the host gitlink points at it; README and DEPLOY.md lost the re-save warning | ✓ VERIFIED | c60abc6 equals origin/main and requires v0.1.17. Host gitlink c60abc6. `grep -ci 'omitted\|re-sav'` gives 0 in both README.md and DEPLOY.md. |
| 21 | UAT runs the build; sha256 matches; both programs run; `.prev` kept; smoke ok | ✓ VERIFIED | See #7 and #8. I could not compare against the local build byte for byte; the server hash matches the release record. |
| 22 | After the restart, no SQLSTATE 42703 (and search returns rows) | ✓ VERIFIED (log + Postgres test) | 0 in 32 lines since 22:47:29. The human search on UAT is a human item. |
| 23 | Posts 53 and 64 repaired: no placeholder, post 64 has its footer | ✓ VERIFIED | Read-only psql now prints `0:1`. The public page `/blog/ekstrakt-z-turkey-tail` renders `<footer class="author-footer" itemscope="" ...>`, and the footer CSS is in `entry.Djv1o0WC.css`. |
| 24 | D-01 and D-03 checked on UAT by a human (end-of-phase check) | ? UNCERTAIN (human) | The human's report predates the re-saves and the v0.1.17 deploy, so it does not count as confirmation. Routed to Human Verification. |
**16.1-03 must_haves**
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 25 | Every piece of 16.1 code has unit tests | ✓ VERIFIED | listState.test.ts, ListView/FormView/useAuth tests, TestListRelationSQL, TestEnabledLocalesDefaultFirst, FormatHTML tests, all passing |
| 26 | Form back, cancel, save-and-close and delete land with the stored query, and the first request carries it | ✓ VERIFIED | FormView.test.ts D-01 block passes |
| 27 | ListView entry logic: restore, URL wins, cleared controls, same-list navigation, 422, page clamp | ✓ VERIFIED | ListView.test.ts "list state entry logic (D-01)" passes. cd25af0 adds recovery from any failed restore (WR-02). |
| 28 | listState helpers: prefix, malformed values dropped, empty query not stored, throwing storage, clear keeps the sidebar key | ✓ VERIFIED | listState.test.ts passes |
| 29 | DryRun SQL matrix (custom pivot keys, self-reference, soft delete, polymorphic, bound and LIKE-escaped values, no outer JOIN, sorts) | ✓ VERIFIED | `--- PASS: TestListRelationSQL` |
| 30 | enabledContentLocales cases | ✓ VERIFIED | `--- PASS: TestEnabledLocalesDefaultFirst` |
| 31 | FormatHTML tests: imported HTML round-trips; no unsafe case survives | ✓ VERIFIED (superseded mechanism) | "Every unsafe case returns an error" became "every unsafe case is stripped" per the bluemonday decision. TestFormatHTMLSanitizesUnsafeHTML, TestFormatHTMLDuplicateAttributes, TestCheckHTML and TestFormatTranslation pass. |
| 32 | Postgres tests exist and pass on a Docker host | ✓ VERIFIED | Re-run here: TestListRelationSearchPostgres, TestListQueryContract, TestPostsAdminResaveKeepsHTML, TestJournalAPITranslatedContentHTML and TestJournalImportTranslatedContentHTML all PASS |
| 33 | vet, vitest, TestDocsTree and dist green | ✓ VERIFIED | See #18 |
**Score:** 32/33 truths verified (0 present-but-behavior-unverified; 1 uncertain, routed to a human)
### CONTEXT decisions accounted for
| Decision | Status | Where |
|----------|--------|-------|
| D-01 list state in the URL and localStorage, no server storage | ✓ | #2, #15, #16, #26-28; no migration, route or OpenAPI diff in 3ca6a0d..cd25af0 |
| D-02 framework-level relation-search fix in cabana | ✓ | #1, #9-12, #17 |
| D-03 content and excerpt shown without loss; re-save keeps HTML | ✓ code / ? UAT visual | #3, #4, #13, #14, #23; UAT visual is a human item |
| D-04 tag, host bump, redeploy, smoke | ✓ (except the nginx vhost install) | #5-8, #19-21 |
| D-05 three plans | ✓ | 01, 02 and 03 are all summarized |
### Post-review remediation (16.1-REVIEW.md)
| Finding | Status | Evidence |
|---------|--------|----------|
| CR-01 stored XSS | Fixed and deployed | sm-journal-plugin a3551f2 (bluemonday); the public layer uses sanitize-html (vue-journal-layer 5beb1c5, tag v0.1.1, pinned by vue app 27b3827) |
| WR-01 "Data:" false refusal | Fixed | The regex gate is gone; TestFormatHTMLKeepsSchemeLikeText passes |
| WR-02 restored query locks the list | Fixed | cd25af0: `loadList(restored)` forgets the query and loads the plain list once; there is a reset button |
| WR-03 list state crosses admins | Fixed | cd25af0: per-admin keys; `clearUser` and `expireSession` clear them |
| Audit W-1 / W-2 | Fixed | 0d00ec8 (FormatTranslation), 5f47083 (duplicate attributes); harness tests pass |
| WR-04 relation SQL only DryRun-tested | Resolved in substance | TestListRelationSearchPostgres runs the EXISTS and string_agg SQL on real Postgres and passes |
| WR-05 to-many sort order differs from display order | Open (warning) | `relatedSelectMany` still joins in Preload order (query.go:142, no ORDER BY). `docs/backend/lists-and-filters.md:62` still says the sort uses "the text the list shows". |
| IN-01..03 | Open (info) | Not addressed |
| Translate Secure cookie, app header hardening | Verified on UAT | `set-cookie: golem15.translate.locale=...; HttpOnly; Secure`; Nuxt at :3016 sends no X-Powered-By and no set-cookie |
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `modules/cabana/query.go` | EXISTS search and to-many sort from the parsed relationship | ✓ VERIFIED | Contains `Relationships.Relations`, `string_agg`, `EXISTS (SELECT 1 FROM`; called from ExecuteList (lines 121, 133) |
| `modules/cabana/query_relation_test.go` | DryRun harness, smoke, matrix, Postgres test | ✓ VERIFIED | Its tests pass |
| `modules/cabana/locale_order_test.go` | default-first tests | ✓ VERIFIED | Passes |
| `../sm-journal-plugin/classes/format_html.go` | goldmark WithUnsafe behind a safety gate | ✓ VERIFIED | `WithUnsafe` plus the bluemonday `htmlPolicy` (supersedes the regex gate) |
| `admin/src/state/listState.ts` | per-list browser storage | ✓ VERIFIED | Contains `summer-admin.list:`; imported by ListView.vue and useAuth.ts |
| `../sm-grzybyfunkcjonalne-app/DEPLOY.md` | release and rollback procedure without the re-save warning | ✓ VERIFIED | Has `## Release`; the warning is removed |
| `../sm-journal-plugin/go.mod` | require of the release tag | ✓ VERIFIED | `summercms v0.1.17`, with the replace to `../summercms.go` kept |
| `admin/tests/state/listState.test.ts` | helper tests | ✓ VERIFIED | Passes |
| `../sm-journal-plugin/posts_admin_smoke_test.go` | TestPostsAdminResaveKeepsHTML | ✓ VERIFIED | PASS on the Docker host |
| `modules/boardwalk/dist/` | rebuilt SPA | ✓ VERIFIED | check-admin-dist.sh: matches a fresh build |
### Key Link Verification
| From | To | Via | Status |
|------|----|-----|--------|
| query.go applyListSearch/applyListOrder | GORM schema.Relationship | `stmt.Parse` + `Relationships.Relations[field]` | ✓ WIRED |
| http.go enabledContentLocales | TranslationWriter.DefaultLocale | default moved to index 0 | ✓ WIRED |
| ListView.vue watch | listState.ts read/writeListState | restore before the first request | ✓ WIRED |
| sm-journal-plugin controllers/posts.go | classes.FormatHTML | content to content_html on admin save (line 213) | ✓ WIRED |
| useAuth.ts clearUser / main.ts onUnauthorized | setListStateOwner(null) | logout and session expiry clear list keys | ✓ WIRED |
| host plugins/golem15/journal gitlink | sm-journal-plugin origin/main | c60abc6 | ✓ WIRED |
| host go.mod | ../summercms.go at the release tag | replace + require v0.1.17 | ✓ WIRED |
| FormView.test.ts | ListView entry restore | `writeListState` then back navigation | ✓ WIRED |
### Data-Flow Trace (Level 4)
| Artifact | Data | Source | Real data | Status |
|----------|------|--------|-----------|--------|
| Posts list (search) | rows/meta | ExecuteList → GORM with EXISTS on the real pivot | Yes (Postgres test; UAT log clean) | ✓ FLOWING |
| Post form ML fields | content/excerpt (pl) | hydrateMLRecord host columns under DefaultLocale; enabledLocales[0] = pl | Yes (UAT locales: pl default, en sort 1) | ✓ FLOWING (visual check is a human item) |
| Public post 64 | content_html footer | UAT DB → Journal API → sanitize-html → v-html | Yes (footer rendered on the SSR page) | ✓ FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Relation SQL and locale order (pure) | `go test -run '^(TestListRelationSearchSmoke\|TestListRelationSQL\|TestEnabledLocalesDefaultFirst\|TestEnabledLocalesDefaultFirstSmoke)$' ./modules/cabana` | 4 PASS lines | ✓ PASS |
| Journal classes | `go -C ../sm-journal-plugin test -v ./classes/` | 9 PASS, ok | ✓ PASS |
| Relation search on real Postgres | Docker sudo path, `-run TestListRelationSearchPostgres\|TestListQueryContract` | PASS, PASS | ✓ PASS |
| Admin re-save and translations keep or sanitize HTML | Docker sudo path, sm-journal-plugin named tests | 3 PASS | ✓ PASS |
| List state vitest | `npm --prefix admin test` | 76 files, 1171 tests passed | ✓ PASS |
| Dist drift | `bash scripts/check-admin-dist.sh` | matches a fresh build | ✓ PASS |
| Docs | `go test ./cmd/summer -run TestDocsTree` | ok | ✓ PASS |
| Browser storage confined | grep in admin/src outside useSidebar.ts and listState.ts | empty | ✓ PASS |
### Probe Execution
| Probe | Command | Result | Status |
|-------|---------|--------|--------|
| UAT on-server smoke | `CURL_HOME=$C scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl` (via ssh, loopback resolve) | `smoke: ok (98 urls)` | PASS |
| UAT data repair | read-only psql: placeholder count : post-64 footer | `0:1` | PASS |
| UAT log | 42703 count since 2026/10/09 22:47:29 | 0 (32 lines) | PASS |
| UAT binary | sha256sum bin/grzybyfunkcjonalne | e5b0ad28…3af232 | PASS |
| Hardened vhost installed | grep CSP-Report-Only in /etc/nginx/sites-enabled/uat.grzybyfunkcjonalne.pl | 0 (uploaded copy: 1) | NOT INSTALLED (human item, needs root) |
### Requirements Coverage
The phase has no REQUIREMENTS.md IDs (Requirements: TBD), and REQUIREMENTS.md maps nothing to Phase 16.1, so no requirement is orphaned. The plans' `requirements:` fields name CONTEXT decisions D-01..D-04, which are covered above.
### Prohibitions (judgment tier, verdicts not authoritative; flagged as unverified-prohibition, human review recommended)
| # | Prohibition | LLM-judge verdict | Basis |
|---|-------------|-------------------|-------|
| 1 | Relation fix not Journal-specific; no consuming-application names in framework code or docs | not violated | The builder is generic over GORM relationships; grep for app names in the touched framework files is empty; TestDocsTree ok |
| 2 | No server-side list state (table, migration, route) | not violated | No migration, route or API diff in summercms.go 3ca6a0d..cd25af0 |
| 3 | Journal API content/content_html shapes unchanged | not violated | Only stored values change (sanitized); README says shapes and codes are unchanged |
| 4 | cabana.RenderMarkdown stays safe-mode | not violated | `modules/cabana/field_markdown.go` has no WithUnsafe |
| 5 | No write SQL on UAT; only human re-saves of 53/64 | not violated (from records) | Records show read-only psql only; cannot be proven after the fact |
| 6 | Live Winter site, vhost, php-fpm and PHP DB untouched | not violated (from records) | The vhost was not installed; there is no evidence of live-site edits |
| 7 | No credentials requested, printed or committed | not violated (from records) | SUMMARYs hold no secrets |
| 8 | Tests touch no UAT, network or shared DB | not violated | DryRun on DSN port 1; testcontainers for Postgres |
| 9 | Tests not weakened | not violated | Refuse cases became strip cases under the accepted bluemonday decision, and the payload set grew to 35 |
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| (phase files) | - | TBD/FIXME/XXX/TODO/HACK | none found | - |
| `docs/backend/lists-and-filters.md` | 62 | Docs claim a to-many sort uses "the text the list shows" but the display order is Preload order (WR-05) | ⚠️ Warning | A list sorted by categories can look out of order; docs inaccurate |
| `../sm-grzybyfunkcjonalne-app/sm-grzybyfunkcjonalne-app` | - | 46 MB untracked, not-ignored binary in the host repo root (built 2026-10-10 00:50 local) | ℹ️ Info | Could be committed by accident; not part of any commit |
| `.planning/phases/16.2-.../16.2-05-PLAN.md` (via ROADMAP line 1014) | - | Plans to cut "summercms tag v0.1.17", which 16.1 already used | ℹ️ Info | 16.2's release must pick the next free tag (v0.1.18) |
| UAT Journal API | - | With no locale given (and with Accept-Language pl-PL), the API sets `golem15.translate.locale=en` although the default is pl and en has sort_order 1 | ℹ️ Info | Outside 16.1 scope; may be pre-existing translate behavior; worth a look |
| `16.1-VALIDATION.md`, `16.1-SECURITY.md` | - | Still say "Docker-host suites pending" / sign-off unchecked | ℹ️ Info | Stale bookkeeping: the Docker-host suites have now passed |
### Human Verification Required
#### 1. Install the hardened staging vhost (root)
**Test:** Install `/srv/grzybyfunkcjonalne/deploy/grzybyfunkcjonalne-staging.conf` (host fc10094, 387d0f4) as the uat vhost, then run `nginx -t` and reload.
**Expected:** The security headers and CSP Report-Only appear on UAT responses, the `/gfadmin` auth_basic exemption still holds, and the smoke still passes.
**Why human:** It needs root on the server. Today the enabled vhost does not have CSP-RO.
#### 2. UAT posts list search
**Test:** Search "cordyceps" in `/gfadmin/golem15/journal/posts`.
**Expected:** Rows appear and no SQLSTATE 42703 shows up in the log.
**Why human:** It needs basic auth and an admin login. The Go log does not record successful queries.
#### 3. UAT list state round trip (D-01)
**Test:** Set a sort and a category filter. Go open post → back, then save-and-close, then rail navigation. Paste the URL in a new tab. Log out and back in.
**Expected:** State is kept and visible in the URL. After logout and login the list opens unfiltered.
**Why human:** It is an interactive flow. The earlier human report predates the v0.1.17 deploy.
#### 4. UAT post form locales and fields (D-03)
**Test:** Open an imported post.
**Expected:** `pl` is first, and title, slug, content and excerpt are filled.
**Why human:** It is a visual check on real data.
#### 5. Judgment-tier prohibitions
**Test:** Review the nine verdicts above.
**Expected:** Accept or overturn each one.
**Why human:** The verifier's verdicts are not authoritative.
### Gaps Summary
There are no blocking gaps. Every roadmap criterion and plan must-have I could check automatically holds in the code, the tests (including the Docker-backed Postgres suites, which I re-ran), the tags and pushes, and the deployed UAT server (binary hash, smoke, log, SQL, public headers, post-64 footer). Two plan wordings were superseded by stricter post-review fixes, and both are recorded in an accepted decision note or a commit:
- FormatHTML now strips unsafe markup with bluemonday instead of refusing it with a regex.
- List-state keys are now scoped per admin.
Still open:
- **Human items:** the root-only nginx vhost install, and the three UAT browser checks.
- **WR-05:** a non-blocking warning. The docs sentence on to-many sort order is inaccurate.
---
_Verified: 2026-10-09T23:05:00Z_
_Verifier: Claude (msd-verifier)_

View File

@@ -21,6 +21,7 @@ files_modified:
- modules/cabana/settings.go
- modules/cabana/relation_form.go
- modules/cabana/extension.go
- modules/cabana/relation_field.go
- modules/cabana/admin_openapi.go
- modules/cabana/security_coverage_test.go
- modules/cabana/README.md
@@ -37,6 +38,7 @@ files_modified:
- admin/src/components/form/FieldRenderer.vue
- admin/src/components/form/PreviewField.vue
- admin/src/components/form/FormField.vue
- admin/src/components/form/FormGrid.vue
- admin/src/components/form/formState.ts
- admin/src/components/form/mlLocale.ts
- admin/src/components/form/formContext.ts
@@ -44,21 +46,24 @@ files_modified:
- admin/src/components/form/fields/MLPluginField.vue
- admin/src/components/relation/RelationChildModal.vue
- admin/tests/form/PluginField.test.ts
- admin/tests/form/FormField.test.ts
- admin/tests/form/formState.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
tokens: 140000
raw_tokens: 140000
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.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, and stores the returned pact.FormFieldDecoded Value; 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-10 (framework half, amended 2026-10-10): a Decode may return pact.FormFieldNotice values next to the value it stores (for example what a sanitizer removed); cabana localizes them in the request locale and answers the create or update with meta.notices [{field, locale, message}]; a save whose Decode calls returned no notice answers exactly today's JSON (no notices key); the admin SPA shows each notice under its field and in the saved toast, and reloads the stored value from the response into the form and the element."
- "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."
@@ -69,8 +74,11 @@ must_haves:
- "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"
provides: "FormFieldType, FormFieldValue, FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldUploadResult, FormFieldError, HasFormFieldTypes"
contains: "HasFormFieldTypes"
- path: "modules/cabana/relation_field.go"
provides: "RecordMeta.Notices and FieldNotice (the save response's meta.notices)"
contains: "notices,omitempty"
- path: "modules/cabana/field_type.go"
provides: "per-boot field type registry, compile checks, decode/skip/load/list runner"
contains: "compileFieldTypes"
@@ -98,6 +106,14 @@ must_haves:
to: "pact.FormFieldType Decode and Load"
via: "decodePluginFields between loadRecord and lagoon.Fill inside the save transaction"
pattern: "decodePluginFields"
- from: "modules/cabana/crud.go save"
to: "RecordResult.Meta.Notices (JSON meta.notices)"
via: "localizeNotices over the FormFieldDecoded notices, after the transaction commits"
pattern: "localizeNotices"
- from: "admin/src/views/FormView.vue save"
to: "FormField notice line and the saved toast"
via: "noticeLines(result.data.meta.notices) passed through FormGrid like errors"
pattern: "noticeLines"
- from: "admin/src/main.ts and admin/src/views/LoginView.vue"
to: "GET {api}/field-types"
via: "loadFieldTypes next to loadNavigation"
@@ -127,6 +143,12 @@ ROADMAP Goal (not in user-story format): plugins can register their own admin fo
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.
## Revision 2026-10-10 (CONTEXT D-10 amended)
D-10 now strips instead of refusing, and an admin save must tell the admin what was removed. That report is a generic field-type capability, not Journal code in cabana: `Decode` returns `pact.FormFieldDecoded{Value, Notices}`, cabana localizes the notices and answers the save with `meta.notices`, and the SPA shows them and reloads the stored value. `pact.FormFieldError` (422) stays for values a type cannot accept at all, such as a non-string. The release tag named in Task 1 is now v0.1.18 or the next free v0.1.x, because 16.1 already tagged v0.1.17. Everything else in this plan is unchanged.
RESEARCH and PATTERNS were written before 16.1 replaced the Journal regex gate with bluemonday and before D-10 was amended. Where they say "CheckHTML refuses" or "no sanitizer", this plan's text wins.
## 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).
@@ -149,15 +171,15 @@ The locked plan count (6) puts the whole framework half in this plan. It has fou
| 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-02 | HTML in content and content_html on admin save, both sanitized | 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-04 | Text, images, tables, embeds, raw HTML block (raw block goes through the sanitizer) | 02, 03 | COVERED |
| CONTEXT | D-05 | Embed placeholder; both policies extended only for the editor's own output, pinned by a corpus test; iframes still refused | 02 (corpus), 03 (Go policy), 04 (layer mirror), 06 | 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-10 | Strip, never refuse, on every locale for posts and categories; the save names what was removed; no goldmark on the admin path | 01 (notices), 03 (Journal strip), 05 (UAT), 06 | 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 |
@@ -166,7 +188,7 @@ The locked plan count (6) puts the whole framework half in this plan. It has fou
| 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 |
| CONTEXT | D-19 | Category content uses blogeditor and is stripped like posts (the 422 gate is retired) | 03, 06 | 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 |
@@ -176,30 +198,31 @@ The locked plan count (6) puts the whole framework half in this plan. It has fou
| 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 | P7 | Embed expansion after the sanitizer (last step at the v-html sink) | 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 |
| DRIFT | 16.1 | bluemonday policy (`SanitizeHTML`, `FormatTranslation`, the `CheckHTML` predicate (reports any change)), layer sanitize-html mirror, v0.1.17 and layer v0.1.1 already tagged, post 64 re-saved without its style block | 01-06 (revision 2026-10-10) | 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`.
- `pact.FormFieldType`, `pact.FormFieldValue`, `pact.FormFieldDecoded`, `pact.FormFieldNotice`, `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.FieldNotice`, and the `RecordMeta.Notices` field (JSON `meta.notices`, omitted when empty).
- `cabana.FormField` JSON keys `element`, `translatable`, `typeOptions` (Go fields `Element`, `Translatable`, `TypeOptions`).
- Unexported: `fieldTypeRegistry`, `compileFieldTypes`, `readClientAssets`, `liftPluginValues`, `decodePluginFields`, `hydratePluginFields`, `servedFieldValue`, `isMLField`, `fieldUpload`, `fieldTypesList`, `withFieldWrites`.
- Unexported: `fieldTypeRegistry`, `compileFieldTypes`, `readClientAssets`, `liftPluginValues`, `decodePluginFields`, `hydratePluginFields`, `servedFieldValue`, `isMLField`, `fieldUpload`, `fieldTypesList`, `withFieldWrites`, `localizeNotices`.
- 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`.
- SPA exports: `loadFieldTypes`, `loadGlobalStyles`, `GLOBAL_STYLE_OWNER`, `rendererForField`, `isRegisteredField`, `isMLField`, `noticeLines`, `FIELD_CHANGE_EVENT`, `FIELD_UPLOAD_EVENT`, `FIELD_UPLOAD_RESULT_EVENT`; FormField and FormGrid gain a `notices` prop.
- 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).
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 notices and 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.
Purpose: Journal (plans 02 and 03) and later plugins ship their own value-bearing field types without a framework release per type, and a type that changes a value on save (Journal strips unsafe HTML per D-10) can tell the admin what it changed.
Output: the pact types, the cabana registry, compile, save (with notices), show, list and upload paths, the two routes, regenerated OpenAPI and TS types, PluginField/MLPluginField, the global loader and the notice display, rebuilt dist, READMEs and docs.
</objective>
<execution_context>
@@ -230,7 +253,7 @@ type FormFieldType struct {
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
Decode func(ctx context.Context, in FormFieldValue) (FormFieldDecoded, 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
@@ -245,6 +268,21 @@ type FormFieldValue struct {
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
}
// FormFieldDecoded is Decode's answer: Value is stored (a string for a
// translatable type); Notices are non-blocking messages about it, such as what
// a sanitizer removed. No notices means the save answers exactly as before.
type FormFieldDecoded struct {
Value any
Notices []FormFieldNotice
}
// FormFieldNotice is one message for the admin. cabana localizes Message
// (phrase key or text) in the request locale with Params; Count > 0 selects
// the plural form (phrasebook Choice, :count set).
type FormFieldNotice struct {
Message string
Params map[string]string
Count int
}
type FormFieldUpload struct {
DB *gorm.DB
Record any // nil on the create form ({id} 0)
@@ -271,8 +309,16 @@ func WithFieldWrites(ctx context.Context, writes map[string][]FieldWrite) contex
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:"-"`
type FieldNotice struct {
Field string `json:"field"`
Locale string `json:"locale,omitempty"` // set for a translatable type's value
Message string `json:"message"` // already localized
}
// RecordMeta (relation_field.go) gains: Notices []FieldNotice `json:"notices,omitempty"`
```
Save responses: `POST /{vendor}/{plugin}/{controller}` and `PUT .../{id}` keep `{"data":...,"meta":{"labels":...}}`; when a Decode returned notices, `meta` also carries `"notices":[{"field":"content","locale":"pl","message":"2 elements were removed: style, iframe"}]`. Without notices the JSON is byte-for-byte today's.
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.
@@ -291,6 +337,8 @@ Existing code the executor builds on (read, do not re-derive):
- `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.
- `modules/cabana/relation_field.go`: `RecordMeta` (91), `RecordEnvelope`, `RecordResult`; `modules/cabana/crud.go`: `localizeForbidden` (the pattern for `localizeNotices`: request locale via `schemaLocale`, `translateKey`), `CRUDService.tr`; `modules/phrasebook/translator.go`: `Get(ctx, key, params)` and `Choice(ctx, key, count, params)`.
- `admin/src/views/FormView.vue`: `adopt` (173, reloads the saved record into the form), `save` (315), the saved toast (337); `admin/src/components/form/FormGrid.vue` (47, `:errors` per field) and `FormField.vue` (the `errors` prop and error line): the path notices follow.
- `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>
@@ -303,9 +351,9 @@ Existing code the executor builds on (read, do not re-derive):
<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>
Then print the proposal sheet: the pact types and func signatures exactly as in the interfaces block, including Decode's FormFieldDecoded answer and FormFieldNotice (the D-10 amendment: a type reports what it changed instead of refusing); which funcs are optional (CheckOptions, Load, ListValue, Upload) and which is required (Decode); the cabana additions (FieldWrite, FieldWritesFromContext, WithFieldWrites, CompileFormWithTypes, FieldNotice, RecordMeta.Notices as meta.notices omitted when empty, 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>
<context>D-12 rates the pact interface one-way: once summercms is tagged (plan 05: v0.1.18, or the next free v0.1.x; v0.1.17 is already the 16.1 security release) 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>
@@ -334,25 +382,27 @@ Then print the proposal sheet: the pact types and func signatures exactly as in
<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>
<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/relation_field.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, localizeForbidden, scalarFormField and its callers), modules/cabana/relation_field.go (RecordMeta, RecordResult), modules/phrasebook/translator.go (Get, Choice), modules/phrasebook/backend/lang/pl/lang.yaml (lines 40-44: plural map format), 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.
pact (D-12, D-10): add FormFieldType, FormFieldValue, FormFieldDecoded, FormFieldNotice, 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, notice localization and the untouched-value skip; Decode returns FormFieldDecoded (the value to store plus optional notices; a notice reports a change the type made to an accepted value, a FormFieldError refuses a value the type cannot accept), 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.
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}, sets projected[field] to the result's Value and keeps the result's Notices with the field name; a *pact.FormFieldError becomes &ValidationError{Details: {field: Messages}} (422 validation_failed) and discards every collected notice; 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. So the save response always carries the stored value, which the SPA adopts into the form (the reload D-10 asks for).
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.
Notices (D-10, framework half): after the transaction commits, new localizeNotices(ctx, s.tr, collected) turns each notice into a FieldNotice{Field, Locale, Message} in the request locale, the way localizeForbidden localizes a refusal: Message is a phrase key or text, resolved with tr.Choice(ctx, key, Count, Params) when Count > 0 and tr.Get(ctx, key, Params) otherwise; a nil translator keeps the text. At most 10 notices per field and locale are kept and each localized message is cut to 500 runes with an ellipsis. RecordMeta (relation_field.go) gains Notices []FieldNotice with json "notices,omitempty", set on the RecordResult that create and update write; with no notice the encoded response is byte-for-byte today's.
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), document the FormField keys and the optional RecordMeta notices (FieldNotice); 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.
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, trims surrounding spaces and reports that with the notice Message "acme.demo::lang.trimmed" (Count 1), and otherwise returns the value with no notice, 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"; a value " x " is stored as "x" and localizeNotices yields one FieldNotice for "notes" (a nil translator keeps the key text); the JSON encoding of a RecordMeta without notices has no "notices" key; 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>
Docs (CLAUDE.md documentation rules, neutral names only): pact README (Features and API reference rows for each new pact identifier, FormFieldDecoded and FormFieldNotice included); cabana README (the GET /field-types row in Admin API routes, CompileFormWithTypes, FieldNotice, the meta.notices key of the create and update responses 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 (value, notices, refusal) 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 &amp;&amp; go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestFormSchemaCompile|TestFormSchemaRejects)$' -v ./modules/cabana 2&gt;&amp;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>
@@ -360,14 +410,15 @@ Docs (CLAUDE.md documentation rules, neutral names only): pact README (Features
<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.
- `grep -n "type HasFormFieldTypes interface" modules/pact/capabilities.go`, `grep -n "type FormFieldDecoded struct" modules/pact/capabilities.go` and `grep -n "func compileFieldTypes" modules/cabana/field_type.go` each print one line.
- `grep -n 'json:"notices,omitempty"' modules/cabana/relation_field.go` prints one line and `grep -n "func localizeNotices" modules/cabana/*.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" and the property "notices".
- 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, Decode notice to FieldNotice, no notices key without notices, 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>
<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 (its notices reach meta.notices, and a save without notices answers as before) and loaded on show, and the SPA renders its element with a two-way value binding.</done>
</task>
<task type="auto" tdd="true">
@@ -376,14 +427,15 @@ Docs (CLAUDE.md documentation rules, neutral names only): pact README (Features
<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.
- Notices per locale (D-10): a notice returned for en becomes a FieldNotice with field and locale "en", one for the default locale carries locale "pl"; a non-translatable type's notices carry no locale; a FormFieldError on any locale answers 422 with no notices.
- 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 and yields no notice; 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.
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. Every locale's notices are kept with that locale (FieldNotice.Locale), the default locale included. A translatable Decode's FormFieldDecoded.Value must be 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.
@@ -395,7 +447,7 @@ Upload route (D-18, RESEARCH Pattern 9): new modules/cabana/field_type_upload.go
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>
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", "Notices" (FormFieldDecoded notices, per-locale FieldNotice, localization, the 10-per-field and 500-rune caps, no key without notices), "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 &amp;&amp; go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2&gt;&amp;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>
@@ -405,23 +457,25 @@ Docs: cabana README rows for FieldWrite, FieldWritesFromContext, WithFieldWrites
<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).
- TestFieldTypeExpansionSmoke has passing subtests for: per-locale Decode with default on the host and non-default through the writer, per-locale notices with their locale, a non-default FormFieldError 422 with no write and no notices, untouched skip on update for host and translation (no notice), 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>
<done>Translatable plugin types store through the existing ML path with per-locale notices, 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>
<name>Task 4: Expansion (SPA): MLPluginField, element uploads through the SPA, plugin list columns, save notices, 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/components/form/FormGrid.vue, admin/src/components/form/FormField.vue, admin/src/views/FormView.vue, admin/src/views/ListView.vue, admin/src/components/relation/RelationChildModal.vue, admin/tests/form/PluginField.test.ts, admin/tests/form/FormField.test.ts, admin/tests/form/formState.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, adopt at 173, save at 315, the saved toast at 337), admin/src/components/form/FormGrid.vue (errors per field at 47), admin/src/components/form/FormField.vue (errors prop, error line, aria-describedby), admin/src/main.css (.summer-callout--warning near line 400), 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, admin/tests/form/FormField.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.
- Notices (D-10): a save answer with meta.notices shows each message under its field (prefixed with the upper-case locale for a translatable field) as a warning status line, and the saved toast appends the messages; the lines clear on the next save attempt; a save answer without notices shows none and the toast text is unchanged; a notice that looks like HTML renders as literal text.
- After a save whose stored value differs from what the element holds (a type stripped something), the form value equals the response's value and PluginField sets the element's value property once to it.
</behavior>
<action>Finish the SPA half (per D-06, D-13, D-14, D-18).
<action>Finish the SPA half (per D-06, D-10, 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.
@@ -429,20 +483,23 @@ Uploads (D-18): formContext.ts adds FIELD_UPLOAD_EVENT = 'summer-upload' and FIE
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.
Notices (D-10, generic for any field type): formState.ts exports noticeLines(notices) returning a field name to message list map, where a notice with a locale reads "<LOCALE>: message". FormView keeps a notices ref, clears it when a save starts, and after a successful create or update sets it from result.data.meta.notices (absent means none); it passes it through FormGrid to FormField the same way it passes errors. FormField gains an optional notices prop rendered under the control as a status line in the existing warning callout tone (summer-callout--warning, role="status", referenced by aria-describedby), with text interpolation only, never as raw HTML. The saved toast appends the joined notice messages, so "Zapisz i zamknij", which navigates away, still tells the admin. adopt(result.data) already reloads the stored values; keep PluginField's echo rule, so the element gets the stored value exactly when it differs from element.value.
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>
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), the reload of a changed stored value, and isMLField on the formState helpers; FormField.test.ts with the notice line (literal text for an HTML-looking message, aria-describedby, none without notices); formState.test.ts with noticeLines (locale prefix, no locale, empty).
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), the translatable variant and how the admin shows notices; 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 &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; 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.
- `grep -n "export function noticeLines" admin/src/components/form/formState.ts` prints one line, `grep -n "meta.notices\|meta?.notices" admin/src/views/FormView.vue` prints at least one line, and `grep -n "v-html" admin/src/components/form/FormField.vue` prints nothing.
- PluginField.test.ts passes its ML, upload (success, 422, network), stored-value reload and isMLField cases; FormField.test.ts and formState.test.ts pass their notice cases; the full vitest suite passes.
- docs/backend/field-types.md documents summer-change, summer-upload, summer-upload-result and notices.
- 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>
<done>Translatable plugin fields switch locales like mlmarkdown, elements upload through the SPA without credentials, plugin list columns render, save notices show under the field and in the toast, and the docs describe the full contract.</done>
</task>
</tasks>
@@ -466,8 +523,9 @@ Docs and dist: docs/backend/field-types.md gains the full element contract (valu
| 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-07 | Information Disclosure | plugin errors | medium | mitigate | Only pact.FormFieldError messages (422) and FormFieldNotice messages (meta.notices) reach the client; 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-33 | Tampering (admin XSS) / Denial of Service | save notices (localizeNotices, FormField notice line) | medium | mitigate | A notice can quote attacker-written markup (for example a removed tag name); the SPA renders notices with text interpolation only, never v-html (acceptance grep and an HTML-looking-message test); cabana keeps at most 10 notices per field and locale and cuts each localized message to 500 runes, so a value with thousands of removed parts cannot inflate the response |
| 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>
@@ -481,7 +539,7 @@ Docs and dist: docs/backend/field-types.md gains the full element contract (valu
<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.
- D-03 server skip, D-10 notices (meta.notices only when a Decode reported something, shown under the field and in the toast, stored value reloaded) and 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>

View File

@@ -0,0 +1,264 @@
---
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
plan: 01
subsystem: admin
tags: [pact, cabana, admin-spa, field-types, custom-elements, i18n, uploads, openapi]
requires:
- phase: 16.1-journal-admin-urgent-fixes-relation-search-list-state-post-f
provides: bluemonday-era Journal plugin and the v0.1.17 framework release this API builds on
provides:
- pact plugin field type API (HasFormFieldTypes, FormFieldType, FormFieldValue, FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldUploadResult, FormFieldError)
- cabana per-boot field type registry, compile checks, Decode/Load/ListValue runner, translatable storage through the ML path, untouched skip, meta.notices
- cabana FieldWrite, FieldWritesFromContext, WithFieldWrites, CompileFormWithTypes, FieldTypeAssets, FieldTypesResponse, FieldUploadResponse, FieldNotice, RecordMeta.Notices
- routes GET {api}/field-types and POST {api}/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload
- admin SPA global field type loader, PluginField and MLPluginField element hosts, element uploads, save notices
affects: [16.2-02, 16.2-03, 16.2-05, 16.2-06, journal, payment]
actuals:
tokens: 62800
tasks: 4
commits: 5
plan_head_before: 0754a6e5ab88b1a4ad3b6fc3599a338490220077
plan_head_after: 4adb48a76e5bd81f589385d22fc82a5c55dcb3e7
tech-stack:
added: []
patterns:
- "Plugin capability registry built per Activate (compileFieldTypes) and passed down the compile functions; no package-level registry"
- "Untouched-value skip by canonical encoding/json comparison against the served value (Load of the stored value)"
- "Global plugin stylesheets owned by GLOBAL_STYLE_OWNER, never disabled by activateStyles"
key-files:
created:
- modules/cabana/field_type.go
- modules/cabana/field_type_upload.go
- modules/cabana/field_type_smoke_test.go
- modules/cabana/example_field_type_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
modified:
- modules/pact/capabilities.go
- modules/pact/README.md
- modules/cabana/form_schema.go
- modules/cabana/schema_types.go
- modules/cabana/registry.go
- modules/cabana/contracts.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/query.go
- modules/cabana/settings.go
- modules/cabana/relation.go
- modules/cabana/relation_form.go
- modules/cabana/relation_field.go
- modules/cabana/extension.go
- modules/cabana/admin_openapi.go
- modules/cabana/security_coverage_test.go
- modules/cabana/phase10_coverage_test.go
- modules/cabana/phase10_csrf_test.go
- modules/cabana/README.md
- admin/openapi/admin.json
- admin/src/api/schema.d.ts
- admin/src/api/types.ts
- admin/src/app/pluginAssets.ts
- admin/src/main.ts
- admin/src/views/LoginView.vue
- admin/src/views/FormView.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/FormGrid.vue
- admin/src/components/form/formState.ts
- admin/src/components/form/formContext.ts
- admin/src/components/form/mlLocale.ts
- admin/src/components/form/fields/MLField.vue
- admin/src/components/relation/RelationChildModal.vue
- admin/tests/form/FormField.test.ts
- admin/tests/form/formState.test.ts
- admin/tests/list/CellValue.test.ts
- modules/boardwalk/dist
- docs/backend/forms.md
- docs/backend/lists-and-filters.md
key-decisions:
- "pact.FormFieldType.ListValue is func(ctx context.Context, value any) string (human adjust at the Task 1 checkpoint); plans 03, 05 and 06 must use the ctx form"
- "The plugin field type registry is built per Activate by compileFieldTypes and passed down; CompileForm, compileRegistry, compileList, compileRelations and compileSetting keep their signatures as nil-registry wrappers"
- "Only a pact.FormFieldError from Decode or Upload is a 422 (messages localized); every other plugin error, Load errors included, is the opaque lifecycle 500"
- "Untouched values (D-03 server half) are skipped on update when the canonical encoding/json of the submitted value equals the served value (stored host value or exact translation, then Load)"
- "FieldTypesResponse is the GET /field-types envelope {data, meta}; FieldUploadResponse is the upload data object {url}, served as Envelope[FieldUploadResponse]"
patterns-established:
- "Plugin field type: alias + element + assets + options + Go funcs; cabana owns routes, permissions, scope, locale storage, notices and the untouched skip"
- "Element contract: value property, plain attributes, summer-change, summer-upload/summer-upload-result; no credentials, --c-* only"
requirements-completed: [D-03, D-06, D-10, D-11, D-12, D-13, D-14, D-15, D-18]
coverage:
- id: D1
description: "pact field type API and cabana boot checks: alias collisions (two plugins, core type), element prefix, unknown keys, CheckOptions errors, settings/relation refusal"
requirement: D-11
verification:
- kind: unit
ref: "modules/cabana/field_type_smoke_test.go#TestFieldTypeTracerSmoke"
status: pass
human_judgment: false
- id: D2
description: "Save decodes plugin values (FormFieldError 422, opaque 500 otherwise), meta.notices only when a type reported something, Show/save serve through Load, GET /field-types and form schema keys"
requirement: D-12
verification:
- kind: unit
ref: "modules/cabana/field_type_smoke_test.go#TestFieldTypeTracerSmoke"
status: pass
- kind: unit
ref: "modules/cabana/example_field_type_test.go#ExampleCompileFormWithTypes"
status: pass
human_judgment: false
- id: D3
description: "Translatable types per locale through the ML path, per-locale notices, untouched skip on update, FieldWritesFromContext in Form hooks, ListValue list columns, upload route (201, 404, 403, CSRF, 413, 422)"
requirement: D-14
verification:
- kind: unit
ref: "modules/cabana/field_type_smoke_test.go#TestFieldTypeExpansionSmoke"
status: pass
human_judgment: false
- id: D4
description: "Admin SPA: global field type loader, PluginField/MLPluginField, element uploads, save notices under fields and in the toast, stored value reload, plugin list columns as text"
requirement: D-13
verification:
- kind: unit
ref: "admin/tests/form/PluginField.test.ts"
status: pass
- kind: unit
ref: "admin/tests/form/FormField.test.ts#save notices"
status: pass
- kind: unit
ref: "admin/tests/form/formState.test.ts#noticeLines"
status: pass
- kind: unit
ref: "admin/tests/list/CellValue.test.ts#plugin field type columns (16.2)"
status: pass
human_judgment: false
- id: D5
description: "OpenAPI, schema.d.ts, dist, READMEs and docs/backend/field-types.md describe the API with neutral names"
requirement: D-15
verification:
- kind: other
ref: "bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree"
status: pass
human_judgment: false
- id: D6
description: "Postgres-backed save, show and upload with a real record (id != 0 upload path, real transaction, real translation writer)"
verification: []
human_judgment: true
rationale: "No Docker on this host (RESEARCH Pitfall 11); plan 06 runs these on a Docker host"
duration: 34min
completed: 2026-10-10
status: complete
---
# Phase 16.2 Plan 01: Plugin field type framework Summary
**Plugins register value-bearing admin field types through pact.HasFormFieldTypes; cabana compiles, decodes (with localized meta.notices), loads, lists, uploads and stores them (translatable ones through the ML path, untouched values skipped), and the admin SPA hosts their custom elements globally with uploads and save notices.**
## Performance
- **Duration:** 34 min
- **Started:** 2026-10-10T08:53:13Z
- **Completed:** 2026-10-10T09:27:00Z
- **Tasks:** 4 (Task 1 checkpoint resolved by the orchestrator, Tasks 2-4 executed)
- **Files modified:** 51 (excluding the rebuilt dist assets)
## Task 1 checkpoint (resolved)
Baseline verify was run by the orchestrator (vet passed, 4 PASS lines, clean `git status --porcelain -- modules admin docs scripts`). Human reply, verbatim:
> adjust: ListValue(ctx context.Context, value any) string
Final identifier list used by Tasks 2-4: every pact and cabana identifier exactly as in the plan's `<interfaces>` block, except `pact.FormFieldType.ListValue` is `func(ctx context.Context, value any) string` (was `func(value any) string`). The element contract is unchanged (value property; attributes field-name, record-id, locale, ui-locale, options, disabled, invalid; events summer-change, summer-upload, summer-upload-result).
**Note for plans 03, 05 and 06:** they refer to `ListValue` and must use the ctx form; plan 03's Journal `ListValue` implementation receives `ctx`.
## Accomplishments
- pact: `FormFieldType` (Name, Element, Translatable, Scripts, Styles, Options, CheckOptions, Decode, Load, ListValue with ctx, Upload), `FormFieldValue`, `FormFieldDecoded`, `FormFieldNotice`, `FormFieldUpload`, `FormFieldUploadResult`, `FormFieldError`, `HasFormFieldTypes`.
- cabana boot: `compileFieldTypes` (alias shape, core-type and cross-plugin collisions naming both plugins, element prefix via `checkWidgetTag`, Decode required, option keys, assets through the extracted `readClientAssets`), passed to `compileRegistryWithTypes`; fields.yaml accepts a plugin alias with the general keys plus its options (`typeOptions`, `element`, `translatable` in the schema); `CheckOptions` at boot; settings and relation forms refuse plugin aliases; `CompileFormWithTypes`.
- cabana save/show: `liftPluginValues`, `decodePluginFields` inside the transaction (per locale for translatable types, untouched skip on update via `servedFieldValue`), `localizeNotices` into `RecordMeta.Notices` (`meta.notices`, max 10 per field and locale, 500 runes), `hydratePluginFields` (per locale), `FieldWritesFromContext` for Form hooks, `isMLField` routing translatable types through `liftMLValues`/`applyMLTranslations`/`hydrateMLRecord`.
- cabana list and routes: plugin aliases as column types with `ListValue` in `projectRowCtx`; `GET /field-types`; `POST .../{id}/fields/{field}/upload` (`fieldUpload`), annotated, in `phase09Routes`, CSRF inventories updated.
- SPA: `loadFieldTypes` after boot and login, `loadGlobalStyles`/`GLOBAL_STYLE_OWNER`, `PluginField` (value/attributes/summer-change/echo rule/uploads), `MLPluginField`, `rendererForField`/`isRegisteredField`/`isMLField`, `noticeLines` with the notice status line in `FormField` and the saved toast, rebuilt `modules/boardwalk/dist`.
- Docs: pact and cabana READMEs, new `docs/backend/field-types.md` (registration, aliases, options, Decode/notices/refusal, Load, notices, translatable types, untouched values, companion columns, list columns, uploads, full element contract), links from forms.md and lists-and-filters.md.
## Task Commits
1. **Task 1: Confirm the pact API** - resolved by the orchestrator (no commit)
2. **Task 2: Tracer** - `1e20ace` (feat, pact), `717d46e` (feat, cabana + docs + OpenAPI), `83fa2f7` (feat, admin SPA + dist)
3. **Task 3: Go expansion** - `8fa92db` (feat)
4. **Task 4: SPA expansion** - `4adb48a` (feat)
## Smoke test results
- `go vet ./...` clean; `TestFieldTypeTracerSmoke` (15 subtests), `TestFieldTypeExpansionSmoke` (14 subtests), `TestPhase09ContractInventory`, `TestPhase09PermissionMatrix`, `TestFormSchemaCompile`, `TestFormSchemaRejects`, `TestListRelationSearchSmoke`, `TestEnabledLocalesDefaultFirstSmoke`, `TestPhase10CSRF`, `ExampleCompileFormWithTypes` pass.
- `go test ./...`: the only failures are the testcontainers suites that need Docker (permission denied on /var/run/docker.sock), the same set as before this plan; no non-Docker failure message.
- Admin: `npm run typecheck` clean, full vitest suite 77 files / 1190 tests pass, `check-admin-openapi.sh --check`, `check-admin-dist.sh`, `TestDocsTree` and `summer docs:build --check` pass.
- Neutral names: `grep -rniE 'journal|grzyby' modules/pact/README.md modules/cabana/README.md docs/backend/field-types.md` prints nothing.
## Decisions Made
See key-decisions in the frontmatter. In short: ListValue takes ctx (human), the registry is passed down per boot with nil-registry wrappers for existing callers, only `pact.FormFieldError` is a 422, untouched detection compares canonical JSON with the served value, and the two response types are an envelope (`FieldTypesResponse`) and a data object (`FieldUploadResponse`).
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Route inventories outside the plan's file list**
- **Found during:** Task 3
- **Issue:** `TestPhase10CSRF` (counts 29 state-changing routes) and `TestPhase10Coverage/every unsafe mounted route is CSRF-walked` failed once the upload route was mounted.
- **Fix:** Added the route to both inventories (29 -> 30).
- **Files modified:** modules/cabana/phase10_csrf_test.go, modules/cabana/phase10_coverage_test.go
- **Commit:** 8fa92db
**2. [Rule 3 - Blocking] Docs Go snippets need src= references**
- **Found during:** Task 2
- **Issue:** TestDocsTree refuses a Go code block without `src=`.
- **Fix:** Added `modules/cabana/example_field_type_test.go` (`NotesPlugin.FormFieldTypes`, `ExampleCompileFormWithTypes` with output) and referenced it from docs/backend/field-types.md (synced with `summer docs:sync`).
- **Commit:** 717d46e
**3. [Rule 2 - Correctness] Strict error mapping for plugin code**
- **Found during:** Task 2
- **Issue:** `lifecycleFailure` passes a plugin-returned `ValidationError` or `ForbiddenError` through; the plan allows only `pact.FormFieldError` to reach the client.
- **Fix:** `fieldTypeFailure` maps only `*pact.FormFieldError` (messages localized) to 422; every other Decode/Upload error and every Load error is logged and becomes the opaque lifecycle 500.
- **Commit:** 717d46e
**4. Additional files touched for threading the registry and list values**
- modules/cabana/contracts.go (`Registry.fieldTypes`), modules/cabana/relation.go (`compileRelationsWithTypes`), modules/cabana/query.go (`projectRowCtx` with the request ctx for `ListValue`), docs/backend/lists-and-filters.md (plugin column types), admin/tests/list/CellValue.test.ts. `admin/src/views/ListView.vue` needed no change: `CellValue` already renders a column type it does not know as plain text.
**5. [CLAUDE.md] TDD commit shape**
- Tasks 3 and 4 are `tdd="true"`, but CLAUDE.md requires `go test ./...` green at every commit, so no failing RED test was committed. Task 3: the expansion smoke test was written first against compile stubs and failed on the planned behavior (`compile: ... unsupported column type notes`) before the implementation made it pass; test and implementation were committed together. Task 4: the SPA implementation was written before its tests in the same uncommitted change; the tests were then added and pass.
---
**Total deviations:** 3 auto-fixed (2 blocking, 1 correctness) plus 2 documented process/scope notes. **Impact:** no change to the planned API or behaviour; extra files are route inventories, a docs example and registry plumbing.
## Issues Encountered
- No Docker on this host: the Postgres-backed save, show and upload paths with a real record (the upload route with `{id}` other than 0, a real transaction and translation writer) are covered only by DryRun/unit smoke tests here; full coverage is plan 06 on a Docker host.
## Known Stubs
None.
## Next Phase Readiness
- Plans 02 (Journal editor element) and 03 (Journal Go integration) can build on this API; plan 03 must implement `ListValue(ctx, value)`.
## Self-Check: PASSED
- Created files exist: modules/cabana/field_type.go, modules/cabana/field_type_upload.go, modules/cabana/field_type_smoke_test.go, modules/cabana/example_field_type_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.
- Commits exist: 1e20ace, 717d46e, 83fa2f7, 8fa92db, 4adb48a.

View File

@@ -23,14 +23,16 @@ files_modified:
- ../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/fixtures/editor-corpus.json
- ../sm-journal-plugin/tests/blogeditor/smoke.test.ts
- ../sm-journal-plugin/tests/blogeditor/corpus.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
tokens: 118000
raw_tokens: 118000
tasks: 3
confidence: low
must_haves:
@@ -40,6 +42,7 @@ must_haves:
- "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-05 (editor half of the policy cross-check): tests/blogeditor/fixtures/editor-corpus.json holds the editor's own serialized output (embed, image with and without caption, table with header row, links, headings, bullet and ordered lists including one starting at 3, blockquote, inline marks, code, horizontal rule, hard break, and one document combining them); corpus.test.ts rebuilds every entry through editor commands and fails when the committed file differs, so plan 03 (Go policy) and plan 04 (layer mirror) test the real output; links the editor creates use only http, https, mailto or a relative URL."
- "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:
@@ -55,7 +58,14 @@ must_haves:
- path: "../sm-journal-plugin/admin.go"
provides: "embed of the editor bundle"
contains: "assets/dist/blogeditor.js"
- path: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json"
provides: "the editor's own output, checked against both sanitizer policies"
contains: "data-embed"
key_links:
- from: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json"
to: "plan 03 classes/editor_policy_test.go and plan 04 vue-journal-layer tests/fixtures/editor-corpus.json"
via: "the Go test reads it in place; the layer keeps a byte copy that the phase gate compares"
pattern: "editor-corpus.json"
- 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)"
@@ -85,6 +95,10 @@ ROADMAP Goal (not in user-story format): plugins can register their own admin fo
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.
## Revision 2026-10-10 (16.1 drift, CONTEXT D-05 and D-10 amended)
The server no longer refuses unsafe markup. 16.1 replaced the regex gate with a bluemonday policy (`classes.SanitizeHTML`), and under amended D-10 every admin save stores the sanitized value and reports what was removed. Anything the editor writes that the policy does not allow would therefore vanish silently on save. Amended D-05 requires the editor's own output to pass both the Go policy and the layer's sanitize-html mirror byte for byte. This plan now (1) records that output as a committed corpus that plan 03 and plan 04 test against, and (2) limits the links the editor creates to the URL schemes both policies allow. The element still never sanitizes. Where RESEARCH or PATTERNS describe CheckHTML refusing, this plan's text wins.
## 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).
@@ -96,6 +110,8 @@ Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to
- **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`.
- **Links the editor creates (D-05, D-10 amended):** only `http`, `https`, `mailto` and relative URLs, the schemes both sanitizer policies keep; no `target`, `rel` or `class` is added. A stored link with another scheme, or with `target`/`rel`, is preserved in the editor but stripped by the server if the post is saved after an edit.
- **Policy corpus (D-05):** the editor's own output is committed as `tests/blogeditor/fixtures/editor-corpus.json`, rebuilt and compared by `corpus.test.ts` (set `UPDATE_CORPUS=1` to rewrite it). The imported-post fixture is deliberately not in it: it holds legacy markup (the style block, `target`, `rel`) that the server strips on an edited save.
## Artifacts this phase produces (this plan)
@@ -107,7 +123,8 @@ Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to
- `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`.
- Test files: `tests/blogeditor/fixtures/imported-post.html`, `tests/blogeditor/smoke.test.ts`, `tests/blogeditor/corpus.test.ts`.
- Corpus: `tests/blogeditor/fixtures/editor-corpus.json` (array of `{name, html}`), `isPolicyUrl(url)` in `extensions.ts` (the link URL rule).
<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.
@@ -154,7 +171,7 @@ set value(v: string) {
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.
Policy the server applies on save (plan 03, sm-journal-plugin classes/format_html.go `SanitizeHTML`, the bluemonday UGC policy plus the Journal extensions in `newHTMLPolicy`): it strips what it does not allow and never refuses (D-10 amended). Stripped are, among others, `script`, `style`, `iframe`, `object`, `embed`, `form`, `svg`, comments, every `on*` attribute, `target` and `rel` on links, `alt` values outside UGC's Paragraph pattern, `start` on `ol`, `data-*` on `figure`, and URLs other than http, https, mailto and relative. Plan 03 extends the policy for exactly this element's own output: `figure` `data-embed` (`youtube`) and `data-id` (11 characters `[A-Za-z0-9_-]`), `img` `alt` with any text, and `ol` `start` (integer); plan 04 mirrors the same four in the layer. The save response then names what was removed and the SPA sets the stored value back into the element through the value property (plan 01). The element does not try to rewrite content.
</interfaces>
</context>
@@ -189,7 +206,7 @@ Setup (D-16): in ../sm-journal-plugin add .gitignore with node_modules/; a priva
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).
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). It deliberately keeps legacy markup that the server policy strips (the style block, target, rel): the element must return it byte for byte; stripping is the server's job on an edited save (D-10). The live post 64 on UAT no longer has the style block in content_html (re-saved in 16.1); the local export still does, which is why the fixture comes from the export.
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.
@@ -211,19 +228,20 @@ Build and embed: npm run build, commit assets/dist/blogeditor.js and blogeditor.
<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>
<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/tests/blogeditor/corpus.test.ts, ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json, ../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), the installed OrderedList source (../sm-journal-plugin/node_modules/@tiptap/extension-list/src in TipTap 3, or the package StarterKit imports it from: the start attribute and its input rule), ../sm-journal-plugin/classes/format_html.go (newHTMLPolicy, SanitizeHTML: what the server keeps), ../vue-journal-layer/app/utils/htmlGate.ts (allowedUrl: the URL rule to mirror), ../sm-journal-plugin/README.md, .planning/notes/journal-html-sanitizing-bluemonday.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; its CheckHTML text predates the bluemonday switch)</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.
- Links: isAllowedUri, the link input and isPolicyUrl accept only http, https, mailto and relative URLs; javascript:, vbscript:, data:, tel: and a scheme hidden by a tab or newline are refused; a created link has href only.
- Corpus (D-05): corpus.test.ts builds each entry through editor commands (embed from a youtube.com URL; image without caption whose alt contains a colon, a question mark and double quotes; image with caption; 3x3 table with header row; links http, https, mailto and relative; headings 2 to 4; bullet list; ordered list; ordered list starting at 3; blockquote; bold, italic, strike, underline and inline code; code block; horizontal rule; hard break; one document combining all of them) and compares each serialized value with editor-corpus.json; with UPDATE_CORPUS=1 it rewrites the file instead.
</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.
extensions.ts exports preservationExtensions(): StarterKit as configured in Task 2, with Link's isAllowedUri replaced by isPolicyUrl (also exported): the URL is accepted when, after removing ASCII control characters, spaces and DEL, it has no scheme (relative, including # and ?) or its scheme is http, https or mailto (the rule both sanitizer policies apply; see allowedUrl in the layer's htmlGate.ts); 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.
@@ -231,19 +249,22 @@ embed.ts exports EMBED_PROVIDERS (one entry: youtube, id pattern ^[A-Za-z0-9_-]{
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>
Corpus (D-05): new tests/blogeditor/corpus.test.ts builds the entries listed in the behavior block on happy-dom through editor commands only (insertContent of HTML is not used, so each entry is what the toolbar and input rules produce), serializes each with serializeDocument, and compares the array of {name, html} with tests/blogeditor/fixtures/editor-corpus.json; with UPDATE_CORPUS=1 it writes the file (two-space JSON, trailing newline). Generate and commit the file. The corpus must hold no iframe, no on* attribute, no target or rel, no style attribute and no URL outside isPolicyUrl; the test asserts that too. Plans 03 and 04 consume this file, so its entry names stay stable once committed.
Tests: extend tests/blogeditor/smoke.test.ts with one case per remaining 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, the link URL rule, the policy corpus (UPDATE_CORPUS=1 to regenerate; plans 03 and 04 check it against the Go policy and the layer) and the upload flow. Commit as feat(editor) with the dist, the corpus and README, staged by explicit path.</action>
<verify>
<automated>../sm-journal-plugin/node_modules/.bin/tsc --noEmit -p ../sm-journal-plugin &amp;&amp; ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin &amp;&amp; bash ../sm-journal-plugin/scripts/check-dist.sh &amp;&amp; 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>
<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 and the URL rule, embed parsing and round trip, upload flow, or the corpus differing from the editor's output), 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.
- `grep -n "export function serializeDocument" ../sm-journal-plugin/assets/src/blogeditor/serializer.ts`, `grep -n "export function parseEmbedUrl" ../sm-journal-plugin/assets/src/blogeditor/embed.ts` and `grep -n "export function isPolicyUrl" ../sm-journal-plugin/assets/src/blogeditor/extensions.ts` each print one line.
- ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json parses as JSON, holds at least 15 entries including one whose html contains `data-embed="youtube"`, one with `<figcaption>`, one with `<table>` and one with `start="3"`, and `grep -cE '<iframe|target=|rel=| on[a-z]+=|style=' ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` prints 0.
- 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>
<done>Edited documents keep unknown markup and unedited blocks, the toolbar covers text, links, tables, images, embeds and raw HTML, images upload through the SPA contract, and the editor's own output is committed as the policy corpus that plans 03 and 04 test.</done>
</task>
</tasks>
@@ -262,8 +283,8 @@ Tests: extend tests/blogeditor/smoke.test.ts with one case per behavior bullet (
| 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-10 | Tampering (stored XSS) | Source tab and pasted HTML | high | mitigate | The element does not try to sanitize; every saved value goes through classes.SanitizeHTML in the plan 03 Decode on every locale (D-10 amended: strip, never refuse), the save names what was removed and the SPA puts the stored value back into the element; the layer sanitizes again before v-html |
| T-16.2-11 | Tampering | link mark | medium | mitigate | isAllowedUri is replaced by isPolicyUrl (http, https, mailto, relative only, scheme read after removing control characters and spaces), the same rule both sanitizer policies apply; tests cover javascript:, data:, tel: and a tab-split scheme |
| 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 |
@@ -276,7 +297,7 @@ Tests: extend tests/blogeditor/smoke.test.ts with one case per behavior bullet (
</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.
- D-01, D-03 (client half), D-04, D-05 (editor half, including the committed policy corpus), 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>

View File

@@ -0,0 +1,298 @@
---
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
plan: 02
subsystem: journal-admin-editor
tags: [sm-journal-plugin, tiptap, prosemirror, custom-elements, vite, vitest, blog-editor, embeds, uploads]
requires:
- phase: 16.2-01
provides: the field type element contract (value property, attributes, summer-change, summer-upload, summer-upload-result) and the SPA host that loads plugin field type scripts as module scripts
provides:
- custom element golem15-journal-blogeditor (BlogEditorElement), committed bundle assets/dist/blogeditor.js and blogeditor.css embedded through admin.go
- preservationExtensions, isPolicyUrl, RawHtml, Embed, Figure, FigureImage, Figcaption, Span, HtmlAttributes (extensions.ts)
- serializeDocument and captureBlocks (serializer.ts), parseEmbedUrl and EMBED_PROVIDERS (embed.ts), labelsFor (labels.ts), buildToolbar (toolbar.ts)
- tests/blogeditor/fixtures/editor-corpus.json, the editor's own output for the plan 03 Go policy test and the plan 04 layer copy
- sm-journal-plugin JS build (package.json, package-lock.json, vite.config.ts, tsconfig.json) and the scripts/check-dist.sh drift gate
affects: [16.2-03, 16.2-04, 16.2-05, 16.2-06, journal]
actuals:
tokens: 29500
tasks: 2
commits: 3
plan_head_before: c60abc6d6220d5f9f15659d1cc5ec7d8dd1f5020
plan_head_after: 086e485
commits_repo: ../sm-journal-plugin
tech-stack:
added:
- "@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"
- "vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3 (dev, same pins as the admin SPA)"
patterns:
- "Untouched value: the element returns the exact served string until a user-originated docChanged transaction or a Source tab input"
- "Top-level block reuse: the original HTML is split by source position, each piece parsed alone, and runs of nodes still Node.eq to the captured ones are emitted as their original bytes (LCS alignment after prefix/suffix trimming)"
- "Stored HTML never reaches the live admin DOM: raw blocks are text cards, serializer and raw renderHTML work in an inert document, on* attributes are never captured"
- "Committed, minified plugin bundle with a temp-dir rebuild and diff -r drift gate"
key-files:
created:
- ../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/fixtures/editor-corpus.json
- ../sm-journal-plugin/tests/blogeditor/helpers.ts
- ../sm-journal-plugin/tests/blogeditor/smoke.test.ts
- ../sm-journal-plugin/tests/blogeditor/corpus.test.ts
modified:
- ../sm-journal-plugin/admin.go
- ../sm-journal-plugin/plugin_test.go
- ../sm-journal-plugin/README.md
key-decisions:
- "TipTap 3.31.4 pinned exactly for all five editor packages after the human package check (reply: packages approved)"
- "The bundle is built through Vite's application pipeline with a single ES entry, fixed names and preserved exports, not build.lib: library mode never minifies whitespace in ES output (587 KB vs 466 KB)"
- "Images are inline (Image inline: true), so an uncaptioned image is <p><img src alt></p>; a captioned one is <figure><img><figcaption></figure> through a block figureImage node"
- "Link parsing, rendering and commands all use isPolicyUrl: a stored link with another scheme (tel:, ftp:) keeps its text but loses the link when its block is edited, the same result the server policy gives on an edited save"
- "Raw HTML cards cover the plan's tags plus article, main, address, dl, iframe, video, audio, picture, object, embed, form, svg, math, script and noscript, so more stored markup survives an edit; the server still strips what its policy does not allow"
- "Event handler attributes (on*) are never captured by the global attribute map, so they never reach the live admin DOM"
- "External values (value setter, Source tab re-parse) are loaded outside the undo history"
patterns-established:
- "Plugin-owned JS build: package.json with exact pins, committed lockfile, committed dist, scripts/check-dist.sh"
- "Editor corpus: entries built only through editor commands and typing, committed as JSON, rewritten with UPDATE_CORPUS=1"
requirements-completed: [D-01, D-03, D-04, D-05, D-13, D-16, D-18]
coverage:
- id: D1
description: "Element defined once; untouched imported post returns byte for byte; no summer-change on set or echo; one summer-change per user edit; Source tab shows and edits the exact string; disabled and invalid; value kept across detach"
requirement: D-03
verification:
- kind: unit
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#blog editor element (tracer)"
status: pass
human_judgment: false
- id: D2
description: "Raw HTML cards (text only, no live img/script), unmodelled attributes and spans kept on edited blocks, block reuse leaves every other top-level block byte-identical (and undo restores the exact fixture), tables with thead and bare cell text, links keep target/rel and gain none, no trailing paragraph"
requirement: D-04
verification:
- kind: unit
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#preservation schema"
status: pass
human_judgment: false
- id: D3
description: "parseEmbedUrl accepts watch?v=, youtu.be/, shorts/, embed/ and refuses everything else; the embed node renders exactly the placeholder, shows a card and never an iframe; a malformed placeholder stays raw HTML; toolbar URL input"
requirement: D-05
verification:
- kind: unit
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#embeds"
status: pass
human_judgment: false
- id: D4
description: "Picker and drop send summer-upload {id, file}; summer-upload-result {id, url} opens the alt/caption form and inserts img or figure; {id, error} and the 60 s timeout show the error and insert nothing; non-matching ids are ignored"
requirement: D-18
verification:
- kind: unit
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#image uploads"
status: pass
human_judgment: false
- id: D5
description: "isPolicyUrl, setLink and the link input accept only http, https, mailto and relative URLs; javascript:, vbscript:, data:, tel: and tab/newline-split schemes are refused; created links have href only"
requirement: D-05
verification:
- kind: unit
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#links and the URL rule"
status: pass
human_judgment: false
- id: D6
description: "Toolbar offers every D-04 feature, en and pl labels, formatting, undo/redo and table editing, raw HTML block opens Source at the insert, raw card opens its source"
requirement: D-13
verification:
- kind: unit
ref: "../sm-journal-plugin/tests/blogeditor/smoke.test.ts#toolbar"
status: pass
human_judgment: false
- id: D7
description: "editor-corpus.json (15 entries) equals a fresh build through editor commands and holds no iframe, handler, target, rel or style and only policy URLs"
requirement: D-05
verification:
- kind: unit
ref: "../sm-journal-plugin/tests/blogeditor/corpus.test.ts"
status: pass
human_judgment: false
- id: D8
description: "Committed bundle matches a fresh build, is embedded, has no eval/new Function; go vet clean"
requirement: D-16
verification:
- kind: other
ref: "bash ../sm-journal-plugin/scripts/check-dist.sh && go -C ../sm-journal-plugin vet ./..."
status: pass
human_judgment: false
- id: D9
description: "The element in the real admin SPA: typing, selection, toolbar, drag and drop, uploads through the cabana route, CSS in light and dark mode"
verification: []
human_judgment: true
rationale: "happy-dom covers the logic, not browser selection or layout; plan 03 registers the field type and plan 05/06 run the UAT browser check"
duration: 25min
completed: 2026-10-10
status: complete
---
# Phase 16.2 Plan 02: Blog Editor element Summary
**`golem15-journal-blogeditor`, a TipTap 3.31.4 WYSIWYG editor with an exact-HTML Source tab, returns an untouched imported post byte for byte and, after an edit, keeps every unedited top-level block's original bytes; it ships raw HTML cards, figures, YouTube embed placeholders, policy-only links, SPA-mediated image uploads, a committed policy corpus and a minified bundle embedded in sm-journal-plugin with a drift gate.**
## Performance
- **Duration:** about 25 min
- **Started:** 2026-10-10T09:31:47Z
- **Completed:** 2026-10-10T09:56Z
- **Tasks:** 3 (Task 1 resolved by the orchestrator, Tasks 2 and 3 executed)
- **Files:** 24 in sm-journal-plugin (21 created, 3 modified)
## Task 1 checkpoint (resolved): package report
The orchestrator ran the plan's registry verify command for the five editor packages at 3.31.4. Result: `registry-ok`.
| Package | Version | License | Repository | Install scripts |
|---------|---------|---------|------------|-----------------|
| @tiptap/core | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
| @tiptap/pm | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
| @tiptap/starter-kit | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
| @tiptap/extension-table | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
| @tiptap/extension-image | 3.31.4 | MIT | https://github.com/ueberdosis/tiptap | none |
Maintainers are on the tiptap.dev and ueber.io domains plus tiptap-bot; 3.31.4 is the current `latest` dist-tag.
Human reply, verbatim:
> packages approved
Version Task 2 installed: **3.31.4**, pinned exactly (`npm install --save-exact`) for all five packages. Installed devDependencies: vite 7.3.5, vitest 3.2.7, happy-dom 20.11.6, typescript 5.9.3. npm 12 blocked esbuild 0.27.7's `postinstall` (not in `allowScripts`); esbuild works without it through its optional platform package `@esbuild/linux-x64`, so no script was approved.
## Accomplishments
- **Build (D-16):** private ESM `package.json` with exact pins and a committed `package-lock.json`; `vite.config.ts` builds `assets/src/blogeditor/index.ts` into `assets/dist/blogeditor.js` and `blogeditor.css` (fixed names, minified, exports kept) and configures vitest on happy-dom; `scripts/check-dist.sh` rebuilds into a temp dir and fails with `check-dist: assets/dist is stale; run npm run build and commit assets/dist`; `node_modules/` ignored; `admin.go` embeds both files.
- **Element (D-01, D-03, D-13):** light-DOM `BlogEditorElement` with the value/dirty logic of the interfaces skeleton, `emitUpdate: false` loads outside the undo history, `summer-change` on user edits only, an exact-string Source tab, `disabled`, `invalid`, `locale` (as `lang`) and `ui-locale` (labels); `injectCSS: false` with the ProseMirror base rules in `blogeditor.css`, every selector under the tag and only `--c-*` colours.
- **Preservation (D-03, D-04):** `rawHtml` text cards with an "Edit as HTML" action, `figure`/`figureImage`/`figcaption`, `embed`, `span` mark, the `htmlAttributes` global attribute map, tables without colgroup or min-width, cells without TipTap's align rewrite, StarterKit without the trailing node and with links that gain nothing.
- **Serializer:** `captureBlocks` splits the source by position, validates that the pieces parse to the same DOM as the whole, and parses each alone; `serializeDocument` aligns the captured blocks with the document (prefix, suffix, LCS) and emits original bytes for unchanged runs, raw HTML verbatim and the rest through `DOMSerializer` in an inert document, with tables in thead/bare-cell form. `editor.getHTML()` is never the value.
- **Toolbar, embeds, links, uploads (D-04, D-05, D-18):** `buildToolbar` with every listed control; `parseEmbedUrl` and `EMBED_PROVIDERS` (YouTube only); `isPolicyUrl` as Link's `isAllowedUri` and in the link input; uploads by picker or drop as `summer-upload`, insert on `summer-upload-result` with a required alt and optional caption, error and 60 s timeout in an `aria-live` status line.
- **Corpus (D-05):** `editor-corpus.json` with 15 entries (`embed-youtube`, `image-without-caption`, `image-with-caption`, `table-header-row`, `links`, `headings`, `bullet-list`, `ordered-list`, `blockquote`, `ordered-list-start-3`, `inline-marks`, `code-block`, `horizontal-rule`, `hard-break`, `combined`), built only through editor commands and typing.
- **README:** a "Blog Editor" section (element, contract, untouched values, embeds, link rule, upload flow, JS build, corpus) plus Dependencies and Testing lines.
## Task Commits
All code commits are in `../sm-journal-plugin` (branch main):
1. **Task 1: package check** - resolved by the orchestrator (no commit)
2. **Task 2: tracer** - `234e616` build(editor): blog editor JS build and drift gate; `47f3032` feat(editor): blog editor element with exact untouched value
3. **Task 3: expansion** - `086e485` feat(editor): preservation schema, block reuse, toolbar, uploads and embeds
## Bundle
- `assets/dist/blogeditor.js`: 466,051 bytes minified (147,281 bytes gzip -9)
- `assets/dist/blogeditor.css`: 7,480 bytes
- `grep -cE "eval\(|new Function\(" assets/dist/blogeditor.js` prints 0; the only `createElement("style")` is TipTap's unused `createStyleTag` (injectCSS is false).
## Verification
- Task 2 verify (tracer gate re-run before expansion): smoke tests, `check-dist.sh`, `go vet ./...` and `go test ./classes/ ./controllers/...` passed.
- Task 3 verify: `tsc --noEmit`, the full vitest run (2 files, 33 tests), `check-dist.sh` and `go vet ./...` pass.
- Acceptance greps: the tag is in the bundle, `admin.go` has one embed line for the bundle, `git check-ignore node_modules` prints `node_modules`, `serializeDocument`/`parseEmbedUrl`/`isPolicyUrl` are exported, the corpus has 15 entries with the embed, figcaption, table and `start="3"` and 0 matches of `<iframe|target=|rel=| on[a-z]+=|style=`, `data-embed` is in the bundle, and all three event names are in it.
## TDD (Task 3, tdd="true")
- **RED:** the expanded `smoke.test.ts` and `corpus.test.ts` were written and run against the Task 2 element before the schema, serializer, toolbar and element wiring. Target test `preservation schema > leaves every other top-level block byte-identical after editing one paragraph` failed on its assertion (footer, style block, table and figure flattened, link attributes reordered). `msd_run check tdd-red-evidence` returned `RED_EVIDENCE_OK` (`target_test_failed`). vitest's `tap-flat` reporter prints no `# tests/# pass/# fail` lines, so those three lines were counted from its non-skipped `ok`/`not ok` lines and appended to the record.
- **GREEN:** the implementation made all 33 cases pass.
- **Commit shape:** the plan prescribes one `feat(editor)` commit for Task 3, so no separate `test(...)` RED commit exists. `embed.ts`, `labels.ts` and `extensions.ts` were drafted just before the tests, so the `parseEmbedUrl` and `isPolicyUrl` cases were already green at RED time; every behavior that needed the schema, serializer, toolbar or upload flow was red.
## Decisions Made
See key-decisions in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Fixture source not readable; synthetic fixture with the same structure**
- **Found during:** Task 2
- **Issue:** The environment's permission classifier refused every read of `/media/nvme/dev/golem15/grzybyfunkcjonalne.pl/journal-export/ekstrakt-z-turkey-tail.json` (PII data handling), including `ls`.
- **Fix:** `imported-post.html` is a synthetic post with the structure the plan requires: rendered-markdown blocks (h2, h3, p with em/strong, ul, ol, blockquote, links), the author footer in the shape the repository already uses in `classes/format_html_test.go` (`itemscope`, `itemtype`, `itemprop`, `div` and `span` with `style`, neutral name and `cdn.example.test` photo), a `style` block, a table with thead, a figure with img and figcaption, a link with `target` and `rel`, a paragraph with `&quot;` entities, plus `&amp;`, `&nbsp;`, `&ndash;` and `&times;`. No real author or post text is copied. A human may swap in the real export later; the tests do not depend on its wording.
- **Commit:** 47f3032
**2. [Rule 3 - Blocking] Library mode does not minify ES output**
- **Found during:** Task 2
- **Issue:** Vite 7 `build.lib` with `formats: ['es']` forces `minifyWhitespace: false` (587 KB); the acceptance assumes a minified bundle.
- **Fix:** the same single ES entry through `rollupOptions.input` with `preserveEntrySignatures: 'exports-only'`, `entryFileNames: 'blogeditor.js'`, `assetFileNames: 'blogeditor[extname]'`, `modulePreload: false` (384 KB at Task 2, 466 KB at Task 3). The admin loads it as `type="module"`, as before.
- **Files modified:** vite.config.ts
- **Commit:** 234e616
**3. [Rule 2 - Security] on* attributes never captured; uploaded URLs checked**
- **Found during:** Task 3
- **Issue:** a global attribute map that captures every unmodelled attribute would render `onerror`/`onload` from stored HTML into the live admin DOM; an upload answer could carry a URL outside the policy.
- **Fix:** `captureAttributes` and `renderCaptured` skip `on*` names (test asserts the editor's `img` has no `on*` attribute); a `summer-upload-result` URL that fails `isPolicyUrl` is shown as an upload error. The serializer and the raw HTML `renderHTML` build DOM only in an inert document.
- **Commit:** 086e485
**4. [Rule 1 - Bug] Replaced values in the undo history; edited value lost on detach**
- **Found during:** Task 3
- **Issue:** TipTap's `setContent` records history, so undo after an external value brought back the replaced document; after `disconnectedCallback` destroyed the editor, a dirty value fell back to the served string.
- **Fix:** loads run as `chain().setMeta('addToHistory', false).setContent(...)`; on disconnect a dirty WYSIWYG value is kept as the element's value. Both have tests.
- **Commit:** 086e485
**5. Files outside the plan's lists**
- `serializer.ts` was created in Task 2 (minimal, `DOMSerializer` only) so the tracer never used `getHTML()`; Task 3 replaced it as planned.
- `tests/blogeditor/helpers.ts` holds the shared mount/typing helpers of both test files.
- `plugin_test.go` (`TestJournalSVGEmbed`) asserts that `AdminFS` holds both bundle files and that the script defines the tag. The root package's `TestMain` starts testcontainers, so this runs only on a Docker host (plan 06); it compiles and `go vet` is clean here.
**6. Scope notes on plan wording**
- `RAW_HTML_TAGS` covers more tags than the plan lists (see key-decisions).
- Stored links with a scheme outside the policy keep their text but not the link once their block is edited (the plan said "preserved in the editor"); unedited blocks keep their bytes. This matches what the server does on an edited save.
- In edited blocks, link attributes come out as `target`, `rel`, `href` (TipTap merges the null option defaults first); values are unchanged and unedited blocks keep their bytes.
- Table cells drop TipTap's `align` attribute so a stored `style="text-align: ..."` or `align` is kept verbatim through the attribute map instead of being rewritten.
- In the corpus, the `blockquote` builder sits between the two ordered-list builders so the `combined` document keeps two lists (TipTap joins adjacent lists).
---
**Total deviations:** 4 auto-fixed (2 blocking, 1 security, 1 bug) plus file and scope notes. **Impact:** no change to the element contract, the embed markup or the corpus shape that plans 03 and 04 consume.
## Issues Encountered
- No Docker on this host: the root package's Go tests (including the new AdminFS assertion) need testcontainers and run in plan 06.
- happy-dom covers the element logic, but not browser selection, IME, real drag and drop or layout; the browser check belongs to the UAT step.
## Known Stubs
None. The `field-name`, `record-id` and `options` attributes are accepted and unused, because this type has no options and does not need the record id; that is the contract, not a stub.
## Next Phase Readiness
- Plan 03 registers `blogeditor`/`mlblogeditor` with `Element: "golem15-journal-blogeditor"`, `Scripts: ["assets/dist/blogeditor.js"]` and `Styles: ["assets/dist/blogeditor.css"]` (already in `adminFS`), and reads `tests/blogeditor/fixtures/editor-corpus.json` in place for the Go policy test. The policy must allow `figure` `data-embed`/`data-id`, any `img` `alt` (the corpus has one with `:`, `?` and `"`), and `ol` `start`.
- Plan 04 copies `editor-corpus.json` byte for byte into the layer and mirrors the embed provider table (one YouTube entry, id `^[A-Za-z0-9_-]{11}$`).
## Broken-windows ledger
- deviation: `tests/blogeditor/fixtures/imported-post.html` (sm-journal-plugin) is synthetic.
- unrun-verify: the new `TestJournalSVGEmbed` assertion in sm-journal-plugin `plugin_test.go` needs Docker (plan 06).
## Self-Check: PASSED
- Created files exist in ../sm-journal-plugin: assets/src/blogeditor/{index,element,extensions,serializer,toolbar,embed,labels}.ts, blogeditor.css, assets/dist/blogeditor.js and .css, tests/blogeditor/{helpers,smoke.test,corpus.test}.ts, fixtures/imported-post.html and editor-corpus.json, scripts/check-dist.sh, package.json, package-lock.json, vite.config.ts, tsconfig.json, .gitignore.
- Commits exist in ../sm-journal-plugin: 234e616, 47f3032, 086e485.

View File

@@ -4,12 +4,16 @@ plan: 03
type: execute
wave: 2
depends_on: ["16.2-01", "16.2-02"]
coupling_justified: ["16.2-04: both plans add the same four editor-output rules (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt any text, ol start integer), each in its own repository; the set is fixed in both plans' text and checked against the same plan 02 corpus, neither plan reads the other's files, and either one stops and reports if the corpus needs a fifth rule"]
files_modified:
- ../sm-journal-plugin/classes/format_html.go
- ../sm-journal-plugin/classes/editor_policy_test.go
- ../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/categories.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
@@ -18,29 +22,37 @@ files_modified:
- ../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/journal_api_categories_test.go
- ../sm-journal-plugin/README.md
- .planning/notes/journal-html-sanitizing-bluemonday.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]
requirements: [D-02, D-03, D-04, 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
tokens: 110000
raw_tokens: 110000
tasks: 3
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-10 (amended 2026-10-10) and D-04: Decode accepts a string (nil decodes to the empty string) and stores classes.SanitizeHTMLReport's output for every locale cabana hands it, post and category content alike, the raw HTML block and source-tab edits included: the exact submitted bytes and no notice when the policy keeps everything, otherwise the stripped HTML plus pact.FormFieldNotice values naming the removed elements, attributes and comments (lang keys with en and pl plural forms); a save never fails because of unsafe markup; any other value type is a FormFieldError (422 on content)."
- "D-02: an admin save that writes content stores the same sanitized HTML in content and 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-19 (amended): category content uses blogeditor (not translatable, matching the Go model and the frozen PHP tree) and is stripped and reported like post content; the 422 CheckHTML gate in the Categories controller's FormBeforeCreate/FormBeforeUpdate is retired (blank content still becomes NULL) and TestCategoriesAdminContent asserts the strip; no public category write path exists (routes.go mounts GET /_journal/api/v1/categories only), so no public contract changes."
- "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."
- "D-05 (amended): htmlPolicy is extended for exactly the Blog Editor's own output, and nothing else: figure data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$, img alt with any text, ol start matching ^[0-9]+$; every entry of plan 02's editor-corpus.json passes SanitizeHTML byte for byte with an empty removal report (TestEditorOutputPassesPolicy), while iframe, other data-* attributes, other providers and malformed ids are still stripped."
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/format_html.go"
provides: "SanitizeHTMLReport, HTMLRemovals and the editor-output policy extension"
contains: "func SanitizeHTMLReport"
- path: "../sm-journal-plugin/classes/editor_policy_test.go"
provides: "the Go half of the editor-output policy cross-check"
contains: "TestEditorOutputPassesPolicy"
- path: "../sm-journal-plugin/classes/media_store.go"
provides: "StoreMedia shared by the public upload route and the editor Upload"
contains: "func StoreMedia"
@@ -51,6 +63,14 @@ must_haves:
provides: "post content on the Blog Editor"
contains: "mlblogeditor"
key_links:
- from: "../sm-journal-plugin/classes/blog_editor.go decodeBlogHTML"
to: "../sm-journal-plugin/classes/format_html.go SanitizeHTMLReport"
via: "FormFieldDecoded{Value: sanitized, Notices: removalNotices(report)} per locale"
pattern: "SanitizeHTMLReport"
- from: "../sm-journal-plugin/classes/editor_policy_test.go"
to: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (plan 02)"
via: "every corpus entry must come back from SanitizeHTML unchanged"
pattern: "editor-corpus.json"
- from: "../sm-journal-plugin/controllers/posts.go preparePostWrite"
to: "cabana.FieldWritesFromContext"
via: "content_html := the written default-locale content"
@@ -64,7 +84,10 @@ must_haves:
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."
- statement: "htmlPolicy is never relaxed for iframe, object, embed, script, style, event attributes, target, rel, other data-* attributes or URL schemes; its only 16.2 additions are the four editor-output attributes, and the layer mirror (plan 04) adds the same four."
status: unresolved
verification: judgment
- statement: "The admin path never stores submitted bytes the policy would change: every written content value is SanitizeHTMLReport output, and no admin save fails because of unsafe markup."
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."
@@ -79,7 +102,17 @@ must_haves:
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.
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 sanitized HTML in both columns without goldmark and reports what it stripped; the shared policy admits the editor's own output; editor uploads reuse the existing media storage.
## Revision 2026-10-10 (16.1 drift, CONTEXT D-02, D-04, D-05, D-10 and D-19 amended)
16.1 replaced the regex gate with bluemonday v1.0.27 (sm-journal-plugin a3551f2, 0d00ec8, 5f47083): `rejectUnsafe` is gone; `FormatHTML` renders with goldmark and then strips through `SanitizeHTML`; `FormatTranslation` sanitizes per-locale `content_html` on API and import writes; `CheckHTML` refuses whenever the policy would change the HTML. Under the amended D-10, every admin write of post and category content stores the policy's output, never fails because of unsafe markup, and reports what was removed. This plan therefore:
- decodes with `classes.SanitizeHTMLReport` (new) instead of `CheckHTML`, and returns the removals as plan 01's `pact.FormFieldNotice` values;
- retires the Categories controller's 422 gate (`TestCategoriesAdminContent` now asserts the strip);
- extends the policy for exactly the editor's own output (amended D-05) and pins it with plan 02's corpus (new Task 3).
Where RESEARCH (Pattern 6, Pitfall 10, Code Example 2, Security Domain) or PATTERNS describe CheckHTML refusing or "no sanitizer", this plan's text wins. The pre-phase sm-journal-plugin head is now c60abc6 (requires summercms v0.1.17).
## Spec-less probe fallback
@@ -91,23 +124,30 @@ Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to
- **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.
- **The posts import action** (`runImportPosts`, `console/import_posts.go`) keeps calling `FormatHTML` (goldmark, then `SanitizeHTML`) and `FormatTranslation` on PHP exports; it is not a form save and reports nothing.
- **Public API category writes (API parity check, done at planning time):** `routes.go` mounts only `GET /_journal/api/v1/categories`; there is no public category write path, so retiring the admin 422 changes no public contract. The public posts routes keep `FormatHTML` and `FormatTranslation` (D-09); their responses do not change shape.
- **`classes.CheckHTML` stays exported** (sm-journal-plugin is a tagged module; removing an exported function would be a breaking change for no gain). After this plan no admin or API write path calls it; it remains the predicate counterpart of the layer's `isSafeJournalHtml` and is used by the policy cross-check test. Its doc comment and README row say exactly that.
- **Removal report:** `SanitizeHTMLReport` aligns the start tags of the input with those of bluemonday's output in order (bluemonday only removes, never adds or reorders). An unmatched input tag is a removed element; a raw attribute name missing from, or with a changed decoded value in, the matched output tag is a removed attribute (a `style` that lost a property counts under `style`); comments count separately; anything else that changed sets `Other`. Its report is empty exactly when the returned string equals the input. Names are distinct, first-seen order, at most 10 per list (counts stay exact).
- **Policy extension (amended D-05), fixed set shared with plan 04's layer mirror:** `figure` `data-embed` `^youtube$` and `data-id` `^[A-Za-z0-9_-]{11}$` (the embed placeholder); `img` `alt` with any value (the editor's alt field takes free text; UGC's Paragraph pattern drops `:`, `?`, `"` and more; an attribute value is escaped on output and carries no URL); `ol` `start` `^[0-9]+$` (TipTap's ordered-list input rule writes it when a list does not start at 1). If plan 02's corpus shows any other attribute the policy strips, stop and report: plans 03 and 04 must add it together, and only if it is not a URL, style, event or iframe attribute.
- **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.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`, `removalNotices`.
- `classes.SanitizeHTMLReport(doc string) (string, classes.HTMLRemovals)`, `classes.HTMLRemovals{Elements, ElementCount, Attributes, AttributeCount, Comments, Other}` with `Empty() bool`; `SanitizeHTML` becomes the string half of it; `CheckHTML` kept (doc comment updated); `newHTMLPolicy` gains the four editor-output attributes.
- `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).
- Lang keys `golem15.journal::lang.editor.content_invalid`, `golem15.journal::lang.editor.image_invalid`, `golem15.journal::lang.editor.upload_failed`, and the plural notices `golem15.journal::lang.editor.removed_elements`, `removed_attributes`, `removed_comments` and the plain `markup_changed` (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`.
- Smoke test `TestBlogEditorTracerSmoke`, `TestBlogEditorUploadSmoke`; moved tests in `classes/media_store_test.go`; `TestEditorOutputPassesPolicy` and `TestSanitizeHTMLReport` in `classes/editor_policy_test.go`.
- Updated Postgres tests (compiled by vet, run on a Docker host): `TestPostsFormCompiles`, `TestPostsAdminCreateSmoke`, `TestPostsAdminResaveKeepsHTML`, `TestPostsAdminSaveSanitizesHTML`, `TestCategoriesAdminContent`.
- Decision note amendment in `.planning/notes/journal-html-sanitizing-bluemonday.md` (summercms.go, docs commit).
<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).
Wire the Blog Editor into Journal: field types, strip-and-report Decode, Load, post and category fields, admin save hooks, the policy extension for the editor's own output and editor uploads (D-02 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.
Purpose: Journal post and category content are edited with the Blog Editor; an admin save keeps imported HTML intact when nothing is unsafe, strips what is and tells the admin what it removed.
Output: classes/blog_editor.go, SanitizeHTMLReport and the policy extension in classes/format_html.go, classes/media_store.go, field_types.go, hook changes (posts and categories), fields.yaml switches, lang keys, the policy cross-check test, updated Docker tests, smoke tests, README, decision note amendment.
</objective>
<execution_context>
@@ -123,7 +163,7 @@ Output: classes/blog_editor.go, classes/media_store.go, field_types.go, hook cha
@.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.
Repository: `../sm-journal-plugin` (branch main), building against the `../summercms.go` working tree through its go.mod `replace` and `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 c60abc6 (16.1 shipped a3551f2, 0d00ec8, 5f47083 and c60abc6 after this phase was first planned); acceptance checks below diff against it. The decision note lives in this repository (summercms.go `.planning/notes/`) and is committed there as a separate docs commit.
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).
@@ -132,8 +172,11 @@ 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)
CheckOptions func(map[string]any) error; Decode func(context.Context, FormFieldValue) (FormFieldDecoded, error)
Load func(context.Context, FormFieldValue) (any, error)
ListValue func(any) string; Upload func(context.Context, FormFieldUpload) (FormFieldUploadResult, error) }
type FormFieldDecoded struct { Value any; Notices []FormFieldNotice }
type FormFieldNotice struct { Message string; Params map[string]string; Count int } // cabana localizes; Count > 0 = plural
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 }
@@ -149,24 +192,30 @@ type TranslationWriter interface { /* DefaultLocale, EnabledLocales, WriteTransl
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/categories.go` `prepareCategoryWrite` (65-82): blank to NULL plus the CheckHTML 422. The 422 is retired in Task 2 (D-19 amended); blank to NULL stays.
- `journal_api_categories_test.go` `TestCategoriesAdminContent` (138): subtests "unsafe create refused" and "unsafe update refused" with `assertContentRefused`; they become strip assertions in Task 2.
- `posts_admin_smoke_test.go` `TestPostsAdminSaveSanitizesHTML` (159): asserts content stored as submitted and content_html sanitized; under D-02 both columns hold the sanitized HTML (Task 1).
- `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`.
- `classes/format_html.go`: `newHTMLPolicy` (UGC plus footer, class, microdata, four styles, footnote roles, dir, two chat data attributes), `FormatHTML(src string) (string, error)` (goldmark then SanitizeHTML), `SanitizeHTML(doc string) string`, `FormatTranslation(attrs map[string]any) error`, `CheckHTML(doc string) error` (error when the policy would change doc), unexported `sanitizeHTML` (returns doc unchanged when `sameTokens` holds), `sameTokens`, `htmlTokens`, `rawAttrNames` (duplicate attributes count as a change).
- `classes/format_html_test.go` `unsafePayloads` (the 35 stored-XSS payloads), `classes/check_html_test.go`, `classes/format_translation_test.go`: existing tests that must keep passing.
- `routes.go`: the public routes (no category write).
</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>
<name>Task 1: Tracer: post content on mlblogeditor end to end (types on the plugin, fields.yaml, strip-and-report Decode, 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).
<files>../sm-journal-plugin/classes/format_html.go, ../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 (sanitizeHTML, sameTokens, htmlTokens, rawAttrNames), ../sm-journal-plugin/classes/format_html_test.go (unsafePayloads), ../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 (TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML), ../summercms.go/modules/phrasebook/backend/lang/pl/lang.yaml (lines 40-44: plural map format), ../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-04, 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.
Removal report (D-10 amended), in classes/format_html.go: add HTMLRemovals and SanitizeHTMLReport(doc) as described in the assumptions (in-order start-tag alignment; elements, attributes via rawAttrNames so a repeated name counts, comments, Other; at most 10 distinct names per list with exact counts; Empty() is false exactly when the returned string differs from doc). SanitizeHTML returns SanitizeHTMLReport's string, so both share one implementation; FormatHTML, FormatTranslation and CheckHTML keep their behaviour and tests.
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 amended, D-04): nil decodes to ""; any other non-string value is a FormFieldError with "golem15.journal::lang.editor.content_invalid"; a string goes through SanitizeHTMLReport and the result is pact.FormFieldDecoded{Value: the sanitized string, Notices: removalNotices(report)}, so a value the policy keeps whole is returned byte for byte with no notice. removalNotices emits, in this order and only when non-zero: golem15.journal::lang.editor.removed_elements (Count ElementCount, Params names = the element names joined with ", ", plus "…" when more than 10 were cut), removed_attributes (the same for attributes), removed_comments (Count Comments), markup_changed (Other, no count). Decode never refuses because of markup. 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.
@@ -174,38 +223,40 @@ models/post/fields.yaml: content becomes type mlblogeditor with htmlColumn: cont
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.
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, and the notices in the phrasebook plural map format: en removed_elements one ":count element was removed: :names", other ":count elements were removed: :names"; removed_attributes one ":count attribute was removed: :names", other ":count attributes were removed: :names"; removed_comments one ":count HTML comment was removed", other ":count HTML comments were removed"; markup_changed "The HTML filter changed some markup."; pl with one, few, many and other ("Usunięto :count element: :names" / "Usunięto :count elementy: :names" / "Usunięto :count elementów: :names" / "Usunięto :count elementu: :names", likewise atrybut/atrybuty/atrybutów/atrybutu and komentarz HTML/komentarze HTML/komentarzy HTML/komentarza HTML; markup_changed "Filtr HTML zmienił część znaczników.").
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).
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 and that the create response has no meta.notices key; TestPostsAdminResaveKeepsHTML compares content_html with the exact content bytes (no TrimSpace); TestPostsAdminSaveSanitizesHTML (D-02, D-10 amended) keeps its unsafe input but now asserts that the save succeeds, that content and content_html are both the sanitized HTML and equal to each other for pl (host) and en (translation), that the "Data:" text is kept, and that the response's meta.notices names content for pl and en (removed elements a and base, removed attribute onerror).
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).
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 returns a clean HTML string byte for byte with no notice, strips a script element and an onerror attribute and returns two notices (removed_elements Count 1 names "script", removed_attributes Count 1 names "onerror"), decodes nil to "" and refuses a number 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>
Commit as "feat(classes): report what the HTML policy removes" (format_html.go only) and "feat(admin): post content on the Blog Editor field type" (blog_editor.go, field_types.go, posts.go, fields.yaml, lang, tests), staged by explicit path.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^TestBlogEditorTracerSmoke$' -v ./controllers/ 2&gt;&amp;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/... &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLKeepsRawHTML|TestFormatHTMLRejectsUnsafeHTML|TestCheckHTML)$' -v ./classes/ 2&gt;&amp;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>
<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 stores unsafe HTML or refuses instead of stripping, a clean value changes or gets a notice, 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/... &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^(TestFormatHTMLKeepsRawHTML|TestFormatHTMLSanitizesUnsafeHTML|TestFormatHTMLDuplicateAttributes|TestSanitizeHTML|TestCheckHTML)$' -v ./classes/ 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 5</automated>
<fails_when>Non-zero exit: a classes or controllers test fails, or fewer than five "--- PASS: Test" lines for the FormatHTML, SanitizeHTML and CheckHTML tests (the API-path renderer, the byte-stability rule, the duplicate-attribute rule or the predicate regressed when SanitizeHTML moved onto SanitizeHTMLReport).</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.
- `git -C ../sm-journal-plugin diff --quiet c60abc6 -- controllers/api/posts_helpers.go controllers/api/posts.go routes.go` exits 0 (D-09: the public posts API code and the route table are unchanged since the pre-phase head).
- `grep -n "func SanitizeHTMLReport" ../sm-journal-plugin/classes/format_html.go` prints one line, and `grep -n "SanitizeHTMLReport" ../sm-journal-plugin/classes/blog_editor.go` prints at least one line.
- `grep -n "CheckHTML(" ../sm-journal-plugin/classes/blog_editor.go ../sm-journal-plugin/controllers/posts.go` prints nothing (no call: the admin post path never refuses on markup).
- TestBlogEditorTracerSmoke passes with its compile, Decode (clean unchanged, strip with notices, nil, non-string), 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>
<done>Post content compiles as mlblogeditor, unsafe HTML is stripped per locale with notices naming what was removed (clean HTML is stored byte for byte), 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>
<name>Task 2: Expansion: category content on blogeditor (stripped like posts, the 422 gate retired), 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/categories.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/models/category/fields.yaml, ../sm-journal-plugin/journal_api_categories_test.go, ../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 (prepareCategoryWrite 65-82), ../sm-journal-plugin/journal_api_categories_test.go (TestCategoriesAdminContent 138: assertContentRefused, the two unsafe subtests, blank update stores null), ../sm-journal-plugin/routes.go (public routes: GET categories only), ../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.
- Category content (D-19 amended): models/category/fields.yaml content is type blogeditor with uploadFolder categories; CompileFormWithTypes accepts it; Load serves the stored HTML for a *string (nil gives ""); Decode strips `<p onclick="steal()">x</p>` to `<p>x</p>` with a removed_attributes notice; prepareCategoryWrite maps blank (including whitespace only) to NULL and accepts every other value it receives, returning no error for markup.
</behavior>
<action>Finish the Journal half (per D-18, D-19, D-09).
@@ -213,11 +264,13 @@ Media storage (D-18, D-09): new classes/media_store.go with MediaMaxBytes (10240
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.
Category (D-19 amended, D-10): models/category/fields.yaml content becomes type blogeditor with uploadFolder: categories (keep label, comment, size and span), so category content goes through the same strip-and-report Decode as posts. In controllers/categories.go, prepareCategoryWrite keeps the blank-to-NULL rule and drops the HTML gate and its 422; its doc comment says category content arrives already sanitized by the blogeditor Decode (D-10 amended, decision note), and that rows written by SQL or import commands are not sanitized here. Remove the classes import if nothing else in the file uses it. API parity: there is no public category write route (routes.go), so no public contract changes; if the executor finds one, it stops and reports instead of changing it.
Postgres test (correctness, compiled by vet, run on a Docker host): in journal_api_categories_test.go TestCategoriesAdminContent, replace the 422 refusal helper with an assertContentStripped helper. "unsafe create refused" becomes "unsafe create stripped": the POST answers 201, the row exists with content `<p>x</p>`, and the response's meta.notices has one entry for field content naming onclick. "unsafe update refused" becomes "unsafe update stripped": the PUT answers 200, the stored content is "x" (bluemonday drops the javascript: href and the bare a element), and meta.notices names the removed a element. The safe create keeps its byte-identical assertion and gains "no notices key in meta"; "blank update stores null" and "posts-only token forbidden" stay as they are.
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>
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 (the sanitized HTML in content and content_html per locale; a value the policy keeps whole is stored byte for byte; anything the policy does not allow is removed and the save names what was removed; untouched content is not rewritten; markdown posts open with their rendered HTML and become HTML on the first edit); that category content follows the same rule and is no longer refused with a 422 (replace the "refuses content that fails classes.CheckHTML" sentence in the categories section); 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" (category fields.yaml, categories.go, Upload, tests, README) 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 ./... &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorTracerSmoke|TestBlogEditorUploadSmoke)$' -v ./controllers/ 2&gt;&amp;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>
@@ -226,12 +279,59 @@ README.md (plugin README, may name the plugin): an Admin section update describi
</verify>
<acceptance_criteria>
- `grep -n "type: blogeditor" ../sm-journal-plugin/models/category/fields.yaml` prints one line.
- `grep -n "CheckHTML(" ../sm-journal-plugin/controllers/categories.go` prints nothing and `grep -n "ValidationError" ../sm-journal-plugin/controllers/categories.go` prints nothing (the 422 gate is retired).
- `grep -n "assertContentRefused" ../sm-journal-plugin/journal_api_categories_test.go` prints nothing and `grep -n "unsafe create stripped" ../sm-journal-plugin/journal_api_categories_test.go` prints one line; `go -C ../sm-journal-plugin vet ./...` compiles the updated test.
- `grep -n "categories" ../sm-journal-plugin/routes.go` prints only GET lines (no public category write path).
- `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.
- TestBlogEditorUploadSmoke passes its sniff, svg, safe-name, size and category (compile, Load, strip with notice, blank to NULL) subtests.
- README.md mentions blogeditor and mlblogeditor, and its categories section no longer says the form refuses content with a 422.
</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>
<done>Category content uses the Blog Editor and is stripped and reported like posts (no 422), editor images upload into the same media storage and URLs as the public route, and the public route's contract is unchanged.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Expansion: the HTML policy admits exactly the Blog Editor's own output, pinned by plan 02's corpus; CheckHTML and README documentation; decision note</name>
<reversibility rating="costly">D-05: the embed placeholder and the editor's alt and start attributes become stored content that the policy must keep accepting; narrowing the policy later would strip them from live posts on their next edited save.</reversibility>
<precondition>../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json exists (plan 02 Task 3) and parses as JSON.</precondition>
<files>../sm-journal-plugin/classes/format_html.go, ../sm-journal-plugin/classes/editor_policy_test.go, ../sm-journal-plugin/controllers/posts_editor_smoke_test.go, ../sm-journal-plugin/README.md, .planning/notes/journal-html-sanitizing-bluemonday.md</files>
<read_first>../sm-journal-plugin/classes/format_html.go (newHTMLPolicy, SanitizeHTMLReport from Task 1), ../sm-journal-plugin/classes/format_html_test.go (unsafePayloads, TestFormatHTMLSanitizesUnsafeHTML), ../sm-journal-plugin/classes/check_html_test.go, ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (plan 02), ../sm-journal-plugin/tests/blogeditor/fixtures/imported-post.html (plan 02), ~/go/pkg/mod/github.com/microcosm-cc/bluemonday@v1.0.27/policies.go (UGCPolicy) and helpers.go (AllowImages, AllowLists, AllowTables: what UGC already allows), ../sm-journal-plugin/README.md (Posts bullet, the CheckHTML row in API reference, Dependencies), .planning/notes/journal-html-sanitizing-bluemonday.md, ../vue-journal-layer/app/utils/htmlGate.ts (the mirror plan 04 extends with the same four rules)</read_first>
<behavior>
- Every entry of editor-corpus.json comes back from SanitizeHTML byte for byte, with an empty SanitizeHTMLReport and a nil CheckHTML.
- The placeholder `<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>` is kept, both through SanitizeHTML and as raw HTML inside markdown through FormatHTML.
- Still stripped and reported: data-embed other than youtube; data-id of 10 or 12 characters or holding a quote, space or angle bracket; data-embed or data-id on div, img or p; any other data attribute on figure; onload on the placeholder figure; an iframe next to a placeholder (the placeholder stays).
- img alt with a colon, a question mark and double quotes is kept; an alt whose text contains a double quote followed by an event-handler-like string stays one alt attribute (no new attribute appears in the tokens).
- ol start="3" is kept; start="x" on ol and start on ul are stripped.
- The 35 unsafePayloads are stripped exactly as before (TestFormatHTMLSanitizesUnsafeHTML unchanged) and the existing CheckHTML table passes unchanged.
- SanitizeHTMLReport: a style element, an onclick attribute, a comment, a repeated attribute, a style attribute that loses one property and more than ten distinct removed names each produce the documented report; for every unsafePayload, the imported-post fixture and every corpus entry, report.Empty() is true exactly when the output equals the input.
</behavior>
<action>Extend the shared policy for exactly the editor's own output (per D-05 amended, D-04, D-10).
In newHTMLPolicy add, each with a comment naming the Blog Editor output (Phase 16.2 D-05) it serves: data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$ on figure only; alt with any value on img only (added next to UGC's Paragraph rule; bluemonday accepts an attribute when any of its rules matches, and escapes the value on output); start matching ^[0-9]+$ on ol only. Add nothing else: no iframe allowance, no blanket data attribute allowance, no target, rel, style property or URL scheme. These are the same four rules plan 04 mirrors in the layer. If the corpus fails on any attribute outside this set, stop and report it (plans 03 and 04 must change together) instead of widening the policy.
classes/editor_policy_test.go: TestEditorOutputPassesPolicy reads ../tests/blogeditor/fixtures/editor-corpus.json with os.ReadFile (a missing or empty file fails the test, never skips) and checks every entry plus the placeholder and negative cases of the behavior block; TestSanitizeHTMLReport covers the report cases and the Empty property, including the imported-post fixture.
Update CheckHTML's doc comment: since Phase 16.2 no admin or API write path refuses with it (admin saves strip and report, D-10 amended); it reports whether the policy would change doc, the counterpart of the layer's isSafeJournalHtml, and the policy cross-check uses it. Extend TestBlogEditorTracerSmoke with the placeholder passing Decode unchanged with no notice.
README.md: the API reference gains SanitizeHTMLReport and HTMLRemovals rows; the CheckHTML row says it is a predicate that no write path refuses with any more; the Posts and policy text lists the four editor-output additions and says admin saves strip and name what they removed.
Decision note (summercms.go): append to the 2026-10-10 amendment a short paragraph and four table rows for the editor-output additions (figure data-embed and data-id, img alt any text, ol start), naming plan 02's corpus as the test that pins them and plan 04 as the layer mirror.
Commit in sm-journal-plugin as "feat(classes): allow the Blog Editor's own markup in the HTML policy" (format_html.go, editor_policy_test.go, the smoke test, README), and in summercms.go, separately, as "docs(16.2): record the editor-output policy extension" (the note only), each staged by explicit path.</action>
<verify>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^(TestEditorOutputPassesPolicy|TestSanitizeHTMLReport|TestFormatHTMLSanitizesUnsafeHTML|TestFormatHTMLDuplicateAttributes|TestFormatHTMLKeepsRawHTML|TestSanitizeHTML|TestCheckHTML)$' -v ./classes/ 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 7</automated>
<fails_when>Non-zero exit: vet fails, or fewer than seven "--- PASS: Test" lines: a corpus entry is changed by the policy (an editor attribute is still stripped), a negative case is no longer stripped (the extension is too wide), the report disagrees with the output, or an existing FormatHTML, SanitizeHTML or CheckHTML test regressed.</fails_when>
<automated>go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorTracerSmoke|TestBlogEditorUploadSmoke)$' -v ./controllers/ 2&gt;&amp;1 | grep -c '^--- PASS: TestBlogEditor' | grep -qx 2</automated>
<fails_when>Non-zero exit or fewer than two "--- PASS: TestBlogEditor" lines: a classes or controllers test fails, or the placeholder no longer passes Decode unchanged.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n "data-embed" ../sm-journal-plugin/classes/format_html.go` and `grep -n "editor-corpus.json" ../sm-journal-plugin/classes/editor_policy_test.go` each print at least one line.
- `grep -n "func TestEditorOutputPassesPolicy" ../sm-journal-plugin/classes/editor_policy_test.go` and `grep -n "func TestSanitizeHTMLReport" ../sm-journal-plugin/classes/editor_policy_test.go` each print one line.
- `grep -n "SanitizeHTMLReport" ../sm-journal-plugin/README.md` prints at least one line.
- `grep -n "Phase 16.2" .planning/notes/journal-html-sanitizing-bluemonday.md` prints at least one line and the note names figure data-embed, data-id, img alt and ol start.
- `git -C ../sm-journal-plugin diff --quiet c60abc6 -- classes/format_html_test.go classes/check_html_test.go` exits 0 (the existing policy tests are not weakened).
</acceptance_criteria>
<done>The policy keeps every piece of markup the editor writes, still strips everything else it stripped before, and the corpus test fails the moment the editor's output and the policy drift apart.</done>
</task>
</tasks>
@@ -249,7 +349,9 @@ README.md (plugin README, may name the plugin): an Admin section update describi
| 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-14 | Tampering (stored XSS) | decodeBlogHTML (posts and categories) | high | mitigate | SanitizeHTMLReport on every locale value cabana decodes (default and non-default), for post and category content alike; the stored value is always the policy's output, never submitted bytes the policy would change; the removal is reported to the admin (no silent loss), and the layer sanitizes again before v-html; smoke and Postgres tests cover script, onerror, onclick and javascript: being stripped with notices and clean HTML staying byte for byte |
| T-16.2-31 | Tampering (stored XSS) | newHTMLPolicy editor-output extension | high | mitigate | Exactly four additions with the narrowest patterns (figure data-embed ^youtube$, figure data-id ^[A-Za-z0-9_-]{11}$, img alt as an escaped attribute value, ol start ^[0-9]+$); no iframe, URL, style, event or blanket data attribute; TestEditorOutputPassesPolicy pins the corpus and the negative cases, the 35 existing payload tests stay unchanged (diff check against c60abc6), and the iframe exists only in the layer's rendered page (D-05) |
| T-16.2-34 | Denial of Service | SanitizeHTMLReport and removal notices | low | mitigate | One pass over two token streams; at most 10 names per list with exact counts, so a body with thousands of removed parts yields at most four short notices (cabana also caps notices, T-16.2-33) |
| 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 |
@@ -258,14 +360,16 @@ README.md (plugin README, may name the plugin): an Admin section update describi
</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.
- Task 1: go vet, TestBlogEditorTracerSmoke, the classes and controllers packages, the FormatHTML, SanitizeHTML and CheckHTML tests pass.
- Task 2: go vet, both TestBlogEditor smoke tests, the classes and controllers packages pass; the categories gate is gone.
- Task 3: the corpus cross-check, the report tests and every existing policy test pass.
- The root-package Postgres tests (TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent) 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.
- Post content on mlblogeditor and category content on blogeditor, both stripped by the shared policy per locale with a removal report; no admin save fails because of markup.
- Admin saves store the same sanitized HTML in both columns per written locale and leave untouched content byte-identical; the public API is unchanged.
- The policy admits exactly the editor's own output (corpus test) and nothing more.
- Editor uploads use the shared media storage with a magic-byte check.
</success_criteria>

View File

@@ -0,0 +1,272 @@
---
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
plan: 03
subsystem: journal-admin
tags: [sm-journal-plugin, field-types, blog-editor, bluemonday, sanitizing, uploads, cabana]
requires:
- phase: 16.2-01
provides: pact field type API (FormFieldType with ListValue(ctx, value), FormFieldDecoded, FormFieldNotice, FormFieldUpload, FormFieldError), cabana FieldWritesFromContext, WithFieldWrites, TxFromContext, CompileFormWithTypes, TranslationWriter
- phase: 16.2-02
provides: golem15-journal-blogeditor element, assets/dist/blogeditor.js and .css in adminFS, tests/blogeditor/fixtures/editor-corpus.json
provides:
- blogeditor and mlblogeditor field types on the Journal plugin (journal.Plugin.FormFieldTypes, classes.BlogEditorTypes, classes.BlogEditorElement, classes.LooksLikeHTML)
- classes.SanitizeHTMLReport and classes.HTMLRemovals (removal report behind the admin notices); SanitizeHTML is its string half
- newHTMLPolicy editor-output extension (figure data-embed/data-id, img alt, ol start)
- classes.StoreMedia, StoredMedia, MediaError, MediaMaxBytes, MediaFolderPattern shared by the public upload route and the editor upload
- postsAdminController FormAfterCreate/FormAfterUpdate writing content_html per written locale
- editor lang keys (content_invalid, image_invalid, upload_failed, removed_elements, removed_attributes, removed_comments, markup_changed) in en and pl
affects: [16.2-04, 16.2-05, 16.2-06, journal]
actuals:
tokens: 20600
tasks: 3
commits: 5
plan_head_before: 086e485215f69bd8a78f61fb1d5e69b2246366cd
plan_head_after: 2384a61e38f10afa522f38f8180cb62a32a9d9c0
commits_repo: ../sm-journal-plugin
docs_commits: [332993c]
tech-stack:
added: []
patterns:
- "Removal report by in-order start-tag alignment: greedy match on an attribute-compatible output tag, falling back to element-name alignment; Other covers any change nothing else explains, so the report is empty exactly when the output equals the input"
- "Companion column through Form hooks: FieldWritesFromContext Default write to the host column in FormBefore*, non-default writes through the TranslationWriter in FormAfter*"
- "Policy pinned by the editor's own corpus: every corpus entry must survive byte for byte with an empty report"
key-files:
created:
- ../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/classes/editor_policy_test.go
- ../sm-journal-plugin/field_types.go
- ../sm-journal-plugin/controllers/posts_editor_smoke_test.go
modified:
- ../sm-journal-plugin/classes/format_html.go
- ../sm-journal-plugin/controllers/posts.go
- ../sm-journal-plugin/controllers/admin_registry.go
- ../sm-journal-plugin/controllers/categories.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/journal_api_categories_test.go
- ../sm-journal-plugin/README.md
- .planning/notes/journal-html-sanitizing-bluemonday.md
key-decisions:
- "The policy needed exactly the four planned editor-output rules: a probe of the 15-entry corpus against the pre-change policy showed only figure data-embed/data-id, img alt and ol start being stripped; no fifth rule"
- "Editor upload: the sniffed type must be JPEG, PNG, GIF or WebP and must match an allowed extension; a name without an extension gets the sniffed one; a disallowed extension reaches StoreMedia's 'The file must be an image.'"
- "uniqueMediaKey fails instead of overwriting when all 999 numbered names are taken (the old code returned the original path); StoreMedia also checks the folder rule itself"
- "Load without a translation writer (Translated nil) serves FormatHTML(content) for a markdown value"
- "Removal notices end a cut name list with ', …'; HTMLRemovals keeps unexported cut flags"
patterns-established:
- "A plugin field type's Decode reports changes as notices and never refuses on content it can repair"
requirements-completed: [D-02, D-03, D-04, D-05, D-06, D-07, D-08, D-09, D-10, D-14, D-18, D-19]
coverage:
- id: D1
description: "Post content compiles as mlblogeditor (element, translatable, htmlColumn, uploadFolder; assets exist); Decode keeps clean HTML and the embed placeholder byte for byte with no notice, strips script/onerror with two notices, nil to '', refuses a number; Load serves HTML as stored, markdown as content_html or FormatHTML; ListValue; hooks write content_html per written locale and leave untouched content alone; en/pl plural phrases"
requirement: D-08
verification:
- kind: unit
ref: "../sm-journal-plugin/controllers/posts_editor_smoke_test.go#TestBlogEditorTracerSmoke"
status: pass
human_judgment: false
- id: D2
description: "SanitizeHTMLReport: elements, attributes (repeats and changed style), comments, Other, ten-name cut with exact counts; Empty exactly when the output equals the input for every unsafe payload, the imported-post fixture and every corpus entry"
requirement: D-10
verification:
- kind: unit
ref: "../sm-journal-plugin/classes/editor_policy_test.go#TestSanitizeHTMLReport"
status: pass
human_judgment: false
- id: D3
description: "Policy admits every editor-corpus entry byte for byte (empty report, nil CheckHTML) and the placeholder through FormatHTML; other providers, malformed ids, data attributes on other elements, other data-* on figure, onload, iframe, non-numeric start and start on ul are still stripped; the 35 unsafe payloads and the CheckHTML table are unchanged (diff against c60abc6 empty)"
requirement: D-05
verification:
- kind: unit
ref: "../sm-journal-plugin/classes/editor_policy_test.go#TestEditorOutputPassesPolicy"
status: pass
- kind: unit
ref: "../sm-journal-plugin/classes/format_html_test.go#TestFormatHTMLSanitizesUnsafeHTML"
status: pass
- kind: unit
ref: "../sm-journal-plugin/classes/check_html_test.go#TestCheckHTML"
status: pass
human_judgment: false
- id: D4
description: "StoreMedia keeps the public route's key layout, unique suffix, URL, extension list and size message; editor Upload sniffs the type, uses safe lowercase names and the field's uploadFolder, refuses html/svg/mismatched types, maps MediaError to FormFieldError and a missing bucket to an opaque error; the route's error bodies are unchanged"
requirement: D-18
verification:
- kind: unit
ref: "../sm-journal-plugin/controllers/posts_editor_smoke_test.go#TestBlogEditorUploadSmoke"
status: pass
- kind: unit
ref: "../sm-journal-plugin/controllers/api/media_test.go#TestWriteMediaStoreError"
status: pass
- kind: unit
ref: "../sm-journal-plugin/classes/media_store_test.go#TestMediaObjectPath"
status: pass
human_judgment: false
- id: D5
description: "Category content compiles as blogeditor, Load serves the stored *string, Decode strips onclick with a notice, the hooks map blank to NULL and accept any other content (422 retired)"
requirement: D-19
verification:
- kind: unit
ref: "../sm-journal-plugin/controllers/posts_editor_smoke_test.go#TestBlogEditorUploadSmoke"
status: pass
human_judgment: false
- id: D6
description: "Public posts API and route table unchanged since c60abc6 (D-09)"
requirement: D-09
verification:
- kind: other
ref: "git -C ../sm-journal-plugin diff --quiet c60abc6 -- controllers/api/posts_helpers.go controllers/api/posts.go routes.go"
status: pass
human_judgment: false
- id: D7
description: "Postgres-backed admin saves and the media route: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, journal_api_task3_test.go media upload, plugin boot with the field types"
verification: []
human_judgment: true
rationale: "No Docker on this host (docker.sock permission denied); the tests compile under go vet and run on a Docker host in plan 06"
duration: 13min
completed: 2026-10-10
status: complete
---
# Phase 16.2 Plan 03: Journal Blog Editor integration Summary
**Journal post content (mlblogeditor) and category content (blogeditor) are edited with the Blog Editor; every admin save stores bluemonday's output per locale, byte for byte when nothing is unsafe, writes the same HTML to content and content_html without goldmark, and names what it removed through SanitizeHTMLReport notices; the policy admits exactly the editor's four extra attributes, pinned by the editor corpus, and editor images go through the extracted StoreMedia with a magic-byte check.**
## Performance
- **Duration:** about 13 min
- **Started:** 2026-10-10T09:58:55Z
- **Completed:** 2026-10-10T10:12Z
- **Tasks:** 3 (tracer + 2 TDD expansions)
- **Files:** 20 in sm-journal-plugin (6 created, 14 modified), 1 decision note in summercms.go
## Accomplishments
- **Removal report (D-10 amended):** `SanitizeHTMLReport(doc) (string, HTMLRemovals)` aligns the input's start tags with bluemonday's output in order; unmatched tags are removed elements, missing, repeated or changed attributes are removed attributes (a `style` that lost a property counts under `style`), comments are counted, `Other` covers anything else. At most ten distinct names per list, exact counts. `SanitizeHTML` is its string half; `FormatHTML`, `FormatTranslation` and `CheckHTML` keep their behaviour.
- **Field types (D-08, D-14):** `classes.BlogEditorTypes` returns `blogeditor` (uploadFolder) and `mlblogeditor` (htmlColumn, uploadFolder) on `golem15-journal-blogeditor` with the plan 02 bundle; `journal.Plugin.FormFieldTypes` registers them. `checkBlogOptions` checks the options at boot.
- **Decode / Load / ListValue (D-04, D-07, D-10):** Decode returns the policy output plus `removed_elements`, `removed_attributes`, `removed_comments` and `markup_changed` notices; nil is `""`, a non-string is a `FormFieldError` (`content_invalid`). Load serves HTML as stored and markdown (`LooksLikeHTML` false) as that locale's `content_html`, else `FormatHTML(content)`. ListValue (ctx form) strips tags and cuts at 120 runes.
- **Admin save path (D-02, D-03):** `preparePostWrite` no longer calls goldmark; it copies the Default `content` write to `ContentHTML`. New `FormAfterCreate`/`FormAfterUpdate` write `content_html` for each non-default written locale through the `TranslationWriter` in the save transaction. Untouched content is not written, so both columns stay byte-identical.
- **Categories (D-19 amended):** `content` is `blogeditor` with `uploadFolder: categories`; the `CheckHTML` 422 gate is gone, blank still becomes NULL; the field comment (en, pl) says unsafe HTML is removed on save.
- **Media (D-18, D-09):** `classes.StoreMedia` holds the public route's storage (extensions, 10240 KiB, `/journal/<folder>/<name>`, unique suffix, `attach.PublicURL`); `UploadMedia` keeps auth, permissions, folder and form checks and the same bodies. The editor `Upload` sniffs the first 512 bytes (`http.DetectContentType`), requires a matching JPEG/PNG/GIF/WebP type, builds a safe lowercase name and stores in the field's folder (`editor` by default).
- **Policy (D-05 amended):** `figure` `data-embed` `^youtube$` and `data-id` `^[A-Za-z0-9_-]{11}$`, `img` `alt` any text, `ol` `start` `^[0-9]+$`; nothing else. `CheckHTML` documented as a predicate no write path refuses with.
- **Docs:** plugin README (field types, admin save rules, category rule, unchanged public API, editor uploads, the four policy rules, API reference rows); decision note amendment with the four table rows (summercms.go `332993c`).
## Task Commits
All code commits are in `../sm-journal-plugin` (branch main):
1. **Task 1: tracer** - `cbf4891` feat(classes): report what the HTML policy removes; `fbd1c85` feat(admin): post content on the Blog Editor field type
2. **Task 2: categories, uploads, README** - `15fcad7` refactor(api): share media storage with the editor upload; `cb1c688` feat(admin): category content on the Blog Editor and editor image uploads
3. **Task 3: policy extension** - `2384a61` feat(classes): allow the Blog Editor's own markup in the HTML policy; summercms.go `332993c` docs(16.2): record the editor-output policy extension
## Smoke test output
```
--- PASS: TestBlogEditorTracerSmoke (13 subtests: compile, type definitions, decode clean, decode placeholder,
decode strip with notices, decode nil/non-string, load HTML, load markdown content_html, load FormatHTML fallback,
list value, hooks write content_html, hooks leave untouched content, plural phrases)
--- PASS: TestBlogEditorUploadSmoke (9 subtests: StoreMedia layout, StoreMedia refusals, editor upload safe name,
editor upload refusals, missing bucket, category compile, category load, category decode, category hooks)
--- PASS: TestEditorOutputPassesPolicy (15 corpus entries, placeholder, 18 strip cases, alt, ol start)
--- PASS: TestSanitizeHTMLReport
--- PASS: TestFormatHTMLSanitizesUnsafeHTML, TestFormatHTMLDuplicateAttributes, TestFormatHTMLKeepsRawHTML,
TestSanitizeHTML, TestCheckHTML (unchanged since c60abc6)
--- PASS: TestWriteMediaStoreError, TestMediaObjectPath, TestMediaFolderPatternRejectsDotDot
```
- `go -C ../sm-journal-plugin vet ./...` clean (compiles the updated Postgres tests).
- `go test -short ./...`: `classes`, `controllers`, `controllers/api` pass; the root package, `models` and `updates` fail only on `docker.sock: permission denied`.
- `npx vitest run` 33/33 and `scripts/check-dist.sh` ("assets/dist matches a fresh build") still pass.
## Postgres tests that need a Docker host (plan 06)
- `posts_admin_smoke_test.go`: `TestPostsFormCompiles` (expects `"type":"mlblogeditor"`), `TestPostsAdminCreateSmoke` (HTML content, `ContentHTML == Content`, no `meta.notices`), `TestPostsAdminResaveKeepsHTML` (exact bytes), `TestPostsAdminSaveSanitizesHTML` (save succeeds, both columns sanitized for en host and pl translation, notices name `a, base` and `onerror` per locale).
- `journal_api_categories_test.go`: `TestCategoriesAdminContent` (schema type `blogeditor`, "unsafe create stripped" 201 + `<p>x</p>` + onclick notice, "unsafe update stripped" 200 + `x` + `a` notice, safe create without notices).
- `journal_api_task3_test.go` media upload tests (the route now runs through `StoreMedia`), `plugin_test.go`, and a full plugin boot with `FormFieldTypes` (cabana's boot-time asset and alias checks against the embedded `adminFS`).
## TDD Gate Compliance
Tasks 2 and 3 are `tdd="true"`. CLAUDE.md requires `go test ./...` green at every commit, so no failing `test(...)` commit exists; as in plans 01 and 02, RED was run and verified before GREEN and tests were committed with the implementation.
- **Task 2 RED:** compile stubs for `StoreMedia` and the moved helpers, then `TestBlogEditorUploadSmoke` failed on its assertions (`not implemented`, "blogeditor has no Upload") and `TestMediaObjectPath` on its path assertion. `go test -json` was converted to TAP and checked: `msd_run check tdd-red-evidence` returned `RED_EVIDENCE_OK` (`target_test_failed`, target `TestBlogEditorUploadSmoke`). The category subtests were masked by the parent's fatal at RED time; the category schema and hook subtests then failed (type `textarea`, `validation_failed`) after Upload went green and before the category change, and passed after it.
- **Task 3 RED:** `TestEditorOutputPassesPolicy` failed on 18 subtests (corpus entries with the embed, alt and start; placeholder; the negative cases, whose `want` keeps the placeholder) before the policy change: `RED_EVIDENCE_OK`, target `TestEditorOutputPassesPolicy`. `TestSanitizeHTMLReport` was already green because the report shipped in the Task 1 tracer.
- **GREEN:** the implementation commits listed above; no refactor commit was needed.
## Decisions Made
See key-decisions in the frontmatter.
## Deviations from Plan
### Auto-fixed Issues
**1. [Plan text] Host and translation locales in TestPostsAdminSaveSanitizesHTML**
- **Found during:** Task 1
- **Issue:** The plan says "pl (host) and en (translation)", but the test harness (`bootJournal`) makes `en` the default locale and `pl` the translation.
- **Fix:** The test asserts the host columns for `en` and `golem15_translate_attributes` for `pl`, and notices for both locales.
- **Files modified:** posts_admin_smoke_test.go
- **Commit:** fbd1c85
**2. [Rule 2 - Security] uniqueMediaKey no longer overwrites**
- **Found during:** Task 2
- **Issue:** The moved `uniqueMediaKey` returned the original (existing) path when all 999 numbered names were taken, so a write would overwrite an object; the threat model (T-16.2-16) requires that it never overwrites.
- **Fix:** It returns an error instead (a 500 on the public route in that pathological case only). `StoreMedia` also re-checks the folder rule (MediaError on `folder`), which the route already enforces first, so no response changes.
- **Files modified:** classes/media_store.go
- **Commit:** 15fcad7
**3. [Rule 2 - Correctness] Editor upload type must match its extension**
- **Found during:** Task 2
- **Issue:** The plan's "first bytes sniff as that image type" needs a rule for names whose extension disagrees with the bytes, or that have none.
- **Fix:** A sniffed type outside JPEG/PNG/GIF/WebP, or one that differs from an allowed extension's type, is `image_invalid`; a name without an extension gets the sniffed one; a disallowed extension gets StoreMedia's "The file must be an image." `safeUploadName` also trims leading and trailing `-` and `.` (no hidden or dot-only names).
- **Files modified:** classes/blog_editor.go
- **Commit:** cb1c688
**4. [Rule 2 - Correctness] Category field comment**
- **Found during:** Task 2
- **Issue:** `category.content_comment` (en, pl) told administrators unsafe HTML "is refused", which is no longer true.
- **Fix:** It now says unsafe HTML is removed on save and the form names what was removed.
- **Files modified:** lang/en/lang.yaml, lang/pl/lang.yaml
- **Commit:** cb1c688
**5. Files and shapes outside the plan's lists**
- `controllers/admin_registry.go` holds the new `pact.FormAfterCreate`/`pact.FormAfterUpdate` assertions next to the controller's other compile-time assertions (commit fbd1c85).
- `controllers/api/media_test.go` now holds `TestWriteMediaStoreError` (the route's 422 and 500 bodies after the move); the two path tests moved to `classes/media_store_test.go` as planned.
- `HTMLRemovals` carries two unexported flags recording that a name list was cut, so `removalNotices` can append `, …`.
- Task 2 committed the refactor (`15fcad7`) before the feature (`cb1c688`) so that each commit builds on its own.
- `loadBlogHTML` serves `FormatHTML(content)` when no translation writer is published (`Translated` is nil), and serves blank content as it is.
---
**Total deviations:** 4 auto-fixed (1 plan text, 3 correctness/security) plus file and ordering notes. **Impact:** no change to the planned API, the public routes' contract or the four policy rules.
## Issues Encountered
- No Docker on this host: the Postgres tests listed above compile but did not run (recorded in `.planning/WINDOWS.md` as unrun-verify entries 15 and 16).
## Known Stubs
None.
## Next Phase Readiness
- Plan 04 mirrors the same four policy rules in the layer and checks the same `editor-corpus.json`; no fifth rule was needed.
- Plan 05/06: run the Docker-only tests above, then the browser UAT of the editor in the admin (typing, uploads through the cabana route, notices after a stripped save).
## Self-Check: PASSED
- Created files exist in ../sm-journal-plugin: classes/blog_editor.go, classes/media_store.go, classes/media_store_test.go, classes/editor_policy_test.go, field_types.go, controllers/posts_editor_smoke_test.go.
- Commits exist in ../sm-journal-plugin: cbf4891, fbd1c85, 15fcad7, cb1c688, 2384a61; in summercms.go: 332993c.

View File

@@ -2,10 +2,15 @@
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
plan: 04
type: execute
wave: 1
depends_on: []
wave: 2
depends_on: ["16.2-02"]
coupling_justified: ["16.2-03: both plans add the same four editor-output rules (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt any text, ol start integer), each in its own repository; the set is fixed in both plans' text and checked against the same plan 02 corpus, neither plan reads the other's files, and either one stops and reports if the corpus needs a fifth rule, so both execution orders give matching policies"]
files_modified:
- ../vue-journal-layer/app/utils/embeds.ts
- ../vue-journal-layer/app/utils/htmlGate.ts
- ../vue-journal-layer/tests/htmlGate.test.ts
- ../vue-journal-layer/tests/fixtures/editor-corpus.json
- ../vue-journal-layer/docs/decisions/0001-sanitize-html.md
- ../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
@@ -19,20 +24,27 @@ files_modified:
autonomous: false
requirements: [D-05, D-17]
estimate:
tokens: 45000
raw_tokens: 45000
tokens: 55000
raw_tokens: 55000
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 and D-05: JournalPostBody and JournalCategoryHeader render expandEmbeds(sanitizeJournalHtml(html)): the sanitizer runs first at the v-html sink and expansion is the very last step, so the iframe it builds is never passed through a sanitizer that would strip it; the payload transforms (toPostView, the category page) only sanitize and keep the placeholder; 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 (amended): htmlGate.ts mirrors plan 03's Go policy extension exactly (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt with any text, ol start integer) and nothing more; every entry of tests/fixtures/editor-corpus.json, a byte copy of plan 02's corpus, is safe under gateJournalHtml, and the placeholder survives sanitizeJournalHtml twice (payload, then sink) before it expands."
- "D-05: a raw iframe in stored HTML is stripped by sanitizeJournalHtml before expansion (the rest of the body renders, no iframe comes from stored markup)."
- "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/app/utils/htmlGate.ts"
provides: "the sanitize-html mirror with the editor-output extension"
contains: "data-embed"
- path: "../vue-journal-layer/tests/fixtures/editor-corpus.json"
provides: "byte copy of the Blog Editor's output corpus (plan 02)"
contains: "data-embed"
- path: "../vue-journal-layer/tests/embeds.test.ts"
provides: "node:test smoke of the transform and the gate order"
contains: "expandEmbeds"
@@ -42,14 +54,18 @@ must_haves:
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"
via: "v-html of expandEmbeds(sanitizeJournalHtml(html)), expansion last"
pattern: "expandEmbeds"
- from: "../vue-journal-layer/tests/fixtures/editor-corpus.json"
to: "../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json"
via: "byte copy; the plan 06 phase gate compares the two files"
pattern: "editor-corpus.json"
- 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."
- statement: "The layer never builds an iframe from anything but a validated provider id and a fixed host; htmlGate.ts gains only the four editor-output rules plan 03 adds to the Go policy, never iframe, URL schemes, styles, event attributes or other data attributes."
status: unresolved
verification: judgment
- statement: "A pushed layer tag is never moved or deleted; a defect after the push gets v0.2.1."
@@ -61,7 +77,16 @@ must_haves:
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.
This plan's slice (user decision 1, plan 04): the frontend half of embeds (D-17). The reusable Nuxt blog views layer keeps the editor's markup through its sanitizer, expands the allowlisted embed placeholders into privacy-enhanced YouTube iframes as the last step before v-html, ships as tag v0.2.0, and the site app pins it.
## Revision 2026-10-10 (16.1 drift, CONTEXT D-05 amended)
16.1 replaced the layer's regex gate with sanitize-html 2.18.0 (5beb1c5, decision note `docs/decisions/0001-sanitize-html.md`, tag v0.1.1 at 294a8d8). The layer now never blanks a body: `sanitizeJournalHtml` strips what the policy refuses, `gateJournalHtml` returns `{safe, html}` with `html` always sanitized, and there is no blocked notice. The body is sanitized twice: once in the payload (`toPostView`, the category page) and again at the sink (`JournalPostBody`, `JournalCategoryHeader`). Two consequences for this plan:
- **Order.** Expansion must be the very last step at the sink, after `sanitizeJournalHtml`. Run anywhere earlier, for example in `toPostView`, the sink's sanitizer would strip the iframe again.
- **Mirror.** The placeholder's `data-embed`/`data-id` (and the editor's `alt` text and `ol start`) are stripped by the mirror today. Amended D-05 extends the Go policy (plan 03) and this mirror with the same four rules, pinned by plan 02's corpus. That is why this plan now waits for plan 02 (wave 2, parallel with plan 03).
The site app pins `#v0.1.1` today, and the layer's package.json version field still reads 0.1.0. Where RESEARCH Pattern 7 says the gate "refuses" a raw iframe, read "strips".
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.
@@ -73,22 +98,26 @@ Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to
- 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).
- Iframe attributes: `title="YouTube video"`, `loading="lazy"`, `referrerpolicy="strict-origin-when-cross-origin"`, `allow="encrypted-media; picture-in-picture; fullscreen"`, `allowfullscreen`. Since 16.1 the UAT vhost sends a public Content-Security-Policy-Report-Only whose default-src 'self' has no frame-src, so the iframe loads (report-only) but logs a violation; plan 05 adds frame-src https://www.youtube-nocookie.com to that policy in the host repository (RESEARCH Pattern 7's "no CSP" predates 16.1).
- 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.
- Mirror extension: exactly the four rules plan 03 adds to the Go policy: `figure` `data-embed` `^youtube$` and `data-id` `^[A-Za-z0-9_-]{11}$`, `img` `alt` with any value, and `ol` `start` (`INTEGER`). The corpus copy lives at `tests/fixtures/editor-corpus.json` with a test header comment naming its source; the copy is never edited by hand.
## Artifacts this phase produces (this plan)
- `app/utils/embeds.ts`: `EMBED_PLACEHOLDER` (RegExp), `EMBED_PROVIDERS`, `expandEmbeds(html: string): string`.
- `app/utils/htmlGate.ts`: `figure` and `ol` entries plus the widened `img` `alt` rule in `ELEMENT_ATTRS` (mirror of the Go extension).
- `tests/fixtures/editor-corpus.json` (byte copy of plan 02's corpus); corpus, mirror and pipeline cases in `tests/htmlGate.test.ts` and `tests/embeds.test.ts`.
- `docs/decisions/0001-sanitize-html.md`: the Policy section lists the four editor-output additions.
- 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.
Mirror the editor-output policy extension in the layer's sanitizer, expand editor embed placeholders as the last step of the post and category sinks (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.
Purpose: grzybyfunkcjonalne.pl shows the YouTube embeds authors add in the Blog Editor, and keeps their alt text and list numbering, while stored HTML still goes through the strict sanitizer.
Output: the htmlGate.ts mirror and corpus test, embeds.ts with tests and SSR proof, tag v0.2.0, app pin and lockfile.
</objective>
<execution_context>
@@ -101,7 +130,7 @@ Output: embeds.ts with tests and SSR proof, tag v0.2.0, app pin and lockfile.
@.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.
Repositories: `../vue-journal-layer` (branch main at 294a8d8 = tag v0.1.1, earlier tags v0.0.1 and v0.1.0; depends on sanitize-html 2.18.0; package.json version field 0.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:
@@ -112,50 +141,57 @@ Placeholder written by the editor (plan 02) and the expansion it 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`.
Existing sanitizer (app/utils/htmlGate.ts, sanitize-html 2.18.0 mirroring the Go bluemonday policy): `sanitizeJournalHtml(html): string` strips what the policy refuses (iframe, script and style with their content; on* attributes; target and rel; URLs other than http, https, mailto and relative; attribute values failing their pattern) and always returns sanitize-html's own serialization; `gateJournalHtml(html) -> { safe, html }` never blanks (`safe` reports whether the policy kept everything); `isSafeJournalHtml(html)`. Policy tables: `GLOBAL_ATTRS`, `ELEMENT_ATTRS` (img alt uses `PARAGRAPH`; no figure or ol entry today), `EMPTY_ATTRS`. Call sites: `toPostView` (app/utils/postView.ts) and app/journal-pages/CategoryPage.vue sanitize the payload; `JournalPostBody` renders `v-html` of `sanitizeJournalHtml(props.html)`; `JournalCategoryHeader` renders `v-html` of `sanitizeJournalHtml(props.category.content)`.
Pipeline after this plan: payload `sanitizeJournalHtml` (placeholder kept) → sink `sanitizeJournalHtml` (placeholder kept) → `expandEmbeds` (last) → `v-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>
<name>Task 1: Tracer: a stored placeholder renders as a privacy-enhanced YouTube iframe on the post page (mirror extension, sanitize, expand last, 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>
<precondition>../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json exists (plan 02 Task 3) and parses as JSON.</precondition>
<files>../vue-journal-layer/app/utils/htmlGate.ts, ../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/htmlGate.test.ts, ../vue-journal-layer/tests/embeds.test.ts, ../vue-journal-layer/tests/fixtures/editor-corpus.json, ../vue-journal-layer/testing/fixtures/acme/posts.json, ../vue-journal-layer/testing/expect-acme.txt, ../vue-journal-layer/docs/decisions/0001-sanitize-html.md, ../vue-journal-layer/README.md</files>
<read_first>../vue-journal-layer/app/utils/htmlGate.ts (ELEMENT_ATTRS, PARAGRAPH, INTEGER, EMPTY_ATTRS, filterAttributeValues, KEEP_ALL), ../vue-journal-layer/tests/htmlGate.test.ts (test style), ../vue-journal-layer/app/utils/postView.ts, ../vue-journal-layer/app/journal-pages/CategoryPage.vue, ../vue-journal-layer/app/journal-pages/PostPage.vue, ../vue-journal-layer/docs/decisions/0001-sanitize-html.md (Policy section), ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (plan 02), ../sm-journal-plugin/classes/format_html.go (newHTMLPolicy: the Go side of the mirror), ../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 "".
Mirror (D-05 amended): in htmlGate.ts add the same four rules plan 03 adds to the Go policy and nothing else: an ELEMENT_ATTRS entry for figure with data-embed matching ^youtube$ and data-id matching ^[A-Za-z0-9_-]{11}$; an ol entry gaining start (INTEGER) next to type; img alt accepting any value (a new rule constant, documented as the editor's free-text alt; sanitize-html escapes attribute values). Extend the header comment's list of additions. Copy ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json byte for byte to tests/fixtures/editor-corpus.json. In tests/htmlGate.test.ts: every corpus entry is safe under gateJournalHtml; the placeholder is kept by sanitizeJournalHtml applied twice; data-embed other than youtube, a 10- or 12-character id, data-id on div and any other data attribute on figure are still stripped; an iframe is still stripped with its content; ol start="3" kept and start="x" stripped; an alt with a colon, a question mark and quotes kept. Update the Policy section of docs/decisions/0001-sanitize-html.md with the four additions and their reason (the Blog Editor's own output, pinned by the corpus, mirroring the Go policy).
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).
embeds.ts (header comment naming the editor's placeholder and the Go policy 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 "". The regex matches the serialization sanitize-html gives the placeholder (attribute order kept, double quotes), because its input is always sanitizer output.
Wiring (order is the point): PostBody.vue computes the rendered HTML as expandEmbeds(sanitizeJournalHtml(props.html)) and CategoryHeader.vue as expandEmbeds(sanitizeJournalHtml(props.category.content)); expansion is the last step before v-html and its output is never sanitized again (sanitize-html would strip the iframe). toPostView and CategoryPage.vue keep only sanitizing (the payload carries the placeholder, not the iframe); do not expand there. Update the components' header comments to say so. 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.
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; the composed sink pipeline expandEmbeds(sanitizeJournalHtml(x)) turns a stored placeholder into the iframe but strips a raw iframe in x, so the only iframe in the output is the one built from the placeholder; the payload-then-sink pipeline (sanitize, sanitize, expand) gives the same result; two placeholders in one body both expand; the corpus embed entry expands after sanitizing.
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>
README.md: an "Embeds" section describing the placeholder, the allowlist, the sanitize-first, expand-last order and the CSS classes; the HTML sanitizer bullet names the four editor-output additions. Commit as "feat: keep Blog Editor markup in the sanitizer and expand Journal embed placeholders into privacy-enhanced iframes", staged by explicit path.</action>
<verify>
<automated>npm --prefix ../vue-journal-layer test 2&gt;&amp;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>
<fails_when>No "# fail 0" line in the TAP summary: an embeds or mirror test failed (a corpus entry is unsafe, the placeholder does not survive two sanitizer passes, a negative case is no longer stripped, or a stored iframe reaches the output), or an existing htmlGate, postView, routes, seo, sitemap, i18n, pagination or client test failed.</fails_when>
<automated>pnpm -C ../vue-journal-layer install --frozen-lockfile &amp;&amp; pnpm -C ../vue-journal-layer run build:playground &amp;&amp; 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&gt;&amp;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>
<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 /blog/unsafe-sample containing <p>Before</p><p>After</p> without alert().</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 -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, and `grep -c "expandEmbeds" ../vue-journal-layer/app/utils/postView.ts ../vue-journal-layer/app/journal-pages/CategoryPage.vue` reports 0 for each (no expansion before the sink's sanitizer).
- `grep -n "data-embed" ../vue-journal-layer/app/utils/htmlGate.ts` prints at least one line, and `cmp ../vue-journal-layer/tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` exits 0.
<!-- planner-discipline-allow: expandEmbeds -->
- `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>
<done>The layer's sanitizer keeps exactly the editor's own markup, 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 stripped.</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>
<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: Blog Editor markup kept by the sanitizer; Journal embed placeholders expand into privacy-enhanced YouTube iframes", the app change (pin #v0.1.1 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.1). 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>
<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 (today ada03b2, pinning #v0.1.1) is what plan 05 deploys to UAT through the host gitlink. The embed expansion and the four mirror rules are harmless before any post holds editor markup.</context>
<options>
<option id="push">
<name>Push now</name>
@@ -188,7 +224,7 @@ README.md: an "Embeds" section describing the placeholder, the allowlist, the ga
<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>
Pin in the site app: in ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app change the vue-journal-layer dependency from #v0.1.1 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>
@@ -219,8 +255,9 @@ Pin in the site app: in ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
| 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-20 | Elevation of Privilege | sanitize-then-expand order | high | mitigate | sanitizeJournalHtml runs first at each v-html sink and expandEmbeds is the last step, so stored raw iframes are stripped and the only iframe is the one built from a validated placeholder; expansion never happens in a payload transform (acceptance grep); tests compose the sink and the payload-then-sink pipelines |
| T-16.2-32 | Tampering (stored XSS) / policy drift | htmlGate.ts mirror extension | high | mitigate | Exactly the four editor-output rules the Go policy gains in plan 03, with the same patterns; no iframe, URL scheme, style or other data attribute; every corpus entry (byte copy of plan 02's corpus, compared with cmp here and in the plan 06 gate) is safe, and the negative cases stay stripped |
| 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; the site's report-only CSP allows frames only from that host (frame-src, added by plan 05) |
| 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>
@@ -233,7 +270,7 @@ Pin in the site app: in ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
<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.
- D-05: the sanitizer mirrors the Go policy's four editor-output rules and nothing more, still runs first at every sink, and expansion is last.
</success_criteria>
<output>

View File

@@ -0,0 +1,247 @@
---
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
plan: 04
subsystem: journal-frontend
tags: [vue-journal-layer, nuxt, sanitize-html, embeds, youtube-nocookie, release, pnpm, vue-grzybyfunkcjonalne-app]
requires:
- phase: 16.2-02
provides: tests/blogeditor/fixtures/editor-corpus.json (the Blog Editor's output corpus) and the placeholder format <figure data-embed="youtube" data-id="ID"></figure>
- phase: 16.1
provides: sanitize-html 2.18.0 mirror (htmlGate.ts, decision note 0001-sanitize-html.md), layer tag v0.1.1, app pin #v0.1.1
provides:
- "vue-journal-layer htmlGate.ts mirror of the Go editor-output extension: figure data-embed ^youtube$, figure data-id ^[A-Za-z0-9_-]{11}$, img alt free text, ol start INTEGER"
- "vue-journal-layer app/utils/embeds.ts: EMBED_PLACEHOLDER, EMBED_PROVIDERS (youtube only), expandEmbeds(html)"
- "JournalPostBody and JournalCategoryHeader render expandEmbeds(sanitizeJournalHtml(html)); expansion is last, payload transforms only sanitize"
- "CSS classes .journal-embed and .journal-embed--youtube (16 / 9 frame)"
- "tests/fixtures/editor-corpus.json, a byte copy of plan 02's corpus"
- "Annotated tag vue-journal-layer v0.2.0 (tag object 042b65d0, commit 759e684e) on origin, installable anonymously over git+https"
- "vue-grzybyfunkcjonalne-app main 465286a pins #v0.2.0 with a lockfile resolving to 759e684e"
affects: [16.2-05, 16.2-06, vue-journal-layer, vue-grzybyfunkcjonalne-app]
actuals:
tokens: 9700
tasks: 3
commits: 3
plan_head_before: 294a8d8b19b999d54cc3b1266d0f42968159f5f2
plan_head_after: 759e684e862bd8a2f34e2bbcdf258f66aeddad4f
commits_repo: ../vue-journal-layer
app_head_before: ada03b202bdfce2c09812dc81c58b0890cf4b86b
app_head_after: 465286a880f0aae6118c2915aa1475a5da353295
app_commits_repo: ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
app_commits: 1
tech-stack:
added: []
patterns:
- "Sanitize first, expand last: the v-html sink runs sanitizeJournalHtml and then expandEmbeds; nothing sanitizes after expansion, and payload transforms never expand"
- "Exact-markup placeholder expansion: one global RegExp matching the sanitizer's serialization, a provider allowlist with an id pattern, and an iframe src built from a fixed host plus the validated id; any other match is returned unchanged"
- "Mirror pinned by the editor's corpus: the layer keeps a byte copy of the Go side's corpus and every entry must be safe under gateJournalHtml"
key-files:
created:
- ../vue-journal-layer/app/utils/embeds.ts
- ../vue-journal-layer/tests/embeds.test.ts
- ../vue-journal-layer/tests/fixtures/editor-corpus.json
modified:
- ../vue-journal-layer/app/utils/htmlGate.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/htmlGate.test.ts
- ../vue-journal-layer/testing/fixtures/acme/posts.json
- ../vue-journal-layer/testing/expect-acme.txt
- ../vue-journal-layer/docs/decisions/0001-sanitize-html.md
- ../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
key-decisions:
- "The layer mirror gained exactly the four editor-output rules of the Go policy (figure data-embed ^youtube$ and data-id ^[A-Za-z0-9_-]{11}$, img alt free text, ol start INTEGER); no fifth rule was needed for the corpus"
- "Embed expansion runs only at the v-html sinks, after sanitizeJournalHtml; the payload (toPostView, CategoryPage) carries the placeholder, never the iframe"
- "Human approved the one-way release at the Task 2 blocking-human checkpoint with the reply \"push\"; vue-journal-layer v0.2.0 is published and any later defect gets v0.2.1"
- "The release commit also moved the README install line from #v0.1.0 to #v0.2.0 so the documented pin matches the published tag"
patterns-established:
- "A layer release is a separate 'chore: release vX.Y.Z' commit (package.json version plus README install line), tagged annotated, pushed after a blocking-human checkpoint, proven by scripts/check-git-install.sh, then pinned in the consuming app with a regenerated lockfile"
requirements-completed: [D-05, D-17]
coverage:
- id: D1
description: "htmlGate.ts mirrors the Go editor-output extension and nothing more; every corpus entry is safe, the placeholder survives two sanitizer passes, negative cases (other provider, 10/12-char id, data-id on div, other data attributes, iframe, ol start=x) are stripped"
requirement: "D-05"
verification:
- kind: unit
ref: "../vue-journal-layer/tests/htmlGate.test.ts (npm test: 136 pass, 0 fail)"
status: pass
- kind: other
ref: "cmp ../vue-journal-layer/tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json (exit 0)"
status: pass
human_judgment: false
- id: D2
description: "expandEmbeds turns only the exact allowlisted placeholder into a youtube-nocookie iframe, last at the post and category sinks; a raw stored iframe is stripped before expansion"
requirement: "D-17"
verification:
- kind: unit
ref: "../vue-journal-layer/tests/embeds.test.ts (npm test: 136 pass, 0 fail)"
status: pass
- kind: integration
ref: "node testing/smoke-ssr.mjs against the built playground: smoke-ssr: ok (29 checks), including 200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"
status: pass
human_judgment: false
- id: D3
description: "vue-journal-layer v0.2.0 is an annotated tag on origin and installs anonymously; the scratch lockfile pins the tag commit and the acme SSR expectations pass"
requirement: "D-17"
verification:
- kind: integration
ref: "bash ../vue-journal-layer/scripts/check-git-install.sh v0.2.0 -> check-git-install: ok v0.2.0 759e684e862bd8a2f34e2bbcdf258f66aeddad4f"
status: pass
human_judgment: false
- id: D4
description: "vue-grzybyfunkcjonalne-app main pins #v0.2.0, its lockfile resolves to 759e684e, it builds, its unit and SSR tests pass, and main equals origin/main"
requirement: "D-17"
verification:
- kind: integration
ref: "Task 3 app verify command -> # pass 26, # fail 0, app-pin-ok; pnpm run smoke:ssr -> smoke-ssr: ok (37 checks)"
status: pass
human_judgment: false
- id: D5
description: "A Blog Editor YouTube embed plays on the deployed grzybyfunkcjonalne.pl UAT page"
requirement: "D-17"
verification: []
human_judgment: true
rationale: "The UAT deploy and the CSP frame-src https://www.youtube-nocookie.com addition belong to plan 05; a real browser on the deployed site is needed to confirm the frame loads"
duration: "about 50 min across two executor runs (Task 3 continuation 7 min)"
completed: 2026-10-10
status: complete
---
# Phase 16.2 Plan 04: Journal Embeds in the Nuxt Layer Summary
**vue-journal-layer v0.2.0 keeps the Blog Editor's markup through its sanitize-html mirror (four rules matching the Go policy) and expands `<figure data-embed="youtube" data-id="…">` placeholders into youtube-nocookie iframes as the last step before v-html. The tag is published and installs anonymously, and vue-grzybyfunkcjonalne-app main pins it.**
## Performance
- **Duration:** about 50 min over two executor runs. The first run covered Task 1, the release commit and the Task 2 checkpoint (layer commits at 10:19Z). This continuation ran Task 3 from 2026-10-10T11:01:53Z to about 11:08Z.
- **Started:** about 2026-10-10T10:14Z, after plan 03 closed. The prior run did not record an exact start time.
- **Completed:** 2026-10-10T11:10Z
- **Tasks:** 3/3 (Task 1 tracer, Task 2 blocking-human decision, Task 3 release)
- **Files modified:** 15 (13 in vue-journal-layer, 2 in vue-grzybyfunkcjonalne-app)
## Accomplishments
- **Mirror (D-05 amended).** `htmlGate.ts` gained exactly the four editor-output rules that plan 03 added to the Go bluemonday policy: figure `data-embed` `^youtube$`, figure `data-id` `^[A-Za-z0-9_-]{11}$`, img `alt` free text and ol `start` INTEGER. Every entry of the byte-copied corpus is safe. The Policy section of decision note 0001 lists the four additions.
- **Embeds (D-17).** `app/utils/embeds.ts` exports `EMBED_PLACEHOLDER`, `EMBED_PROVIDERS` (YouTube only) and `expandEmbeds`. The iframe src is the fixed host `https://www.youtube-nocookie.com/embed/` plus the validated id, and the iframe gets lazy loading, a strict-origin-when-cross-origin referrer policy and a restricted `allow` list.
- **Order.** `JournalPostBody` and `JournalCategoryHeader` render `expandEmbeds(sanitizeJournalHtml(html))`. `toPostView` and `CategoryPage.vue` only sanitize. A raw iframe in stored HTML is stripped before expansion.
- **SSR proof.** The acme fixture post `hello-acme` carries a placeholder, and the built playground renders the iframe for it.
- **Release.** Annotated tag `v0.2.0` is on origin and `check-git-install.sh v0.2.0` passes.
- **App pin.** The site app pins `#v0.2.0` on its pushed main, with a lockfile that resolves to the tag commit.
## Task Commits
| Task | Name | Repo | Commit |
|---|---|---|---|
| 1 | Tracer: a stored placeholder renders as a privacy-enhanced YouTube iframe | vue-journal-layer | `a6ee443` (feat) |
| 2 | Approve pushing vue-journal-layer v0.2.0 and the site app pin (prep: release commit) | vue-journal-layer | `759e684` (chore: release v0.2.0) |
| 3 | Push the layer tag, prove the git install, pin v0.2.0 in the site app | vue-grzybyfunkcjonalne-app | `465286a` (chore(deps)) |
Task 3 also created the annotated tag `v0.2.0` (tag object `042b65d072f276f7ee6f1d8bc6f66f518411161e`, peeled commit `759e684e862bd8a2f34e2bbcdf258f66aeddad4f`).
**Pushed heads:**
- vue-journal-layer `main`: `294a8d8..759e684` (`759e684e862bd8a2f34e2bbcdf258f66aeddad4f`) and tag `v0.2.0`, pushed to git@git.golem15.com:golem15/vue-journal-layer.git.
- vue-grzybyfunkcjonalne-app `main`: `ada03b2..465286a` (`465286a880f0aae6118c2915aa1475a5da353295`), pushed to git@git.golem15.com:golem15/vue-grzybyfunkcjonalne-app.git.
The summercms.go host gitlink and the sm-grzybyfunkcjonalne-app host gitlink were not touched. Plan 05 bumps them.
## Evidence
**Task 1 (tracer):**
- `npm test`: 136 pass, 0 fail.
- `pnpm install --frozen-lockfile` and `build:playground`: OK.
- `smoke-ssr: ok (29 checks)`, including `200 /blog/hello-acme contains=https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ`.
- `cmp tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json`: exit 0.
- Exactly four mirror rules were added: figure data-embed `^youtube$`, figure data-id `^[A-Za-z0-9_-]{11}$`, img alt FREE_TEXT, ol start INTEGER.
**Task 2 (checkpoint:decision, gate blocking-human):**
- The verify command printed `push-precheck-ok`.
- Release sheet:
- Layer commits to push: `a6ee443` feat, `759e684` chore: release v0.2.0.
- Tag `v0.2.0`, message "v0.2.0: Blog Editor markup kept by the sanitizer; Journal embed placeholders expand into privacy-enhanced YouTube iframes".
- App change: pin `#v0.1.1` to `#v0.2.0`, plus the regenerated lockfile.
- Rollback: a defect means v0.2.1, and the app pin can go back to `#v0.1.1`.
- Human reply, verbatim: "push"
**Task 3 (release):**
- Re-check before tagging: `git ls-remote --tags origin refs/tags/v0.2.0` was empty, the layer and app trees were clean, and neither was behind origin/main.
- `git ls-remote --tags origin`: `042b65d0… refs/tags/v0.2.0` and `759e684e… refs/tags/v0.2.0^{}`.
- `bash scripts/check-git-install.sh v0.2.0`: `smoke-ssr: ok (29 checks)`, then `check-git-install: ok v0.2.0 759e684e862bd8a2f34e2bbcdf258f66aeddad4f`.
- App `pnpm install`: `- vue-journal-layer 0.1.0` / `+ vue-journal-layer 0.2.0`. The lockfile diff only touches the layer's specifier, version and resolution `commit: 759e684e862bd8a2f34e2bbcdf258f66aeddad4f`.
- App verify (`grep pin && pnpm install --frozen-lockfile && pnpm build && pnpm test && HEAD == origin/main`): `# pass 26`, `# fail 0`, `app-pin-ok`.
- App `pnpm run smoke:ssr` (extra check): `smoke-ssr: ok (37 checks)`.
## Files Created/Modified
- `../vue-journal-layer/app/utils/embeds.ts`: placeholder RegExp, provider allowlist and `expandEmbeds`.
- `../vue-journal-layer/app/utils/htmlGate.ts`: figure and ol entries and the widened img alt rule.
- `../vue-journal-layer/app/components/Journal/PostBody.vue` and `CategoryHeader.vue`: sanitize, then expand, then v-html.
- `../vue-journal-layer/app/assets/css/journal-base.css`: `.journal-embed` frame at a 16 / 9 aspect ratio.
- `../vue-journal-layer/tests/embeds.test.ts`, `tests/htmlGate.test.ts` and `tests/fixtures/editor-corpus.json`: transform, pipeline and mirror tests, plus the corpus copy.
- `../vue-journal-layer/testing/fixtures/acme/posts.json` and `testing/expect-acme.txt`: the SSR fixture and its expectation.
- `../vue-journal-layer/docs/decisions/0001-sanitize-html.md` and `README.md`: the Policy additions and the Embeds section.
- `../vue-journal-layer/package.json`: version 0.2.0.
- `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` and `pnpm-lock.yaml`: the `#v0.2.0` pin, resolving to 759e684e.
## Decisions Made
- The mirror has exactly four rules, matching the Go policy. The corpus needed no fifth rule.
- Expansion happens only at the sinks and comes last, so the payload carries the placeholder and never an iframe.
- The release is a separate commit, and the README install line moves with the version.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Correctness] README install line bumped with the version**
- **Found during:** Task 2 (release prep, prior run)
- **Issue:** The plan's release commit changes only the package.json version, but the README install snippet still pinned `#v0.1.0`. A consumer copying it would install an outdated layer.
- **Fix:** The release commit also changes the README install line from `#v0.1.0` to `#v0.2.0`.
- **Files modified:** ../vue-journal-layer/README.md
- **Committed in:** `759e684` (chore: release v0.2.0)
**2. [Rule 3 - Blocking] check-git-install could not write the shared /tmp pnpm store**
- **Found during:** Task 3
- **Issue:** The script's scratch app lives under `$TMPDIR` (/tmp), where pnpm picks the store `/tmp/.pnpm-store/v11`. On this machine another local user (`jin`) owns that store, so `pnpm install` failed with `ERR_SQLITE_ERROR attempt to write a readonly database`. The tag was not the cause.
- **Fix:** I re-ran the unchanged script with `TMPDIR` and `npm_config_store_dir`/`pnpm_config_store_dir` pointing into the session scratchpad. The script, the tag and the remote were not touched.
- **Files modified:** none
- **Verification:** `check-git-install: ok v0.2.0 759e684e862bd8a2f34e2bbcdf258f66aeddad4f`
---
**Total deviations:** 2 (1 correctness, 1 environment blocker).
**Impact on plan:** No change to the shipped code. The tag was created and pushed exactly as planned, with no moved tag and no force-push.
## Issues Encountered
- On this machine, `scripts/check-git-install.sh` fails under the default `/tmp` because the store there belongs to another user (see deviation 2). This is a machine-local environment issue, not a layer defect. Anyone re-running the script here should set `npm_config_store_dir`, or a `TMPDIR` whose pnpm store they own.
## User Setup Required
None. No external service configuration is required.
## Next Phase Readiness
- Plan 05 can bump the sm-grzybyfunkcjonalne-app host gitlink to app `465286a`. It also needs to add `frame-src https://www.youtube-nocookie.com` to the report-only CSP, then deploy to UAT.
- The plan 06 phase gate can `cmp` the layer corpus copy against `../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json`.
---
*Phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget*
*Completed: 2026-10-10*
## Self-Check: PASSED
- FOUND: ../vue-journal-layer/app/utils/embeds.ts, tests/embeds.test.ts, tests/fixtures/editor-corpus.json
- FOUND commits: a6ee443, 759e684 (vue-journal-layer), 465286a (vue-grzybyfunkcjonalne-app)
- expandEmbeds count 0 in postView.ts and CategoryPage.vue; app pin #v0.2.0 present; corpus cmp exit 0

View File

@@ -9,11 +9,14 @@ files_modified:
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
- ../sm-grzybyfunkcjonalne-app/README.md
- ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf
- ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh
- ../sm-grzybyfunkcjonalne-app/DEPLOY.md
autonomous: false
requirements: [D-02, D-03, D-05, D-07, D-17, D-18, D-19]
requirements: [D-02, D-03, D-05, D-07, D-10, 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)"
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 (including a save that must strip markup and report it) and checks the public page (D-03, D-05, D-10, 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"
@@ -24,10 +27,11 @@ estimate:
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 (16.1 D-04 pattern): an annotated summercms tag (v0.1.18, or the next free v0.1.x if the concurrent session tagged first; v0.1.17 is the 16.1 security release) 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-03 and todo journal-imported-html-admin-resave on UAT: after a human saves post 64 (ekstrakt-z-turkey-tail: markdown content with the raw author footer; its content_html was re-rendered in 16.1 without the style block, whose CSS now lives in the app's base.css) and one HTML post without editing, md5(content), md5(content_html) and the md5 of their translation rows are unchanged; the read-only snapshot taken first records post 64's current state (whether content and content_html contain a style element) instead of assuming it."
- "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-10 on UAT (amended): a save of the test post after a style block and an onclick attribute were added in the source tab succeeds, the admin sees a notice naming the removed style element and onclick attribute, the editor reloads without them, and the stored content and content_html are equal and contain neither."
- "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:
@@ -64,7 +68,11 @@ ROADMAP Goal (not in user-story format): plugins can register their own admin fo
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.
## Revision 2026-10-10 (16.1 drift, CONTEXT D-10 amended)
16.1 closed after this plan was written. The release facts below are refreshed: v0.1.17 is taken, so this phase tags v0.1.18 or the next free tag; the plugin head is c60abc6; the layer is at v0.1.1. Posts 53 and 64 were re-saved on UAT, so post 64's content_html no longer has its style block, and the snapshot records its state read-only instead of assuming it. The UAT refusal step is replaced by a strip-and-report step (amended D-10). The public vhost now sends a report-only CSP, so the host commit also allows the youtube-nocookie frame.
Wave order: 01 and 02 (wave 1), 03 and 04 (wave 2; 04 needs plan 02's editor corpus), 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
@@ -72,14 +80,14 @@ Visible skip: "spec-less probe fallback skipped: phase has no requirement IDs to
## Artifacts this phase produces (this plan)
- summercms annotated tag `v0.1.17` (or the next free `v0.1.x`) on origin.
- summercms annotated tag `v0.1.18` (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.
- sm-grzybyfunkcjonalne-app master on origin: journal and vue-grzybyfunkcjonalne-app gitlink bumps, the README admin note, and `frame-src https://www.youtube-nocookie.com` in the staging vhost's report-only CSP (with its check-deploy.sh needle and the DEPLOY.md line).
- 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.
- Session values recorded in the SUMMARY: `RELEASE_TAG`, `RESTART_TS`, `HTML_POST`, `POST64_STATE`, 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.
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-10, D-17, D-18 and D-19 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.
@@ -101,7 +109,7 @@ Output: pushed tag and branches, a running UAT release, a passing on-server smok
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.
Release state at planning time (revised 2026-10-10, after 16.1 closed): summercms latest tag v0.1.17 (the 16.1 security release, tag commit c21fe36), so this phase's tag is v0.1.18 unless someone tags first; sm-journal-plugin pre-phase head c60abc6 (go.mod requires v0.1.17 with a replace to ../summercms.go); host sm-grzybyfunkcjonalne-app master 0b93ac2 with journal gitlink c60abc6 and vue-grzybyfunkcjonalne-app gitlink ada03b2; vue-journal-layer v0.1.1 (294a8d8, already ships sanitize-html) before plan 04, and the app pins #v0.1.1. UAT data: posts 53 and 64 were re-saved during 16.1; post 64's content_html no longer holds its style block (the footer CSS moved to the app's base.css, 2c46185).
</context>
<tasks>
@@ -113,11 +121,11 @@ Release state at planning time: summercms latest tag v0.1.16; sm-journal-plugin
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.
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.18 if nobody tagged after v0.1.17) 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>
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, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML and TestCategoriesAdminContent; ./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>
@@ -134,13 +142,13 @@ Release sheet: the proposed or reused tag; commits to push per repository (summe
<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>
<cons>UAT keeps the markdown field, whose admin save renders content through goldmark and the 16.1 sanitizer</cons>
</option>
</options>
<resume-signal>Reply "release vX" (release-now), "docker passed, release vX" (docker-first, after the human ran the suites), or "hold".</resume-signal>
<verify>
<automated>go vet ./... &amp;&amp; go -C ../sm-journal-plugin vet ./... &amp;&amp; go test -count=1 -run '^(TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase09ContractInventory|TestPhase09PermissionMatrix|TestListRelationSearchSmoke|TestEnabledLocalesDefaultFirstSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 6 &amp;&amp; go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... &amp;&amp; go test -count=1 ./cmd/summer -run TestDocsTree &amp;&amp; npm --prefix admin test &amp;&amp; bash scripts/check-admin-dist.sh &amp;&amp; bash scripts/check-admin-openapi.sh --check &amp;&amp; ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin &amp;&amp; bash ../sm-journal-plugin/scripts/check-dist.sh &amp;&amp; echo release-gate-ok</automated>
<fails_when>Non-zero exit or "release-gate-ok" absent: vet fails in either repository, fewer than 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>
<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 (including the editor-output policy cross-check and the removal report tests), TestDocsTree fails, a vitest suite fails (including the editor corpus test), 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.
@@ -153,19 +161,19 @@ Release sheet: the proposed or reused tag; commits to push per repository (summe
<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".
<files>../sm-journal-plugin/go.mod, ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app, ../sm-grzybyfunkcjonalne-app/README.md, ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf, ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh, ../sm-grzybyfunkcjonalne-app/DEPLOY.md</files>
<read_first>../sm-grzybyfunkcjonalne-app/DEPLOY.md (Build and upload, Release, Smoke, Security headers), ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf (line 91: Content-Security-Policy-Report-Only), ../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), save notices (meta.notices), 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.
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), that saving an untouched post keeps its stored HTML, and that a save removes markup the HTML policy does not allow and names what it removed. Embeds and the public CSP (D-17): the staging vhost's Content-Security-Policy-Report-Only (16.1) has default-src 'self' and no frame-src, so every embed would log a violation and block the later switch to an enforced policy. Add frame-src https://www.youtube-nocookie.com to that policy in deploy/nginx/grzybyfunkcjonalne-staging.conf, add the needle frame-src https://www.youtube-nocookie.com to the vhost checks in scripts/check-deploy.sh, and name it in DEPLOY.md's Security headers bullet. Installing a changed vhost on UAT needs root (16.1 UAT item 1), which the executor does not have: record it in the SUMMARY as a pending human step; it blocks nothing here, because the policy only reports. Commit both gitlinks, README.md and the three deploy files 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>
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. Record post 64's current state as POST64_STATE with one more read-only SELECT printing (content LIKE '%<style%')::int || ':' || (content_html LIKE '%<style%')::int for id 64. 16.1 left content_html without the style block (expected x:0); record whatever it prints, and report a 1 in the second position in the SUMMARY instead of treating it as a failure. The untouched re-save in Task 3 must not change either column, whatever their state.</action>
<verify>
<automated>ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" &gt; "$C/.curlrc"; cd /srv/grzybyfunkcjonalne/go &amp;&amp; CURL_HOME="$C" scripts/smoke.sh --url https://uat.grzybyfunkcjonalne.pl; rc=$?; rm -rf "$C"; exit $rc' | grep '^smoke: ok ('</automated>
<fails_when>Non-zero exit: smoke.sh failed on the server, the output contains "smoke: FAIL", or no line starts with "smoke: ok (".</fails_when>
@@ -176,19 +184,20 @@ Snapshot for Task 3 (read-only): pick HTML_POST as the lowest published post id
- `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.
- `grep -c "frame-src https://www.youtube-nocookie.com" ../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf ../sm-grzybyfunkcjonalne-app/scripts/check-deploy.sh` reports at least 1 for each file, and check-deploy.sh passed before the build; the SUMMARY lists the vhost install as a pending root step.
- 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.
- The SUMMARY records the tag, the pushed heads, both sha256 values, RESTART_TS, HTML_POST, POST64_STATE 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>
<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; unsafe markup is stripped and reported, never refused</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.
Human steps in https://uat.grzybyfunkcjonalne.pl/gfadmin/golem15/journal/posts: (1) open post 64 (ekstrakt-z-turkey-tail: markdown content with the raw author footer, served in the editor as its content_html, which 16.1 re-rendered without the style block) and post HTML_POST; on each press "Zapisz" without touching the editor (D-03); no removal notice may appear. (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 containing a colon and a caption, and a YouTube embed from a youtube.com URL; save; no removal notice may appear (D-02, D-05, D-18). (3) Still unpublished, in the source tab of the test post add a harmless style block that colours paragraphs red and an onclick="void 0" attribute on one paragraph, then save (D-10 amended): the save succeeds, a notice under the field names the removed style element and the onclick attribute, and the editor reloads without them. (4) Publish the post for the site check, then reply with what the notice in step 3 said.
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>
@@ -196,23 +205,23 @@ After the reply run the verify commands, then tell the human to unpublish or del
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>
<resume-signal>Type "saved and published" once steps 1 to 4 are done, with the step 3 notice text, 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 - &lt;(printf '%s\n' "$SNAPSHOT_POSTS") &amp;&amp; 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 - &lt;(printf '%s\n' "$SNAPSHOT_TRANSLATIONS") &amp;&amp; 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 '%&lt;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' &amp;&amp; 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 "psql -X -d grzybyfunkcjonalne -tAc \"SELECT (content = content_html)::int || ':' || (content LIKE '%data-embed=_youtube_%')::int || ':' || (content LIKE '%&lt;figcaption%')::int || ':' || (content LIKE '%/storage/app/uploads/public/journal/posts/%')::int || ':' || (position('&lt;style' in content) = 0 AND position('onclick' in content) = 0)::int FROM golem15_journal_posts WHERE slug = 'uat-16-2-blog-editor'\"" | grep -qx '1:1:1:1:1' &amp;&amp; echo edit-ok</automated>
<fails_when>Non-zero exit or "edit-ok" absent: the test post is missing, content differs from content_html, it lacks the embed placeholder (the policy stripped data-embed), the figcaption or an uploaded image URL under journal/posts, or the step 3 style element or onclick attribute was stored (the strip did not happen).</fails_when>
<automated>ssh grzybyfunkcjonalne-uat 'C=$(mktemp -d); printf "resolve = uat.grzybyfunkcjonalne.pl:443:127.0.0.1\n" &gt; "$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$' &amp;&amp; ssh grzybyfunkcjonalne-uat 'sudo supervisorctl tail -65536 grzybyfunkcjonalne-go' | awk -v since="$RESTART_TS" 'substr($0,1,19) &gt;= since' | grep -ciE 'panic|SQLSTATE' | grep -qx 0 &amp;&amp; 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>
<human-check>In the admin (D-19): open a category with content, change one word in the Blog Editor and save; no removal notice appears (all 6 UAT category bodies passed the policy in 16.1) and 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.
- The SUMMARY records the human's replies, the step 3 notice text, 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>
<done>Untouched saves of imported posts are byte-identical on UAT, a Blog Editor edit with an upload and an embed is stored in both columns and renders on the site, and unsafe markup added in the source tab is stripped and reported instead of refused.</done>
</task>
</tasks>
@@ -235,6 +244,7 @@ After the reply run the verify commands, then tell the human to unpublish or del
| 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-35 | Tampering | UAT strip check (Task 3 step 3) | low | mitigate | Only harmless markup is added (a red-paragraph style block and onclick="void 0"), on an unpublished test post; the strip is verified read-only before the post is published for the site check; the layer sanitizes again at render; the test post is unpublished or deleted afterwards |
| 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>
@@ -247,9 +257,9 @@ After the reply run the verify commands, then tell the human to unpublish or del
<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.
- D-03 proven on real imported posts; D-02, D-05, D-10 (strip and report), D-17 and D-18 proven with a test post, D-19 with a category edit; 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.
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, POST64_STATE, the snapshot and every check result.
</output>

View File

@@ -9,6 +9,7 @@ files_modified:
- modules/cabana/field_type_test.go
- modules/cabana/field_type_postgres_test.go
- admin/tests/form/PluginField.test.ts
- admin/tests/form/FormField.test.ts
- admin/tests/form/registry.test.ts
- admin/tests/form/formState.test.ts
- admin/tests/app/fieldTypes.test.ts
@@ -16,6 +17,7 @@ files_modified:
- 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/editor_policy_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
@@ -25,22 +27,25 @@ files_modified:
- ../sm-journal-plugin/tests/blogeditor/serializer.test.ts
- ../sm-journal-plugin/tests/blogeditor/embed.test.ts
- ../vue-journal-layer/tests/embeds.test.ts
- ../vue-journal-layer/tests/htmlGate.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]
requirements: [D-02, 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
tokens: 125000
raw_tokens: 125000
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."
- "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 and reports 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 with no meta.notices, 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-10 and D-14 (framework): a FormFieldError on the default or a non-default locale is a 422 on the field with no write and no notices; a non-FormFieldError and a non-string translatable Decode value are opaque 500s; FormFieldDecoded notices become meta.notices entries with field and locale, localized (plural forms through Count), at most 10 per field and locale and 500 runes each; a save without notices has no notices key; the SPA renders notices as text under the field and in the saved toast."
- "D-10 and D-02 (Journal, amended): SanitizeHTMLReport returns the input byte for byte with an empty report exactly when the policy keeps everything (property checked over the 35 unsafe payloads, the imported-post fixture and the editor corpus); post and category admin saves strip and report, never answer 422 for markup, and store equal sanitized HTML in content and content_html per locale; an untouched save writes and reports nothing."
- "D-05 (amended): every editor-corpus entry passes the Go policy byte for byte and is safe under the layer's sanitizer, the two corpus copies are identical, and the four editor-output rules admit nothing else (other providers, malformed ids, data attributes elsewhere, iframes stay stripped in both policies)."
- "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."
- "D-17 and D-05: expandEmbeds expands only exact allowlisted placeholders and runs last, after sanitizeJournalHtml at each sink; a stored iframe never reaches the output; parseEmbedUrl accepts only the four YouTube URL forms."
- "Postgres-backed tests (cabana TestFieldTypeSavePostgres, the sm-journal-plugin admin harness tests including TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent and TestCategoriesAdminBlogEditor) 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"
@@ -68,7 +73,7 @@ must_haves:
- 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."
- statement: "Tests are not weakened to pass: every existing assertion that still describes the shipped behaviour stays (FormatHTML strip and byte-stability cases, the 35 unsafe payloads, the duplicate-attribute rule, CheckHTML as a predicate, upload sniffing, alias collision, upload permission, sanitize-then-expand order, widget, ML, list-state and SSR expectations); only the two category 422 assertions retired by amended D-10 became strip-and-report assertions (plan 03), and nothing else is relaxed."
status: unresolved
verification: judgment
---
@@ -79,16 +84,20 @@ ROADMAP Goal (not in user-story format): plugins can register their own admin fo
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.
## Revision 2026-10-10 (16.1 drift, CONTEXT D-10 amended)
Admin saves now strip and report instead of refusing, so the refusal-case assertions planned here became strip-and-report assertions: a FormFieldError still answers 422, but markup never does. The plan adds the policy cross-check (the editor corpus against both policies, and the identity of the two copies), category strip coverage, "notices only when something was stripped" and "an untouched save writes and reports nothing". The tests that still hold are unchanged in intent: upload sniffing, alias collision, permission, sanitize-then-expand order, byte-stability, the duplicate-attribute rule.
## 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`).
- Go tests: `TestFormFieldErrorMessage` (pact); `TestFieldTypeRegistry`, `TestFieldTypeOptions`, `TestFieldTypeCompile`, `TestFieldTypeDecode`, `TestFieldTypeNotices`, `TestFieldTypeUntouched`, `TestFieldTypeLoad`, `TestFieldTypeListValue`, `TestFieldTypeWrites`, `TestFieldTypesRoute`, `TestFieldUpload` (cabana); `TestFieldTypeSavePostgres` (cabana, Docker); `TestBlogEditorDecode`, `TestBlogEditorNotices`, `TestBlogEditorLoad`, `TestLooksLikeHTML`, `TestBlogEditorOptions`, `TestBlogEditorListValue`, `TestBlogEditorUpload`, `TestStoreMedia` and the extended `TestEditorOutputPassesPolicy` and `TestSanitizeHTMLReport` (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`, `FormField.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` and `tests/htmlGate.test.ts`.
- `scripts/check-phase16.2.sh` (stages `go`, `admin`, `editor`, `layer`, `docs`, `postgres`; the layer stage also compares the two editor-corpus copies; 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.
@@ -126,11 +135,11 @@ Element (element.test.ts, happy-dom): for the imported-post fixture and for a ma
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.
cabana (field_type_test.go, TestFieldTypeUntouched): with a translatable stub type (whose Decode always returns a notice) and a recording writer, an update whose submitted pl and en values equal servedFieldValue writes nothing and reports nothing (no projected key, no WriteTranslated call, no FieldWrite, no notice); one changed locale writes and reports 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.
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 and that the PUT response has no meta.notices key, then PUTs an edited pl and en value of clean HTML and asserts content equals content_html for pl, that the en content_html translation equals the en content, and that no notices are reported.
Commit per repository as test(...) commits, staged by explicit path.</action>
<verify>
@@ -139,7 +148,7 @@ Commit per repository as test(...) commits, staged by explicit path.</action>
</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).
- TestPostsAdminResaveKeepsHTML compares bytes without TrimSpace and covers an HTML post, a markdown post and an edited save, and asserts no meta.notices on the untouched and clean saves (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>
@@ -147,28 +156,30 @@ Commit per repository as test(...) commits, staged by explicit path.</action>
<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>
<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/editor_policy_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/relation_field.go (RecordMeta.Notices, FieldNotice), 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/format_html.go (SanitizeHTMLReport, newHTMLPolicy), ../sm-journal-plugin/classes/editor_policy_test.go (plan 03), ../sm-journal-plugin/classes/format_html_test.go (unsafePayloads), ../sm-journal-plugin/lang/en/lang.yaml, ../sm-journal-plugin/lang/pl/lang.yaml, ../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, ../sm-journal-plugin/journal_api_categories_test.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.
- Decode/Load: default and non-default FormFieldError become a 422 on the field with no write and no notices; other errors and a non-string translatable Decode value are opaque 500 without the plugin text; nil Load keeps the stored value; Load errors are opaque 500.
- Notices: a Decode notice becomes one FieldNotice with field and locale (none for a non-translatable type); Count selects the plural form and Params fill placeholders through a real phrasebook translator; text that is not a phrase key passes through; more than 10 notices per field and locale are cut to 10 and a message over 500 runes is cut with an ellipsis; the encoded RecordMeta has no notices key when there are none.
- 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.
- Journal: Decode table (clean string unchanged with no notice; nil gives ""; non-string FormFieldError; each of the 35 unsafePayloads stripped with at least one notice and an output equal to SanitizeHTML; the placeholder and every corpus entry unchanged with no notice); removal notices (order elements, attributes, comments, other; Count; names joined; ellipsis after 10 names; every key present in lang/en with one and other and in lang/pl with one, few, many and other); SanitizeHTMLReport property over the unsafePayloads, the imported-post fixture and the corpus (report empty exactly when the output equals the input); the corpus cross-check negatives (other provider, 10- and 12-character ids, data attributes on other elements, extra data attribute on figure, onload on the placeholder, iframe); 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 content stored stripped with a notice in meta.notices and no 422, safe content byte-identical with no notices key.
</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>
<action>Write the Go tests listed in the behavior block (per D-02, D-05, D-10, D-11, D-12, D-14, D-18, D-19), table-driven where the cases share a shape. Extend plan 03's TestEditorOutputPassesPolicy and TestSanitizeHTMLReport in place rather than duplicating them. 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 &amp;&amp; go test -count=1 ./modules/pact &amp;&amp; go test -count=1 -run '^(TestFieldTypeRegistry|TestFieldTypeOptions|TestFieldTypeCompile|TestFieldTypeDecode|TestFieldTypeUntouched|TestFieldTypeLoad|TestFieldTypeListValue|TestFieldTypeWrites|TestFieldTypesRoute|TestFieldUpload|TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke)$' -v ./modules/cabana 2&gt;&amp;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 ./... &amp;&amp; go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorDecode|TestBlogEditorLoad|TestLooksLikeHTML|TestBlogEditorOptions|TestBlogEditorListValue|TestBlogEditorUpload|TestStoreMedia)$' -v ./classes/ 2&gt;&amp;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>
<automated>go vet ./modules/pact ./modules/cabana &amp;&amp; go test -count=1 ./modules/pact &amp;&amp; go test -count=1 -run '^(TestFieldTypeRegistry|TestFieldTypeOptions|TestFieldTypeCompile|TestFieldTypeDecode|TestFieldTypeNotices|TestFieldTypeUntouched|TestFieldTypeLoad|TestFieldTypeListValue|TestFieldTypeWrites|TestFieldTypesRoute|TestFieldUpload|TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke)$' -v ./modules/cabana 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 13</automated>
<fails_when>Non-zero exit: vet or the pact package fails, or fewer than thirteen top-level "--- PASS: Test" lines for the named cabana tests (one missing or failing).</fails_when>
<automated>go -C ../sm-journal-plugin vet ./... &amp;&amp; go -C ../sm-journal-plugin test -count=1 ./classes/ ./controllers/... &amp;&amp; go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorDecode|TestBlogEditorNotices|TestBlogEditorLoad|TestLooksLikeHTML|TestBlogEditorOptions|TestBlogEditorListValue|TestBlogEditorUpload|TestStoreMedia|TestEditorOutputPassesPolicy|TestSanitizeHTMLReport)$' -v ./classes/ 2&gt;&amp;1 | grep -c '^--- PASS: Test' | grep -qx 10</automated>
<fails_when>Non-zero exit: vet fails, a classes or controllers test fails, or fewer than ten "--- PASS: Test" lines for the named Journal classes tests (a strip, notice, report, corpus or upload case regressed).</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.
- The thirteen named cabana tests and ten named Journal classes tests pass; `go -C ../sm-journal-plugin test -count=1 -run '^TestPostsEditorHooks$' ./controllers/` passes.
- `git -C ../sm-journal-plugin diff --quiet c60abc6 -- classes/format_html_test.go classes/check_html_test.go classes/format_translation_test.go` exits 0 (the 16.1 policy tests are not weakened).
- 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>
@@ -176,18 +187,18 @@ Commit per repository as test(...) commits, staged by explicit path.</action>
<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>
<files>admin/tests/app/fieldTypes.test.ts, admin/tests/form/registry.test.ts, admin/tests/form/formState.test.ts, admin/tests/form/FormField.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, ../vue-journal-layer/tests/htmlGate.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/app/utils/htmlGate.ts, ../vue-journal-layer/app/components/Journal/PostBody.vue, ../vue-journal-layer/tests/embeds.test.ts, ../vue-journal-layer/tests/htmlGate.test.ts, ../sm-journal-plugin/tests/blogeditor/corpus.test.ts, admin/src/components/form/FormField.vue, 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.
- 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; noticeLines groups notices by field with the locale prefix. FormField: notices render as literal text in a status line linked by aria-describedby, none without notices. 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.
- Embed: parseEmbedUrl table (watch, youtu.be, shorts, embed, extra query params, 10 and 12 character ids, other hosts, javascript:), node round trip, insert flow; isPolicyUrl table (http, https, mailto, relative, #fragment accepted; javascript:, vbscript:, data:, tel: and tab- or newline-split schemes refused); 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); the sanitize-then-expand pipeline at the sink and the payload-then-sink pipeline (stored iframe stripped, placeholder expanded, expansion output never sanitized again); the mirror: every corpus entry safe, the four rules accepted, the negative cases stripped, existing htmlGate cases unchanged.
- 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); the editor stage includes corpus.test.ts and the go stage the Journal policy cross-check; the layer stage fails when the layer's tests/fixtures/editor-corpus.json differs from the plugin's (cmp); --self-test proves the detectors and the corpus comparison fail on planted output; --all ends with "Phase 16.2 gate passed"; the postgres stage runs the Docker suites (including TestPostsAdminSaveSanitizesHTML and TestCategoriesAdminContent) 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>
<action>Write the JS tests in the behavior block (per D-03, D-04, D-05, D-10, 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, including the strip, notices, corpus and category rows added in the 2026-10-10 revision), 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 and the UAT strip check, 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 &amp;&amp; ../sm-journal-plugin/node_modules/.bin/vitest run --root ../sm-journal-plugin &amp;&amp; npm --prefix ../vue-journal-layer test 2&gt;&amp;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>
@@ -218,7 +229,7 @@ Commit per repository as test(...) commits, staged by explicit path.</action>
|-----------|----------|-----------|----------|-------------|-----------------|
| 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-30 | Tampering (security regression) | strip, policy and permission tests | medium | mitigate | The strip-and-report cases (35 payloads, report property, category strip), the editor-corpus cross-check against both policies and the corpus-copy comparison, upload sniffing, alias collision, upload permission and sanitize-then-expand order are explicit tests in the fail-closed gate, and the 16.1 policy test files are diff-checked against c60abc6, 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>

View File

@@ -21,17 +21,17 @@ Out of scope: a core cabana `richeditor` type, a media library browser, and HTML
### 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-02:** **An admin save stores HTML in both columns.** `content` holds the edited HTML and `content_html` holds the same HTML after the sanitizer (D-10); both columns store the sanitized HTML. 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-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 the sanitizer (D-10).
- **D-05:** **Embeds are an allowlisted placeholder node**, for example `<figure data-embed="youtube" data-id="…">`, that passes the bluemonday policy (and the layer's sanitize-html policy) byte for byte; both policies are extended for exactly the editor's own output (`figure` `data-embed`/`data-id`, and any image or table attributes the editor emits), pinned by a test. **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`).
- **D-10 (amended 2026-10-10, supersedes the original refuse-only gate):** **Admin save gate: strip, never refuse.** Every admin write of post and category content (default and non-default locale) stores the output of the shared bluemonday policy (`classes.SanitizeHTML`, decision note `journal-html-sanitizing-bluemonday.md`), which returns the bytes unchanged when nothing is stripped. A save never fails because of unsafe markup. When the policy removed anything, the save response tells the admin what was removed (for example "2 elements were removed: style, iframe") and the form reloads the stored value; a save that removed nothing keeps today's response shape. 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`). — *Original text: "`CheckHTML` refuses, nothing is sanitized … No sanitizer dependency is added." Superseded because 16.1 adopted bluemonday and the user chose strip (WordPress/Winter-post behaviour) so an edit never fails on legacy blocks the author did not touch.*
### 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.
@@ -50,7 +50,7 @@ Out of scope: a core cabana `richeditor` type, a media library browser, and HTML
- **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.
- **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. Category content follows D-10 (amended): it is stripped like posts, not refused. This replaces the 422 `CheckHTML` gate in the Categories controller's `FormBeforeCreate`/`FormBeforeUpdate` (Winter stored category content unsanitized; that was a gap, not a contract to keep).
### 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.

View File

@@ -7,6 +7,7 @@ status: draft
nyquist_compliant: false
wave_0_complete: false
created: "2026-10-09"
revised: "2026-10-10"
---
# Phase 16.2 — Validation Strategy
@@ -42,16 +43,23 @@ created: "2026-10-09"
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 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.2 / D-10 | XSS | Decode value stored per locale; FormFieldError → 422 with no write; markup never answers 422 | unit | `go test -short ./modules/cabana -run TestFieldTypeDecode` | ❌ W0 | ⬜ pending |
| TBD | — | — | D-10 | — | Decode notices → meta.notices (field, locale, localized, capped); no notices key when nothing was reported | unit | `go test -short ./modules/cabana -run TestFieldTypeNotices` | ❌ W0 | ⬜ pending |
| TBD | — | — | D-10 / D-02 | XSS | Journal Decode strips per locale and reports what it removed; clean HTML byte-identical with no notice; report empty exactly when nothing changed | unit | `go -C ../sm-journal-plugin test -short ./classes -run 'TestBlogEditorDecode\|TestBlogEditorNotices\|TestSanitizeHTMLReport'` | ❌ W0 | ⬜ pending |
| TBD | — | — | D-05 | XSS | Editor-output corpus passes SanitizeHTML byte for byte; the four editor rules admit nothing else; iframe still stripped | unit | `go -C ../sm-journal-plugin test -short ./classes -run TestEditorOutputPassesPolicy` | ❌ W0 | ⬜ pending |
| TBD | — | — | D-10 | XSS | Save notices render as text under the field and in the toast; stored value reloaded into the element | vitest | `npm --prefix admin test -- FormField formState PluginField` | ❌ W0 | ⬜ pending |
| TBD | — | — | D-03 | — | Untouched value writes nothing and reports 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 | — | — | D-03 / D-04 | XSS | Element round-trips; rawHtml card shows escaped text; links limited to http, https, mailto, relative | vitest | `npm --prefix ../sm-journal-plugin test` | ❌ W0 | ⬜ pending |
| TBD | — | — | D-05 | — | Committed editor corpus equals the editor's current output | vitest | `npm --prefix ../sm-journal-plugin test -- corpus` | ❌ W0 | ⬜ pending |
| TBD | — | — | todo resave | — | Untouched re-save byte-identical, no notices | integration (Postgres) | `go -C ../sm-journal-plugin test -run TestPostsAdminResaveKeepsHTML ./...` | ✅ (tighten) | ⬜ pending |
| TBD | — | — | D-10 / D-02 | XSS | Admin post save strips unsafe markup in both columns per locale and reports it (no 422) | integration (Postgres) | `go -C ../sm-journal-plugin test -run TestPostsAdminSaveSanitizesHTML ./...` | ✅ (rewrite) | ⬜ pending |
| TBD | — | — | D-19 / D-10 | XSS | Category content stripped with a notice, blank to NULL, no 422 | integration (Postgres) | `go -C ../sm-journal-plugin test -run 'TestCategoriesAdminContent\|TestCategoriesAdminBlogEditor' ./...` | ✅ (rewrite) | ⬜ pending |
| TBD | — | — | D-17 / D-05 | iframe | Embed allowlist and id regex; sanitize first, expand last at each sink; stored iframe stripped; corpus safe under the mirror; corpus copies identical | node:test + cmp | `npm --prefix ../vue-journal-layer test && cmp ../vue-journal-layer/tests/fixtures/editor-corpus.json ../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` | ❌ W0 | ⬜ pending |
| TBD | — | — | docs | — | README/docs identifiers exist | unit | `go test ./cmd/summer -run TestDocsTree` | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
@@ -61,6 +69,8 @@ created: "2026-10-09"
## Wave 0 Requirements
- [ ] `../sm-journal-plugin/package.json`, `package-lock.json`, `vite.config.ts`, vitest + happy-dom setup, `scripts/check-dist.sh`
- [ ] `../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` (plan 02) and its layer copy `../vue-journal-layer/tests/fixtures/editor-corpus.json` (plan 04)
- [ ] `../sm-journal-plugin/classes/editor_policy_test.go` (plan 03)
- [ ] `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`
@@ -72,7 +82,8 @@ created: "2026-10-09"
| 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 |
| Editing imported posts on UAT | Phase goal | Real browser editing of live posts | Open an imported post, save untouched (no notice, md5 unchanged), then edit and save; compare columns |
| Strip and report on UAT | D-10 (amended) | Needs the admin session and a real save | Add a harmless style block and onclick in the source tab of an unpublished test post, save: it succeeds, the notice names both, the stored columns are equal and contain neither (plan 05 Task 3) |
---

View File

@@ -142,7 +142,7 @@
"next": {
"command": "/msd:progress --next",
"label": "Advance to the next step",
"reason": "Phase 16.1 of 34 · executing"
"reason": "Phase 16.2 of 34 · executing"
},
"updated_at": "2026-10-09T18:51:01.734Z"
"updated_at": "2026-10-10T11:07:18.598Z"
}

View File

@@ -514,6 +514,21 @@
],
"type": "object"
},
"cabana.Envelope-cabana_FieldUploadResponse": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.FieldUploadResponse"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_FileItem": {
"properties": {
"data": {
@@ -680,6 +695,24 @@
],
"type": "object"
},
"cabana.FieldNotice": {
"properties": {
"field": {
"type": "string"
},
"locale": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": [
"field",
"message"
],
"type": "object"
},
"cabana.FieldPreset": {
"properties": {
"field": {
@@ -695,6 +728,68 @@
],
"type": "object"
},
"cabana.FieldTypeAssets": {
"properties": {
"element": {
"type": "string"
},
"name": {
"type": "string"
},
"scripts": {
"items": {
"type": "string"
},
"type": "array"
},
"styles": {
"items": {
"type": "string"
},
"type": "array"
},
"translatable": {
"type": "boolean"
}
},
"required": [
"element",
"name",
"scripts",
"styles",
"translatable"
],
"type": "object"
},
"cabana.FieldTypesResponse": {
"properties": {
"data": {
"items": {
"$ref": "#/components/schemas/cabana.FieldTypeAssets"
},
"type": "array"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.FieldUploadResponse": {
"properties": {
"url": {
"type": "string"
}
},
"required": [
"url"
],
"type": "object"
},
"cabana.FileItem": {
"properties": {
"content_type": {
@@ -802,6 +897,10 @@
"displayFormat": {
"type": "string"
},
"element": {
"description": "Element is the custom-element tag of a field whose type a plugin\nregisters (pact.HasFormFieldTypes); the admin renders it for the field.",
"type": "string"
},
"emptyOption": {
"type": "string"
},
@@ -935,6 +1034,10 @@
],
"description": "ThumbOptions carries the preview thumbnail mode."
},
"translatable": {
"description": "Translatable is true when a plugin field type's value is a map of\ncontent locale to string.",
"type": "boolean"
},
"twelveHour": {
"description": "TwelveHour shows a 12-hour clock.",
"type": "boolean"
@@ -942,6 +1045,11 @@
"type": {
"type": "string"
},
"typeOptions": {
"additionalProperties": {},
"description": "TypeOptions are a plugin field type's own fields.yaml keys and values;\nthe admin hands them to the element as JSON.",
"type": "object"
},
"useCaption": {
"description": "UseCaption lets the admin edit each file's title and description.",
"type": "boolean"
@@ -1643,6 +1751,12 @@
"type": "array"
},
"type": "object"
},
"notices": {
"items": {
"$ref": "#/components/schemas/cabana.FieldNotice"
},
"type": "array"
}
},
"required": [
@@ -2496,6 +2610,62 @@
]
}
},
"/field-types": {
"get": {
"description": "Every admin form field type a plugin registers (pact.HasFormFieldTypes), sorted by name, with the URLs of its scripts and stylesheets (each with a ?v= content hash). The admin loads every script once after sign-in and keeps every stylesheet enabled on all pages; a form field whose `element` is set renders that custom element. An empty list when no plugin registers a type. Any backend session may call it.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.FieldTypesResponse"
}
}
},
"description": "OK"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Plugin field types",
"tags": [
"admin"
]
}
},
"/lang": {
"get": {
"description": "Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.",
@@ -3079,7 +3249,7 @@
]
},
"post": {
"description": "Relation fields are sent by field name with ids ({\"genre\": 3, \"artists\": [4, 9]}); the response carries the same shape plus meta.labels.",
"description": "Relation fields are sent by field name with ids ({\"genre\": 3, \"artists\": [4, 9]}); the response carries the same shape plus meta.labels. A plugin field type's value is decoded by its type inside the save; when a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.",
"parameters": [
{
"description": "Vendor",
@@ -3757,7 +3927,7 @@
},
"/{vendor}/{plugin}/{controller}/schema/form": {
"get": {
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.",
"description": "The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys).",
"parameters": [
{
"description": "Vendor",
@@ -4442,6 +4612,7 @@
]
},
"put": {
"description": "A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.",
"parameters": [
{
"description": "Vendor",
@@ -4696,6 +4867,149 @@
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload": {
"post": {
"description": "Stores one multipart part named file through the Upload of the field's plugin field type (pact.FormFieldType) and answers its URL. id 0 is the create form and passes no record; any other id loads the record through the controller's FormExtendQuery scope. A field that is not in the form, has a built-in type, has a type without Upload or whose context does not allow the operation answers 404, as does a record outside the scope. A body over http.body_limits.upload_bytes answers 413; a malformed body, a second part or a value the type refuses (pact.FormFieldError) answers 422 on the field.",
"parameters": [
{
"description": "Vendor",
"in": "path",
"name": "vendor",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Plugin",
"in": "path",
"name": "plugin",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Controller",
"in": "path",
"name": "controller",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Record id (0 for the create form)",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "integer"
}
},
{
"description": "Field name",
"in": "path",
"name": "field",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"properties": {
"file": {
"description": "The file",
"format": "binary",
"type": "string"
}
},
"required": [
"file"
],
"type": "object"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_FieldUploadResponse"
}
}
},
"description": "Created"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unauthorized"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Forbidden"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Not Found"
},
"413": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Request Entity Too Large"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.ErrorEnvelope"
}
}
},
"description": "Unprocessable Entity"
}
},
"security": [
{
"BackendBearer": []
}
],
"summary": "Upload a file for a plugin field type",
"tags": [
"admin"
]
}
},
"/{vendor}/{plugin}/{controller}/{id}/files/{field}": {
"get": {
"description": "The files attached to the record minus the session's pending removals, plus the session's pending uploads, in sort_order. id 0 is the record being created in the X-Session-Key session (the key is then required). url and thumb_url are set only for a public relation.",

View File

@@ -449,6 +449,72 @@ export interface paths {
patch?: never;
trace?: never;
};
"/field-types": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Plugin field types
* @description Every admin form field type a plugin registers (pact.HasFormFieldTypes), sorted by name, with the URLs of its scripts and stylesheets (each with a ?v= content hash). The admin loads every script once after sign-in and keeps every stylesheet enabled on all pages; a form field whose `element` is set renders that custom element. An empty list when no plugin registers a type. Any backend session may call it.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.FieldTypesResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/lang": {
parameters: {
query?: never;
@@ -991,7 +1057,7 @@ export interface paths {
put?: never;
/**
* Create an admin record
* @description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels.
* @description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels. A plugin field type's value is decoded by its type inside the save; when a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.
*/
post: {
parameters: {
@@ -1514,7 +1580,7 @@ export interface paths {
};
/**
* Admin form schema
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.
* @description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys).
*/
get: {
parameters: {
@@ -1965,7 +2031,10 @@ export interface paths {
};
};
};
/** Update an admin record */
/**
* Update an admin record
* @description A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.
*/
put: {
parameters: {
query?: never;
@@ -2211,6 +2280,111 @@ export interface paths {
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Upload a file for a plugin field type
* @description Stores one multipart part named file through the Upload of the field's plugin field type (pact.FormFieldType) and answers its URL. id 0 is the create form and passes no record; any other id loads the record through the controller's FormExtendQuery scope. A field that is not in the form, has a built-in type, has a type without Upload or whose context does not allow the operation answers 404, as does a record outside the scope. A body over http.body_limits.upload_bytes answers 413; a malformed body, a second part or a value the type refuses (pact.FormFieldError) answers 422 on the field.
*/
post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vendor */
vendor: string;
/** @description Plugin */
plugin: string;
/** @description Controller */
controller: string;
/** @description Record id (0 for the create form) */
id: number;
/** @description Field name */
field: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": {
/**
* Format: binary
* @description The file
*/
file: string;
};
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_FieldUploadResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Request Entity Too Large */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
/** @description Unprocessable Entity */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.ErrorEnvelope"];
};
};
};
};
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/{vendor}/{plugin}/{controller}/{id}/files/{field}": {
parameters: {
query?: never;
@@ -4648,6 +4822,10 @@ export interface components {
data: components["schemas"]["cabana.BulkResult"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_FieldUploadResponse": {
data: components["schemas"]["cabana.FieldUploadResponse"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_FileItem": {
data: components["schemas"]["cabana.FileItem"];
meta: components["schemas"]["cabana.SuccessMeta"];
@@ -4694,10 +4872,29 @@ export interface components {
"cabana.ErrorEnvelope": {
error: components["schemas"]["cabana.ErrorBody"];
};
"cabana.FieldNotice": {
field: string;
locale?: string;
message: string;
};
"cabana.FieldPreset": {
field: string;
type: string;
};
"cabana.FieldTypeAssets": {
element: string;
name: string;
scripts: string[];
styles: string[];
translatable: boolean;
};
"cabana.FieldTypesResponse": {
data: components["schemas"]["cabana.FieldTypeAssets"][];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.FieldUploadResponse": {
url: string;
};
"cabana.FileItem": {
content_type: string;
created_at: string;
@@ -4736,6 +4933,11 @@ export interface components {
*/
deferrable?: boolean;
displayFormat?: string;
/**
* @description Element is the custom-element tag of a field whose type a plugin
* registers (pact.HasFormFieldTypes); the admin renders it for the field.
*/
element?: string;
emptyOption?: string;
/** @description FileTypes are the allowed lower-case extensions of a fileupload field. */
fileTypes?: string[];
@@ -4812,9 +5014,21 @@ export interface components {
tab?: string;
/** @description ThumbOptions carries the preview thumbnail mode. */
thumbOptions?: components["schemas"]["cabana.ThumbOptions"];
/**
* @description Translatable is true when a plugin field type's value is a map of
* content locale to string.
*/
translatable?: boolean;
/** @description TwelveHour shows a 12-hour clock. */
twelveHour?: boolean;
type: string;
/**
* @description TypeOptions are a plugin field type's own fields.yaml keys and values;
* the admin hands them to the element as JSON.
*/
typeOptions?: {
[key: string]: unknown;
};
/** @description UseCaption lets the admin edit each file's title and description. */
useCaption?: boolean;
/** @description Widget is the custom-element tag of a `type: widget` field (D-06). */
@@ -5025,6 +5239,7 @@ export interface components {
labels: {
[key: string]: components["schemas"]["cabana.RelationOption"][];
};
notices?: components["schemas"]["cabana.FieldNotice"][];
};
"cabana.RelationColumn": {
key: string;

View File

@@ -54,6 +54,12 @@ export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
export type AdminActionResult = Schemas['cabana.AdminActionResult']
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */
export type ControllerAssets = Schemas['cabana.ControllerAssets']
/** A plugin field type: alias, element tag, translatable flag and its asset URLs. */
export type FieldTypeAssets = Schemas['cabana.FieldTypeAssets']
/** Answer of a plugin field type upload: the URL of the stored file. */
export type FieldUploadResponse = Schemas['cabana.FieldUploadResponse']
/** One save notice of a plugin field type: field, content locale and message. */
export type FieldNotice = Schemas['cabana.FieldNotice']
/** A registered toolbar action the admin may run, with its localized label. */
export type ToolbarAction = Schemas['cabana.ToolbarAction']
/** One node of a rendered partial: an allowlisted element or a text node. */

View File

@@ -0,0 +1,52 @@
// Plugin field types (pact.HasFormFieldTypes, D-13). After sign-in the SPA
// asks GET {api}/field-types once and loads every type's scripts as module
// scripts (each URL once, through loadScript) and its stylesheets as global
// links that no navigation disables, so a type's element works in any
// controller's form. Nothing here ever rejects: a script that fails to load
// surfaces later as that field's failure state.
import { api } from '../api/client'
import type { FieldTypeAssets } from '../api/types'
import { loadGlobalStyles, loadScript } from './pluginAssets'
// Element tag -> the script URLs of its type that failed to load.
const scriptsByElement = new Map<string, Promise<string[]>>()
/**
* Fetches the registered field types, starts loading their scripts and
* enables their styles. Resolves with the list (empty on any failure)
* without waiting for the scripts.
*/
export async function loadFieldTypes(): Promise<FieldTypeAssets[]> {
let types: FieldTypeAssets[] = []
try {
const { data, response } = await api.GET('/field-types')
types = response.ok && data ? data.data : []
} catch {
return []
}
for (const type of types) {
loadGlobalStyles(type.styles ?? [])
const urls = type.scripts ?? []
scriptsByElement.set(
type.element,
Promise.allSettled(urls.map((url) => loadScript(url))).then((results) =>
urls.filter((_, index) => results[index]?.status === 'rejected'),
),
)
}
return types
}
/**
* Resolves once the scripts of the field type that defines `element` have
* loaded or failed, with the URLs that failed; at once with none when the
* element belongs to no loaded field type.
*/
export function fieldTypeScripts(element: string): Promise<string[]> {
return scriptsByElement.get(element) ?? Promise.resolve([])
}
/** Forgets the loaded field types (tests and a new session). */
export function resetFieldTypes(): void {
scriptsByElement.clear()
}

View File

@@ -7,7 +7,10 @@
// every navigation to another controller or to a screen that is not a
// controller view, so plugin CSS never styles a view it was not written for.
// Only the router picks the enabled controller; a schema response that
// arrives after its view was left adds its links disabled.
// arrives after its view was left adds its links disabled. Stylesheets of
// plugin field types are global instead (GLOBAL_STYLE_OWNER): a field type's
// element can appear in any controller's form, so its links are never
// disabled on navigation.
// Files load only through script and link elements; nothing here fetches.
import type { ControllerAssets } from '../api/types'
import { runtime } from './runtime'
@@ -15,6 +18,9 @@ import { runtime } from './runtime'
/** Attribute naming the controller that owns a plugin stylesheet link. */
export const OWNER_ATTRIBUTE = 'data-summer-controller'
/** Owner of stylesheet links that stay enabled on every screen (field types). */
export const GLOBAL_STYLE_OWNER = '*'
const scripts = new Map<string, Promise<void>>()
// Links by controller and URL: two controllers of one plugin may share a
// file, and each keeps its own link so disabling one never hides the other.
@@ -90,7 +96,7 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void
const link = document.createElement('link')
link.rel = 'stylesheet'
link.setAttribute(OWNER_ATTRIBUTE, controllerId)
link.disabled = controllerId !== active
link.disabled = !styleEnabled(controllerId)
link.href = url
styles.set(key, { owner: controllerId, link })
document.head.appendChild(link)
@@ -105,10 +111,23 @@ export function loadStyles(controllerId: string, urls: readonly string[]): void
export function activateStyles(controllerId: string): void {
active = controllerId
for (const { owner, link } of styles.values()) {
link.disabled = owner !== active
link.disabled = !styleEnabled(owner)
}
}
/** Whether links of this owner are enabled now: the active controller's and the global ones. */
function styleEnabled(owner: string): boolean {
return owner === GLOBAL_STYLE_OWNER || owner === active
}
/**
* Adds stylesheet links that stay enabled on every screen: the styles of
* plugin field types, whose elements may render in any controller's form.
*/
export function loadGlobalStyles(urls: readonly string[]): void {
loadStyles(GLOBAL_STYLE_OWNER, urls)
}
/**
* Adds the controller's stylesheet links and loads its scripts. The links
* are enabled only while the router shows that controller, so a response

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import { rendererFor, type FieldControlProps } from './registry'
import { rendererForField, type FieldControlProps } from './registry'
// Picks the control for field.type from the registry (D-05) and forwards the
// Picks the control for the field from the registry (D-05; a plugin field
// type's element host when the field names an element) and forwards the
// shared control props and the value update.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const control = computed(() => rendererFor(props.field.type))
const control = computed(() => rendererForField(props.field))
</script>
<template>

View File

@@ -3,14 +3,18 @@ import { computed } from 'vue'
import { CircleAlert } from '@lucide/vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import FieldRenderer from './FieldRenderer.vue'
import { groupLabelled, ownsLabel } from './registry'
import { groupLabelled, isPluginField, ownsLabel } from './registry'
// One form row: label (600, red aria-hidden asterisk when required), the
// control, the comment and the error line linked by aria-describedby.
// control, the comment, the error line and the save notices (what a plugin
// field type changed, D-10) linked by aria-describedby. Notices are text
// interpolation only: a notice may quote markup an administrator submitted.
const props = defineProps<{
field: FormField
modelValue: unknown
errors?: string[]
/** Save notices of this field: warning status lines under the control. */
notices?: string[]
labels?: RelationOption[]
source?: ControllerParams | null
recordId?: number | null
@@ -23,15 +27,22 @@ const base = computed(() => `${props.idPrefix ?? 'field'}-${props.field.name}`)
const controlId = computed(() => base.value)
const commentId = computed(() => `${base.value}-comment`)
const errorId = computed(() => `${base.value}-error`)
const noticeId = computed(() => `${base.value}-notice`)
const invalid = computed(() => (props.errors?.length ?? 0) > 0)
const noticed = computed(() => (props.notices?.length ?? 0) > 0)
const selfLabelled = computed(() => ownsLabel(props.field.type))
// A widget or partial row labels a role="group" host, so its label is a span
// the host names with aria-labelledby, not a label for an input.
const labelsGroup = computed(() => groupLabelled(props.field.type))
// A widget, partial or plugin field type row labels a role="group" host, so
// its label is a span the host names with aria-labelledby, not a label for
// an input.
const labelsGroup = computed(() => groupLabelled(props.field.type) || isPluginField(props.field))
const showComment = computed(() => !selfLabelled.value && !!props.field.comment)
const describedBy = computed(() =>
[showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '', invalid.value ? errorId.value : '']
[
showComment.value || (selfLabelled.value && props.field.comment) ? commentId.value : '',
invalid.value ? errorId.value : '',
noticed.value ? noticeId.value : '',
]
.filter(Boolean)
.join(' '),
)
@@ -57,6 +68,9 @@ const describedBy = computed(() =>
@update:model-value="(value: unknown) => emit('update:modelValue', value)"
/>
<p v-if="showComment" :id="commentId" class="text-[13px] text-muted">{{ field.comment }}</p>
<div v-if="noticed" :id="noticeId" role="status" data-field-notice class="summer-callout summer-callout--warning text-[13px]!">
<p v-for="(text, index) in notices" :key="index" class="summer-callout__text">{{ text }}</p>
</div>
<div v-if="invalid" :id="errorId" class="flex flex-col gap-0.5 text-[13px] text-danger">
<p v-for="(text, index) in errors" :key="index" class="flex items-center gap-1.5">
<CircleAlert :size="14" class="shrink-0" aria-hidden="true" />

View File

@@ -10,6 +10,8 @@ defineProps<{
fields: FormFieldSchema[]
values: AdminRecord
errors: Record<string, string[]>
/** Save notices per field (meta.notices as noticeLines); none when absent. */
notices?: Record<string, string[]>
labels?: RecordMeta['labels']
source?: ControllerParams | null
recordId?: number | null
@@ -45,6 +47,7 @@ function spanClass(field: FormFieldSchema): string {
:field="field"
:model-value="values[field.name]"
:errors="errors[field.name]"
:notices="notices?.[field.name]"
:labels="labels?.[field.name]"
:source="source"
:record-id="recordId"

View File

@@ -3,13 +3,14 @@ import { computed } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import { t } from '../../app/i18n'
import { toggleOn } from './control'
import { rendererFor } from './registry'
import { isPluginField, rendererForField } from './registry'
// One field of the preview screen (UI-SPEC S3, D-11): a label and its value
// as a dt/dd pair. Values are text, never disabled inputs, so they keep full
// contrast and are read in order. Controls that already have a read-only
// mode (datepicker, fileupload, permissioneditor) and partials are rendered
// through their own component with the field marked read-only.
// mode (datepicker, fileupload, permissioneditor), partials and plugin field
// types (their element gets `disabled`) are rendered through their own
// component with the field marked read-only.
const props = defineProps<{
field: FormField
value: unknown
@@ -27,7 +28,7 @@ const labelId = computed(() => `${controlId.value}-label`)
const kind = computed(() => {
const type = props.field.type
if (ownControl.has(type)) {
if (ownControl.has(type) || isPluginField(props.field)) {
return 'control'
}
if (type === 'checkbox' || type === 'switch') {
@@ -41,7 +42,7 @@ const kind = computed(() => {
/** The field as its own control sees it on preview: always read-only. */
const readOnlyField = computed<FormField>(() => ({ ...props.field, readOnly: true }))
const control = computed(() => rendererFor(props.field.type))
const control = computed(() => rendererForField(props.field))
function isEmpty(value: unknown): boolean {
return value === null || value === undefined || value === ''

View File

@@ -2,8 +2,9 @@
// The one locale-switching wrapper of the ML controls (mltext, mltextarea,
// mlmarkdown). It owns the locale select, follows ML_LOCALE_CHANGE from every
// other ML control, edits only the active locale and emits the whole map. The
// inner control (TextField, TextareaField, MarkdownField) is passed in and
// edits a plain string.
// inner control (TextField, TextareaField, MarkdownField, or PluginField for a
// translatable plugin field type) is passed in and edits a plain string; with
// contentLocale set it also receives the active locale as content-locale.
import { computed, inject, onMounted, onUnmounted, ref, type Component } from 'vue'
import type { FieldControlProps } from '../control'
import { FORM_ENABLED_LOCALES } from '../formContext'
@@ -16,6 +17,8 @@ interface MLFieldProps extends FieldControlProps {
innerType: string
/** The ML field type, shown on the root as data-ml-field. */
mlType: string
/** Hand the inner control the active locale (PluginField's content-locale). */
contentLocale?: boolean
}
const props = defineProps<MLFieldProps>()
@@ -34,6 +37,9 @@ const editorId = computed(() => `${props.controlId}-editor`)
const activeText = computed(() => values.value[activeLocale.value] ?? '')
// A stable field object for the inner control: it keeps size and attributes.
const innerField = computed(() => ({ ...props.field, type: props.innerType }))
// Only an inner control that declares content-locale receives it; a text
// input would show it as an attribute.
const innerExtras = computed(() => (props.contentLocale ? { contentLocale: activeLocale.value } : {}))
function setLocale(locale: string) {
if (!locale) {
@@ -87,6 +93,9 @@ onUnmounted(() => {
:control-id="editorId"
:invalid="invalid"
:described-by="describedBy"
:source="source"
:record-id="recordId"
v-bind="innerExtras"
@update:model-value="onText"
/>
</div>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
// A translatable plugin field type (D-14): MLField around PluginField. The
// element edits the active content locale's string and receives that locale
// in its locale attribute; the form value is the locale -> string map.
import type { FieldControlProps } from '../control'
import MLField from './MLField.vue'
import PluginField from './PluginField.vue'
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: Record<string, string>] }>()
</script>
<template>
<MLField
v-bind="props"
:inner="PluginField"
:inner-type="field.type"
:ml-type="field.type"
content-locale
@update:model-value="emit('update:modelValue', $event)"
/>
</template>

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { api } from '../../../api/client'
import { errorMessage } from '../../../api/files'
import { fieldTypeScripts } from '../../../app/fieldTypes'
import { currentLocale, t } from '../../../app/i18n'
import ExtensionFailure from '../../ui/ExtensionFailure.vue'
import type { FieldControlProps } from '../control'
import { FIELD_CHANGE_EVENT, FIELD_UPLOAD_EVENT, FIELD_UPLOAD_RESULT_EVENT, FORM_LOCALE, WIDGET_TIMEOUT } from '../formContext'
// A field whose type a plugin registers (pact.HasFormFieldTypes, D-13). The
// type's custom element is created imperatively inside a node Vue never
// renders children into. The SPA hands it the field value through the
// `value` property and plain attributes (field-name, record-id, locale,
// ui-locale, options, disabled, invalid); never a token, a cookie, a
// function or a Vue instance. The element reports an edit with a bubbling
// summer-change event whose detail.value becomes the form value. The SPA
// sets `value` only when the form value differs from the element's current
// value, so the value the element just emitted never resets it. A
// translatable type renders inside MLField (MLPluginField), which hands the
// active content locale as contentLocale. The element asks for an upload with
// a bubbling summer-upload event {id, file}; the SPA posts the file with the
// admin session to the field upload route and answers on the element with
// summer-upload-result {id, url} or {id, error}.
interface PluginFieldProps extends FieldControlProps {
/** The content locale a translatable type's value belongs to (MLField). */
contentLocale?: string
}
const props = defineProps<PluginFieldProps>()
const emit = defineEmits<{ 'update:modelValue': [value: unknown] }>()
const formLocale = inject(FORM_LOCALE, null)
/** The plugin element: an HTMLElement with a `value` property. */
type ValueElement = HTMLElement & { value?: unknown }
const status = ref<'loading' | 'ready' | 'failed'>('loading')
const mountPoint = ref<HTMLElement | null>(null)
let element: ValueElement | null = null
let unmounted = false
const uiLocale = computed(() => formLocale?.value || currentLocale.value)
const optionsJson = computed(() => JSON.stringify(props.field.typeOptions ?? {}))
const disabled = computed(() => props.field.readOnly === true)
/** Equal values: the same primitive, or objects with the same JSON. */
function sameValue(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) {
return true
}
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
return false
}
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch {
return false
}
}
function syncValue(target: ValueElement, value: unknown): void {
if (!sameValue(target.value, value)) {
target.value = value
}
}
function toggle(target: HTMLElement, name: string, on: boolean): void {
if (on) {
target.setAttribute(name, '')
} else {
target.removeAttribute(name)
}
}
function applyAttributes(target: HTMLElement): void {
target.setAttribute('field-name', props.field.name)
target.setAttribute('record-id', props.recordId == null ? '' : String(props.recordId))
target.setAttribute('locale', props.contentLocale ?? '')
target.setAttribute('ui-locale', uiLocale.value)
target.setAttribute('options', optionsJson.value)
toggle(target, 'disabled', disabled.value)
toggle(target, 'invalid', props.invalid === true)
}
function timeout(ms: number): { promise: Promise<never>; cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | undefined
const promise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('field type element was not defined in time')), ms)
})
return { promise, cancel: () => clearTimeout(timer) }
}
async function defined(tag: string): Promise<void> {
const failed = await fieldTypeScripts(tag)
if (customElements.get(tag)) {
return
}
if (failed.length > 0) {
throw new Error(`plugin script failed: ${failed.join(', ')}`)
}
const limit = timeout(WIDGET_TIMEOUT)
try {
await Promise.race([customElements.whenDefined(tag), limit.promise])
} finally {
limit.cancel()
}
}
/** Posts an element's file to the field upload route and answers it. */
async function onUpload(event: Event): Promise<void> {
const target = element
const detail: unknown = (event as CustomEvent<unknown>).detail
if (!target || detail === null || typeof detail !== 'object') {
return
}
const { id, file } = detail as { id?: unknown; file?: unknown }
const reply = (answer: { url: string } | { error: string }) => {
if (!unmounted && element === target) {
target.dispatchEvent(new CustomEvent(FIELD_UPLOAD_RESULT_EVENT, { detail: { id, ...answer } }))
}
}
const failed = () => t('backend::lang.fileupload.upload_failed')
const source = props.source
if (!(file instanceof Blob) || !source) {
reply({ error: failed() })
return
}
const name = file instanceof File && file.name !== '' ? file.name : 'upload'
try {
// The typed client sends X-Requested-With and the session cookie and
// keeps the 401 refresh-and-replay; FormData sets the multipart type.
const result = await api.POST('/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload', {
params: { path: { ...source, id: props.recordId ?? 0, field: props.field.name } },
body: { file: '' },
bodySerializer: () => {
const form = new FormData()
form.append('file', file, name)
return form
},
})
if (result.data) {
reply({ url: result.data.data.url })
return
}
const message = result.response.status === 422 ? errorMessage(result.error?.error) : ''
reply({ error: message || failed() })
} catch {
reply({ error: failed() })
}
}
function uploadListener(event: Event): void {
void onUpload(event)
}
function onChange(event: Event): void {
const detail: unknown = (event as CustomEvent<unknown>).detail
if (detail !== null && typeof detail === 'object' && Object.hasOwn(detail, 'value')) {
emit('update:modelValue', (detail as { value: unknown }).value)
}
}
onMounted(async () => {
const tag = props.field.element ?? ''
try {
if (tag === '') {
throw new Error('plugin field without an element')
}
await defined(tag)
if (unmounted || !mountPoint.value) {
return
}
const created = document.createElement(tag) as ValueElement
applyAttributes(created)
syncValue(created, props.modelValue)
created.addEventListener(FIELD_CHANGE_EVENT, onChange)
created.addEventListener(FIELD_UPLOAD_EVENT, uploadListener)
mountPoint.value.append(created)
element = created
status.value = 'ready'
} catch {
if (!unmounted) {
status.value = 'failed'
}
}
})
// The attributes first: on a locale switch the element sees its new locale
// before the value of that locale.
watch(
() => [props.recordId, props.invalid, props.contentLocale, disabled.value, uiLocale.value, optionsJson.value],
() => {
if (element) {
applyAttributes(element)
}
},
)
watch(
() => props.modelValue,
(value) => {
if (element) {
syncValue(element, value)
}
},
{ deep: true },
)
onBeforeUnmount(() => {
unmounted = true
element?.removeEventListener(FIELD_CHANGE_EVENT, onChange)
element?.removeEventListener(FIELD_UPLOAD_EVENT, uploadListener)
element = null
})
</script>
<template>
<ExtensionFailure
v-if="status === 'failed'"
:id="controlId"
data-plugin-field-failed
:aria-describedby="describedBy || undefined"
:text="t('backend::lang.extension.widget_failed')"
/>
<div
v-else
:id="controlId"
role="group"
data-plugin-field
:aria-labelledby="field.label ? `${controlId}-label` : undefined"
:aria-describedby="describedBy || undefined"
:aria-busy="status === 'loading' ? 'true' : undefined"
class="flex min-h-input min-w-0 flex-col"
>
<span v-if="status === 'loading'" data-plugin-field-skeleton class="h-[42px] w-full rounded-control bg-skel" aria-hidden="true" />
<div ref="mountPoint" class="contents" />
</div>
</template>

View File

@@ -92,5 +92,24 @@ export const WIDGET_EVENT = 'summer-action'
*/
export const WIDGET_RESULT_EVENT = 'summer-result'
/**
* Event a plugin field type's element dispatches (bubbling) when the
* administrator edits its value; detail.value is the new form value.
*/
export const FIELD_CHANGE_EVENT = 'summer-change'
/**
* Event a plugin field type's element dispatches (bubbling) to upload a file,
* with detail {id, file}: the element's own request id and the File. The SPA
* posts it with the admin session to the field upload route.
*/
export const FIELD_UPLOAD_EVENT = 'summer-upload'
/**
* Event the SPA dispatches on the element when an upload ends, with detail
* {id, url} on success or {id, error} with a message on failure.
*/
export const FIELD_UPLOAD_RESULT_EVENT = 'summer-upload-result'
/** How long a widget waits for its custom element to be defined. */
export const WIDGET_TIMEOUT = 5000

View File

@@ -1,11 +1,11 @@
// Helpers shared by the record form and the settings form: context
// filtering, the save payload, 422 detail mapping and focus.
import type { AdminRecord, ErrorBody, FormField } from '../../api/types'
import type { AdminRecord, ErrorBody, FieldNotice, FormField } from '../../api/types'
import { permissionValues } from './control'
import { isMLFieldType, localeRecord } from './mlLocale'
import { isRegistered } from './registry'
import { isMLField, localeRecord } from './mlLocale'
import { isRegisteredField } from './registry'
export { ML_LOCALE_CHANGE, broadcastMLLocale, isMLFieldType, localeRecord } from './mlLocale'
export { ML_LOCALE_CHANGE, broadcastMLLocale, isMLField, isMLFieldType, localeRecord } from './mlLocale'
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
export type FormMode = 'create' | 'update' | 'preview'
@@ -53,7 +53,7 @@ export function contextAllows(field: FormField, mode: FormMode): boolean {
export function editablePayload(fields: FormField[], values: AdminRecord, mode: FormMode = 'create'): AdminRecord {
const out: AdminRecord = {}
for (const field of fields) {
if (field.readOnly || !isRegistered(field.type)) {
if (field.readOnly || !isRegisteredField(field)) {
continue
}
const value = values[field.name]
@@ -65,7 +65,7 @@ export function editablePayload(fields: FormField[], values: AdminRecord, mode:
out[field.name] = permissionValues(field, value)
continue
}
if (isMLFieldType(field.type)) {
if (isMLField(field)) {
out[field.name] = localeRecord(value)
continue
}
@@ -136,7 +136,7 @@ export function presetUpdates(
isEdited: PresetEdited,
): AdminRecord {
const out: AdminRecord = {}
const sourceML = isMLFieldType(fields.find((field) => field.name === source)?.type)
const sourceML = isMLField(fields.find((field) => field.name === source))
for (const field of fields) {
const preset = field.preset
if (!preset || preset.field !== source || (field.type !== 'text' && field.type !== 'mltext')) {
@@ -184,7 +184,7 @@ export function initialValues(fields: FormField[], enabledLocales: readonly stri
out[field.name] = false
} else if (field.type === 'relation' && field.multiple) {
out[field.name] = []
} else if (isMLFieldType(field.type)) {
} else if (isMLField(field)) {
const seed: Record<string, string> = {}
for (const code of enabledLocales) {
seed[code] = ''
@@ -225,12 +225,12 @@ export function recordDisplayName(
values: AdminRecord,
enabledLocales: readonly string[],
): string {
const first = fields?.find((field) => field.type === 'text' || isMLFieldType(field.type))
const first = fields?.find((field) => field.type === 'text' || isMLField(field))
const value = first ? values[first.name] : undefined
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim()
}
if (!first || !isMLFieldType(first.type)) {
if (!first || !isMLField(first)) {
return ''
}
const texts = localeRecord(value)
@@ -246,6 +246,23 @@ export function recordDisplayName(
return ''
}
/**
* A save's meta.notices (D-10, 16.2) as field name -> lines shown under the
* field: a notice about one content locale reads "<LOCALE>: message". Absent
* or empty notices give an empty map.
*/
export function noticeLines(notices: readonly FieldNotice[] | null | undefined): Record<string, string[]> {
const out: Record<string, string[]> = {}
for (const notice of notices ?? []) {
if (!notice || typeof notice.message !== 'string' || notice.message === '' || typeof notice.field !== 'string') {
continue
}
const line = notice.locale ? `${notice.locale.toUpperCase()}: ${notice.message}` : notice.message
;(out[notice.field] ??= []).push(line)
}
return out
}
/** D-10 422 details (field -> messages) as a string-list map. */
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
const out: Record<string, string[]> = {}

View File

@@ -1,3 +1,5 @@
import type { FormField } from '../../api/types'
/** The multilingual field types: their value is a locale -> text map. */
export const ML_FIELD_TYPES: ReadonlySet<string> = new Set(['mltext', 'mltextarea', 'mlmarkdown'])
@@ -6,6 +8,17 @@ export function isMLFieldType(type: string | undefined): boolean {
return type !== undefined && ML_FIELD_TYPES.has(type)
}
/**
* Reports whether a field's value is a locale -> text map: a built-in ML type
* or a translatable plugin field type (an `element` with `translatable`).
*/
export function isMLField(field: Pick<FormField, 'type' | 'element' | 'translatable'> | undefined): boolean {
if (!field) {
return false
}
return isMLFieldType(field.type) || (!!field.element && field.translatable === true)
}
/** Window event that keeps every ML control on one active locale. */
export const ML_LOCALE_CHANGE = 'summer:ml-locale'

View File

@@ -17,7 +17,13 @@
// The ML controls (mltext, mltextarea, mlmarkdown) are thin shells over one
// locale-switching wrapper, MLField, around the text, textarea and markdown
// controls; each holds a locale -> text map, labelled as a group.
// Phase 16.2 adds plugin field types (D-13): a field whose schema carries an
// `element` renders PluginField whatever its type alias (MLPluginField, the
// MLField locale switcher around it, when the type is translatable), holds a
// form value and is labelled as a group. Look such fields up with rendererForField and
// isRegisteredField, which know the field, not only its type.
import type { Component } from 'vue'
import type { FormField } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue'
import DatepickerField from './fields/DatepickerField.vue'
import DropdownField from './fields/DropdownField.vue'
@@ -25,6 +31,8 @@ import FileuploadField from './fields/FileuploadField.vue'
import NumberField from './fields/NumberField.vue'
import PartialField from './fields/PartialField.vue'
import PasswordField from './fields/PasswordField.vue'
import MLPluginField from './fields/MLPluginField.vue'
import PluginField from './fields/PluginField.vue'
import PermissionEditorField from './fields/PermissionEditorField.vue'
import RelationManager from '../relation/RelationManager.vue'
import RelationField from './fields/RelationField.vue'
@@ -101,6 +109,24 @@ export function isRegistered(type: string): boolean {
return (renderers.has(type) || lazyRenderers.has(type)) && !valueless.has(type)
}
/** Whether a field's type is registered by a plugin: its schema names an element. */
export function isPluginField(field: Pick<FormField, 'element'>): boolean {
return typeof field.element === 'string' && field.element !== ''
}
/** The control of one field: a plugin field type's host, else rendererFor its type. */
export function rendererForField(field: FormField): Component {
if (isPluginField(field)) {
return field.translatable ? MLPluginField : PluginField
}
return rendererFor(field.type)
}
/** Whether the SPA edits this field's value: a plugin field type always does. */
export function isRegisteredField(field: FormField): boolean {
return isPluginField(field) || isRegistered(field.type)
}
/** Whether a field type renders only on an existing record (relation manager). */
export function needsRecord(type: string): boolean {
return recordBound.has(type)

View File

@@ -25,7 +25,7 @@ import {
fieldErrors,
focusField,
initialValues,
isMLFieldType,
isMLField,
mergeMLValue,
panelDomId,
snapshot,
@@ -218,7 +218,7 @@ function adopt(record: AdminRecord, meta?: RecordMeta): void {
for (const field of fields.value) {
if (field.type === 'password') {
next[field.name] = ''
} else if (isMLFieldType(field.type)) {
} else if (isMLField(field)) {
next[field.name] = mergeMLValue(record[field.name], locales)
}
}

View File

@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import App from './App.vue'
import { createAdminRouter } from './app/router'
import { onUnauthorized } from './api/client'
import { loadFieldTypes } from './app/fieldTypes'
import { loadStrings } from './app/i18n'
import { applyColorScheme } from './app/theme'
import { expireSession, me } from './state/useAuth'
@@ -13,13 +14,16 @@ async function boot(): Promise<void> {
// Dark mode follows the system preference before anything renders (A6).
applyColorScheme()
// Boot order (research Pattern 7): the public strings bundle first, so the
// login screen is translated, then the session, then navigation.
// login screen is translated, then the session, then navigation and the
// plugin field types.
await loadStrings().catch(() => undefined)
// A 401 here only means "not signed in"; the router guard sends the
// visitor to the login route with the requested path as redirect.
const user = await me().catch(() => null)
if (user) {
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => [])])
// Plugin field types load once per session: their scripts define the
// elements any controller's form may use, their styles stay enabled.
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => []), loadFieldTypes()])
}
const router = createAdminRouter()
// A session that cannot be recovered ends here: the profile and every

View File

@@ -24,8 +24,9 @@ import {
fieldErrors,
focusField,
initialValues,
isMLFieldType,
isMLField,
mergeMLValue,
noticeLines,
panelDomId,
presetUpdates,
recordDisplayName,
@@ -68,6 +69,9 @@ const errors = ref<Record<string, string[]>>({})
// The text of a save the server refused with 403 (UI-SPEC S6). It is a
// banner, not a toast: it stays readable until the next save attempt.
const forbidden = ref<string | null>(null)
// What plugin field types changed on the last save (meta.notices, D-10):
// lines under each field until the next save attempt.
const notices = ref<Record<string, string[]>>({})
const loading = ref(true)
const failed = ref(false)
const busy = ref(false)
@@ -177,7 +181,7 @@ function adopt(record: RecordEnvelope | undefined): void {
for (const field of fields.value) {
if (field.type === 'password') {
next[field.name] = ''
} else if (isMLFieldType(field.type)) {
} else if (isMLField(field)) {
next[field.name] = mergeMLValue(record.data[field.name], locales)
}
}
@@ -248,8 +252,7 @@ function update(name: string, value: unknown): void {
const previous = values.value[name]
const next = { ...values.value, [name]: value }
edited.add(name)
const type = fields.value.find((field) => field.name === name)?.type
if (isMLFieldType(type)) {
if (isMLField(fields.value.find((field) => field.name === name))) {
let locales = editedLocales.get(name)
if (!locales) {
locales = new Set<string>()
@@ -319,6 +322,7 @@ async function save(): Promise<RecordEnvelope | null> {
}
busy.value = true
forbidden.value = null
notices.value = {}
try {
const body = editablePayload(fields.value, values.value, mode)
const result =
@@ -330,11 +334,18 @@ async function save(): Promise<RecordEnvelope | null> {
})
if (result.data) {
errors.value = {}
// adopt reloads the stored values, so a field whose type changed the
// submitted value shows what was stored.
adopt(result.data)
notices.value = noticeLines(result.data.meta.notices)
// The save committed everything held against the session.
pendingChanges.value = 0
revision.value++
showToast(message(schema.value.messages.saved, undefined, { name: recordName.value }))
const savedText = message(schema.value.messages.saved, undefined, { name: recordName.value })
// The toast repeats the notices, so a save that navigates away (save
// and close) still tells the administrator what changed.
const noticeText = Object.values(notices.value).flat().join(' ')
showToast(noticeText === '' ? savedText : `${savedText} ${noticeText}`)
return result.data
}
if (result.response.status === 422) {
@@ -502,6 +513,7 @@ void load()
:fields="panelFields"
:values="values"
:errors="errors"
:notices="notices"
:labels="labels"
:source="path"
:record-id="recordId"

View File

@@ -2,6 +2,7 @@
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CircleAlert, Sun } from '@lucide/vue'
import { loadFieldTypes } from '../app/fieldTypes'
import { t } from '../app/i18n'
import { safeRedirect } from '../app/router'
import { login, me } from '../state/useAuth'
@@ -29,7 +30,7 @@ async function submit(): Promise<void> {
return
}
password.value = ''
await Promise.all([loadNavigation(), loadSettings().catch(() => [])])
await Promise.all([loadNavigation(), loadSettings().catch(() => []), loadFieldTypes()])
await router.replace(safeRedirect(route.query.redirect) ?? '/')
} finally {
busy.value = false

View File

@@ -10,6 +10,28 @@ beforeEach(() => {
resetState()
})
describe('save notices', () => {
it('shows notices as a warning status line the control points at', () => {
const wrapper = mount(FormField, {
props: { field: formField('code'), modelValue: '', notices: ['PL: <style> was removed', 'Second'], idPrefix: 'f' },
})
const line = wrapper.find('#f-code-notice')
expect(line.attributes('role')).toBe('status')
expect(line.classes()).toEqual(expect.arrayContaining(['summer-callout', 'summer-callout--warning']))
expect(line.findAll('p').map((p) => p.text())).toEqual(['PL: <style> was removed', 'Second'])
// Literal text: an HTML-looking message never becomes an element.
expect(line.find('style').exists()).toBe(false)
expect(line.html()).toContain('&lt;style&gt;')
expect(wrapper.find('#f-code').attributes('aria-describedby')).toBe('f-code-comment f-code-notice')
})
it('shows nothing without notices', () => {
const wrapper = mount(FormField, { props: { field: formField('code'), modelValue: '', notices: [], idPrefix: 'f' } })
expect(wrapper.find('[data-field-notice]').exists()).toBe(false)
expect(wrapper.find('#f-code').attributes('aria-describedby')).toBe('f-code-comment')
})
})
describe('form field', () => {
it('links the label to the control and hides the required asterisk from assistive tech', () => {
const wrapper = mount(FormField, { props: { field: formField('name'), modelValue: 'Blue', idPrefix: 'f' } })

View File

@@ -0,0 +1,287 @@
// PluginField (D-13): a field whose type a plugin registers renders the
// type's custom element, hands it the value property and plain attributes,
// takes summer-change events as the new form value and never writes back the
// value the element just emitted. Mounted on its own with the form context
// FormView would provide.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { ref } from 'vue'
import MLPluginField from '../../src/components/form/fields/MLPluginField.vue'
import PluginField from '../../src/components/form/fields/PluginField.vue'
import {
FIELD_CHANGE_EVENT,
FIELD_UPLOAD_EVENT,
FIELD_UPLOAD_RESULT_EVENT,
FORM_ENABLED_LOCALES,
FORM_LOCALE,
WIDGET_TIMEOUT,
} from '../../src/components/form/formContext'
import { editablePayload, initialValues, isMLField, recordDisplayName } from '../../src/components/form/formState'
import { isRegisteredField, rendererForField } from '../../src/components/form/registry'
import type { FormField } from '../../src/api/types'
import { t } from '../../src/app/i18n'
import { API, mockApi, resetState, type Route } from '../helpers'
/** A stub plugin element that counts writes to its value property. */
class AcmeDemoNotes extends HTMLElement {
writes: unknown[] = []
private current: unknown = undefined
get value(): unknown {
return this.current
}
set value(next: unknown) {
this.writes.push(next)
this.current = next
}
/** What a real element does on a user edit: keep the value, announce it. */
edit(next: unknown): void {
this.current = next
this.dispatchEvent(new CustomEvent(FIELD_CHANGE_EVENT, { bubbles: true, detail: { value: next } }))
}
}
if (!customElements.get('acme-demo-notes')) {
customElements.define('acme-demo-notes', AcmeDemoNotes)
}
function notesField(change: Partial<FormField> = {}): FormField {
return { name: 'notes', type: 'notes', label: 'Notes', element: 'acme-demo-notes', typeOptions: { rows: 4 }, ...change }
}
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'notes' }
function mountPlugin(props: { field?: FormField; modelValue?: unknown; recordId?: number | null; invalid?: boolean } = {}) {
const locale = ref('pl')
const wrapper = mount(PluginField, {
props: {
field: props.field ?? notesField(),
modelValue: props.modelValue === undefined ? 'hello' : props.modelValue,
controlId: 'f-notes',
describedBy: 'f-notes-comment',
recordId: props.recordId === undefined ? 7 : props.recordId,
invalid: props.invalid,
source: SOURCE,
},
global: { provide: { [FORM_LOCALE as symbol]: locale } },
})
return { wrapper, locale }
}
function element(wrapper: VueWrapper): AcmeDemoNotes {
const found = wrapper.find('acme-demo-notes').element
if (!(found instanceof AcmeDemoNotes)) {
throw new Error('plugin element not mounted')
}
return found
}
beforeEach(() => {
resetState()
})
afterEach(() => {
vi.useRealTimers()
document.body.innerHTML = ''
})
describe('PluginField', () => {
it('sets the value property and the contract attributes, nothing else', async () => {
const { wrapper } = mountPlugin({ invalid: true })
await flushPromises()
const el = element(wrapper)
expect(el.value).toBe('hello')
expect(el.writes).toEqual(['hello'])
expect(el.getAttribute('field-name')).toBe('notes')
expect(el.getAttribute('record-id')).toBe('7')
expect(el.getAttribute('locale')).toBe('')
expect(el.getAttribute('ui-locale')).toBe('pl')
expect(JSON.parse(el.getAttribute('options') ?? '')).toEqual({ rows: 4 })
expect(el.hasAttribute('invalid')).toBe(true)
expect(el.hasAttribute('disabled')).toBe(false)
// Attributes only: no token, cookie, function or Vue instance.
const names = el.getAttributeNames().sort()
expect(names).toEqual(['field-name', 'invalid', 'locale', 'options', 'record-id', 'ui-locale'])
const host = wrapper.find('[data-plugin-field]')
expect(host.attributes('role')).toBe('group')
expect(host.attributes('aria-labelledby')).toBe('f-notes-label')
expect(host.attributes('aria-describedby')).toBe('f-notes-comment')
expect(host.attributes('aria-busy')).toBeUndefined()
})
it('marks a read-only field disabled and the create form with an empty record id', async () => {
const { wrapper } = mountPlugin({ field: notesField({ readOnly: true }), recordId: null })
await flushPromises()
const el = element(wrapper)
expect(el.hasAttribute('disabled')).toBe(true)
expect(el.getAttribute('record-id')).toBe('')
})
it('takes summer-change as the new form value', async () => {
const { wrapper } = mountPlugin()
await flushPromises()
element(wrapper).edit('edited')
expect(wrapper.emitted('update:modelValue')).toEqual([['edited']])
})
it('does not write back the value the element just emitted', async () => {
const { wrapper } = mountPlugin()
await flushPromises()
const el = element(wrapper)
el.edit('edited')
await wrapper.setProps({ modelValue: 'edited' })
await flushPromises()
expect(el.writes).toEqual(['hello'])
await wrapper.setProps({ modelValue: 'from the server' })
await flushPromises()
expect(el.writes).toEqual(['hello', 'from the server'])
})
it('compares object values by content', async () => {
const { wrapper } = mountPlugin({ modelValue: { blocks: [1, 2] } })
await flushPromises()
const el = element(wrapper)
el.edit({ blocks: [1, 2, 3] })
await wrapper.setProps({ modelValue: { blocks: [1, 2, 3] } })
await flushPromises()
expect(el.writes).toEqual([{ blocks: [1, 2] }])
})
it('ends in the failure state when the tag is never defined', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const { wrapper } = mountPlugin({ field: notesField({ element: 'acme-demo-never' }) })
await flushPromises()
expect(wrapper.find('[data-plugin-field]').attributes('aria-busy')).toBe('true')
await vi.advanceTimersByTimeAsync(WIDGET_TIMEOUT)
await flushPromises()
expect(wrapper.find('[data-plugin-field-failed]').exists()).toBe(true)
expect(wrapper.find('[data-plugin-field]').exists()).toBe(false)
})
it('is the renderer of any field that names an element, and holds a value', () => {
expect(rendererForField(notesField())).toBe(PluginField)
expect(isRegisteredField(notesField())).toBe(true)
expect(isRegisteredField({ name: 'x', type: 'notes' })).toBe(false)
expect(rendererForField({ name: 'x', type: 'notes' })).not.toBe(PluginField)
})
})
/** Dispatches summer-upload on the element and collects the answers. */
function upload(el: HTMLElement, detail: unknown): unknown[] {
const answers: unknown[] = []
el.addEventListener(FIELD_UPLOAD_RESULT_EVENT, (event) => answers.push((event as CustomEvent<unknown>).detail))
el.dispatchEvent(new CustomEvent(FIELD_UPLOAD_EVENT, { bubbles: true, detail }))
return answers
}
const UPLOAD = `POST ${API}/acme/demo/notes/7/fields/notes/upload`
describe('PluginField uploads', () => {
async function mounted(routes: Record<string, Route>) {
const calls = mockApi(routes)
const { wrapper } = mountPlugin()
await flushPromises()
return { el: element(wrapper), calls }
}
it('posts the file to the field upload route and answers with the url', async () => {
const { el, calls } = await mounted({ [UPLOAD]: { status: 201, body: { data: { url: '/storage/a.png' }, meta: {} } } })
const answers = upload(el, { id: 'u1', file: new File(['png'], 'a.png', { type: 'image/png' }) })
await flushPromises()
expect(answers).toEqual([{ id: 'u1', url: '/storage/a.png' }])
expect(calls).toHaveLength(1)
const request = calls[0]!
expect(request.method).toBe('POST')
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
const form = await request.clone().formData()
const file = form.get('file')
expect(file).toBeInstanceOf(File)
expect((file as File).name).toBe('a.png')
expect([...form.keys()]).toEqual(['file'])
})
it('answers a 422 with the server message', async () => {
const { el } = await mounted({
[UPLOAD]: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { notes: ['Not an image.'] } } } },
})
const answers = upload(el, { id: 'u2', file: new File(['x'], 'a.txt') })
await flushPromises()
expect(answers).toEqual([{ id: 'u2', error: 'Not an image.' }])
})
it('answers a 413 and a network failure with the generic message', async () => {
const { el } = await mounted({ [UPLOAD]: { status: 413, body: { error: { code: 'payload_too_large', message: 'Too large', details: {} } } } })
const too = upload(el, { id: 'u3', file: new File(['x'], 'a.txt') })
await flushPromises()
expect(too).toEqual([{ id: 'u3', error: t('backend::lang.fileupload.upload_failed') }])
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))
const down = upload(el, { id: 'u4', file: new File(['x'], 'a.txt') })
await flushPromises()
expect(down).toEqual([{ id: 'u4', error: t('backend::lang.fileupload.upload_failed') }])
})
it('answers an upload without a file without a request', async () => {
const { el, calls } = await mounted({})
const answers = upload(el, { id: 'u5', file: 'not a file' })
await flushPromises()
expect(answers).toEqual([{ id: 'u5', error: t('backend::lang.fileupload.upload_failed') }])
expect(calls).toHaveLength(0)
})
})
describe('stored value reload', () => {
it('sets the stored value once when a save changed it', async () => {
const { wrapper } = mountPlugin({ modelValue: '<p>a</p><style>x</style>' })
await flushPromises()
const el = element(wrapper)
// The save answered with what the type stored; the form adopts it.
await wrapper.setProps({ modelValue: '<p>a</p>' })
await flushPromises()
await wrapper.setProps({ modelValue: '<p>a</p>' })
await flushPromises()
expect(el.writes).toEqual(['<p>a</p><style>x</style>', '<p>a</p>'])
})
})
describe('MLPluginField', () => {
class AcmeDemoNotesml extends AcmeDemoNotes {}
if (!customElements.get('acme-demo-notesml')) {
customElements.define('acme-demo-notesml', AcmeDemoNotesml)
}
const mlField = notesField({ name: 'body', type: 'notesml', element: 'acme-demo-notesml', translatable: true })
it('renders through the ML switcher and edits one locale', async () => {
expect(rendererForField(mlField)).toBe(MLPluginField)
const wrapper = mount(MLPluginField, {
props: { field: mlField, modelValue: { pl: 'Polski', en: 'English' }, controlId: 'f-body', recordId: 7, source: SOURCE },
global: { provide: { [FORM_ENABLED_LOCALES as symbol]: ref(['pl', 'en']), [FORM_LOCALE as symbol]: ref('pl') } },
})
await flushPromises()
const el = wrapper.find('acme-demo-notesml').element as AcmeDemoNotesml
expect(el.getAttribute('locale')).toBe('pl')
expect(el.value).toBe('Polski')
expect(el.getAttribute('record-id')).toBe('7')
await wrapper.find('select[data-ml-locale]').setValue('en')
await flushPromises()
expect(el.getAttribute('locale')).toBe('en')
expect(el.value).toBe('English')
el.edit('English 2')
expect(wrapper.emitted('update:modelValue')).toEqual([[{ pl: 'Polski', en: 'English 2' }]])
})
it('is a locale map in the form state helpers', () => {
expect(isMLField(mlField)).toBe(true)
expect(isMLField(notesField())).toBe(false)
expect(isMLField({ type: 'notesml', translatable: true })).toBe(false)
expect(initialValues([mlField], ['pl', 'en'])).toEqual({ body: { pl: '', en: '' } })
expect(editablePayload([mlField, notesField()], { body: { pl: 'P', en: 'E', xx: 3 }, notes: { blocks: [] } })).toEqual({
body: { pl: 'P', en: 'E' },
notes: { blocks: [] },
})
expect(recordDisplayName([mlField], { body: { pl: 'Tytuł', en: 'Title' } }, ['pl', 'en'])).toBe('Tytuł')
})
})

View File

@@ -8,6 +8,7 @@ import {
focusField,
initialValues,
mergeMLValue,
noticeLines,
panelDomId,
presetUpdates,
presetValue,
@@ -302,3 +303,24 @@ describe('recordDisplayName', () => {
expect(recordDisplayName(fields, { notify: true, tags: [1], title: { en: 'Post', pl: '' }, name: 'Anna' }, locales)).toBe('Post')
})
})
describe('noticeLines', () => {
it('maps notices to lines per field, prefixing the content locale', () => {
expect(
noticeLines([
{ field: 'content', locale: 'pl', message: '2 elements were removed: style, iframe' },
{ field: 'content', locale: 'en', message: 'A script was removed' },
{ field: 'notes', message: 'Trimmed' },
]),
).toEqual({
content: ['PL: 2 elements were removed: style, iframe', 'EN: A script was removed'],
notes: ['Trimmed'],
})
})
it('is empty without notices', () => {
expect(noticeLines(undefined)).toEqual({})
expect(noticeLines([])).toEqual({})
expect(noticeLines([{ field: 'x', message: '' }])).toEqual({})
})
})

View File

@@ -17,6 +17,14 @@ beforeEach(() => {
resetState()
})
describe('plugin field type columns (16.2)', () => {
it('renders the ListValue text of a plugin type as plain text', () => {
const wrapper = cell({ ...text, type: 'notes' }, '<b>3 blocks</b>')
expect(wrapper.text()).toBe('<b>3 blocks</b>')
expect(wrapper.find('b').exists()).toBe(false)
})
})
describe('cell value (D-12)', () => {
it.each([
['a string', 'Blue', 'Blue'],

140
docs/backend/field-types.md Normal file
View File

@@ -0,0 +1,140 @@
---
title: Plugin field types
description: Register your own admin form field types from a plugin, with a custom element in the admin and Go code that decodes, loads and formats the value.
section: backend
order: 25
---
# Plugin field types
The built-in field types of a [form](forms.md) cover text, choices, relations, files and dates. When a plugin needs an editor of its own (a block editor, a map picker, a structured list), it registers a field type: a short alias any controller's `fields.yaml` can use as `type:`, a custom element the admin renders for the field, and Go functions that decide what is stored. The plugin ships no framework change and no admin build of the framework: [cabana](../../modules/cabana/README.md) owns the routes, the permissions, the record scope and the transaction, and the type only carries its own logic.
## Registering a type
A plugin implements `pact.HasFormFieldTypes` and returns one `pact.FormFieldType` per alias:
```go src=modules/cabana/example_field_type_test.go#NotesPlugin.FormFieldTypes
// FormFieldTypes registers the notes field type: a custom element with one
// option, rows, whose Decode trims the submitted text.
func (NotesPlugin) FormFieldTypes() []pact.FormFieldType {
return []pact.FormFieldType{{
Name: "notes",
Element: "acme-blog-notes",
Scripts: []string{"assets/js/notes.js"},
Options: []string{"rows"},
CheckOptions: func(options map[string]any) error {
if rows, ok := options["rows"].(int); ok && rows > 40 {
return errors.New("rows must be at most 40")
}
return nil
},
Decode: func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
text, ok := in.Value.(string)
if !ok {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"acme.blog::lang.notes.invalid"}}
}
return pact.FormFieldDecoded{Value: strings.TrimSpace(text)}, nil
},
}}
}
```
`pact.HasFormFieldTypes` is implemented by the plugin itself, like `pact.HasAdminControllers`, not by a controller. A form then uses the alias:
```yaml
fields:
notes:
label: acme.blog::lang.post.notes
type: notes
rows: 6
```
The admin loads every registered type's scripts once after sign-in, and keeps its stylesheets enabled on every page, so the element works in any controller's form. `GET <prefix>/api/v1/field-types` lists the types with their script and stylesheet URLs; each URL carries a content hash, and the files are served from the plugin's embedded `assets/` tree like [controller assets](partials-and-widgets.md#plugin-javascript-and-css).
## Aliases and boot checks
Every rule below stops the start-up with a message naming the plugin and the alias:
- The alias is lowercase letters and digits starting with a letter (`notes`, `geo2`).
- It is not a built-in field type (`text`, `markdown`, `mlmarkdown`, `widget`, `partial` and the others in the [field type table](forms.md#field-types)).
- No other plugin registers the same alias; the message names both plugins.
- `Element` is a valid custom-element name that starts with the plugin's prefix: `acme.blog` gives `acme-blog-`.
- `Decode` is set.
- `Scripts` (`.js`, `.mjs`) and `Styles` (`.css`) are clean paths under `assets/` that exist in the plugin's `AdminFS`; a plugin that declares files implements `pact.AdminAssets`.
- Each `Options` key is letters and digits starting with a letter, is declared once and is not a general field key such as `label`, `options` or `mode`.
Plugin field types work in controller forms. A settings form and a relation manager's forms refuse a plugin alias with "plugin field type `<name>` is supported in controller forms only".
## Options
A plugin-typed field accepts the general keys `label`, `comment`, `span`, `type`, `required`, `tab`, `context`, `attributes`, `size` and `default`, plus the type's own `Options`. Any other key stops the start-up with "unknown field". The option values are decoded into JSON values (string, boolean, number, list or map) and sent to the admin as the field's `typeOptions`; the element receives them as its `options` attribute.
`CheckOptions`, when set, checks a field's options at start-up, as the `rows` check in the example above does. An error stops it and names the field and the type.
The form schema carries a plugin field with `element`, `translatable` (only when true) and `typeOptions` (only when the field sets options). `cabana.CompileFormWithTypes` compiles a form with one plugin's types outside a running application, which is how a plugin tests its `fields.yaml`.
## Decode, notices and refusals
A save hands each submitted plugin field value to the type's `Decode`, inside the save transaction, after cabana loaded the record through the controller's `pact.FormExtendQuery` scope and before the record is filled. `pact.FormFieldValue` carries the transaction (`DB`), the loaded record on update or the new model on create (`Record`), the field name, its options and the submitted `Value` (any JSON value). The value `Decode` returns in `pact.FormFieldDecoded` is the value written to the column; the field binds to a model column of the same name like any value field, and `required` works on it.
`Decode` answers in one of three ways:
- It accepts the value: `pact.FormFieldDecoded{Value: v}`. The save answers exactly as a save without plugin field types.
- It accepts the value but changes it, and tells the administrator: `Notices` carries one `pact.FormFieldNotice` per message, for example what a sanitizer removed. The save goes through, and the create or update response carries the messages in `meta.notices`:
```json
{"data": {"id": 7, "notes": "..."}, "meta": {"labels": {}, "notices": [{"field": "notes", "message": "2 elements were removed: style, iframe"}]}}
```
`Message` is a phrase key or text, localized in the request locale with `Params`; a `Count` above zero selects the plural form of the phrase and sets `:count`. A save whose `Decode` calls return no notice has no `notices` key at all.
- It refuses the value: a `*pact.FormFieldError` is a 422 `validation_failed` on the field with the error's messages (phrase keys or text, localized), and nothing is written. Use it for a value the type cannot accept at all, such as a number where it expects text.
Any other error from `Decode` is logged and answered as the opaque 500 of a failed lifecycle hook; the plugin's error text never reaches the client.
## Load
`Load`, when set, turns the stored value into the value the element receives. It runs on the record response of `GET .../{id}` and of every create and update, with the same `pact.FormFieldValue` (the read transaction, the record, the stored `Value`). The save response therefore always carries the stored value, and the admin reloads it into the form after a save, so the administrator sees what `Decode` kept. Without `Load` the stored value is sent as it is. A `Load` error is an opaque 500.
## Notices
A notice is how a type that changes an accepted value tells the administrator what it changed. cabana collects the notices of every `Decode` call of a save, and only when the save commits does it localize them and put them in the response's `meta.notices`, one entry per message with the `field`, the content `locale` of a translatable type's value (absent for other types) and the localized `message`. A refused save (any 422 or other error) carries no notices. At most 10 notices are kept per field and locale, and each message is cut to 500 characters, so a value with thousands of removed parts cannot inflate the response. A notice may quote what the administrator submitted, such as a removed tag name; the admin shows it as text, never as markup.
## Translatable types
A type with `Translatable: true` holds one string per content locale, like `mlmarkdown`. The admin sends and receives a map of locale to string, and cabana stores it through its own multilingual path: the default locale's value goes to the host column, every other locale through the published `cabana.TranslationWriter`, inside the same permissioned save transaction. The plugin implements no locale storage.
`Decode` runs once per submitted locale, with `Locale` set to that content locale and `Default` true for the host column's locale; its `Value` must be a string. Each locale's notices carry that locale. `Load` runs once per locale of the record response. `Translated` reads another attribute of the same record in the same locale, exactly and without fallback, which a type that keeps two columns in step (a source and a rendered copy, for example) uses to compare them. Without a translation writer a locale map is a 422, as for the built-in multilingual types.
## Untouched values
On update, cabana compares each submitted value with the value the form was served for that field and locale: the stored host value or translation (an empty string for a missing translation), passed through `Load`. When their JSON encodings are equal, the value is neither decoded nor written, neither to the host column nor as a translation, and it yields no notice. So a record whose stored value a type would rewrite (an imported document a sanitizer would change, for instance) is left byte for byte as it is until the administrator edits that field. On create nothing is skipped. An element should not re-serialize a value the administrator did not edit either, so the comparison holds.
## Companion columns in Form hooks
A type sometimes implies a write to another column: a rendered copy next to its source, a word count, a search text. The type's `Decode` returns only the field's own value; a controller's Form hooks write the rest. `cabana.FieldWritesFromContext` returns, from the context of `pact.FormBeforeCreate`, `pact.FormAfterCreate`, `pact.FormBeforeUpdate` and `pact.FormAfterUpdate`, the values the save actually writes for plugin fields, keyed by field name, one `cabana.FieldWrite` per written locale with `Default` set for the host column's value. An untouched value is not listed, so a hook updates a companion column only for the locales that changed. A test of such a hook builds its context with `cabana.WithFieldWrites`.
## List columns
A column in `columns.yaml` may name a plugin alias as its `type`. The list then shows the type's `ListValue` for the stored value (the host column's value for a translatable type), a plain string the admin renders as text. A type without `ListValue` shows the stored value as it is.
## Uploads
A type that stores files (images in a document, say) sets `Upload`. The element never uploads anything itself: it asks the admin, which posts the file with the administrator's session to `POST <prefix>/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` as one multipart part named `file`. cabana checks the backend session, the `X-Requested-With` header, the controller's permissions, that the field has a plugin type with `Upload` and that its `context` allows the operation, then caps the body at `http.body_limits.upload_bytes`. `{id}` 0 is the create form; any other id loads the record through the controller's `pact.FormExtendQuery` scope inside a transaction, and a record outside it is a 404. `Upload` receives a `pact.FormFieldUpload` with the transaction, the record (nil on the create form), the field, its options, the file name and the capped body, and returns the URL the element shows. The route answers 201 `{"data": {"url": "..."}}`; a `pact.FormFieldError` is a 422 on the field, a body over the cap a 413, and any other error the opaque 500. Checking the file (its size, its real type) is the type's job.
## The element
The admin creates the type's element for the field and talks to it through one property, attributes and events, nothing else. The element contract:
- The `value` property holds the field's value: a string (the active locale's text) for a translatable type, any JSON value otherwise.
- The attributes are `field-name`; `record-id`, empty on the create form; `locale`, the content locale of the value for a translatable type and empty otherwise; `ui-locale`, the admin's interface language; `options`, the JSON of the field's `typeOptions`; `disabled`, present when the field is read-only (on a preview screen, for example); and `invalid`, present while the field has an error.
- `summer-change` is a bubbling `CustomEvent` the element dispatches when the administrator edits the value, and only then. The admin takes `detail.value` as the field's new value; for a translatable type it replaces only the active locale's string in the locale map.
- `summer-upload` is a bubbling `CustomEvent` the element dispatches to upload a file, with `detail` `{id, file}`: an id of the element's choosing and the `File`. The admin posts the file with the administrator's session to the field's [upload route](#uploads) and answers by dispatching `summer-upload-result` on the element, with `detail` `{id, url}` on success or `{id, error}` with a message to show (the server's message for a refused file, a generic one for a file that is too large or a network failure).
- The admin sets `value` only when the form value differs from the element's current `value` (compared by JSON for objects), so setting the value the element just emitted never resets it. The element must still accept a `value` it did not emit: the stored value after a save, which may differ from what it emitted when `Decode` changed it, and the other locale's text when the administrator switches locales (the admin sets `locale` first, then `value`).
- An element should not re-serialize a value the administrator did not edit, so an untouched value stays equal to the one it was served and the save skips it.
The element never receives a token, a cookie, a function or a Vue instance, and it makes no requests of its own: the admin owns HTTP and the session. It styles itself only through the admin's public `--c-*` CSS variables (`--c-bg`, `--c-surface`, `--c-text`, `--c-primary` and the others the cabana README lists, which switch with dark mode), with every selector scoped under its own tag, because its stylesheet is enabled on every admin page. A type whose script fails to load, or whose tag is not defined within five seconds, shows the admin's failure box in place of the field.
A translatable type renders inside the admin's locale switcher, the same one `mlmarkdown` uses: one element edits the active locale, and switching locales changes its `locale` attribute and its `value`.
### How the admin shows notices
After a save whose response carries `meta.notices`, the admin shows each message under its field as a warning status line, prefixed with the upper-case locale for a translatable type (`PL: 2 elements were removed: style, iframe`), and appends the messages to the saved toast, so "Save and close", which leaves the form, still tells the administrator. The lines go away on the next save attempt. The admin then reloads the stored values from the response into the form, and the element receives the stored value.

View File

@@ -102,7 +102,7 @@ for _, f := range form.Fields {
| `mltextarea` | A multi-line text field with one editor per locale; it takes `size` as `textarea` does. See [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
| `mlmarkdown` | A markdown field with one editor per locale; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
The WinterCMS widgets that are not in this list (the rich editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up.
The WinterCMS widgets that are not in this list (the rich editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
### Field options

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,8 +7,8 @@
<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-CNLOPBsM.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-COkYalVD.css">
<script type="module" crossorigin src="./assets/index-CuK_LdRk.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DgNySjE5.css">
</head>
<body>
<div id="app"></div>

View File

@@ -22,6 +22,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`.
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
- Plugin field types: a plugin implementing `pact.HasFormFieldTypes` registers admin form field types under short aliases that any controller's `fields.yaml` uses as `type:`. Boot checks the aliases (no built-in type, no alias claimed by two plugins), the element prefix, the scripts and styles and each field's option keys and `CheckOptions`. A save runs the type's `Decode` inside the save transaction after the record is loaded: the decoded value is stored, a `pact.FormFieldError` is a 422 on the field, any other error an opaque 500, and the notices a type returns are localized into the response's `meta.notices` (`cabana.FieldNotice`), which is absent when no type reported anything. Show and save responses serve the value through the type's `Load`. A translatable type's value is a locale map stored through the same lift, `cabana.TranslationWriter` and hydrate path as `mlmarkdown`, decoded and loaded per locale. On update, a value equal to the one the form was served for that field and locale is neither decoded nor written. Form hooks read what a save wrote through `cabana.FieldWritesFromContext`. A list column may name a plugin alias as its `type` and shows the type's `ListValue`. The cabana-owned upload route hands an element's file to the type's `Upload`. The form schema carries `element`, `translatable` and `typeOptions` for such a field. Settings and relation forms refuse plugin field types (supported in controller forms only). See [Plugin field types](../../docs/backend/field-types.md).
- Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms.
- Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only.
- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected; the admin form preview renders through it via POST `/markdown/preview`. `type: mltext`, `type: mltextarea` and `type: mlmarkdown` take a JSON object of locale code to string; `mltextarea` is the multi-line variant and takes `size` like `textarea`. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published, default locale first and then the other enabled codes in the writer's order, so every ML control opens on the locale stored in the host columns; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot.
@@ -50,6 +51,8 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| PUT `/account` | Save the signed-in administrator's own first name, last name, email and subtitle and, with `current_password`, a new password (`cabana.AdminAccountRequest`); answers the `cabana.AdminProfile`. Other keys are a 422. The email must be valid, 6 to 255 characters and not equal, ignoring case, another administrator's email or login. A password needs at least 4 characters, at most 72 bytes and an equal `password_confirmation`. Needs any backend session. |
| POST and DELETE `/account/avatar` | Upload the signed-in administrator's avatar (one multipart `file_data` part: a jpg, jpeg, png, gif or webp image of at most 2 MiB; anything else is a 422 on `avatar`, a body over the cap a 413) replacing the previous one, or remove it (also 200 when there is none). Both answer the `cabana.AdminProfile`. Needs any backend session. |
| GET `/navigation`, GET `/settings` | Navigation and settings entries the administrator may open; a settings entry's `controller` names the admin controller a link entry opens and is empty for a singleton form. |
| GET `/field-types` | The plugin field types (`pact.HasFormFieldTypes`) sorted by name, each with its `element`, `translatable` flag and the versioned URLs of its `scripts` and `styles` (`cabana.FieldTypesResponse`); an empty list when no plugin registers one. The SPA loads them after sign-in. Needs any backend session. |
| POST `/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` | Store one multipart part named `file` through the `Upload` of the field's plugin field type and answer 201 `{url}` (`cabana.FieldUploadResponse`). `{id}` 0 is the create form and passes no record; any other id loads the record through the form scope (404 outside it). 404 for a field without a plugin type with `Upload` or whose context does not allow the operation, 413 over `http.body_limits.upload_bytes`, 422 on the field for a malformed body or a `pact.FormFieldError`. |
| GET `/settings/{code}/schema`, GET and PUT `/settings/{code}` | Settings form schema, values and update. |
| POST `/markdown/preview` | Render `{markdown}` through `cabana.RenderMarkdown` for the form preview and answer `{html}`; a refused output is a 422 `validation_failed` on `markdown`. Needs any backend session. |
| GET `/{vendor}/{plugin}/{controller}/schema/list`, `.../schema/form`, `.../schema/relation/{name}` | Localized list, form and relation schemas. |
@@ -192,6 +195,11 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.AdminPrefix` | Reads and validates `backend.uri`; `cabana.DefaultAdminPrefix` is the fallback. |
| `cabana.RuntimeCommands` | Returns the `admin:create` and `admin:reset-password` commands. |
| `cabana.CompileList` / `cabana.CompileForm` | Compile a controller's list and form YAML into cached schemas. |
| `cabana.CompileFormWithTypes` | `cabana.CompileForm` that also accepts the plugin field types of one plugin (`[]pact.FormFieldType`), with the boot checks of their aliases, option keys and `CheckOptions`. |
| `cabana.FieldTypeAssets` / `cabana.FieldTypesResponse` | One entry of GET `/field-types` (`name`, `element`, `translatable`, `scripts`, `styles`) and the route's answer. |
| `cabana.AdminFieldTypes` | Swag annotation of the field types route. |
| `cabana.RecordMeta` | Meta of a record response: `labels` of relation fields, `actions` (show only) and `notices` (create and update only, omitted when empty). |
| `cabana.FieldNotice` | One entry of `meta.notices`: the `field`, the content `locale` of a translatable field type's value, and the localized `message` a field type's `Decode` reported. At most 10 per field and locale, each cut to 500 characters. |
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. `cabana.Registry.Setting` returns singleton settings only, never a controller link. |
@@ -227,6 +235,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.Allows` | Checks a principal against required permission codes. |
| `cabana.TxFromContext` | The transaction a write route is running in, from the context of a lifecycle hook or scope. |
| `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. |
| `cabana.FieldWrite` | One value a save wrote for a plugin field type: the content `Locale` (translatable types), `Default` (written to the host column) and the decoded `Value`. |
| `cabana.FieldWritesFromContext` | The values a create or update save writes for the form's plugin field types, per field and locale, from the context of a Form hook; untouched values are not listed. A copy; false outside a save. |
| `cabana.WithFieldWrites` | Puts field writes on a context as a save does, for tests of Form hooks that read `cabana.FieldWritesFromContext`. |
| `cabana.FieldUploadResponse` / `cabana.AdminFieldUpload` | Answer (`url`) and swag annotation of the field upload route. |
| `cabana.PermissionOption` | One permission a `type: permissioneditor` field offers: code, label, optional tab and comment, and `Locked`. |
| `cabana.PermissionEditorProvider` | Controller capability behind a `type: permissioneditor` field: `AdminPermissionOptions`, `AdminPermissionValues` and `AdminSetPermissionValues`. |
| `cabana.FormMeta` | Form (and optional list) schema meta: `Locale` is the UI locale of the response; `EnabledLocales` lists content locales for ML fields when a `cabana.TranslationWriter` is published, default locale first and then the other enabled codes in the writer's order (`json:"enabledLocales,omitempty"` so list schemas omit the key). |
@@ -295,7 +307,7 @@ printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:reset-password admin@example.
- SummerCMS modules: [backpack](../backpack/README.md), [boardwalk](../boardwalk/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [lagoon](../lagoon/README.md), [pact](../pact/README.md), [party](../party/README.md), [phrasebook](../phrasebook/README.md), [towel](../towel/README.md).
- Third-party: `gorm.io/gorm` (with `gorm.io/gorm/clause`), `github.com/goccy/go-yaml` (with its `ast` package), `golang.org/x/net/html` (with its `atom` package; parses rendered partials for the allowlist walk).
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`.
- Standard library: `bytes`, `context`, `crypto/sha256`, `database/sql`, `encoding/hex`, `encoding/json`, `errors`, `fmt`, `html/template`, `io`, `io/fs`, `log/slog`, `math`, `net`, `net/http`, `path`, `reflect`, `regexp`, `sort`, `strconv`, `strings`, `time`, `unicode/utf8`.
- Tests additionally use `github.com/testcontainers/testcontainers-go` and its `modules/postgres` package.
## Testing

View File

@@ -261,6 +261,20 @@ func AdminAccountAvatarRemove() {}
// @Router /navigation [get]
func AdminNavigation() {}
// AdminFieldTypes documents GET /field-types.
//
// @Summary Plugin field types
// @Description Every admin form field type a plugin registers (pact.HasFormFieldTypes), sorted by name, with the URLs of its scripts and stylesheets (each with a ?v= content hash). The admin loads every script once after sign-in and keeps every stylesheet enabled on all pages; a form field whose `element` is set renders that custom element. An empty list when no plugin registers a type. Any backend session may call it.
// @Tags admin
// @Produce json
// @Security BackendBearer
// @Success 200 {object} FieldTypesResponse
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Router /field-types [get]
func AdminFieldTypes() {}
// AdminSettingsList documents GET /settings.
//
// @Summary List admin settings
@@ -356,7 +370,7 @@ func AdminListSchema() {}
// AdminFormSchema documents the form schema route.
//
// @Summary Admin form schema
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable.
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint. A `type: password` field and every other field the controller lists as virtual is sent in a save body and never has a value in a record response. `preset` on a text or mltext field names the text or mltext field it follows on the create form (type slug or exact; an mltext pair follows per locale) until the administrator edits it. A `type: permissioneditor` field carries `permissionOptions`, the permissions the controller offers the requesting administrator; its value in a record response and in a save body is an object of permission code to integer (radio mode 1 or -1, checkbox mode 1). A `type: relation` field over a protected foreign key is `readOnly` unless its contract declares the key writable. A field whose type a plugin registers carries `element` (the custom-element tag the admin renders), `translatable` (its value is an object of content locale to string) and `typeOptions` (the type's own fields.yaml keys).
// @Tags admin
// @Produce json
// @Security BackendBearer
@@ -454,7 +468,7 @@ func AdminList() {}
// AdminCreate documents the record create route.
//
// @Summary Create an admin record
// @Description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels.
// @Description Relation fields are sent by field name with ids ({"genre": 3, "artists": [4, 9]}); the response carries the same shape plus meta.labels. A plugin field type's value is decoded by its type inside the save; when a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.
// @Tags admin
// @Accept json
// @Produce json
@@ -643,6 +657,7 @@ func AdminShow() {}
// AdminUpdate documents the record update route.
//
// @Summary Update an admin record
// @Description A plugin field type's value is decoded by its type inside the save; a value equal to the one the form was served for that field and locale is neither decoded nor written. When a type reports what it changed, meta.notices lists the localized messages per field (and content locale), and the key is absent otherwise.
// @Tags admin
// @Accept json
// @Produce json
@@ -1156,6 +1171,29 @@ func AdminFileList() {}
// @Router /{vendor}/{plugin}/{controller}/{id}/files/{field} [post]
func AdminFileUpload() {}
// AdminFieldUpload documents the upload route of a plugin field type.
//
// @Summary Upload a file for a plugin field type
// @Description Stores one multipart part named file through the Upload of the field's plugin field type (pact.FormFieldType) and answers its URL. id 0 is the create form and passes no record; any other id loads the record through the controller's FormExtendQuery scope. A field that is not in the form, has a built-in type, has a type without Upload or whose context does not allow the operation answers 404, as does a record outside the scope. A body over http.body_limits.upload_bytes answers 413; a malformed body, a second part or a value the type refuses (pact.FormFieldError) answers 422 on the field.
// @Tags admin
// @Accept multipart/form-data
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param id path integer true "Record id (0 for the create form)"
// @Param field path string true "Field name"
// @Param file formData file true "The file"
// @Success 201 {object} Envelope[FieldUploadResponse]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 413 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload [post]
func AdminFieldUpload() {}
// AdminFileUpdate documents the caption route of a fileupload field.
//
// @Summary Save a file's title and description

View File

@@ -149,6 +149,9 @@ type Registry struct {
// assets are every controller's declared plugin files keyed by URL tail
// (vendor/plugin/<path after assets/>); the asset route serves only these.
assets map[string]*pluginAsset
// fieldTypes are the plugin field types of this boot
// (pact.HasFormFieldTypes); nil when no plugin registers one.
fieldTypes *fieldTypeRegistry
}
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).

View File

@@ -201,7 +201,7 @@ func BindWritableFields(cc *CompiledController) error {
cc.virtual = virtualFieldSet(cc.Controller)
bindings := make([]WritableField, 0, len(cc.Form.Fields))
for _, field := range cc.Form.Fields {
if !scalarFormField(field.Type) || protectedFillKey(field.Name) || cc.virtual[field.Name] {
if !valueFormField(field) || protectedFillKey(field.Name) || cc.virtual[field.Name] {
continue
}
if _, known := cols[field.Name]; !known {
@@ -444,6 +444,9 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil {
return err
}
if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil {
return err
}
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
return err
})
@@ -601,7 +604,13 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil {
return RecordResult{}, err
}
var result RecordResult
// Plugin field values are lifted the same way and decoded by their type
// inside the transaction, once the record is loaded.
pluginValues := liftPluginValues(cc, in.Body, op)
var (
result RecordResult
notices []pluginNotice
)
err = s.transaction(ctx, func(ctx context.Context, tx *gorm.DB) error {
ctx = withVirtualFields(withTx(ctx, tx), virtual)
target, err := newWritableModel(cc)
@@ -618,6 +627,13 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
}
}
projected := projectOperation(cc, in.Body, op)
decoded, err := decodePluginFields(ctx, pluginDecodeInput{Tx: tx, Tr: s.tr, Writer: s.writer, CC: cc, Target: target, Values: pluginValues, Projected: projected, Translations: translations, Update: update})
if err != nil {
return err
}
notices = decoded.notices
// The Form hooks read what this save writes for plugin field types.
ctx = withFieldWrites(ctx, decoded.writes)
if err := lagoon.Fill(target, fillAllowed(cc, target, op), projected, false); err != nil {
// A value that does not fit its column is the admin's input,
// not a missing capability: answer it on the field.
@@ -706,11 +722,15 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil {
return err
}
return hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, op)
if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, op); err != nil {
return err
}
return hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, op)
})
if err != nil {
return RecordResult{}, err
}
result.Meta.Notices = localizeNotices(ctx, s.tr, notices)
return result, nil
}
@@ -1070,7 +1090,7 @@ func mergedRules(ctx context.Context, cc *CompiledController, model any, op stri
// Relation fields are not writable columns. required stays on the
// schema for the client, but it cannot be checked by Fill. A virtual
// field is checked against its submitted value.
if field.Required && (scalarFormField(field.Type) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
if field.Required && (valueFormField(field) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
out[field.Name] = mergeRequired(out[field.Name])
}
}
@@ -1231,6 +1251,12 @@ func scalarFormField(typ string) bool {
}
}
// valueFormField reports whether a controller form field binds to its model
// column: a scalar built-in type or a plugin field type.
func valueFormField(field FormField) bool {
return scalarFormField(field.Type) || field.fieldType != nil
}
func protectedFillKey(key string) bool {
switch strings.ToLower(key) {
case "id", "created_at", "updated_at", "deleted_at",

View File

@@ -0,0 +1,63 @@
package cabana_test
import (
"context"
"errors"
"fmt"
"strings"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// NotesPlugin is the blog plugin with a notes field type.
type NotesPlugin struct{ BlogPlugin }
// FormFieldTypes registers the notes field type: a custom element with one
// option, rows, whose Decode trims the submitted text.
func (NotesPlugin) FormFieldTypes() []pact.FormFieldType {
return []pact.FormFieldType{{
Name: "notes",
Element: "acme-blog-notes",
Scripts: []string{"assets/js/notes.js"},
Options: []string{"rows"},
CheckOptions: func(options map[string]any) error {
if rows, ok := options["rows"].(int); ok && rows > 40 {
return errors.New("rows must be at most 40")
}
return nil
},
Decode: func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
text, ok := in.Value.(string)
if !ok {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"acme.blog::lang.notes.invalid"}}
}
return pact.FormFieldDecoded{Value: strings.TrimSpace(text)}, nil
},
}}
}
// notesController is an admin controller whose form uses the notes type.
type notesController struct{}
func (notesController) ID() string { return "acme.blog.posts" }
func (notesController) ModelName() string { return "Post" }
func (notesController) ConfigDir() string { return "controllers/posts" }
func ExampleCompileFormWithTypes() {
fsys := fstest.MapFS{
"controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte("name: Post\nform: ~/plugins/acme/blog/models/post/fields.yaml\nmodelClass: Post\n")},
"models/post/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n notes:\n label: Notes\n type: notes\n rows: 6\n")},
}
form, err := cabana.CompileFormWithTypes("acme.blog", notesController{}, fsys, NotesPlugin{}.FormFieldTypes())
if err != nil {
fmt.Println(err)
return
}
for _, f := range form.Fields {
fmt.Println(f.Name, f.Type, f.Element, f.TypeOptions["rows"])
}
// Output:
// notes notes acme-blog-notes 6
}

View File

@@ -256,42 +256,14 @@ func compileClientAssets(pluginID string, cc *CompiledController, fsys fs.FS) er
return nil
}
id := cc.Controller.ID()
vendor, plugin, found := strings.Cut(pluginID, ".")
if !found || !assetSegment.MatchString(vendor) || !assetSegment.MatchString(plugin) {
if _, _, ok := assetPluginSegments(pluginID); !ok {
return fmt.Errorf("cabana: admin controller %s/%s: plugin ID must be vendor.plugin to serve admin assets", pluginID, id)
}
read := func(files []string, exts ...string) ([]*pluginAsset, error) {
out := make([]*pluginAsset, 0, len(files))
seen := map[string]bool{}
for _, name := range files {
if err := checkAssetPath(name, exts); err != nil {
return nil, bootErr(pluginID, id, name, err)
}
if seen[name] {
return nil, bootErr(pluginID, id, name, fmt.Errorf("asset declared twice"))
}
seen[name] = true
body, err := fs.ReadFile(fsys, name)
if err != nil {
return nil, bootErr(pluginID, id, name, fmt.Errorf("asset is not in the plugin's embedded files: %w", err))
}
sum := sha256.Sum256(body)
digest := hex.EncodeToString(sum[:])
out = append(out, &pluginAsset{
key: vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/"),
body: body,
contentType: boardwalk.ContentType(name),
etag: `"` + digest + `"`,
version: digest[:12],
})
}
return out, nil
}
scripts, err := read(src.AdminJS(), ".js", ".mjs")
scripts, err := readClientAssets(pluginID, id, fsys, src.AdminJS(), ".js", ".mjs")
if err != nil {
return err
}
styles, err := read(src.AdminCSS(), ".css")
styles, err := readClientAssets(pluginID, id, fsys, src.AdminCSS(), ".css")
if err != nil {
return err
}
@@ -299,6 +271,53 @@ func compileClientAssets(pluginID string, cc *CompiledController, fsys fs.FS) er
return nil
}
// assetPluginSegments splits a vendor.plugin ID into the two URL segments of
// its asset paths; ok is false when the ID cannot serve admin assets.
func assetPluginSegments(pluginID string) (vendor, plugin string, ok bool) {
vendor, plugin, found := strings.Cut(pluginID, ".")
if !found || !assetSegment.MatchString(vendor) || !assetSegment.MatchString(plugin) {
return "", "", false
}
return vendor, plugin, true
}
// readClientAssets reads and hashes the declared files of one owner (an
// admin controller ID or "field type <name>") from the plugin's embedded
// tree. Each path must be clean, live under assets/, end in one of exts and
// exist; a file declared twice fails. Errors are boot errors naming the
// plugin, the owner and the file.
func readClientAssets(pluginID, owner string, fsys fs.FS, files []string, exts ...string) ([]*pluginAsset, error) {
vendor, plugin, ok := assetPluginSegments(pluginID)
if !ok {
return nil, fmt.Errorf("cabana: %s %s: plugin ID must be vendor.plugin to serve admin assets", pluginID, owner)
}
out := make([]*pluginAsset, 0, len(files))
seen := map[string]bool{}
for _, name := range files {
if err := checkAssetPath(name, exts); err != nil {
return nil, bootErr(pluginID, owner, name, err)
}
if seen[name] {
return nil, bootErr(pluginID, owner, name, fmt.Errorf("asset declared twice"))
}
seen[name] = true
body, err := fs.ReadFile(fsys, name)
if err != nil {
return nil, bootErr(pluginID, owner, name, fmt.Errorf("asset is not in the plugin's embedded files: %w", err))
}
sum := sha256.Sum256(body)
digest := hex.EncodeToString(sum[:])
out = append(out, &pluginAsset{
key: vendor + "/" + plugin + "/" + strings.TrimPrefix(name, "assets/"),
body: body,
contentType: boardwalk.ContentType(name),
etag: `"` + digest + `"`,
version: digest[:12],
})
}
return out, nil
}
func checkAssetPath(name string, exts []string) error {
if name != path.Clean(name) || !strings.HasPrefix(name, "assets/") || len(name) == len("assets/") {
return fmt.Errorf("asset path must be a clean path under assets/")

View File

@@ -52,6 +52,13 @@ func mlFieldType(typ string) bool {
return false
}
// isMLField reports whether a form field's value is a locale map stored
// through the translation writer: a built-in ML type (mlFieldType) or a
// translatable plugin field type (pact.FormFieldType.Translatable).
func isMLField(field FormField) bool {
return mlFieldType(field.Type) || (field.fieldType != nil && field.fieldType.def.Translatable)
}
// liftMLValues takes declared mltext/mltextarea/mlmarkdown maps out of a save body
// before ProjectWritableFields drops nested values. Only fields present in
// the body whose context allows op are lifted. A value must be a JSON object
@@ -64,7 +71,7 @@ func liftMLValues(ctx context.Context, cc *CompiledController, body map[string]a
}
var names []string
for _, field := range cc.Form.Fields {
if !mlFieldType(field.Type) || !contextAllows(cc, field.Name, op) {
if !isMLField(field) || !contextAllows(cc, field.Name, op) {
continue
}
if _, present := body[field.Name]; present {
@@ -186,7 +193,7 @@ func hydrateMLRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, w
}
var names []string
for _, field := range cc.Form.Fields {
if !mlFieldType(field.Type) || !contextAllows(cc, field.Name, op) {
if !isMLField(field) || !contextAllows(cc, field.Name, op) {
continue
}
names = append(names, field.Name)

View File

@@ -0,0 +1,867 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"reflect"
"regexp"
"sort"
"strings"
"unicode/utf8"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml/ast"
"gorm.io/gorm"
)
var (
// fieldTypeAliasPattern is the shape of a plugin field type alias.
fieldTypeAliasPattern = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
// fieldTypeOptionPattern is the shape of a plugin field type option key.
fieldTypeOptionPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*$`)
// pluginFieldKeys are the general fields.yaml keys a plugin-typed field
// accepts next to its type's own options.
pluginFieldKeys = map[string]struct{}{
"label": {}, "comment": {}, "span": {}, "type": {}, "required": {},
"tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {},
}
)
const (
// maxFieldNotices is how many notices one field keeps per locale.
maxFieldNotices = 10
// maxNoticeRunes caps one localized notice message.
maxNoticeRunes = 500
)
// compiledFieldType is one plugin field type after the boot checks.
type compiledFieldType struct {
owner string
def pact.FormFieldType
options map[string]bool
scripts []*pluginAsset
styles []*pluginAsset
}
// fieldTypeRegistry is the plugin field types of one boot keyed by alias. It
// is built per Activate by compileFieldTypes and passed down the compile
// functions; cabana keeps no package-level registry. A refusing registry
// knows the aliases but fails a form that uses one: settings and relation
// forms support plugin field types in controller forms only.
type fieldTypeRegistry struct {
byName map[string]*compiledFieldType
refuse bool
}
// refusing returns the registry as one that refuses its aliases; nil stays nil.
func (r *fieldTypeRegistry) refusing() *fieldTypeRegistry {
if r == nil {
return nil
}
return &fieldTypeRegistry{byName: r.byName, refuse: true}
}
// names are the registered aliases in sorted order.
func (r *fieldTypeRegistry) names() []string {
if r == nil {
return nil
}
out := make([]string, 0, len(r.byName))
for name := range r.byName {
out = append(out, name)
}
sort.Strings(out)
return out
}
// assets are every type's scripts and styles, in alias order.
func (r *fieldTypeRegistry) assets() []*pluginAsset {
var out []*pluginAsset
for _, name := range r.names() {
ft := r.byName[name]
out = append(out, ft.scripts...)
out = append(out, ft.styles...)
}
return out
}
func fieldTypeErr(pluginID, name string, err error) error {
return fmt.Errorf("cabana: plugin %s field type %s: %w", pluginID, name, err)
}
// checkFieldTypeDef runs the checks that need no plugin files: the alias, the
// required Decode and the option keys.
func checkFieldTypeDef(def pact.FormFieldType) (map[string]bool, error) {
if !fieldTypeAliasPattern.MatchString(def.Name) {
return nil, fmt.Errorf("alias must match ^[a-z][a-z0-9]*$")
}
if _, core := formFieldTypes[def.Name]; core {
return nil, fmt.Errorf("alias is a built-in field type")
}
if def.Decode == nil {
return nil, fmt.Errorf("Decode is required")
}
options := make(map[string]bool, len(def.Options))
for _, key := range def.Options {
if !fieldTypeOptionPattern.MatchString(key) {
return nil, fmt.Errorf("option %q must match ^[A-Za-z][A-Za-z0-9]*$", key)
}
if _, general := formFieldKeys[key]; general {
return nil, fmt.Errorf("option %s is a general field key", key)
}
if options[key] {
return nil, fmt.Errorf("option %s is declared twice", key)
}
options[key] = true
}
return options, nil
}
// newFieldTypeRegistry builds a registry from one plugin's types with the
// alias and option checks (CompileFormWithTypes). The element prefix and the
// type's files are checked by compileFieldTypes at activation.
func newFieldTypeRegistry(pluginID string, types []pact.FormFieldType) (*fieldTypeRegistry, error) {
reg := &fieldTypeRegistry{byName: map[string]*compiledFieldType{}}
for _, def := range types {
options, err := checkFieldTypeDef(def)
if err != nil {
return nil, fieldTypeErr(pluginID, def.Name, err)
}
if _, dup := reg.byName[def.Name]; dup {
return nil, fieldTypeErr(pluginID, def.Name, fmt.Errorf("alias is registered twice"))
}
reg.byName[def.Name] = &compiledFieldType{owner: pluginID, def: def, options: options}
}
return reg, nil
}
// compileFieldTypes collects every pact.HasFormFieldTypes plugin's field
// types for one boot. Each failure stops boot with a message naming the
// plugin and the alias; an alias two plugins claim names both. The result is
// nil when no plugin registers a type.
func compileFieldTypes(plugins []party.Plugin) (*fieldTypeRegistry, error) {
reg := &fieldTypeRegistry{byName: map[string]*compiledFieldType{}}
for _, p := range plugins {
if p == nil {
continue
}
src, ok := p.(pact.HasFormFieldTypes)
if !ok || src == nil {
continue
}
pluginID := p.ID()
for _, def := range src.FormFieldTypes() {
options, err := checkFieldTypeDef(def)
if err != nil {
return nil, fieldTypeErr(pluginID, def.Name, err)
}
if prev, dup := reg.byName[def.Name]; dup {
if prev.owner == pluginID {
return nil, fieldTypeErr(pluginID, def.Name, fmt.Errorf("alias is registered twice"))
}
return nil, fieldTypeErr(pluginID, def.Name, fmt.Errorf("alias is already registered by plugin %s", prev.owner))
}
if err := checkWidgetTag(def.Element, widgetTagPrefix(pluginID)); err != nil {
return nil, fieldTypeErr(pluginID, def.Name, fmt.Errorf("element: %w", err))
}
ft := &compiledFieldType{owner: pluginID, def: def, options: options}
if len(def.Scripts)+len(def.Styles) > 0 {
assets, ok := p.(pact.AdminAssets)
if !ok || assets == nil || assets.AdminFS() == nil {
return nil, fieldTypeErr(pluginID, def.Name, fmt.Errorf("scripts and styles need the plugin to implement pact.AdminAssets"))
}
owner := "field type " + def.Name
if ft.scripts, err = readClientAssets(pluginID, owner, assets.AdminFS(), def.Scripts, ".js", ".mjs"); err != nil {
return nil, err
}
if ft.styles, err = readClientAssets(pluginID, owner, assets.AdminFS(), def.Styles, ".css"); err != nil {
return nil, err
}
}
reg.byName[def.Name] = ft
}
}
if len(reg.byName) == 0 {
return nil, nil
}
return reg, nil
}
// pluginFieldTypeOf resolves the `type:` of a fields.yaml entry to a plugin
// field type. It is nil for a built-in type, a missing type and an alias no
// plugin registers (compileFieldNode reports those). A refusing registry
// fails a plugin alias.
func pluginFieldTypeOf(mapping *ast.MappingNode, types *fieldTypeRegistry) (*compiledFieldType, error) {
if types == nil {
return nil, nil
}
for _, entry := range mapping.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil || key != "type" {
continue
}
typ, err := nodeString(unwrapNode(entry.Value))
if err != nil {
return nil, nil
}
if _, core := formFieldTypes[typ]; core {
return nil, nil
}
ft := types.byName[typ]
if ft == nil {
return nil, nil
}
if types.refuse {
return nil, fmt.Errorf("plugin field type %s is supported in controller forms only", typ)
}
return ft, nil
}
return nil, nil
}
// fieldKeyAllowed reports whether key is a valid fields.yaml key for a field
// of the plugin type ft, or of a built-in type when ft is nil.
func fieldKeyAllowed(ft *compiledFieldType, key string) bool {
if ft == nil {
_, known := formFieldKeys[key]
return known
}
if _, general := pluginFieldKeys[key]; general {
return true
}
return ft.options[key]
}
// compilePluginField sets the plugin type's schema keys on field: the
// element, the translatable flag and the decoded options, then runs the
// type's CheckOptions.
func compilePluginField(ft *compiledFieldType, values map[string]ast.Node, field *FormField) error {
field.Element = ft.def.Element
field.Translatable = ft.def.Translatable
field.fieldType = ft
options := map[string]any{}
for _, key := range ft.def.Options {
node, ok := values[key]
if !ok {
continue
}
value, err := nodeJSONValue(node)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}
options[key] = value
}
if len(options) > 0 {
field.TypeOptions = options
}
if ft.def.CheckOptions != nil {
if err := ft.def.CheckOptions(copyJSONMap(options)); err != nil {
return fmt.Errorf("type %s: %w", field.Type, err)
}
}
return nil
}
// nodeJSONValue decodes a YAML option value into a JSON-compatible value:
// nil, string, bool, int, float64, []any or map[string]any.
func nodeJSONValue(node ast.Node) (any, error) {
switch n := unwrapNode(node).(type) {
case nil, *ast.NullNode:
return nil, nil
case *ast.StringNode:
return n.Value, nil
case *ast.LiteralNode:
if n.Value == nil {
return "", nil
}
return n.Value.Value, nil
case *ast.BoolNode:
return n.Value, nil
case *ast.IntegerNode:
switch v := n.Value.(type) {
case int64:
return int(v), nil
case uint64:
if v <= uint64(^uint(0)>>1) {
return int(v), nil
}
return float64(v), nil
case int:
return v, nil
default:
return nil, fmt.Errorf("want an integer, got %T", v)
}
case *ast.FloatNode:
return n.Value, nil
case *ast.SequenceNode:
items := sequenceValues(n)
out := make([]any, 0, len(items))
for _, item := range items {
value, err := nodeJSONValue(item)
if err != nil {
return nil, err
}
out = append(out, value)
}
return out, nil
case *ast.MappingNode:
return mappingJSONValue(n.Values)
case *ast.MappingValueNode:
return mappingJSONValue([]*ast.MappingValueNode{n})
default:
return nil, fmt.Errorf("unsupported value %T", n)
}
}
func mappingJSONValue(entries []*ast.MappingValueNode) (map[string]any, error) {
out := make(map[string]any, len(entries))
for _, entry := range entries {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return nil, fmt.Errorf("key: %w", err)
}
if _, dup := out[key]; dup {
return nil, fmt.Errorf("duplicate key %s", key)
}
value, err := nodeJSONValue(entry.Value)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
out[key] = value
}
return out, nil
}
// copyJSONMap deep-copies decoded JSON options so plugin code never shares
// the cached schema's maps. The result is never nil.
func copyJSONMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = copyJSONValue(value)
}
return out
}
func copyJSONValue(v any) any {
switch value := v.(type) {
case map[string]any:
return copyJSONMap(value)
case []any:
out := make([]any, len(value))
for i, item := range value {
out[i] = copyJSONValue(item)
}
return out
default:
return v
}
}
// pluginNotice is one notice a Decode returned, with its field and locale.
type pluginNotice struct {
field string
locale string
notice pact.FormFieldNotice
}
// liftPluginValues takes the values of plugin-typed fields that are not
// translatable out of a save body before scalar projection, which drops
// nested values. Only fields present in the body whose context allows op are
// lifted; the values are kept as decoded from the JSON body. The result is
// never nil.
func liftPluginValues(cc *CompiledController, body map[string]any, op string) map[string]any {
out := map[string]any{}
if cc == nil || cc.Form == nil || body == nil {
return out
}
for _, field := range cc.Form.Fields {
ft := field.fieldType
if ft == nil || ft.def.Translatable || !contextAllows(cc, field.Name, op) {
continue
}
value, present := body[field.Name]
if !present {
continue
}
delete(body, field.Name)
out[field.Name] = value
}
return out
}
// pluginDecodeInput is what decodePluginFields works on: the save's
// transaction, translator and translation writer, the loaded (update) or new
// (create) record, the lifted values of plain plugin fields, the projected
// body Fill receives and the lifted ML translations.
type pluginDecodeInput struct {
Tx *gorm.DB
Tr *phrasebook.Translator
Writer TranslationWriter
CC *CompiledController
Target any
Values map[string]any
Projected map[string]any
Translations []mlValue
Update bool
}
// pluginDecodeResult is the notices the types reported and the values the
// save writes, per field.
type pluginDecodeResult struct {
notices []pluginNotice
writes map[string][]FieldWrite
}
// decodePluginFields runs each submitted plugin field value through its
// type's Decode inside the save transaction, after the record is loaded and
// before Fill. A plain type's value comes from Values and its decoded value
// goes to Projected. A translatable type is decoded per locale: the default
// locale's value is the projected host value, every other locale's value is
// the matching entry of Translations, which applyMLTranslations writes. On
// update, a value equal to the value Show serves for that field and locale
// (servedFieldValue) is neither decoded nor written. A *pact.FormFieldError
// is validation_failed on the field and discards every notice; any other
// error is the opaque lifecycle error.
func decodePluginFields(ctx context.Context, in pluginDecodeInput) (pluginDecodeResult, error) {
out := pluginDecodeResult{writes: map[string][]FieldWrite{}}
cc := in.CC
if cc == nil || cc.Form == nil {
return out, nil
}
defaultLocale := ""
defaultKnown := false
resolveDefault := func() (string, error) {
if defaultKnown || in.Writer == nil {
return defaultLocale, nil
}
code, err := in.Writer.DefaultLocale(ctx, in.Tx)
if err != nil {
return "", fmt.Errorf("cabana: default locale: %w", err)
}
defaultLocale, defaultKnown = code, true
return defaultLocale, nil
}
for _, field := range cc.Form.Fields {
ft := field.fieldType
if ft == nil {
continue
}
if !ft.def.Translatable {
value, ok := in.Values[field.Name]
if !ok {
continue
}
stored, keep, err := decodeOne(ctx, in, field, "", true, value, &out)
if err != nil {
return pluginDecodeResult{}, err
}
if keep {
in.Projected[field.Name] = stored
}
continue
}
def, err := resolveDefault()
if err != nil {
return pluginDecodeResult{}, err
}
if value, ok := in.Projected[field.Name]; ok {
stored, keep, err := decodeOne(ctx, in, field, def, true, value, &out)
if err != nil {
return pluginDecodeResult{}, err
}
if keep {
in.Projected[field.Name] = stored
} else {
delete(in.Projected, field.Name)
}
}
for _, item := range in.Translations {
if item.field != field.Name {
continue
}
locales := make([]string, 0, len(item.values))
for locale := range item.values {
if locale != def {
locales = append(locales, locale)
}
}
sort.Strings(locales)
for _, locale := range locales {
stored, keep, err := decodeOne(ctx, in, field, locale, false, item.values[locale], &out)
if err != nil {
return pluginDecodeResult{}, err
}
if keep {
item.values[locale] = stored.(string)
} else {
delete(item.values, locale)
}
}
}
}
if len(out.writes) == 0 {
out.writes = map[string][]FieldWrite{}
}
return out, nil
}
// decodeOne decodes one value of a plugin field in one locale. keep is false
// when the value is skipped as untouched (update only). It records the
// notices and the write on out.
func decodeOne(ctx context.Context, in pluginDecodeInput, field FormField, locale string, isDefault bool, value any, out *pluginDecodeResult) (any, bool, error) {
ft := field.fieldType
if in.Update {
served, err := servedFieldValue(ctx, in.Tx, in.CC, in.Writer, in.Target, field, locale, isDefault)
if err != nil {
return nil, false, err
}
if sameJSON(value, served) {
return nil, false, nil
}
}
decoded, err := ft.def.Decode(ctx, pact.FormFieldValue{
DB: in.Tx,
Record: in.Target,
Field: field.Name,
Options: copyJSONMap(field.TypeOptions),
Locale: locale,
Default: isDefault,
Value: value,
Translated: translatedReader(ctx, in.Tx, in.Writer, in.Target, ft, locale),
})
if err != nil {
return nil, false, fieldTypeFailure(ctx, in.Tr, in.CC, field, "decode", err)
}
if ft.def.Translatable {
if _, ok := decoded.Value.(string); !ok {
return nil, false, opaqueFieldTypeFailure(in.CC, field, "decode", fmt.Errorf("translatable type returned %T, want a string", decoded.Value))
}
}
for _, notice := range decoded.Notices {
out.notices = append(out.notices, pluginNotice{field: field.Name, locale: locale, notice: notice})
}
out.writes[field.Name] = append(out.writes[field.Name], FieldWrite{Locale: locale, Default: isDefault, Value: decoded.Value})
return decoded.Value, true, nil
}
// translatedReader is FormFieldValue.Translated: an exact read of another
// attribute of the record in locale through the translation writer. It is
// nil for a type that is not translatable and without a writer.
func translatedReader(ctx context.Context, tx *gorm.DB, writer TranslationWriter, target any, ft *compiledFieldType, locale string) func(string) (string, bool, error) {
if writer == nil || ft == nil || !ft.def.Translatable {
return nil
}
return func(attribute string) (string, bool, error) {
return writer.TranslatedExact(ctx, tx, target, attribute, locale)
}
}
// servedFieldValue is what Show serves for a plugin field in one locale of
// the loaded record: the host column read through the field's column binding
// (pointers dereferenced; a translatable type's host value as text), or the
// exact stored translation of a non-default locale ("" when missing), then
// the type's Load.
func servedFieldValue(ctx context.Context, tx *gorm.DB, cc *CompiledController, writer TranslationWriter, target any, field FormField, locale string, isDefault bool) (any, error) {
ft := field.fieldType
var stored any
if isDefault {
stored = hostFieldValue(cc, target, field.Name)
if ft.def.Translatable {
stored = hostScalarString(stored)
}
} else if writer != nil {
text, ok, err := writer.TranslatedExact(ctx, tx, target, field.Name, locale)
if err != nil {
return nil, err
}
if ok {
stored = text
} else {
stored = ""
}
} else {
stored = ""
}
if ft.def.Load == nil {
return stored, nil
}
value, err := ft.def.Load(ctx, pact.FormFieldValue{
DB: tx,
Record: target,
Field: field.Name,
Options: copyJSONMap(field.TypeOptions),
Locale: locale,
Default: isDefault,
Value: stored,
Translated: translatedReader(ctx, tx, writer, target, ft, locale),
})
if err != nil {
return nil, opaqueFieldTypeFailure(cc, field, "load", err)
}
return value, nil
}
// hostFieldValue reads a field's column on the record through the binding
// BindWritableFields made (the fill key), with pointers dereferenced.
func hostFieldValue(cc *CompiledController, target any, name string) any {
key := name
if cc != nil {
for _, field := range cc.Writable {
if field.Name == name {
key = field.FillKey
break
}
}
}
v := reflect.ValueOf(target)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return nil
}
v = v.Elem()
}
column := fieldByColumn(v, key)
if !column.IsValid() || !column.CanInterface() {
return nil
}
return derefValue(column.Interface())
}
// sameJSON reports whether a and b have the same canonical encoding/json
// form (object keys sorted).
func sameJSON(a, b any) bool {
left, err := json.Marshal(a)
if err != nil {
return false
}
right, err := json.Marshal(b)
if err != nil {
return false
}
return bytes.Equal(left, right)
}
// fieldTypeFailure classifies a plugin field type's error: a
// *pact.FormFieldError is validation_failed on the field with its messages
// localized; anything else is logged and becomes the opaque lifecycle error
// (500), so the plugin's text never reaches the client.
func fieldTypeFailure(ctx context.Context, tr *phrasebook.Translator, cc *CompiledController, field FormField, stage string, err error) error {
var refused *pact.FormFieldError
if errors.As(err, &refused) && refused != nil {
if ctx == nil {
ctx = context.Background()
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
messages := make([]string, 0, len(refused.Messages))
for _, text := range refused.Messages {
messages = append(messages, translateKey(ctx, tr, text))
}
if len(messages) == 0 {
messages = []string{"The " + field.Name + " field has an invalid value."}
}
return &ValidationError{Details: map[string]any{field.Name: messages}}
}
return opaqueFieldTypeFailure(cc, field, stage, err)
}
// opaqueFieldTypeFailure logs a plugin field type's error and returns the
// opaque lifecycle error (500 without the plugin's text).
func opaqueFieldTypeFailure(cc *CompiledController, field FormField, stage string, err error) error {
slog.Error("cabana: field type "+stage+" failed", "controller", controllerID(cc), "field", field.Name, "type", field.Type, "error", err)
return &lifecycleError{ControllerID: controllerID(cc)}
}
// hydratePluginFields serves each plugin field of a Show or save response
// through its type's Load: a translatable type per locale of the hydrated
// locale map, any other type once. A type without Load serves the stored
// value.
func hydratePluginFields(ctx context.Context, tx *gorm.DB, cc *CompiledController, writer TranslationWriter, target any, data map[string]any, op string) error {
if cc == nil || cc.Form == nil || data == nil {
return nil
}
defaultLocale := ""
defaultKnown := false
for _, field := range cc.Form.Fields {
ft := field.fieldType
if ft == nil || ft.def.Load == nil || !contextAllows(cc, field.Name, op) {
continue
}
stored, ok := data[field.Name]
if !ok {
continue
}
load := func(value any, locale string, isDefault bool) (any, error) {
out, err := ft.def.Load(ctx, pact.FormFieldValue{
DB: tx,
Record: target,
Field: field.Name,
Options: copyJSONMap(field.TypeOptions),
Locale: locale,
Default: isDefault,
Value: value,
Translated: translatedReader(ctx, tx, writer, target, ft, locale),
})
if err != nil {
// A Load error is never a 422, even a pact.FormFieldError:
// nothing was submitted.
return nil, opaqueFieldTypeFailure(cc, field, "load", err)
}
return out, nil
}
texts, isMap := stored.(map[string]string)
if !ft.def.Translatable || !isMap {
value, err := load(derefValue(stored), "", true)
if err != nil {
return err
}
data[field.Name] = value
continue
}
if !defaultKnown && writer != nil {
code, err := writer.DefaultLocale(ctx, tx)
if err != nil {
return fmt.Errorf("cabana: default locale: %w", err)
}
defaultLocale, defaultKnown = code, true
}
served := make(map[string]any, len(texts))
for locale, text := range texts {
value, err := load(text, locale, locale == defaultLocale)
if err != nil {
return err
}
served[locale] = value
}
data[field.Name] = served
}
return nil
}
// derefValue dereferences a projected column value; a nil pointer is nil.
func derefValue(v any) any {
rv := reflect.ValueOf(v)
for rv.IsValid() && rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return nil
}
rv = rv.Elem()
}
if !rv.IsValid() || !rv.CanInterface() {
return v
}
return rv.Interface()
}
// localizeNotices turns the notices of a save into the response's
// meta.notices in the request locale, the way localizeForbidden localizes a
// refusal: Message is a phrase key or text, resolved with Choice when Count
// is above zero and Get otherwise; without a translator the text is kept. At
// most maxFieldNotices are kept per field and locale and each message is cut
// to maxNoticeRunes. The result is nil when there is no notice, so the
// response carries no notices key.
func localizeNotices(ctx context.Context, tr *phrasebook.Translator, notices []pluginNotice) []FieldNotice {
if len(notices) == 0 {
return nil
}
if ctx == nil {
ctx = context.Background()
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
kept := map[[2]string]int{}
var out []FieldNotice
for _, item := range notices {
if strings.TrimSpace(item.notice.Message) == "" {
continue
}
key := [2]string{item.field, item.locale}
if kept[key] >= maxFieldNotices {
continue
}
kept[key]++
message := item.notice.Message
switch {
case tr == nil:
case item.notice.Count > 0:
message = tr.Choice(ctx, message, item.notice.Count, item.notice.Params)
default:
message = tr.Get(ctx, message, item.notice.Params)
}
out = append(out, FieldNotice{Field: item.field, Locale: item.locale, Message: capRunes(message, maxNoticeRunes)})
}
return out
}
// capRunes cuts s to at most n runes, ending a cut text with an ellipsis.
func capRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
runes := []rune(s)
return string(runes[:n-1]) + "…"
}
// FieldTypeAssets is one plugin field type the admin loads on every page:
// its alias, its custom-element tag, whether its value is a locale map, and
// the same-origin URLs of its scripts and stylesheets, each with a ?v=
// content hash.
type FieldTypeAssets struct {
Name string `json:"name"`
Element string `json:"element"`
Translatable bool `json:"translatable"`
Scripts []string `json:"scripts"`
Styles []string `json:"styles"`
}
// FieldTypesResponse is the GET /field-types answer: every registered field
// type sorted by name, an empty list when no plugin registers one.
type FieldTypesResponse struct {
Data []FieldTypeAssets `json:"data"`
Meta SuccessMeta `json:"meta"`
}
// fieldTypeAssets lists the registered field types for the admin SPA.
func (s *service) fieldTypeAssets() []FieldTypeAssets {
out := []FieldTypeAssets{}
if s == nil || s.reg == nil {
return out
}
base := s.adminPrefix() + "/assets/"
for _, name := range s.reg.fieldTypes.names() {
ft := s.reg.fieldTypes.byName[name]
entry := FieldTypeAssets{Name: name, Element: ft.def.Element, Translatable: ft.def.Translatable, Scripts: []string{}, Styles: []string{}}
for _, file := range ft.scripts {
entry.Scripts = append(entry.Scripts, base+file.key+"?v="+file.version)
}
for _, file := range ft.styles {
entry.Styles = append(entry.Styles, base+file.key+"?v="+file.version)
}
out = append(out, entry)
}
return out
}
// fieldTypesList answers GET {api}/field-types for any backend session: the
// SPA loads every type's scripts once and enables its styles globally.
func (s *service) fieldTypesList(w http.ResponseWriter, r *http.Request) {
principal, ok := bouncer.User(r.Context())
if !ok || principal == nil || !principal.Backend {
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
return
}
WriteData(w, http.StatusOK, s.fieldTypeAssets(), nil)
}
// FieldUploadResponse is the answer of the field upload route: the URL of
// the stored file, from the type's Upload.
type FieldUploadResponse struct {
URL string `json:"url"`
}

View File

@@ -0,0 +1,714 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// ftNote is the model of the field type smoke controller.
type ftNote struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Notes string `gorm:"column:notes"`
Body string `gorm:"column:body"`
}
func (*ftNote) Fillable() []string { return []string{"title", "notes", "body"} }
func (*ftNote) Rules() map[string]string { return map[string]string{} }
// ftController is an admin controller whose form uses plugin field types.
type ftController struct{}
func (ftController) ID() string { return "acme.demo.notes" }
func (ftController) ModelName() string { return "Note" }
func (ftController) ConfigDir() string { return "controllers/notes" }
func (ftController) NewRecord() any { return &ftNote{} }
func (ftController) RequiredPermissions() []string {
return []string{"acme.demo.access"}
}
// ftPlugin registers field types and serves an AdminFS.
type ftPlugin struct {
id string
fsys fs.FS
types []pact.FormFieldType
}
func (p ftPlugin) ID() string { return p.id }
func (ftPlugin) Requires() []string { return nil }
func (ftPlugin) Register(*backpack.App) error { return nil }
func (ftPlugin) Boot(*backpack.App) error { return nil }
func (p ftPlugin) AdminFS() fs.FS { return p.fsys }
func (p ftPlugin) FormFieldTypes() []pact.FormFieldType { return p.types }
func (ftPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{ftController{}}
}
var _ party.Plugin = ftPlugin{}
const ftFormConfig = `name: Note
form: ~/plugins/acme/demo/models/note/fields.yaml
modelClass: Note
`
const ftListConfig = `list: ~/plugins/acme/demo/models/note/columns.yaml
modelClass: Note
recordsPerPage: 20
`
// ftFS is the plugin tree: the notes script, the controller's list and form,
// and fields.yaml with the given notes field body.
func ftFS(notesField string, extra ...string) fstest.MapFS {
return fstest.MapFS{
"assets/js/notes.js": &fstest.MapFile{Data: []byte("customElements.define('acme-demo-notes', class extends HTMLElement {})\n")},
"controllers/notes/config_form.yaml": &fstest.MapFile{Data: []byte(ftFormConfig)},
"controllers/notes/config_list.yaml": &fstest.MapFile{Data: []byte(ftListConfig)},
"models/note/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n")},
"models/note/fields.yaml": &fstest.MapFile{Data: []byte("fields:\n title:\n type: text\n label: Title\n notes:\n" + notesField + strings.Join(extra, ""))},
}
}
const ftNotesField = " type: notes\n label: Notes\n rows: 4\n"
// ftNotesType is the tracer type: Decode refuses "bad", trims surrounding
// spaces and reports the trim as a notice; Load prefixes "served:".
func ftNotesType() pact.FormFieldType {
return pact.FormFieldType{
Name: "notes",
Element: "acme-demo-notes",
Scripts: []string{"assets/js/notes.js"},
Options: []string{"rows"},
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
text, _ := in.Value.(string)
if text == "bad" {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"acme.demo::lang.bad"}}
}
trimmed := strings.TrimSpace(text)
if trimmed != text {
return pact.FormFieldDecoded{Value: trimmed, Notices: []pact.FormFieldNotice{{Message: "acme.demo::lang.trimmed", Count: 1}}}, nil
}
return pact.FormFieldDecoded{Value: text}, nil
},
Load: func(_ context.Context, in pact.FormFieldValue) (any, error) {
text, _ := in.Value.(string)
return "served:" + text, nil
},
Upload: func(_ context.Context, in pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
body, err := io.ReadAll(in.Body)
if err != nil {
return pact.FormFieldUploadResult{}, err
}
if in.Filename == "bad.txt" {
return pact.FormFieldUploadResult{}, &pact.FormFieldError{Messages: []string{"acme.demo::lang.bad_file"}}
}
record := "nil"
if in.Record != nil {
record = "set"
}
return pact.FormFieldUploadResult{URL: fmt.Sprintf("/media/%s?bytes=%d&record=%s&rows=%v", in.Filename, len(body), record, in.Options["rows"])}, nil
},
}
}
// ftCompile runs the boot path for plugins: compileFieldTypes, then the
// registry of their controllers with that field type registry.
func ftCompile(plugins ...party.Plugin) (*Registry, error) {
types, err := compileFieldTypes(plugins)
if err != nil {
return nil, err
}
items, err := collectControllers(plugins)
if err != nil {
return nil, err
}
return compileRegistryWithTypes(items, types)
}
// ftDecode decodes values on a create with no translation writer.
func ftDecode(cc *CompiledController, target any, values, projected map[string]any) (pluginDecodeResult, error) {
return decodePluginFields(context.Background(), pluginDecodeInput{CC: cc, Target: target, Values: values, Projected: projected})
}
func ftDemo(notesField string, types ...pact.FormFieldType) ftPlugin {
if len(types) == 0 {
types = []pact.FormFieldType{ftNotesType()}
}
return ftPlugin{id: "acme.demo", fsys: ftFS(notesField), types: types}
}
func ftRequest(method, target string) *http.Request {
req := httptest.NewRequest(method, target, nil)
return req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}))
}
func TestFieldTypeTracerSmoke(t *testing.T) {
t.Run("alias collision between two plugins fails boot naming both", func(t *testing.T) {
other := ftPlugin{id: "acme.other", fsys: fstest.MapFS{"assets/js/notes.js": &fstest.MapFile{Data: []byte("x")}}, types: []pact.FormFieldType{{
Name: "notes", Element: "acme-other-notes", Scripts: []string{"assets/js/notes.js"},
Decode: func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
return pact.FormFieldDecoded{}, nil
},
}}}
_, err := compileFieldTypes([]party.Plugin{ftDemo(ftNotesField), other})
if err == nil || !strings.Contains(err.Error(), "acme.other") || !strings.Contains(err.Error(), "acme.demo") || !strings.Contains(err.Error(), "notes") {
t.Fatalf("err=%v, want a collision naming acme.demo and acme.other", err)
}
})
t.Run("alias equal to a core type fails boot", func(t *testing.T) {
core := ftNotesType()
core.Name = "markdown"
_, err := compileFieldTypes([]party.Plugin{ftDemo(ftNotesField, core)})
if err == nil || !strings.Contains(err.Error(), "markdown") || !strings.Contains(err.Error(), "built-in") {
t.Fatalf("err=%v, want a built-in alias refusal", err)
}
})
t.Run("element outside the plugin prefix fails boot", func(t *testing.T) {
bad := ftNotesType()
bad.Element = "acme-other-notes"
if _, err := compileFieldTypes([]party.Plugin{ftDemo(ftNotesField, bad)}); err == nil || !strings.Contains(err.Error(), "acme-demo-") {
t.Fatalf("err=%v, want the plugin prefix", err)
}
})
t.Run("unknown key fails boot naming field and key", func(t *testing.T) {
_, err := ftCompile(ftDemo(ftNotesField + " widget: acme-demo-x\n"))
if err == nil || !strings.Contains(err.Error(), "field notes") || !strings.Contains(err.Error(), "unknown field widget") {
t.Fatalf("err=%v", err)
}
})
t.Run("CheckOptions error fails boot naming field and type", func(t *testing.T) {
typ := ftNotesType()
typ.CheckOptions = func(options map[string]any) error {
if rows, _ := options["rows"].(int); rows > 3 {
return errors.New("rows must be at most 3")
}
return nil
}
_, err := ftCompile(ftDemo(ftNotesField, typ))
if err == nil || !strings.Contains(err.Error(), "field notes") || !strings.Contains(err.Error(), "type notes") || !strings.Contains(err.Error(), "rows must be at most 3") {
t.Fatalf("err=%v", err)
}
})
t.Run("settings and relation forms refuse plugin field types", func(t *testing.T) {
reg, err := ftCompile(ftDemo(ftNotesField))
if err != nil {
t.Fatalf("compile: %v", err)
}
_, err = decodeFields([]byte("fields:\n notes:\n type: notes\n"), reg.fieldTypes.refusing())
if err == nil || !strings.Contains(err.Error(), "supported in controller forms only") {
t.Fatalf("err=%v", err)
}
})
reg, err := ftCompile(ftDemo(ftNotesField))
if err != nil {
t.Fatalf("compile: %v", err)
}
svc := &service{reg: reg}
cc, _ := reg.Get("acme.demo.notes")
t.Run("field-types route lists the type with a versioned script", func(t *testing.T) {
rec := httptest.NewRecorder()
svc.fieldTypesList(rec, ftRequest(http.MethodGet, adminAPI("/field-types")))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body FieldTypesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body.Data) != 1 || body.Data[0].Name != "notes" || body.Data[0].Element != "acme-demo-notes" || body.Data[0].Translatable {
t.Fatalf("data=%+v", body.Data)
}
scripts := body.Data[0].Scripts
if len(scripts) != 1 || !strings.HasPrefix(scripts[0], DefaultAdminPrefix+"/assets/acme/demo/js/notes.js?v=") || len(strings.TrimPrefix(scripts[0], DefaultAdminPrefix+"/assets/acme/demo/js/notes.js?v=")) != 12 {
t.Fatalf("scripts=%v", scripts)
}
if reg.assets["acme/demo/js/notes.js"] == nil {
t.Fatal("the asset route does not serve the field type script")
}
empty := httptest.NewRecorder()
(&service{reg: &Registry{}}).fieldTypesList(empty, ftRequest(http.MethodGet, adminAPI("/field-types")))
if !strings.Contains(empty.Body.String(), `"data":[]`) {
t.Fatalf("no types answers %s", empty.Body.String())
}
})
t.Run("form schema carries element and typeOptions", func(t *testing.T) {
req := ftRequest(http.MethodGet, adminAPI("/acme/demo/notes/schema/form"))
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "notes")
rec := httptest.NewRecorder()
svc.formSchema(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data struct {
Fields []map[string]any `json:"fields"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
var notes map[string]any
for _, field := range body.Data.Fields {
if field["name"] == "notes" {
notes = field
}
}
if notes["type"] != "notes" || notes["element"] != "acme-demo-notes" {
t.Fatalf("notes field=%v", notes)
}
if opts, _ := notes["typeOptions"].(map[string]any); opts["rows"] != float64(4) {
t.Fatalf("typeOptions=%v", notes["typeOptions"])
}
if _, ok := notes["translatable"]; ok {
t.Fatalf("a plain type sends translatable: %v", notes)
}
})
t.Run("decode stores the type's value", func(t *testing.T) {
projected := map[string]any{}
res, err := ftDecode(cc, &ftNote{}, map[string]any{"notes": "plain"}, projected)
if err != nil || projected["notes"] != "plain" || len(res.notices) != 0 {
t.Fatalf("projected=%v notices=%v err=%v", projected, res.notices, err)
}
})
t.Run("decode notice becomes a FieldNotice", func(t *testing.T) {
projected := map[string]any{}
res, err := ftDecode(cc, &ftNote{}, map[string]any{"notes": " x "}, projected)
if err != nil || projected["notes"] != "x" {
t.Fatalf("projected=%v err=%v", projected, err)
}
got := localizeNotices(context.Background(), nil, res.notices)
if len(got) != 1 || got[0] != (FieldNotice{Field: "notes", Message: "acme.demo::lang.trimmed"}) {
t.Fatalf("notices=%+v", got)
}
raw, _ := json.Marshal(RecordMeta{Notices: got})
if !strings.Contains(string(raw), `"notices":[{"field":"notes","message":"acme.demo::lang.trimmed"}]`) {
t.Fatalf("meta=%s", raw)
}
})
t.Run("no notices key without notices", func(t *testing.T) {
raw, err := json.Marshal(RecordMeta{Labels: map[string][]RelationOption{}, Notices: localizeNotices(context.Background(), nil, nil)})
if err != nil || strings.Contains(string(raw), "notices") || string(raw) != `{"labels":{}}` {
t.Fatalf("meta=%s err=%v", raw, err)
}
})
t.Run("FormFieldError is validation_failed on the field", func(t *testing.T) {
_, err := ftDecode(cc, &ftNote{}, map[string]any{"notes": "bad"}, map[string]any{})
var invalid *ValidationError
if !errors.As(err, &invalid) {
t.Fatalf("err=%v, want a ValidationError", err)
}
if msgs, _ := invalid.Details["notes"].([]string); len(msgs) != 1 || msgs[0] != "acme.demo::lang.bad" {
t.Fatalf("details=%v", invalid.Details)
}
})
t.Run("any other decode error is opaque", func(t *testing.T) {
typ := ftNotesType()
typ.Decode = func(context.Context, pact.FormFieldValue) (pact.FormFieldDecoded, error) {
return pact.FormFieldDecoded{}, errors.New("secret plugin detail")
}
other, err := ftCompile(ftDemo(ftNotesField, typ))
if err != nil {
t.Fatal(err)
}
occ, _ := other.Get("acme.demo.notes")
_, err = ftDecode(occ, &ftNote{}, map[string]any{"notes": "x"}, map[string]any{})
var life *lifecycleError
if !errors.As(err, &life) || strings.Contains(err.Error(), "secret") {
t.Fatalf("err=%v, want the opaque lifecycle error", err)
}
rec := httptest.NewRecorder()
writeCRUDError(rec, err)
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "secret") {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("show serves the value through Load", func(t *testing.T) {
data := projectRecord(cc, &ftNote{ID: 7, Notes: "stored"})
if err := hydratePluginFields(context.Background(), nil, cc, nil, &ftNote{}, data, "update"); err != nil {
t.Fatal(err)
}
if data["notes"] != "served:stored" || data["title"] != "" {
t.Fatalf("data=%v", data)
}
})
t.Run("lift takes plugin values out of the body", func(t *testing.T) {
body := map[string]any{"title": "T", "notes": map[string]any{"nested": true}}
values := liftPluginValues(cc, body, "update")
if _, still := body["notes"]; still || len(values) != 1 {
t.Fatalf("body=%v values=%v", body, values)
}
if _, ok := values["notes"].(map[string]any); !ok {
t.Fatalf("nested value lost: %v", values)
}
})
}
// ftNotesMLType is a translatable type: Decode refuses "bad", trims and
// reports the trim per locale; ListValue prefixes "list:". It has no Load
// and no Upload.
func ftNotesMLType() pact.FormFieldType {
return pact.FormFieldType{
Name: "notesml",
Element: "acme-demo-notesml",
Translatable: true,
Scripts: []string{"assets/js/notes.js"},
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
text, _ := in.Value.(string)
if text == "bad" {
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"bad in " + in.Locale}}
}
out := pact.FormFieldDecoded{Value: strings.TrimSpace(text)}
if out.Value != text {
out.Notices = []pact.FormFieldNotice{{Message: "trimmed " + in.Locale}}
}
return out, nil
},
ListValue: func(_ context.Context, value any) string { return fmt.Sprintf("list:%v", value) },
}
}
const ftBodyField = " body:\n type: notesml\n label: Body\n"
// ftMLPlugin has both types, a translatable body field and typed columns.
func ftMLPlugin() ftPlugin {
fsys := ftFS(ftNotesField, ftBodyField)
fsys["models/note/columns.yaml"] = &fstest.MapFile{Data: []byte("columns:\n title:\n label: Title\n notes:\n label: Notes\n type: notes\n body:\n label: Body\n type: notesml\n")}
return ftPlugin{id: "acme.demo", fsys: fsys, types: []pact.FormFieldType{ftNotesType(), ftNotesMLType()}}
}
// ftHookController records FieldWritesFromContext in its update hooks.
type ftHookController struct {
ftController
before, after *map[string][]FieldWrite
}
func (c ftHookController) FormBeforeUpdate(ctx context.Context, _ any) error {
writes, ok := FieldWritesFromContext(ctx)
if !ok {
return errors.New("no field writes in FormBeforeUpdate")
}
*c.before = writes
return nil
}
func (c ftHookController) FormAfterUpdate(ctx context.Context, _ any) error {
writes, ok := FieldWritesFromContext(ctx)
if !ok {
return errors.New("no field writes in FormAfterUpdate")
}
*c.after = writes
return nil
}
// ftSave runs the decode half of a save the way CRUDService.save does: the
// ML lift, the plugin lift, the projection and decodePluginFields.
func ftSave(t *testing.T, cc *CompiledController, writer TranslationWriter, target any, body map[string]any, update bool) (pluginDecodeResult, map[string]any, []mlValue, error) {
t.Helper()
op := "create"
if update {
op = "update"
}
translations, err := liftMLValues(context.Background(), cc, body, op, writer, nil)
if err != nil {
t.Fatalf("lift: %v", err)
}
values := liftPluginValues(cc, body, op)
projected := projectOperation(cc, body, op)
res, err := decodePluginFields(context.Background(), pluginDecodeInput{
Writer: writer, CC: cc, Target: target, Values: values, Projected: projected, Translations: translations, Update: update,
})
return res, projected, translations, err
}
func ftWriter() *recordingWriter {
return &recordingWriter{defaultLocale: "pl", enabled: []string{"pl", "en"}}
}
// ftUpload posts one multipart part to the field upload route.
func ftUpload(t *testing.T, svc *service, field, filename string, content []byte, principal *bouncer.Principal, ajax bool) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(content); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, adminAPI("/acme/demo/notes/0/fields/"+field+"/upload"), &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
if ajax {
req.Header.Set("X-Requested-With", "XMLHttpRequest")
}
for name, value := range map[string]string{"vendor": "acme", "plugin": "demo", "controller": "notes", "id": "0", "field": field} {
req.SetPathValue(name, value)
}
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
rec := httptest.NewRecorder()
requireAjax(svc.fieldUpload)(rec, req)
return rec
}
func TestFieldTypeExpansionSmoke(t *testing.T) {
reg, err := ftCompile(ftMLPlugin())
if err != nil {
t.Fatalf("compile: %v", err)
}
cc, _ := reg.Get("acme.demo.notes")
t.Run("translatable decode: default on the host, others through the writer", func(t *testing.T) {
writer := ftWriter()
res, projected, translations, err := ftSave(t, cc, writer, &ftNote{ID: 1}, map[string]any{"body": map[string]any{"pl": " P ", "en": " E "}, "notes": "n"}, false)
if err != nil {
t.Fatal(err)
}
if projected["body"] != "P" || projected["notes"] != "n" {
t.Fatalf("projected=%v", projected)
}
if err := applyMLTranslations(context.Background(), nil, writer, &ftNote{ID: 1}, translations); err != nil {
t.Fatal(err)
}
if writer.attrs["en"]["body"] != "E" || writer.attrs["pl"] != nil {
t.Fatalf("translations written=%v", writer.attrs)
}
if len(res.writes["body"]) != 2 || len(res.writes["notes"]) != 1 {
t.Fatalf("writes=%v", res.writes)
}
})
t.Run("notices carry the locale of a translatable value", func(t *testing.T) {
res, _, _, err := ftSave(t, cc, ftWriter(), &ftNote{ID: 1}, map[string]any{"body": map[string]any{"pl": " P ", "en": " E "}, "notes": " n "}, false)
if err != nil {
t.Fatal(err)
}
got := localizeNotices(context.Background(), nil, res.notices)
want := []FieldNotice{
{Field: "notes", Message: "acme.demo::lang.trimmed"},
{Field: "body", Locale: "pl", Message: "trimmed pl"},
{Field: "body", Locale: "en", Message: "trimmed en"},
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("notices=%+v, want %+v", got, want)
}
})
t.Run("a FormFieldError on a non-default locale is a 422 with no notices", func(t *testing.T) {
writer := ftWriter()
res, _, _, err := ftSave(t, cc, writer, &ftNote{ID: 1}, map[string]any{"body": map[string]any{"pl": " P ", "en": "bad"}}, false)
var invalid *ValidationError
if !errors.As(err, &invalid) {
t.Fatalf("err=%v", err)
}
if msgs, _ := invalid.Details["body"].([]string); len(msgs) != 1 || msgs[0] != "bad in en" {
t.Fatalf("details=%v", invalid.Details)
}
if len(res.notices) != 0 || len(writer.attrs) != 0 {
t.Fatalf("notices=%v attrs=%v", res.notices, writer.attrs)
}
})
t.Run("untouched values are skipped on update", func(t *testing.T) {
writer := ftWriter()
writer.attrs = map[string]map[string]string{"en": {"body": "E"}}
target := &ftNote{ID: 1, Body: "P", Notes: "stored"}
res, projected, translations, err := ftSave(t, cc, writer, target, map[string]any{"body": map[string]any{"pl": "P", "en": "E"}, "notes": "served:stored"}, true)
if err != nil {
t.Fatal(err)
}
if _, ok := projected["body"]; ok {
t.Fatalf("untouched host value projected: %v", projected)
}
if _, ok := projected["notes"]; ok {
t.Fatalf("untouched plain value projected: %v", projected)
}
writer.attrs = nil
if err := applyMLTranslations(context.Background(), nil, writer, target, translations); err != nil {
t.Fatal(err)
}
if len(writer.ids) != 0 || len(res.notices) != 0 || len(res.writes) != 0 {
t.Fatalf("ids=%v notices=%v writes=%v", writer.ids, res.notices, res.writes)
}
writer.attrs = map[string]map[string]string{"en": {"body": "E"}}
res, projected, translations, err = ftSave(t, cc, writer, target, map[string]any{"body": map[string]any{"pl": "P", "en": " E2"}}, true)
if err != nil {
t.Fatal(err)
}
if _, ok := projected["body"]; ok {
t.Fatalf("untouched host value projected: %v", projected)
}
if len(translations) != 1 || translations[0].values["en"] != "E2" {
t.Fatalf("translations=%v", translations)
}
if got := localizeNotices(context.Background(), nil, res.notices); len(got) != 1 || got[0].Locale != "en" {
t.Fatalf("notices=%+v", got)
}
})
t.Run("nothing is skipped on create", func(t *testing.T) {
writer := ftWriter()
writer.attrs = map[string]map[string]string{"en": {"body": "E"}}
target := &ftNote{ID: 1, Body: "P", Notes: "stored"}
_, projected, translations, err := ftSave(t, cc, writer, target, map[string]any{"body": map[string]any{"pl": "P", "en": "E"}, "notes": "served:stored"}, false)
if err != nil {
t.Fatal(err)
}
if projected["body"] != "P" || projected["notes"] != "served:stored" || translations[0].values["en"] != "E" {
t.Fatalf("projected=%v translations=%v", projected, translations)
}
})
t.Run("Form hooks read the written values", func(t *testing.T) {
if _, ok := FieldWritesFromContext(context.Background()); ok {
t.Fatal("writes outside a save")
}
res, _, _, err := ftSave(t, cc, ftWriter(), &ftNote{ID: 1}, map[string]any{"body": map[string]any{"pl": "P", "en": "E"}, "notes": "n"}, false)
if err != nil {
t.Fatal(err)
}
var before, after map[string][]FieldWrite
hooked := *cc
hooked.Controller = ftHookController{before: &before, after: &after}
ctx := withFieldWrites(context.Background(), res.writes)
if err := formBeforeUpdate(ctx, &hooked, &ftNote{}); err != nil {
t.Fatal(err)
}
if err := formAfterUpdate(ctx, &hooked, &ftNote{}); err != nil {
t.Fatal(err)
}
want := map[string][]FieldWrite{
"body": {{Locale: "pl", Default: true, Value: "P"}, {Locale: "en", Value: "E"}},
"notes": {{Default: true, Value: "n"}},
}
if fmt.Sprint(before) != fmt.Sprint(want) || fmt.Sprint(after) != fmt.Sprint(want) {
t.Fatalf("before=%v after=%v", before, after)
}
before["body"][0].Value = "changed"
again, _ := FieldWritesFromContext(ctx)
if again["body"][0].Value != "P" {
t.Fatal("FieldWritesFromContext does not return a copy")
}
exported := WithFieldWrites(context.Background(), want)
if got, ok := FieldWritesFromContext(exported); !ok || len(got) != 2 {
t.Fatalf("WithFieldWrites: %v %v", got, ok)
}
})
t.Run("translatable values are loaded per locale", func(t *testing.T) {
data := map[string]any{"body": map[string]string{"pl": "P", "en": "E"}, "notes": "stored"}
if err := hydratePluginFields(context.Background(), nil, cc, ftWriter(), &ftNote{}, data, "update"); err != nil {
t.Fatal(err)
}
if fmt.Sprint(data["body"]) != fmt.Sprint(map[string]string{"pl": "P", "en": "E"}) || data["notes"] != "served:stored" {
t.Fatalf("data=%v", data)
}
})
t.Run("a list column of a plugin type shows ListValue", func(t *testing.T) {
if cc.List == nil {
t.Fatal("no list")
}
row := projectRowCtx(context.Background(), &ftNote{ID: 3, Title: "T", Notes: "raw", Body: "x"}, cc.Controller, cc.List.Columns)
if row["body"] != "list:x" || row["notes"] != "raw" || row["title"] != "T" {
t.Fatalf("row=%v", row)
}
raw, _ := json.Marshal(cc.List.Columns)
if !strings.Contains(string(raw), `"type":"notesml"`) {
t.Fatalf("columns=%s", raw)
}
})
db, _ := openDryRunDB(t)
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
svc := &service{app: app, reg: reg, uploadBytes: 4096}
admin := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"acme.demo.access": true}}
t.Run("upload answers 201 with the type's URL", func(t *testing.T) {
rec := ftUpload(t, svc, "notes", "a.txt", []byte("hello"), admin, true)
if rec.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data FieldUploadResponse `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Data.URL != "/media/a.txt?bytes=5&record=nil&rows=4" {
t.Fatalf("url=%q", body.Data.URL)
}
})
t.Run("upload to a core field or a type without Upload is 404", func(t *testing.T) {
for _, field := range []string{"title", "body", "missing"} {
if rec := ftUpload(t, svc, field, "a.txt", []byte("x"), admin, true); rec.Code != http.StatusNotFound {
t.Fatalf("%s: status=%d body=%s", field, rec.Code, rec.Body.String())
}
}
})
t.Run("upload without the controller permission is 403", func(t *testing.T) {
other := &bouncer.Principal{ID: 2, Backend: true}
if rec := ftUpload(t, svc, "notes", "a.txt", []byte("x"), other, true); rec.Code != http.StatusForbidden {
t.Fatalf("status=%d", rec.Code)
}
})
t.Run("upload without X-Requested-With is refused", func(t *testing.T) {
if rec := ftUpload(t, svc, "notes", "a.txt", []byte("x"), admin, false); rec.Code != http.StatusForbidden {
t.Fatalf("status=%d", rec.Code)
}
})
t.Run("upload over the cap is 413", func(t *testing.T) {
if rec := ftUpload(t, svc, "notes", "a.txt", bytes.Repeat([]byte("x"), 8192), admin, true); rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
t.Run("upload FormFieldError is 422 on the field", func(t *testing.T) {
rec := ftUpload(t, svc, "notes", "bad.txt", []byte("x"), admin, true)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"notes":["acme.demo::lang.bad_file"]`) {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
})
}

View File

@@ -0,0 +1,159 @@
package cabana
import (
"context"
"errors"
"io"
"net/http"
"strings"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// fieldUpload serves POST .../{id}/fields/{field}/upload (D-18): one file
// an element of a plugin field type uploads, stored by the type's Upload.
// cabana owns the route: the backend guard and the X-Requested-With check
// (mounted with requireAjax), the controller's permissions (protect), the
// field and its context, the record scope and the body cap. {id} 0 is the
// create form and passes no record; any other id loads the record through
// the controller's FormExtendQuery scope inside a transaction, and a record
// outside it is 404. The body is one multipart part named file. It answers
// 201 {data: {url}}; a pact.FormFieldError is a 422 on the field, any other
// Upload error the opaque 500.
func (s *service) fieldUpload(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
id, err := pathID(r)
if err != nil {
writeCRUDError(w, err)
return
}
field, ok := uploadField(cc, r.PathValue("field"), id)
if !ok {
writeNotFound(w, r)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
body := &bodyReader{r: http.MaxBytesReader(w, r.Body, s.fieldUploadCap())}
r.Body = io.NopCloser(body)
mr, err := r.MultipartReader()
if err != nil {
writeCRUDError(w, uploadInvalid(field.Name))
return
}
part, err := mr.NextPart()
if err != nil {
s.writeFieldUploadError(w, body, field, uploadInvalid(field.Name))
return
}
if part.FormName() != "file" || strings.TrimSpace(part.FileName()) == "" {
writeCRUDError(w, uploadInvalid(field.Name))
return
}
run := func(ctx context.Context, tx *gorm.DB, record any) (pact.FormFieldUploadResult, error) {
out, err := field.fieldType.def.Upload(ctx, pact.FormFieldUpload{
DB: tx,
Record: record,
Field: field.Name,
Options: copyJSONMap(field.TypeOptions),
Filename: part.FileName(),
Body: part,
})
if err != nil {
if tooLarge(body) {
return pact.FormFieldUploadResult{}, body.err
}
return pact.FormFieldUploadResult{}, fieldTypeFailure(ctx, s.translator(), cc, field, "upload", err)
}
// Exactly one part: anything after file is refused.
if _, err := mr.NextPart(); !errors.Is(err, io.EOF) {
return pact.FormFieldUploadResult{}, uploadInvalid(field.Name)
}
return out, nil
}
var result pact.FormFieldUploadResult
if id == 0 {
result, err = run(r.Context(), db, nil)
} else {
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
target, err := newWritableModel(cc)
if err != nil {
return err
}
pk, err := coercePK(target, id)
if err != nil {
return err
}
if err := loadRecord(ctx, tx, cc, target, pk); err != nil {
return err
}
result, err = run(ctx, tx, target)
return err
})
}
if err != nil {
s.writeFieldUploadError(w, body, field, err)
return
}
WriteData(w, http.StatusCreated, FieldUploadResponse{URL: result.URL}, nil)
})
}
// uploadField is the form field the upload route names: a field of the
// controller's form whose plugin type has Upload and whose context allows
// the operation (create for id 0, update otherwise).
func uploadField(cc *CompiledController, name string, id uint) (FormField, bool) {
if cc == nil || cc.Form == nil {
return FormField{}, false
}
op := "update"
if id == 0 {
op = "create"
}
for _, field := range cc.Form.Fields {
if field.Name != name {
continue
}
if field.fieldType == nil || field.fieldType.def.Upload == nil || !contextAllows(cc, name, op) {
return FormField{}, false
}
return field, true
}
return FormField{}, false
}
// fieldUploadCap is the body cap of the field upload route:
// http.body_limits.upload_bytes, or the default upload cap.
func (s *service) fieldUploadCap() int64 {
if s != nil && s.uploadBytes > 0 {
return s.uploadBytes
}
return defaultUploadCap
}
// tooLarge reports whether the body read hit the cap.
func tooLarge(body *bodyReader) bool {
var limit *http.MaxBytesError
return body != nil && errors.As(body.err, &limit)
}
func uploadInvalid(field string) error {
return &ValidationError{Details: fieldDetail(field, "The "+field+" upload is invalid.")}
}
// writeFieldUploadError maps an upload failure: a body past the cap is 413,
// everything else goes through writeCRUDError.
func (s *service) writeFieldUploadError(w http.ResponseWriter, body *bodyReader, field FormField, err error) {
var limit *http.MaxBytesError
if tooLarge(body) || errors.As(err, &limit) {
WriteError(w, http.StatusRequestEntityTooLarge, "payload_too_large", msgPayloadTooLarge)
return
}
writeCRUDError(w, err)
}

View File

@@ -149,13 +149,34 @@ type formFieldsFile struct {
Fields fieldMap `yaml:"fields"`
}
// fieldMap is a fields mapping. types, set before decoding, resolves plugin
// field type aliases; nil accepts the built-in types only.
type fieldMap struct {
items []FormField
types *fieldTypeRegistry
}
// CompileForm compiles config_form.yaml and the model fields.yaml it names.
// The returned schema is safe to cache: labels stay as source keys.
// The returned schema is safe to cache: labels stay as source keys. Only the
// built-in field types are accepted; CompileFormWithTypes also accepts plugin
// field types.
func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
return compileForm(pluginID, ctl, fsys, nil)
}
// CompileFormWithTypes is CompileForm with the plugin field types registered
// by plugin pluginID (pact.HasFormFieldTypes) accepted as field types. The
// aliases, option keys and CheckOptions run as at boot; the element prefix and
// the type's scripts and styles are checked when the admin is activated.
func CompileFormWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, types []pact.FormFieldType) (*FormSchema, error) {
reg, err := newFieldTypeRegistry(pluginID, types)
if err != nil {
return nil, err
}
return compileForm(pluginID, ctl, fsys, reg)
}
func compileForm(pluginID string, ctl pact.AdminController, fsys fs.FS, types *fieldTypeRegistry) (*FormSchema, error) {
if ctl == nil {
return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID)
}
@@ -191,7 +212,7 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
fields, err := decodeFields(fieldsRaw)
fields, err := decodeFields(fieldsRaw, types)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
@@ -396,9 +417,11 @@ func localizeOptions(ctx context.Context, tr *phrasebook.Translator, src FormFie
return opts, nil
}
func decodeFields(raw []byte) ([]FormField, error) {
// decodeFields compiles a fields.yaml. types resolves plugin field type
// aliases; nil accepts the built-in types only.
func decodeFields(raw []byte, types *fieldTypeRegistry) ([]FormField, error) {
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
var doc formFieldsFile
doc := formFieldsFile{Fields: fieldMap{types: types}}
if err := dec.Decode(&doc); err != nil {
return nil, normalizeYAMLError(err)
}
@@ -409,7 +432,7 @@ func decodeFields(raw []byte) ([]FormField, error) {
}
func (m *fieldMap) UnmarshalYAML(node ast.Node) error {
items, err := decodeFieldMapping(node, nil)
items, err := decodeFieldMapping(node, nil, m.types)
if err != nil {
return err
}
@@ -419,8 +442,9 @@ func (m *fieldMap) UnmarshalYAML(node ast.Node) error {
// decodeFieldMapping compiles a fields mapping in source order. rename, when
// set, maps a raw field name (such as WinterCMS's pivot[role]) to the name
// that must be an identifier.
func decodeFieldMapping(node ast.Node, rename func(string) string) ([]FormField, error) {
// that must be an identifier. types resolves plugin field type aliases; nil
// accepts the built-in types only.
func decodeFieldMapping(node ast.Node, rename func(string) string, types *fieldTypeRegistry) ([]FormField, error) {
node = unwrapNode(node)
if _, ok := node.(*ast.NullNode); ok || node == nil {
return []FormField{}, nil
@@ -443,7 +467,7 @@ func decodeFieldMapping(node ast.Node, rename func(string) string) ([]FormField,
return nil, fmt.Errorf("duplicate field %s", name)
}
seen[name] = struct{}{}
field, err := compileFieldNode(name, unwrapNode(entry.Value))
field, err := compileFieldNode(name, unwrapNode(entry.Value), types)
if err != nil {
return nil, fmt.Errorf("field %s: %w", name, err)
}
@@ -452,18 +476,25 @@ func decodeFieldMapping(node ast.Node, rename func(string) string) ([]FormField,
return items, nil
}
func compileFieldNode(name string, node ast.Node) (FormField, error) {
func compileFieldNode(name string, node ast.Node, types *fieldTypeRegistry) (FormField, error) {
mapping, ok := node.(*ast.MappingNode)
if !ok {
return FormField{}, fmt.Errorf("must be a mapping")
}
// A plugin field type accepts the general keys plus its own options, so
// the type is resolved before the keys are checked. A built-in type keeps
// the check against formFieldKeys.
plugin, err := pluginFieldTypeOf(mapping, types)
if err != nil {
return FormField{}, err
}
values := map[string]ast.Node{}
for _, entry := range mapping.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return FormField{}, err
}
if _, known := formFieldKeys[key]; !known {
if !fieldKeyAllowed(plugin, key) {
return FormField{}, fmt.Errorf("unknown field %s", key)
}
if _, dup := values[key]; dup {
@@ -475,7 +506,7 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
if err != nil || typ == "" {
return FormField{}, fmt.Errorf("type is required")
}
if _, ok := formFieldTypes[typ]; !ok {
if _, ok := formFieldTypes[typ]; !ok && plugin == nil {
return FormField{}, fmt.Errorf("unsupported type %s", typ)
}
field := FormField{Name: name, Type: typ}
@@ -586,6 +617,11 @@ func compileFieldNode(name string, node ast.Node) (FormField, error) {
return FormField{}, fmt.Errorf("options: %w", err)
}
}
if plugin != nil {
if err := compilePluginField(plugin, values, &field); err != nil {
return FormField{}, err
}
}
return field, nil
}
@@ -915,7 +951,7 @@ func normalizeYAMLError(err error) error {
return err
}
func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS, types *fieldTypeRegistry) (*FormSchema, error) {
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
if dir == "." || strings.HasPrefix(dir, "..") {
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
@@ -927,5 +963,5 @@ func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS)
}
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
return CompileForm(pluginID, ctl, fsys)
return compileForm(pluginID, ctl, fsys, types)
}

View File

@@ -94,7 +94,13 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err != nil {
return nil, err
}
reg, err := compileRegistry(items)
// Plugin field types are compiled first and passed down: every form of
// this boot resolves its `type:` aliases through this registry.
fieldTypes, err := compileFieldTypes(plugins)
if err != nil {
return nil, err
}
reg, err := compileRegistryWithTypes(items, fieldTypes)
if err != nil {
return nil, err
}
@@ -241,6 +247,9 @@ func (s *service) mount(r pact.Router) {
g.Post("/account/avatar", requireAjax(s.avatarUpload))
g.Delete("/account/avatar", requireAjax(s.avatarRemove))
g.Get("/navigation", s.navigation)
// Plugin field types (pact.HasFormFieldTypes): the SPA loads their
// scripts and styles once after sign-in.
g.Get("/field-types", s.fieldTypesList)
// Form previews render through RenderMarkdown; nothing is stored.
g.Post("/markdown/preview", requireAjax(s.markdownPreview))
g.Get("/settings", s.settingsList)
@@ -346,6 +355,10 @@ func (s *service) mount(r pact.Router) {
constrainFileID(g)
g.Get("/{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb", s.fileThumb)
constrainFileID(g)
// Uploads of plugin field type elements (D-18): cabana owns the route,
// the type's Upload only stores the file. {id} 0 is the create form.
g.Post("/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", requireAjax(s.fieldUpload))
constrainFile(g)
})
// The SPA shell: public, no guard. ServeMux prefers every API pattern
// above over the {path...} wildcard.
@@ -1065,6 +1078,13 @@ func (s *service) operationDeclared(w http.ResponseWriter, r *http.Request, cc *
}
func projectRow(row any, controller pact.AdminController, cols []ListColumn) map[string]any {
return projectRowCtx(context.Background(), row, controller, cols)
}
// projectRowCtx is one list row: the id and every visible column. A column
// whose type is a plugin field type with ListValue shows the type's text for
// the stored value.
func projectRowCtx(ctx context.Context, row any, controller pact.AdminController, cols []ListColumn) map[string]any {
v := reflect.ValueOf(row)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
@@ -1091,6 +1111,10 @@ func projectRow(row any, controller pact.AdminController, cols []ListColumn) map
if !field.IsValid() || !field.CanInterface() {
continue
}
if ft := col.fieldType; ft != nil && ft.def.ListValue != nil {
out[col.Key] = ft.def.ListValue(ctx, derefValue(field.Interface()))
continue
}
out[col.Key] = field.Interface()
}
return out

View File

@@ -81,6 +81,13 @@ func CompileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
}
func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) {
return compileListWithTypes(pluginID, ctl, fsys, nil)
}
// compileListWithTypes is compileList with the plugin field types of the
// boot: a column may name a plugin alias as its type, and the list then
// shows the type's ListValue for it.
func compileListWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, types *fieldTypeRegistry) (*ListSchema, error) {
if ctl == nil {
return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID)
}
@@ -107,7 +114,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
columns, err := compileColumns(pluginID, ctl, fsys, colPath)
columns, err := compileColumns(pluginID, ctl, fsys, colPath, types)
if err != nil {
return nil, err
}
@@ -197,7 +204,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
return schema, nil
}
func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPath string) ([]ListColumn, error) {
func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPath string, types *fieldTypeRegistry) ([]ListColumn, error) {
colRaw, err := readAsset(fsys, colPath)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
@@ -232,9 +239,15 @@ func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPa
typ = inferred
}
}
var pluginType *compiledFieldType
if typ != "" {
if _, known := listColumnTypes[typ]; !known {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("unsupported column type %s", typ))
if types != nil && !types.refuse {
pluginType = types.byName[typ]
}
if pluginType == nil {
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("unsupported column type %s", typ))
}
}
}
if spec.Relation != "" || spec.Select != "" {
@@ -267,6 +280,7 @@ func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPa
Relation: spec.Relation,
Select: spec.Select,
Invisible: spec.Invisible,
fieldType: pluginType,
})
}
return compiled, nil

View File

@@ -103,6 +103,7 @@ func TestPhase10Coverage(t *testing.T) {
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}",
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
"POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}",
"POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload",
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}",
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}/reorder",
"POST /{vendor}/{plugin}/{controller}/{id}/relations/{name}/delete",
@@ -120,7 +121,7 @@ func TestPhase10Coverage(t *testing.T) {
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
}
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 29 besides login):\n%s", strings.Join(unsafe, "\n"))
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 30 besides login):\n%s", strings.Join(unsafe, "\n"))
}
// The routes added in Phase 10 are safe reads: GET /lang and the shared
// nested pattern serving field options, filter options and relation lists.

View File

@@ -71,9 +71,9 @@ func TestPhase10CSRF(t *testing.T) {
// unlink, file
// upload, file reorder, file caption, file remove, relation child
// create, update and delete, pivot update, child file upload, reorder,
// caption and remove
if unsafe != 29 {
t.Fatalf("walked %d state-changing routes, want 29: %v", unsafe, router.order)
// caption and remove, plugin field type upload
if unsafe != 30 {
t.Fatalf("walked %d state-changing routes, want 30: %v", unsafe, router.order)
}
loginHandler := router.handlers[login]

View File

@@ -153,7 +153,7 @@ func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in Li
for i := 0; i < values.Len(); i++ {
record := values.Index(i).Addr().Interface()
records = append(records, record)
data = append(data, projectRow(record, cc.Controller, cc.List.Columns))
data = append(data, projectRowCtx(ctx, record, cc.Controller, cc.List.Columns))
}
states, err := listRowStates(ctx, db, cc, records)
if err != nil {

View File

@@ -55,8 +55,21 @@ func checkReservedSegments(items []controllerRef) error {
}
func compileRegistry(items []controllerRef) (*Registry, error) {
return compileRegistryWithTypes(items, nil)
}
// compileRegistryWithTypes compiles every controller with the plugin field
// types built by compileFieldTypes for this boot. types is passed down, never
// stored in a package variable, so two activations never share a registry.
func compileRegistryWithTypes(items []controllerRef, types *fieldTypeRegistry) (*Registry, error) {
byID := make(map[string]*CompiledController, len(items))
assets := map[string]*pluginAsset{}
// Field type scripts and styles are served by the same asset route.
for _, file := range types.assets() {
if _, exists := assets[file.key]; !exists {
assets[file.key] = file
}
}
for _, item := range items {
id := item.ctl.ID()
if _, exists := byID[id]; exists {
@@ -66,15 +79,15 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
if !ok || fsys == nil || fsys.AdminFS() == nil {
return nil, fmt.Errorf("cabana: plugin %s has admin controllers but no AdminFS", item.plugin.ID())
}
list, err := compileList(item.plugin.ID(), item.ctl, fsys.AdminFS())
list, err := compileListWithTypes(item.plugin.ID(), item.ctl, fsys.AdminFS(), types)
if err != nil {
return nil, err
}
form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, fsys.AdminFS())
form, err := compileFormIfPresent(item.plugin.ID(), item.ctl, fsys.AdminFS(), types)
if err != nil {
return nil, err
}
relations, err := compileRelations(item.plugin.ID(), item.ctl, fsys.AdminFS(), form)
relations, err := compileRelationsWithTypes(item.plugin.ID(), item.ctl, fsys.AdminFS(), form, types)
if err != nil {
return nil, err
}
@@ -117,7 +130,7 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
}
byID[id] = compiled
}
return &Registry{byID: byID, assets: assets}, nil
return &Registry{byID: byID, assets: assets, fieldTypes: types}, nil
}
// operationDeclared reports whether the controller's compiled list and form
@@ -209,7 +222,7 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
if !hasAssets || assets == nil || assets.AdminFS() == nil {
return fmt.Errorf("cabana: plugin %s has settings but no AdminFS", plugin.ID())
}
compiled, err := compileSetting(plugin.ID(), item, assets.AdminFS())
compiled, err := compileSettingWithTypes(plugin.ID(), item, assets.AdminFS(), reg.fieldTypes)
if err != nil {
return err
}

View File

@@ -295,6 +295,13 @@ type relationColumnDocument struct {
}
func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema) (map[string]*CompiledRelation, error) {
return compileRelationsWithTypes(pluginID, ctl, fsys, form, nil)
}
// compileRelationsWithTypes is compileRelations with the plugin field types
// of the boot: relation forms refuse them (supported in controller forms
// only).
func compileRelationsWithTypes(pluginID string, ctl pact.AdminController, fsys fs.FS, form *FormSchema, types *fieldTypeRegistry) (map[string]*CompiledRelation, error) {
// fields maps each relation-manager relation to its form field index.
fields := map[string]int{}
if form != nil {
@@ -381,7 +388,7 @@ func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, for
fieldName: form.Fields[fields[name]].Name,
}
schema.Deferrable = cr.deferrable
if err := compileRelationForms(pluginID, ctl, fsys, file, doc, cr); err != nil {
if err := compileRelationForms(pluginID, ctl, fsys, file, doc, cr, types); err != nil {
return nil, err
}
form.Fields[fields[name]].Deferrable = cr.deferrable

View File

@@ -88,9 +88,23 @@ type RelationOption struct {
// actions the requesting admin may run and that apply to the record, in
// declared order; only the show response fills it, and it is omitted when no
// action is offered.
//
// Notices are the messages plugin field types returned while saving the
// record (pact.FormFieldDecoded), localized in the request locale; only the
// create and update responses carry them, and the key is omitted when no type
// reported anything.
type RecordMeta struct {
Labels map[string][]RelationOption `json:"labels"`
Actions []RecordAction `json:"actions,omitempty"`
Notices []FieldNotice `json:"notices,omitempty"`
}
// FieldNotice is one save notice: the field it is about, the content locale
// of the value for a translatable field type, and the localized message.
type FieldNotice struct {
Field string `json:"field"`
Locale string `json:"locale,omitempty"`
Message string `json:"message"`
}
// RecordEnvelope is the show, create and update response.

View File

@@ -57,7 +57,7 @@ func (c relationModelController) NewRecord() any { return c.newRecord() }
// to preview them, and the pivot form (pivot.form, belongsToMany only). It
// also enforces the toolbar rules that depend on them: create and update
// need a manage form, and unlink on a hasMany needs a nullable ForeignKey.
func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS, file string, doc relationDocument, cr *CompiledRelation) error {
func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS, file string, doc relationDocument, cr *CompiledRelation, types *fieldTypeRegistry) error {
name := cr.Contract.Name
fail := func(err error) error {
return bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s: %w", name, err))
@@ -80,7 +80,7 @@ func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS,
}
cr.Schema.relatedModel = cr.Contract.NewRelated
if manageRef != "" {
child, err := compileRelationForm(pluginID, ctl, fsys, file, manageRef, cr, "manage", writes)
child, err := compileRelationForm(pluginID, ctl, fsys, file, manageRef, cr, "manage", writes, types)
if err != nil {
return err
}
@@ -91,7 +91,7 @@ func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS,
if viewRef != "" {
view := cr.child
if viewRef != manageRef || view == nil {
compiled, err := compileRelationForm(pluginID, ctl, fsys, file, viewRef, cr, "view", false)
compiled, err := compileRelationForm(pluginID, ctl, fsys, file, viewRef, cr, "view", false, types)
if err != nil {
return err
}
@@ -109,7 +109,7 @@ func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS,
if cr.hasMany() {
return fail(errors.New("pivot.form is only valid on a belongsToMany relation"))
}
pivot, err := compileRelationForm(pluginID, ctl, fsys, file, ref, cr, "pivot", false)
pivot, err := compileRelationForm(pluginID, ctl, fsys, file, ref, cr, "pivot", false, types)
if err != nil {
return err
}
@@ -125,7 +125,7 @@ func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS,
// model: the related model for the manage and view forms, the pivot model
// for the pivot form. writable additionally requires the model to implement
// lagoon.HasFillable and Rules (create and update fill and validate it).
func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS, cfgFile, ref string, cr *CompiledRelation, purpose string, writable bool) (*CompiledController, error) {
func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS, cfgFile, ref string, cr *CompiledRelation, purpose string, writable bool, types *fieldTypeRegistry) (*CompiledController, error) {
relation := cr.Contract.Name
path, err := assetPath(pluginID, ref)
if err != nil {
@@ -140,9 +140,9 @@ func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS,
}
var fields []FormField
if purpose == "pivot" {
fields, err = decodePivotFields(raw)
fields, err = decodePivotFields(raw, types.refusing())
} else {
fields, err = decodeFields(raw)
fields, err = decodeFields(raw, types.refusing())
}
if err != nil {
return nil, fail(err)
@@ -240,10 +240,11 @@ type pivotFieldsFile struct {
type pivotFieldMap struct {
items []FormField
types *fieldTypeRegistry
}
func (m *pivotFieldMap) UnmarshalYAML(node ast.Node) error {
items, err := decodeFieldMapping(node, pivotFieldName)
items, err := decodeFieldMapping(node, pivotFieldName, m.types)
if err != nil {
return err
}
@@ -251,9 +252,9 @@ func (m *pivotFieldMap) UnmarshalYAML(node ast.Node) error {
return nil
}
func decodePivotFields(raw []byte) ([]FormField, error) {
func decodePivotFields(raw []byte, types *fieldTypeRegistry) ([]FormField, error) {
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
var doc pivotFieldsFile
doc := pivotFieldsFile{Fields: pivotFieldMap{types: types}}
if err := dec.Decode(&doc); err != nil {
return nil, normalizeYAMLError(err)
}

View File

@@ -26,6 +26,10 @@ type ListColumn struct {
// not shown: the admin renders no header or cell for it and list rows do
// not carry its value (columns.yaml invisible).
Invisible bool `json:"invisible,omitempty"`
// fieldType is the plugin field type a column names as its type; the
// list then shows the type's ListValue.
fieldType *compiledFieldType
}
// ListFilter is one compiled config_filter scope. Values stay typed scalars.
@@ -318,8 +322,19 @@ type FormField struct {
// offers the requesting administrator, in display order. They are filled
// per request by the form schema route.
PermissionOptions []PermissionOption `json:"permissionOptions,omitempty"`
// Element is the custom-element tag of a field whose type a plugin
// registers (pact.HasFormFieldTypes); the admin renders it for the field.
Element string `json:"element,omitempty"`
// Translatable is true when a plugin field type's value is a map of
// content locale to string.
Translatable bool `json:"translatable,omitempty"`
// TypeOptions are a plugin field type's own fields.yaml keys and values;
// the admin hands them to the element as JSON.
TypeOptions map[string]any `json:"typeOptions,omitempty"`
optionsMethod string
// fieldType is the plugin field type of the field; nil for a built-in type.
fieldType *compiledFieldType `json:"-"`
}
// FieldPreset is a text or mltext field's `preset`: Field names the text or

View File

@@ -43,6 +43,7 @@ var phase09Routes = []adminRoute{
{key: "POST /account/avatar"},
{key: "DELETE /account/avatar"},
{key: "GET /navigation"},
{key: "GET /field-types"},
{key: "GET /settings"},
{key: "GET /settings/{code}/schema"},
{key: "GET /settings/{code}"},
@@ -88,6 +89,7 @@ var phase09Routes = []adminRoute{
{key: "DELETE /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/download"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}/thumb"},
{key: "POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload"},
{key: "GET /assets/{vendor}/{plugin}/{file...}", public: true, spa: true},
{key: "GET ", public: true, spa: true},
{key: "GET /{path...}", public: true, spa: true},
@@ -300,6 +302,7 @@ func phase09ProtectedCalls() []phase09Call {
{"record-action", (*service).recordAction},
{"widget-action", (*service).widgetAction},
{"toolbar-action", (*service).toolbarAction},
{"field-upload", (*service).fieldUpload},
{"partial", (*service).partial},
{"show", (*service).show},
{"update", (*service).update},

View File

@@ -49,6 +49,12 @@ func compileSettingLink(pluginID string, item pact.SettingsItem) (*CompiledSetti
}
func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*CompiledSetting, error) {
return compileSettingWithTypes(pluginID, item, fsys, nil)
}
// compileSettingWithTypes is compileSetting with the plugin field types of the
// boot: a settings form refuses them (supported in controller forms only).
func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS, types *fieldTypeRegistry) (*CompiledSetting, error) {
if item.NewModel == nil {
return nil, fmt.Errorf("cabana: setting %s has no model factory", item.Code)
}
@@ -71,7 +77,7 @@ func compileSetting(pluginID string, item pact.SettingsItem, fsys fs.FS) (*Compi
if err != nil {
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
}
fields, err := decodeFields(raw)
fields, err := decodeFields(raw, types.refusing())
if err != nil {
return nil, fmt.Errorf("cabana: plugin %s setting %s schema %s: %w", pluginID, item.Code, formPath, err)
}

View File

@@ -65,3 +65,56 @@ func VirtualFieldsFromContext(ctx context.Context) (map[string]any, bool) {
}
return out, true
}
type fieldWritesKey struct{}
// FieldWrite is one value a save wrote for a plugin field type
// (pact.HasFormFieldTypes): Locale is the content locale of a translatable
// type's value and empty otherwise, Default is true when the value went to
// the host column, and Value is what the type's Decode returned.
type FieldWrite struct {
Locale string
Default bool
Value any
}
// withFieldWrites returns ctx carrying the plugin field values a save writes.
func withFieldWrites(ctx context.Context, writes map[string][]FieldWrite) context.Context {
if writes == nil {
writes = map[string][]FieldWrite{}
}
return context.WithValue(ctx, fieldWritesKey{}, writes)
}
// WithFieldWrites returns ctx carrying writes as a save would, so a test can
// call a Form hook that reads FieldWritesFromContext. The map is copied.
func WithFieldWrites(ctx context.Context, writes map[string][]FieldWrite) context.Context {
return withFieldWrites(ctx, copyFieldWrites(writes))
}
// FieldWritesFromContext returns the values a create or update save
// actually writes for the form's plugin field types, keyed by field name, for
// the context handed to a Form hook (pact.FormBeforeCreate,
// pact.FormAfterCreate, pact.FormBeforeUpdate, pact.FormAfterUpdate). A field
// or locale whose submitted value equals the value the form was served is
// not written and not listed; a translatable type lists one entry per
// written locale. The map is a copy. The second result is false outside a
// create or update save.
func FieldWritesFromContext(ctx context.Context) (map[string][]FieldWrite, bool) {
if ctx == nil {
return nil, false
}
writes, ok := ctx.Value(fieldWritesKey{}).(map[string][]FieldWrite)
if !ok {
return nil, false
}
return copyFieldWrites(writes), true
}
func copyFieldWrites(in map[string][]FieldWrite) map[string][]FieldWrite {
out := make(map[string][]FieldWrite, len(in))
for field, list := range in {
out[field] = append([]FieldWrite(nil), list...)
}
return out
}

View File

@@ -15,6 +15,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`. A settings entry is either a singleton form or a link that opens an admin controller from the Settings page.
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), and `pact.AdminPartialData` (the curated view model a partial template renders).
- Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices.
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
- A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either.
@@ -118,6 +119,14 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.AdminRecordActionResult` | What a record action returns: an optional message for the toast. |
| `pact.HasAdminRecordActions` | Registers a controller's record actions for the `recordActions` list of `config_form.yaml`. |
| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
| `pact.HasFormFieldTypes` | Implemented by a plugin (not a controller) that registers admin form field types. |
| `pact.FormFieldType` | One field type: the alias `Name`, the custom-element tag `Element`, `Translatable`, the `Scripts` and `Styles` under `assets/`, the `Options` keys, and the funcs `CheckOptions` (optional), `Decode` (required), `Load`, `ListValue` (`func(ctx context.Context, value any) string`) and `Upload` (optional). |
| `pact.FormFieldValue` | What `Decode` and `Load` receive: the transaction, the record, the field, its options, the content locale (translatable types), whether the value lives in the host column, the value and an exact reader of another attribute in the same locale. |
| `pact.FormFieldDecoded` | `Decode`'s answer: the `Value` to store (a string for a translatable type) and optional `Notices`. |
| `pact.FormFieldNotice` | A message about a saved value: a phrase key or text with params; a positive `Count` selects the plural form. |
| `pact.FormFieldUpload` | What `Upload` receives: the transaction, the scoped record (nil on the create form), the field, its options, the file name and the capped body. |
| `pact.FormFieldUploadResult` | `Upload`'s answer: the URL of the stored file. |
| `pact.FormFieldError` | A value or upload a field type refuses: messages shown on the field (validation failed). |
| `pact.ListRowStates` | Optional controller hook called once per list page; returns the states of the page's records, index-aligned. |
| `pact.RowState` | One state of a list row: `pact.RowStateDeleted`, `pact.RowStateNegative` or `pact.RowStateDisabled`. |
| `pact.FormVirtualFields` | Optional controller list of form fields that are not model columns for the form (a password and its confirmation, for example): never bound, filled or returned; their submitted values reach the Form hooks through the admin framework's context accessor. |
@@ -137,7 +146,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
- SummerCMS modules: [bonfire](../bonfire/README.md) (the command type in `pact.HasCommands`).
- Third-party: `github.com/go-gormigrate/gormigrate/v2`, `gorm.io/gorm`.
- Standard library: `context`, `io/fs`, `net/http`, `time`.
- Standard library: `context`, `encoding/json`, `io`, `io/fs`, `net/http`, `strings`, `time`.
## Testing

View File

@@ -3,8 +3,10 @@ package pact
import (
"context"
"encoding/json"
"io"
"io/fs"
"net/http"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/bonfire"
@@ -368,6 +370,126 @@ type AdminPartialData interface {
PartialData(ctx context.Context, name string, record any) (any, error)
}
// FormFieldType is one admin form field type a plugin registers under a
// short global alias, used as `type:` in any controller's fields.yaml. The
// admin framework owns the HTTP routes, the CSRF check, authentication,
// permissions, record scope, locale storage, notice localization and the
// skip of a value the administrator did not change; the funcs only carry the
// type's own logic.
//
// Name is the alias: lowercase ASCII letters and digits starting with a
// letter, unique across plugins and never a built-in field type. Element is
// the custom-element tag the admin renders for the field; it must start with
// the owning plugin's prefix (vendor.plugin -> "vendor-plugin-") and is
// defined by one of Scripts. Translatable makes the value a map of locale to
// string, stored through the framework's translation writer: the default
// locale in the host column, the others as translations. Scripts (.js, .mjs)
// and Styles (.css) are paths under assets/ in the plugin's AdminFS; the admin
// loads them once on every admin page. Options are the type's own fields.yaml
// keys, next to the general field keys.
//
// CheckOptions, when set, checks a field's options at boot; an error fails
// boot naming the field. Decode is required: it turns a submitted value into
// the value to store and may report what it changed as notices; a
// *FormFieldError refuses a value the type cannot accept at all. Load, when
// set, turns a stored value into the value the element receives. ListValue,
// when set, returns the text a list column of this type shows. Upload, when
// set, stores a file the element uploads and returns its URL.
type FormFieldType struct {
Name string
Element string
Translatable bool
Scripts []string
Styles []string
Options []string
CheckOptions func(options map[string]any) error `json:"-"`
Decode func(ctx context.Context, in FormFieldValue) (FormFieldDecoded, error) `json:"-"`
Load func(ctx context.Context, in FormFieldValue) (any, error) `json:"-"`
ListValue func(ctx context.Context, value any) string `json:"-"`
Upload func(ctx context.Context, in FormFieldUpload) (FormFieldUploadResult, error) `json:"-"`
}
// FormFieldValue is what the framework hands Decode and Load. DB is the save
// transaction (Decode) or the read transaction (Load). Record is the loaded
// record on update and the new model on create. Options are the field's
// options from fields.yaml. Locale is the content locale of the value for a
// translatable type and empty otherwise; Default is true when the value lives
// in the host column (always true for a type that is not translatable). Value
// is the submitted value (Decode) or the stored value (Load); it is a string
// for a translatable type and any JSON value otherwise. Translated, when set,
// reads another attribute of Record in Locale exactly, without fallback; it is
// nil for a type that is not translatable and when no translation writer is
// published.
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)
}
// FormFieldDecoded is Decode's answer. Value is stored: a string for a
// translatable type. Notices are non-blocking messages about the accepted
// value, such as what a sanitizer removed; a save whose Decode calls return
// no notice answers exactly as a save without plugin field types.
type FormFieldDecoded struct {
Value any
Notices []FormFieldNotice
}
// FormFieldNotice is one message for the administrator about a saved value.
// The framework localizes Message (a phrase key or text) in the request
// locale with Params; a Count above zero selects the plural form and sets
// :count.
type FormFieldNotice struct {
Message string
Params map[string]string
Count int
}
// FormFieldUpload is what the framework hands Upload. DB is the upload's
// transaction. Record is the record loaded through the controller's
// FormExtendQuery scope, nil on the create form. Options are the field's
// options. Body is the file's content, already capped by the upload body
// limit.
type FormFieldUpload struct {
DB *gorm.DB
Record any
Field string
Options map[string]any
Filename string
Body io.Reader
}
// FormFieldUploadResult is Upload's answer: the URL the element shows.
type FormFieldUploadResult struct {
URL string
}
// FormFieldError is a value a field type refuses. Messages are phrase keys
// or text, localized by the framework and shown on the field; the save or
// upload writes nothing.
type FormFieldError struct {
Messages []string
}
// Error joins the messages with "; ".
func (e *FormFieldError) Error() string {
if e == nil {
return ""
}
return strings.Join(e.Messages, "; ")
}
// HasFormFieldTypes is implemented by plugins (not admin controllers) that
// register admin form field types, like HasAdminControllers.
type HasFormFieldTypes interface {
FormFieldTypes() []FormFieldType
}
// Permission is one registerPermissions() entry.
type Permission struct {
Code string