Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf10ce8838 | |||
| 1ef7245263 | |||
| 3533937cc9 | |||
| f388d27604 | |||
| c532529d18 | |||
| 3158bb5b57 | |||
| 9899ac61dd | |||
| 85a12e3296 | |||
| 9652834ca0 | |||
| 7c5174eea7 | |||
| 7e113013a3 | |||
| 3da2fd65b0 | |||
| 9f5e807af6 | |||
| 3404a4b3ad | |||
| 08a20021e3 | |||
| c07da3912b | |||
| 4e0b31feb2 | |||
| b5c70b7340 | |||
| 684bfe1dc8 | |||
| 85fe541b03 | |||
| ea42f04ae0 | |||
| ea955b39ae | |||
| aba1ef7391 |
@@ -999,7 +999,7 @@ 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:** 4/6 plans executed
|
||||
**Plans:** 6/6 plans executed
|
||||
|
||||
Plans:
|
||||
**Wave 1**
|
||||
@@ -1011,10 +1011,35 @@ Plans:
|
||||
- [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.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
|
||||
- [x] 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, 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
|
||||
- [x] 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 16.3: Backend admin personal API tokens with scopes (INSERTED)
|
||||
|
||||
**Goal:** Backend users can create personal API tokens with a scope they pick at creation time (for example Journal only, or system-wide; later read-only Payment Gateway), so an agent or integration can call the admin and plugin APIs as that backend user, with no more access than the token's scope and the user's own permissions allow. This is the successor of Apparatus `PersonalApiToken` (todo `backend-admin-api-tokens.md`, note `apparatus-dissolved-into-framework.md`) and reverses the Phase 15 D-15 rule that Journal writes accept only a backend JWT.
|
||||
**Requirements**: TBD
|
||||
**Depends on:** Phase 9, Phase 10, Phase 15
|
||||
**Repos:** `summercms.go` (bouncer guard, token model and migration, scope registry, admin SPA panel, CLI); `sm-journal-plugin` to register its scope and accept tokens on its write routes; host applications only for the version bump
|
||||
**Constraints from explore (2026-10-10):**
|
||||
- Tokens keep the `g15_` prefix and are stored only as a hash. The rest of the format can change, and WinterCMS Apparatus tokens are not migrated.
|
||||
- The user picks the scope when creating a token. Plugins register their own scopes. Scope design (names, read-only variants, system-wide) is settled in discuss-phase.
|
||||
- Tokens are managed in two places, as in WinterCMS: a panel on the admin SPA backend user form (create, show once, revoke) and a `summer` CLI command.
|
||||
|
||||
**Success Criteria** (what must be TRUE):
|
||||
|
||||
1. The `backend` guard accepts a valid, unexpired, unrevoked `g15_` token as well as the backend JWT, and the request runs as the token's backend user.
|
||||
2. A request outside the token's scope is refused even when the user's role would allow it, and a token never grants more than its owner's permissions.
|
||||
3. A backend user can create, list and revoke their own tokens in the admin SPA. The plaintext token is shown once. A CLI command does the same for any backend user.
|
||||
4. The Journal API accepts a Journal-scoped token for writes, so an agent can manage blog posts as a backend author.
|
||||
5. Module READMEs and `docs/` cover the token API, scopes and CLI commands.
|
||||
6. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 16.3 to break down)
|
||||
|
||||
### Phase 17: Blog companions
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@ msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: "16.2"
|
||||
current_phase_name: Plugin-provided field types and the Blog Editor formwidget
|
||||
status: executing
|
||||
stopped_at: Completed 16.2-04-PLAN.md
|
||||
last_updated: "2026-10-10T11:07:23.353Z"
|
||||
status: verifying
|
||||
stopped_at: Phase 16.3 context gathered
|
||||
last_updated: "2026-10-10T14:21:46.539Z"
|
||||
last_activity: 2026-10-10
|
||||
last_activity_desc: Phase 16.2 execution started
|
||||
state_head: 2786faade3761c2121d5dbf780a48fcf211b4abf
|
||||
state_head: 9652834ca02230d338888344c5bb0d0c0550abed
|
||||
progress:
|
||||
total_phases: 34
|
||||
total_phases: 35
|
||||
completed_phases: 13
|
||||
total_plans: 157
|
||||
completed_plans: 151
|
||||
completed_plans: 153
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
---
|
||||
@@ -30,9 +30,9 @@ See: .planning/PROJECT.md (updated 2026-09-16)
|
||||
## Current Position
|
||||
|
||||
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-10 — Phase 16.2 execution started
|
||||
Plan: 6 of 6
|
||||
Status: Phase complete — ready for verification
|
||||
Last activity: 2026-10-10 - Completed quick task 261010-iwu: Blog Editor live preview pane
|
||||
|
||||
Progress: [██████████] 100%
|
||||
|
||||
@@ -206,6 +206,8 @@ Progress: [██████████] 100%
|
||||
| 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 |
|
||||
| Phase 16.2 P05 | 25 min | 3 tasks | 7 files |
|
||||
| Phase 16.2 P06 | 43min | 3 tasks | - files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -231,6 +233,7 @@ Progress: [██████████] 100%
|
||||
- Phase 18.1 inserted after Phase 18: Payment Gateway Subscriptions (split from Phase 18 per 18-CONTEXT D-01)
|
||||
- Phase 16.1 inserted after Phase 16: Journal admin urgent fixes: relation search, list state, post form content and excerpt (found on UAT) (URGENT)
|
||||
- Phase 16.2 inserted after Phase 16.1: Plugin-provided field types and the Blog Editor formwidget
|
||||
- Phase 16.3 inserted after Phase 16: Backend admin personal API tokens with user-selected scopes (Journal agent writes as backend author); successor of Apparatus PersonalApiToken (URGENT)
|
||||
|
||||
### Decisions
|
||||
|
||||
@@ -672,6 +675,8 @@ Recent decisions affecting current work:
|
||||
- [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
|
||||
- [Phase 16.2]: 16.2-05: summercms v0.1.18 released (human: release v0.1.18); a later defect gets v0.1.19
|
||||
- [Phase 16.2]: 16.2-05: UAT Nuxt rollback copy is nuxt/.output.prev; the site-ok log check reads /var/log/supervisor/grzybyfunkcjonalne-go.log from the restart River anchor (supervisorctl tail fails on UAT)
|
||||
|
||||
### Pending Todos
|
||||
|
||||
@@ -699,6 +704,7 @@ Recent decisions affecting current work:
|
||||
- Phase 11 (River dual-driver split) is documented but unverified against a real build — plan with `--research-phase` and budget a timed-latency test.
|
||||
- Phase 8 UI gate: scripts/check-phase8-ui.mjs --final-gate's real Playwright browser matrix (32 UI-SPEC scenarios) is unimplemented (deliberate fatal() at check-phase8-ui.mjs:458, never authored by 08-05); stage_ui_harness must keep failing closed until a Playwright spec is authored. User approved Phase 8 closure on 2026-09-24 with this gap carried forward -- see 08-10-SUMMARY.md and .planning/phases/08-oauth2-1-authorization-server/deferred-items.md.
|
||||
- pending: push sm-user-plugin — 12.1-05 Task 3 condition (c) failed (`git ls-remote --tags origin v0.1.3` empty). Push summercms.go master and annotated tag v0.1.3 (local on df5cace) first, then the plugin, then fonoteka.go. Recorded in 12.1-05-SUMMARY.md.
|
||||
- 16.2-05 open UAT items: re-save HTML post 1 untouched (D-03 evidence vacuous), D-19 category edit check, root vhost install (frame-src), unpublish/delete test post 105
|
||||
|
||||
### Quick Tasks Completed
|
||||
|
||||
@@ -730,6 +736,8 @@ Recent decisions affecting current work:
|
||||
| 261009-i6e | add the anonymous author endpoint to sm-journal-plugin (D-20) | 2026-10-09 | sm-journal-plugin 908ade9, 57a347c | [261009-i6e-add-the-anonymous-author-endpoint-to-sm-](./quick/261009-i6e-add-the-anonymous-author-endpoint-to-sm-/) |
|
||||
| 261009-mgk | add the category content field to sm-journal-plugin (D-21) | 2026-10-09 | sm-journal-plugin 740dcd0, f22d089, 3d533d4 | [261009-mgk-add-the-category-content-field-to-sm-jou](./quick/261009-mgk-add-the-category-content-field-to-sm-jou/) |
|
||||
| 261009-mys | Add subtitle and avatar to backend users and a My account page (avatar menu, like WinterCMS) | 2026-10-09 | 143eca5, 64d21c8, 4f1cda3, 89611ea | [261009-mys-add-subtitle-and-avatar-to-backend-users](./quick/261009-mys-add-subtitle-and-avatar-to-backend-users/) |
|
||||
| 261010-iwu | Blog Editor live preview pane: toggleable, sandboxed iframe, embed thumbnail cards (16.2 follow-up) | 2026-10-10 | sm-journal-plugin df67939, c1f6a83, 2c78211 | [261010-iwu-blog-editor-live-preview-pane-toggleable](./quick/261010-iwu-blog-editor-live-preview-pane-toggleable/) |
|
||||
| 28 | Admin navbar View site link (admin origin root, new tab) | 2026-10-10 | 85fe541 | — |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -741,6 +749,6 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-10-10T11:07:22.612Z
|
||||
Stopped at: Completed 16.2-04-PLAN.md
|
||||
Resume file: None
|
||||
Last session: 2026-10-10T13:33:02.885Z
|
||||
Stopped at: Phase 16.3 context gathered
|
||||
Resume file: .planning/phases/16.3-backend-admin-personal-api-tokens-with-scopes/16.3-CONTEXT.md
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
schema_version: 1
|
||||
open_count: 10
|
||||
open_count: 14
|
||||
waived_count: 0
|
||||
fixed_count: 6
|
||||
total_count: 16
|
||||
last_updated: 2026-10-10T10:12:10.986Z
|
||||
fixed_count: 7
|
||||
total_count: 21
|
||||
last_updated: 2026-10-10T14:17:06.621Z
|
||||
---
|
||||
|
||||
# Broken Windows Ledger
|
||||
@@ -31,6 +31,11 @@ last_updated: 2026-10-10T10:12:10.986Z
|
||||
| 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 | |
|
||||
| 17 | 16.2 | unmet-truth | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md | | 16.2-05 D-03 on an HTML post unproven on UAT: post 1 (HTML_POST) was not written after RESTART_TS (updated_at still 2026-10-09), so posts-unchanged passed vacuously for it; a human must re-save post 1 untouched and the SELECT be re-run | fixed | | 2026-10-10T11:35:56.721Z | 2026-10-10T12:04:50.783Z |
|
||||
| 18 | 16.2 | unmet-truth | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md | | 16.2-05 D-19 human check pending: a one-word category edit in the Blog Editor saves without a removal notice and shows on the site; post 1 source tab shows the stored HTML unchanged | open | | 2026-10-10T11:35:56.955Z | |
|
||||
| 19 | 16.2 | todo | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md | | 16.2-05 UAT root step: install the staging vhost with frame-src https://www.youtube-nocookie.com, nginx -t and reload; unpublish or delete test post uat-16-2-blog-editor (id 105) | open | | 2026-10-10T11:35:57.234Z | |
|
||||
| 20 | 16.2 | unrun-verify | modules/cabana/field_type_postgres_test.go | | 16.2-06 TestFieldTypeSavePostgres (real-database save, show, untouched and refused update, upload in and out of scope) compiles under go vet but needs a Docker host; run bash scripts/check-phase16.2.sh postgres | open | | 2026-10-10T14:17:06.378Z | |
|
||||
| 21 | 16.2 | unrun-verify | .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md | | sm-journal-plugin: 16.2-06 TestCategoriesAdminBlogEditor (categories_admin_editor_test.go) and the rewritten three-stage TestPostsAdminResaveKeepsHTML compile under go vet but need a Docker host; run bash scripts/check-phase16.2.sh postgres | open | | 2026-10-10T14:17:06.621Z | |
|
||||
|
||||
````json
|
||||
[
|
||||
@@ -241,6 +246,71 @@ last_updated: 2026-10-10T10:12:10.986Z
|
||||
"recorded_at": "2026-10-10T10:12:10.986Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"kind": "unmet-truth",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md",
|
||||
"line": null,
|
||||
"description": "16.2-05 D-03 on an HTML post unproven on UAT: post 1 (HTML_POST) was not written after RESTART_TS (updated_at still 2026-10-09), so posts-unchanged passed vacuously for it; a human must re-save post 1 untouched and the SELECT be re-run",
|
||||
"status": "fixed",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T11:35:56.721Z",
|
||||
"resolved_at": "2026-10-10T12:04:50.783Z",
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"kind": "unmet-truth",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md",
|
||||
"line": null,
|
||||
"description": "16.2-05 D-19 human check pending: a one-word category edit in the Blog Editor saves without a removal notice and shows on the site; post 1 source tab shows the stored HTML unchanged",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T11:35:56.955Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"kind": "todo",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md",
|
||||
"line": null,
|
||||
"description": "16.2-05 UAT root step: install the staging vhost with frame-src https://www.youtube-nocookie.com, nginx -t and reload; unpublish or delete test post uat-16-2-blog-editor (id 105)",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T11:35:57.234Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": "modules/cabana/field_type_postgres_test.go",
|
||||
"line": null,
|
||||
"description": "16.2-06 TestFieldTypeSavePostgres (real-database save, show, untouched and refused update, upload in and out of scope) compiles under go vet but needs a Docker host; run bash scripts/check-phase16.2.sh postgres",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T14:17:06.378Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"kind": "unrun-verify",
|
||||
"phase": "16.2",
|
||||
"file": ".planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md",
|
||||
"line": null,
|
||||
"description": "sm-journal-plugin: 16.2-06 TestCategoriesAdminBlogEditor (categories_admin_editor_test.go) and the rewritten three-stage TestPostsAdminResaveKeepsHTML compile under go vet but need a Docker host; run bash scripts/check-phase16.2.sh postgres",
|
||||
"status": "open",
|
||||
"reason": "",
|
||||
"recorded_at": "2026-10-10T14:17:06.621Z",
|
||||
"resolved_at": null,
|
||||
"milestone": "v1.0"
|
||||
}
|
||||
]
|
||||
````
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 05
|
||||
subsystem: release
|
||||
tags: [release, uat, deploy, summercms-tag, journal, blog-editor, embeds, csp, grzybyfunkcjonalne]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-01
|
||||
provides: pact.HasFormFieldTypes, meta.notices, the field upload route and admin SPA plugin fields (released as summercms v0.1.18)
|
||||
- phase: 16.2-02
|
||||
provides: the Blog Editor formwidget and its editor corpus in sm-journal-plugin
|
||||
- phase: 16.2-03
|
||||
provides: Journal post and category content on the Blog Editor, the editor-output policy extension and the strip-and-report save
|
||||
- phase: 16.2-04
|
||||
provides: vue-journal-layer v0.2.0 (embed expansion) pinned by vue-grzybyfunkcjonalne-app main 465286a
|
||||
provides:
|
||||
- "summercms annotated tag v0.1.18 (tag object 04dc5bd, commit f1d8a75) on origin"
|
||||
- "sm-journal-plugin main 9d5bfc4 on origin, go.mod requiring summercms v0.1.18"
|
||||
- "sm-grzybyfunkcjonalne-app master 6f4f222 on origin: journal gitlink 9d5bfc4, vue-grzybyfunkcjonalne-app gitlink 465286a, README Blog Editor note, report-only CSP frame-src https://www.youtube-nocookie.com"
|
||||
- "UAT (uat.grzybyfunkcjonalne.pl) running the Blog Editor release, with rollback copies of the previous binary and Nuxt output"
|
||||
affects: [16.2-06, sm-journal-plugin, sm-grzybyfunkcjonalne-app, uat]
|
||||
|
||||
actuals:
|
||||
tokens: 9000
|
||||
tasks: 3
|
||||
commits: 0
|
||||
plan_head_before: f1d8a757fd5c1174f7f1959779602e2ad11b0de2
|
||||
plan_head_after: f1d8a757fd5c1174f7f1959779602e2ad11b0de2
|
||||
commits_note: "summercms.go got the tag only (no commit). Code commits live in the sibling repositories below."
|
||||
plugin_commits_repo: ../sm-journal-plugin
|
||||
plugin_head_before: c60abc6
|
||||
plugin_head_after: 9d5bfc4a3597be8ba4abee26d80dc2b81d343da8
|
||||
plugin_commits: 1
|
||||
host_commits_repo: ../sm-grzybyfunkcjonalne-app
|
||||
host_head_before: 0b93ac2
|
||||
host_head_after: 6f4f22296b6295e2750e3d376bd0300c8031ba51
|
||||
host_commits: 1
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Restart-anchored log check: the Go log is filtered from the River client_id line the restart logged, read straight from /var/log/supervisor, instead of `supervisorctl tail` (which fails on UAT)"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- ../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
|
||||
|
||||
key-decisions:
|
||||
- "Human chose release-now at the Task 1 gate with the reply \"release v0.1.18\"; summercms v0.1.18 is published and a later defect gets v0.1.19"
|
||||
- "On UAT the Nuxt rollback copy lives at /srv/grzybyfunkcjonalne/nuxt/.output.prev, not go/build/nuxt/.output.prev as the plan assumed"
|
||||
- "The site-ok log check reads /var/log/supervisor/grzybyfunkcjonalne-go.log from the restart's River client_id anchor; `sudo supervisorctl tail` fails on UAT (ExpatError) and would pass vacuously"
|
||||
|
||||
patterns-established:
|
||||
- "A UAT release check that reads a log must prove it read lines (anchor present) before counting zero errors"
|
||||
|
||||
requirements-completed: [D-02, D-05, D-10, D-17, D-18]
|
||||
|
||||
coverage:
|
||||
- id: R1
|
||||
description: "Release: tag v0.1.18, plugin and host bumps pushed; UAT binary sha256 equals the local build; both programs RUNNING; rollback copies kept; on-server smoke passes"
|
||||
requirement: "D-07"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 2 verify: smoke: ok (98 urls); deploy-ok (with the corrected Nuxt rollback path)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R2
|
||||
description: "D-03: an untouched admin save of post 64 (markdown with the raw author footer) leaves md5(content), md5(content_html) and its en translation row unchanged"
|
||||
requirement: "D-03"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 verify: posts-unchanged and translations-unchanged; post 64 updated_at moved to 2026-10-10 11:26:13+00, so the save reached the database"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R3
|
||||
description: "D-03: an untouched admin save of HTML_POST (post 1) leaves its bytes unchanged"
|
||||
requirement: "D-03"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "posts-unchanged passed for post 1, but post 1 updated_at is still 2026-10-09 17:33:51+00 and no post other than 64 and 105 was written after RESTART_TS, so no save of post 1 reached the database and the pass is vacuous"
|
||||
status: inconclusive
|
||||
human_judgment: true
|
||||
rationale: "A human must save post 1 untouched again; then re-run the posts-unchanged SELECT and confirm post 1's updated_at moved"
|
||||
- id: R4
|
||||
description: "D-02, D-05, D-18: the Blog Editor test post stores content = content_html with a YouTube placeholder, a figcaption and an uploaded image URL under journal/posts"
|
||||
requirement: "D-18"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 verify: edit-ok (1:1:1:1:1)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R5
|
||||
description: "D-10 amended: a style element and an onclick attribute added in the source tab are stripped on save and reported"
|
||||
requirement: "D-10"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "edit-ok fifth column 1 (no <style, no onclick stored)"
|
||||
status: pass
|
||||
- kind: manual
|
||||
ref: "Human reply \"Did tests, all points passed, bravo.\" covers the step 3 notice; the notice text was not quoted"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
- id: R6
|
||||
description: "D-17: the published test post's public page contains a youtube-nocookie iframe; the Go log since the restart has no panic or SQLSTATE line"
|
||||
requirement: "D-17"
|
||||
verification:
|
||||
- kind: integration
|
||||
ref: "Task 3 verify: site-ok (1 iframe; 14 log lines after the anchor, 0 panic|SQLSTATE)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: R7
|
||||
description: "D-19: a one-word category edit saves without a removal notice and shows on the site"
|
||||
requirement: "D-19"
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "Not among the four steps the human ran; no journal category row was updated after RESTART_TS, so no read-only check covers it"
|
||||
|
||||
duration: "about 25 min of executor time across three runs, plus the human UAT session (11:26 to 11:30Z)"
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 05: Release and UAT Acceptance of the Blog Editor Summary
|
||||
|
||||
**summercms v0.1.18, sm-journal-plugin 9d5bfc4 and host 6f4f222 are released to UAT. The build there matches the local sha256, and the on-server smoke passes on 98 URLs. On real data, an untouched save of post 64 is byte-identical. A Blog Editor test post stores identical content and content_html with an uploaded figure and a YouTube embed, the embed renders as a youtube-nocookie iframe on the site, and markup that was not allowed was stripped on save. The untouched save of HTML post 1 has no database evidence yet, and the D-19 category check is still open.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** about 25 min of executor time across three runs, plus the human session.
|
||||
- **Started:** about 2026-10-10T11:10Z, after plan 04 closed.
|
||||
- **Completed:** 2026-10-10T11:36Z
|
||||
- **Tasks:** 3/3 (Task 1 blocking-human decision, Task 2 tracer, Task 3 blocking-human human-action)
|
||||
- **Files modified:** 7 across sm-journal-plugin and sm-grzybyfunkcjonalne-app. summercms.go got only the tag.
|
||||
|
||||
## Release Sheet (Task 1)
|
||||
|
||||
- **Gate:** the Task 1 verify printed `release-gate-ok`. It covered vet in both Go repositories, the six-test cabana smoke set, the journal classes and controllers tests, TestDocsTree, the admin vitest suite, the admin dist and OpenAPI drift gates, the journal vitest suite and the journal dist gate. All three repositories were clean and not behind origin.
|
||||
- **Tag:** `v0.1.18`. v0.1.17 was the latest tag, and v0.1.18 did not exist on origin.
|
||||
- **Commits pushed per repository:**
|
||||
- summercms.go master `c21fe36..f1d8a75` (19 commits, including planning docs).
|
||||
- sm-journal-plugin main `c60abc6..9d5bfc4` (9 commits: plans 02 and 03, plus the go.mod bump).
|
||||
- Host master `0b93ac2..6f4f222`.
|
||||
- **UAT D-07 split (read-only SELECT):** 102 posts whose content starts with `<` and are served as content, and 3 markdown posts served as content_html. Re-read after Task 3, so the count includes test post 105. Before the test post it was 101:3.
|
||||
- **Suites not run here (Docker host needed):**
|
||||
- The modules/cabana testcontainers suites.
|
||||
- The sm-journal-plugin root harness: TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML and TestCategoriesAdminContent.
|
||||
- ./models.
|
||||
- These are WINDOWS.md entries 14 to 16, which go to plan 06.
|
||||
- **Review findings:** neither 16.2-REVIEW.md nor 16.2-SECURITY.md exists, so there are no open high findings.
|
||||
- **Human reply, verbatim:** "release v0.1.18" (release-now).
|
||||
|
||||
## Task Commits
|
||||
|
||||
| Task | Name | Repo | Commit |
|
||||
|---|---|---|---|
|
||||
| 1 | Release gate | (none) | release-gate-ok, no commit |
|
||||
| 2 | Tag summercms | summercms.go | annotated tag `v0.1.18` (object `04dc5bdabecd95a58b5b7d3a02ed465a45fbc99a`, commit `f1d8a757fd5c1174f7f1959779602e2ad11b0de2`) |
|
||||
| 2 | Require summercms v0.1.18 | sm-journal-plugin | `9d5bfc4` chore(deps): require summercms v0.1.18 |
|
||||
| 2 | Bump gitlinks, README note, CSP frame-src | sm-grzybyfunkcjonalne-app | `6f4f222` chore: bump journal plugin (Blog Editor) and the site app (embeds) |
|
||||
| 3 | UAT acceptance (read-only checks) | (none) | no commit |
|
||||
|
||||
**Pushed heads:**
|
||||
- summercms.go master `f1d8a75`, plus tag `v0.1.18`.
|
||||
- sm-journal-plugin main `9d5bfc4a3597be8ba4abee26d80dc2b81d343da8`.
|
||||
- sm-grzybyfunkcjonalne-app master `6f4f22296b6295e2750e3d376bd0300c8031ba51`, with journal gitlink `9d5bfc4a…` and vue-grzybyfunkcjonalne-app gitlink `465286a880f0aae6118c2915aa1475a5da353295` (was `ada03b2`; it pins vue-journal-layer v0.2.0).
|
||||
|
||||
## Deploy (Task 2)
|
||||
|
||||
- **Build sha256:** local `01c0b64a53a3b95f6481be21408cfa7450828414516e6868c3801e937680edc4`. The remote copy matched, first as `bin/grzybyfunkcjonalne.new` and again after the move.
|
||||
- **Previous binary:** `bin/grzybyfunkcjonalne.prev`, sha256 `e5b0ad28ca0384993470ac5a3177d502b7bd0b67c553488f86b43ae3363af232`.
|
||||
- **Nuxt rollback copy:** `/srv/grzybyfunkcjonalne/nuxt/.output.prev`.
|
||||
- **Migrations:** `migrate` printed `✓ migrations applied`, rc 0.
|
||||
- **RESTART_TS:** `2026/10/10 11:17:46` (server time, UTC). Log anchor: `client_id=alexandria_2026_10_10T11_17_46_316300`.
|
||||
- **Smoke:** `smoke: ok (98 urls)`, then `deploy-ok`.
|
||||
|
||||
**Snapshot taken before the human saves (read-only):**
|
||||
- `HTML_POST=1` (10-faktow-na-temat-cordycepsu).
|
||||
- `SNAPSHOT_POSTS`:
|
||||
```
|
||||
1:a026264105f900175b9770e637e5ec5c:a026264105f900175b9770e637e5ec5c
|
||||
64:0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c
|
||||
```
|
||||
- `SNAPSHOT_TRANSLATIONS`: `64:en:862fc5b362cc9856e6736a2078dca3ba`. Post 1 has no translation rows.
|
||||
- `POST64_STATE=1:1`. The plan expected `x:0`. Post 64's content and content_html both still contain a `<style` element: one `.author-footer` CSS block, at offset 4584 in content and 5035 in content_html. The style block was not removed from content_html on UAT as the plan assumed. This is reported, not a failure. The untouched re-save kept it byte-for-byte, and a re-read after Task 3 still prints `1:1`.
|
||||
|
||||
## UAT Acceptance (Task 3)
|
||||
|
||||
**Human reply, verbatim:** "Did tests, all points passed, bravo."
|
||||
|
||||
The human confirmed that all four steps passed, including the step 3 strip notice, but did not quote the notice text.
|
||||
|
||||
**Read-only checks, run after the reply:**
|
||||
|
||||
| Check | Result | Detail |
|
||||
|---|---|---|
|
||||
| posts-unchanged | pass | The md5 rows of posts 1 and 64 match the snapshot exactly |
|
||||
| translations-unchanged | pass | `64:en:862fc5b3…` matches; there are still no rows for post 1 |
|
||||
| edit-ok | pass | Test post `uat-16-2-blog-editor` (id 105) prints `1:1:1:1:1`: content = content_html, a `data-embed="youtube"` placeholder, a figcaption, an image under `/storage/app/uploads/public/journal/posts/`, and neither `<style` nor `onclick` |
|
||||
| site-ok | pass | The public page `/blog/uat-16-2-blog-editor`, fetched on the server, has 1 `src="https://www.youtube-nocookie.com/embed/` iframe. The Go log has 14 lines after the restart anchor, with 0 panic and 0 SQLSTATE |
|
||||
|
||||
**Which rows the saves wrote:** only post 64 (11:26:13Z) and post 105 (11:29:56Z) have `updated_at` after RESTART_TS. Post 105 is published. Post 1 still has `updated_at` 2026-10-09 17:33:51Z. The admin save runs GORM `Save`, which always sets `updated_at`, so no save of post 1 reached the database. Either it was not saved, or a different post was opened. The posts-unchanged pass for post 1 is therefore vacuous, and D-03 is proven on post 64 only (see Open Items).
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `../sm-journal-plugin/go.mod`: nominal require of summercms v0.1.18. The go.work replace stays.
|
||||
- `../sm-grzybyfunkcjonalne-app/plugins/golem15/journal` and `vue-grzybyfunkcjonalne-app`: gitlink bumps.
|
||||
- `../sm-grzybyfunkcjonalne-app/README.md`: an Admin note on the Blog Editor, lossless untouched saves, and stripped markup being reported.
|
||||
- `../sm-grzybyfunkcjonalne-app/deploy/nginx/grzybyfunkcjonalne-staging.conf`, `scripts/check-deploy.sh` and `DEPLOY.md`: `frame-src https://www.youtube-nocookie.com` in the report-only CSP, its check needle and its docs line.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Release now with v0.1.18 (human, Task 1).
|
||||
- The real Nuxt rollback path is `nuxt/.output.prev`.
|
||||
- The site-ok log check reads the supervisor log file from the restart anchor.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] The plan's Nuxt rollback path was wrong**
|
||||
- **Found during:** Task 2
|
||||
- **Issue:** The plan copies, checks and restores `go/build/nuxt/.output(.prev)`. On UAT the Nuxt server runs from `/srv/grzybyfunkcjonalne/nuxt/.output`.
|
||||
- **Fix:** The rollback copy was made at `/srv/grzybyfunkcjonalne/nuxt/.output.prev`, and the deploy-ok check tested that path.
|
||||
- **Files modified:** none (server layout only)
|
||||
- **Commit:** none
|
||||
|
||||
**2. [Rule 1 - Bug] The site-ok log check passed vacuously**
|
||||
- **Found during:** Task 2 (log check), applied in Task 3
|
||||
- **Issue:** `sudo supervisorctl tail -65536 grzybyfunkcjonalne-go` fails on UAT with an ExpatError. The pipeline then counted 0 matching lines, so the check would pass whatever the log contained.
|
||||
- **Fix:** Read `/var/log/supervisor/grzybyfunkcjonalne-go.log` directly. The command first requires the restart's River anchor (`client_id=alexandria_2026_10_10T11_17_46_316300`) to be present, then counts panic|SQLSTATE only in the lines after it:
|
||||
`ssh grzybyfunkcjonalne-uat 'F=/var/log/supervisor/grzybyfunkcjonalne-go.log; A=client_id=alexandria_2026_10_10T11_17_46_316300; grep -q "$A" "$F" && awk -v a="$A" "found{print} index(\$0,a){found=1}" "$F" | grep -ciE "panic|SQLSTATE"' | grep -qx 0`
|
||||
The anchor was still in the file (14 lines after it), so the timestamp fallback was not needed. The file holds 6 older SQLSTATE lines, all from before the restart.
|
||||
- **Files modified:** none
|
||||
- **Commit:** none
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 2, both Rule 1 bugs in the plan's server commands. No shipped code changed.
|
||||
**Impact on plan:** Both fixes make the checks stricter. They do not loosen them.
|
||||
|
||||
## Open Items (pending human)
|
||||
|
||||
1. **D-03 on an HTML post is unproven.** Save post 1 (`10-faktow-na-temat-cordycepsu`) in `/gfadmin/golem15/journal/posts` without touching the editor. Then re-run the posts-unchanged SELECT and confirm that post 1's `updated_at` moved past 2026-10-10 11:17:46Z.
|
||||
2. **D-19 category check:** in a category with content, change one word in the Blog Editor and save. No removal notice should appear, and the category page on the site should show the change. Also open post 1's source tab and confirm the stored HTML is unchanged. Not human-verified yet; pending for /msd-verify-work.
|
||||
3. **Vhost install (root):** install `/srv/grzybyfunkcjonalne/deploy/grzybyfunkcjonalne-staging.conf` (frame-src https://www.youtube-nocookie.com), then `sudo nginx -t && sudo systemctl reload nginx`. This blocks nothing, because the CSP is report-only.
|
||||
4. **Test post `uat-16-2-blog-editor` (id 105) is still published.** Unpublish or delete it when it is no longer needed. The human may keep it for now.
|
||||
5. **Docker-host suites:** these go to plan 06 (WINDOWS.md entries 14 to 16).
|
||||
|
||||
**Rollback, if any open item fails** (on the server, in `/srv/grzybyfunkcjonalne`):
|
||||
- Move `go/bin/grzybyfunkcjonalne.prev` back to `go/bin/grzybyfunkcjonalne`.
|
||||
- Move `nuxt/.output.prev` back to `nuxt/.output`.
|
||||
- Run `sudo supervisorctl restart grzybyfunkcjonalne-go grzybyfunkcjonalne-nuxt`.
|
||||
|
||||
## Auth Gates
|
||||
|
||||
None. The executor never asked for, printed or stored a credential. All admin actions were done by the human. On UAT the executor ran only SELECT queries and on-server curl, apart from the Task 2 deploy steps the human approved at the Task 1 gate.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
See Open Items 1 to 4.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Plan 06 (unit tests) can run. Its tests ship with the next summercms tag, v0.1.19.
|
||||
- /msd-verify-work should pick up Open Items 1 and 2.
|
||||
|
||||
---
|
||||
*Phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget*
|
||||
*Completed: 2026-10-10*
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND on origin: tag `v0.1.18` (object `04dc5bd`).
|
||||
- FOUND: sm-journal-plugin `9d5bfc4`, equal to origin/main. go.mod requires summercms v0.1.18, and the replace stays.
|
||||
- FOUND: host `6f4f222`, equal to origin/master. The gitlinks are journal `9d5bfc4` and vue-grzybyfunkcjonalne-app `465286a`.
|
||||
- FOUND: the `frame-src https://www.youtube-nocookie.com` needle in both the staging vhost and check-deploy.sh (1 each).
|
||||
- Task 3 checks: posts-unchanged, translations-unchanged, edit-ok and site-ok were all printed in this run.
|
||||
|
||||
## Post 1 untouched re-save (closes WINDOWS entry 17)
|
||||
|
||||
After the human re-saved post 1 (`10-faktow-na-temat-cordycepsu`) untouched, the orchestrator ran a read-only SELECT on UAT:
|
||||
|
||||
- Post 1: `updated_at` is 2026-10-10 12:02:40.245712+00, after RESTART_TS, so the save reached the database. The md5 of `content` and of `content_html` are both still `a026264105f900175b9770e637e5ec5c`, equal to the snapshot.
|
||||
- Post 64 is unchanged: `0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c`.
|
||||
|
||||
D-03 is now proven on UAT for an HTML post (post 1) and for a markdown post (post 64).
|
||||
|
||||
## Follow-up deploy (preview pane and View site)
|
||||
|
||||
On 2026-10-10, everything committed was released to UAT: the Blog Editor live preview pane and the admin "View site" link.
|
||||
|
||||
- **Pushed refs:**
|
||||
- sm-journal-plugin `2c78211`, equal to origin/main.
|
||||
- Host `0ad1004`, equal to origin/master. Its journal gitlink is `2c78211`.
|
||||
- summercms.go master `b5c70b7` before this record. It includes `85fe541` (View site) and was pushed to origin as `f1d8a75..b5c70b7`.
|
||||
- **Binary sha256:**
|
||||
- New: `c68a2099ec6a67d4cbf74086f501b2af2bb4b54f3e7f94fddc04e459d2ab486f`. The local build, the uploaded `.new` and the installed binary all match.
|
||||
- Rollback copy `go/bin/grzybyfunkcjonalne.prev`: `01c0b64a53a3b95f6481be21408cfa7450828414516e6868c3801e937680edc4` (the v0.1.18 deploy from 11:16).
|
||||
- **Nuxt rollback:** `nuxt/.output.prev` now holds the v0.1.18 output. The old `.prev` was removed before the copy, so `cp -a` replaced it and did not nest into it.
|
||||
- **Release steps:**
|
||||
- `scripts/check-deploy.sh`: ok.
|
||||
- `migrate`: exit 0 ("migrations applied").
|
||||
- Restart of both programs at 2026-10-10T12:37:43Z.
|
||||
- **Smoke:** `smoke: ok (98 urls)`.
|
||||
- **Go log:** checked after the anchor `River client started client_id=alexandria_2026_10_10T12_37_43_865055`. It has 0 panic or SQLSTATE lines and 0 `level=ERROR` lines.
|
||||
- **Feature checks:**
|
||||
- The deployed binary contains `blogeditor__preview` and `previewFrame`, from the journal `assets/dist/blogeditor.js`.
|
||||
- The admin bundle served at `/gfadmin/assets/index-Bqh6wnN4.js` contains `data-view-site`.
|
||||
- **Tag:** none was cut. v0.1.19 is still pending, after the unit-test plan (06). The plugin go.mod still requires summercms v0.1.18 with the local replace, and the host builds against the summercms.go working tree through go.work.
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
plan: 06
|
||||
subsystem: testing
|
||||
tags: [unit-tests, vitest, node-test, testcontainers, phase-gate, cabana, pact, blog-editor, embeds, d-03]
|
||||
|
||||
requires:
|
||||
- phase: 16.2-01
|
||||
provides: pact field type API (ListValue takes ctx), cabana registry, decode, skip, load, list, writes, routes, SPA PluginField/MLPluginField and fieldTypes loader
|
||||
- phase: 16.2-02
|
||||
provides: golem15-journal-blogeditor element, schema, serializer, embeds, editor corpus
|
||||
- phase: 16.2-03
|
||||
provides: Journal blogeditor/mlblogeditor types, SanitizeHTMLReport, StoreMedia, posts and category hooks
|
||||
- phase: 16.2-04
|
||||
provides: vue-journal-layer expandEmbeds and the four-rule sanitizer mirror
|
||||
- phase: 16.2-05
|
||||
provides: UAT release (v0.1.18) plus the untagged preview pane (quick 261010-iwu) and the admin View site link
|
||||
provides:
|
||||
- "Full unit coverage of the phase across summercms.go, sm-journal-plugin and vue-journal-layer (lean rule 3)"
|
||||
- "The imported-post resave contract (D-03) pinned at the element, PluginField, cabana, Journal Load and Postgres layers"
|
||||
- "Postgres tests for a Docker host: TestFieldTypeSavePostgres, TestCategoriesAdminBlogEditor, the three-stage TestPostsAdminResaveKeepsHTML"
|
||||
- "scripts/check-phase16.2.sh: fail-closed stages go, admin, editor, layer, docs, postgres with go/vitest-JSON/TAP detectors and a self-test"
|
||||
- "16.2-VALIDATION.md map filled with real task IDs, nyquist_compliant and wave_0_complete true"
|
||||
affects: [16.2 verify-work, v0.1.19 tag, sm-journal-plugin, vue-journal-layer]
|
||||
|
||||
actuals:
|
||||
tokens: 58692
|
||||
tasks: 3
|
||||
commits: 5
|
||||
plan_head_before: c07da3912b1cc4b5d112200f7af16c5ad3061d02
|
||||
plan_head_after: 7e113013a364f8e1668d0b5033e183204ec18d9f
|
||||
plugin_commits_repo: ../sm-journal-plugin
|
||||
plugin_head_before: 2c78211cb90e27cf8515b104613c3eac1a0aca5f
|
||||
plugin_head_after: 85963b19eff80530c301454095377cde1350621a
|
||||
plugin_commits: 4
|
||||
layer_commits_repo: ../vue-journal-layer
|
||||
layer_head_before: 759e684e862bd8a2f34e2bbcdf258f66aeddad4f
|
||||
layer_head_after: 782aa4da7d618b0d7e678421fc02dee29e5b9f54
|
||||
layer_commits: 1
|
||||
task2_start:
|
||||
summercms.go: 08a2002
|
||||
sm-journal-plugin: d5a5413
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "vitest JSON-reporter detector: a failed, skipped or todo test, a failed file, no passing test or a missing required file or test name fails the gate"
|
||||
- "Docker-only stages are explicit: --all runs postgres only when a daemon answers and otherwise prints NOT RUN; the stage itself fails without Docker"
|
||||
- "Edited-block markup compared by shape (element, sorted attributes, text), byte comparison kept for untouched blocks"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- modules/pact/field_type_test.go
|
||||
- modules/cabana/field_type_test.go
|
||||
- modules/cabana/field_type_postgres_test.go
|
||||
- admin/tests/app/fieldTypes.test.ts
|
||||
- scripts/check-phase16.2.sh
|
||||
- ../sm-journal-plugin/classes/blog_editor_test.go
|
||||
- ../sm-journal-plugin/controllers/posts_editor_test.go
|
||||
- ../sm-journal-plugin/categories_admin_editor_test.go
|
||||
- ../sm-journal-plugin/tests/blogeditor/element.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/schema.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/serializer.test.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/embed.test.ts
|
||||
modified:
|
||||
- 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/list/ListView.test.ts
|
||||
- admin/tests/shell/AppShell.test.ts
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-VALIDATION.md
|
||||
- .planning/WINDOWS.md
|
||||
- ../sm-journal-plugin/classes/editor_policy_test.go
|
||||
- ../sm-journal-plugin/classes/media_store_test.go
|
||||
- ../sm-journal-plugin/posts_admin_smoke_test.go
|
||||
- ../sm-journal-plugin/vite.config.ts
|
||||
- ../vue-journal-layer/tests/embeds.test.ts
|
||||
- ../vue-journal-layer/tests/htmlGate.test.ts
|
||||
|
||||
key-decisions:
|
||||
- "The Postgres resave test adapts the plan's locales to the harness: en is the host locale and pl the translation, as plan 03 already recorded"
|
||||
- "check-phase16.2.sh --all runs the postgres stage only when a Docker daemon answers; without one it prints a loud NOT RUN block and still ends with 'Phase 16.2 gate passed' for the runnable stages. The postgres stage invoked directly fails without Docker, and PHASE162_REQUIRE_POSTGRES=1 makes --all fail too"
|
||||
- "Edited (re-serialized) blocks are compared by element, attribute set and text rather than bytes: attribute order and the DOM's style normalization change there by design (plan 02); untouched blocks keep the byte comparison"
|
||||
- "The happy-dom iframe page loading is disabled in the editor tests so no test reaches the network (T-16.2-28)"
|
||||
|
||||
patterns-established:
|
||||
- "A phase gate names its required tests per stage and fails on a missing one, a skip, a todo or zero tests, in Go, vitest and node:test alike"
|
||||
|
||||
requirements-completed: [D-02, D-03, D-05, D-07, D-10, D-11, D-12, D-13, D-14, D-17, D-18, D-19]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Imported-post resave contract (D-03): untouched value unchanged at the element (Source tab, tab buttons, preview, reattach), PluginField never re-sets an echoed value, cabana writes and reports nothing for a served value, Journal Load serves HTML verbatim and markdown as content_html"
|
||||
requirement: D-03
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/tests/blogeditor/element.test.ts"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "admin/tests/form/PluginField.test.ts#untouched value (D-03)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "modules/cabana/field_type_test.go#TestFieldTypeUntouched"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "../sm-journal-plugin/classes/blog_editor_test.go#TestBlogEditorLoad"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D2
|
||||
description: "Framework coverage: pact FormFieldError, boot rules, compile, decode 422/500, localized capped notices, Load, ListValue, writes, field-types and upload routes"
|
||||
requirement: D-11
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase16.2.sh go (19 named framework tests)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D3
|
||||
description: "Journal coverage: Decode strip-and-report over the 35 payloads, notices and plural forms, LooksLikeHTML, options, ListValue, Upload sniffing and safe names, StoreMedia, policy corpus cross-check, posts hooks"
|
||||
requirement: D-10
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase16.2.sh go (20 named Journal tests, 16.1 policy tests unchanged since c60abc6)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D4
|
||||
description: "JS coverage: admin fieldTypes, registry, formState, FormField, ListView, AppShell View site; editor schema, serializer, embed, preview; layer expandEmbeds order and mirror negatives; corpus copies identical"
|
||||
requirement: D-13
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase16.2.sh admin (1218 tests, 78 files)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase16.2.sh editor (220 tests, 7 files)"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "bash scripts/check-phase16.2.sh layer (144 tests, corpus cmp)"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D5
|
||||
description: "Phase gate and validation map"
|
||||
verification:
|
||||
- kind: other
|
||||
ref: "bash scripts/check-phase16.2.sh --self-test && bash scripts/check-phase16.2.sh --all -> Phase 16.2 gate passed"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
- id: D6
|
||||
description: "Postgres-backed suites: TestFieldTypeSavePostgres, TestPostsAdminResaveKeepsHTML (HTML, markdown, edited), TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor, TestJournalSVGEmbed and the rest of the testcontainers suites"
|
||||
requirement: D-18
|
||||
verification: []
|
||||
human_judgment: true
|
||||
rationale: "No Docker on this host (docker.sock permission denied); the tests compile under go vet and fail rather than skip here. Run bash scripts/check-phase16.2.sh postgres on a Docker host before /msd-verify-work"
|
||||
|
||||
duration: 43min
|
||||
completed: 2026-10-10
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Phase 16.2 Plan 06: Unit Coverage, Postgres Tests and Phase Gate Summary
|
||||
|
||||
**Every Phase 16.2 behaviour is now pinned by a test that fails on regression: the untouched-resave contract at five layers, the framework's field type rules and routes, the Journal editor types, storage and policy cross-check, the editor element, schema, serializer and embeds, the layer's sanitize-then-expand order, and a fail-closed gate (`scripts/check-phase16.2.sh`) that passes here with the Docker-only suites reported as NOT RUN.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** 43 min
|
||||
- **Started:** 2026-10-10T13:34:11Z
|
||||
- **Completed:** 2026-10-10T14:17Z
|
||||
- **Tasks:** 3 (tracer, Go coverage, JS coverage plus gate and validation map)
|
||||
- **Files:** 26 test, script and planning files across three repositories (14 created, 12 modified), plus one test config (`vite.config.ts`)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- **Tracer (Task 1, D-03, D-07):** element.test.ts (15 cases: Source tab, tab buttons, echo, preview, reattach, a real Source edit); PluginField echo-on-connect stub; `TestFieldTypeUntouched` (served values skip Decode, writes and notices; one changed locale only; canonical JSON; create never skips); `TestBlogEditorLoad`; `TestPostsAdminResaveKeepsHTML` rewritten in three stages (HTML post, markdown post with imported content_html, edited clean save), byte comparisons without TrimSpace and no meta.notices.
|
||||
- **Go (Task 2):** pact `TestFormFieldErrorMessage`, `TestFormFieldTypeContract`; cabana `TestFieldTypeRegistry`, `TestFieldTypeOptions`, `TestFieldTypeCompile`, `TestFieldTypeDecode`, `TestFieldTypeNotices`, `TestFieldTypeLoad`, `TestFieldTypeListValue`, `TestFieldTypeWrites`, `TestFieldTypesRoute`, `TestFieldUpload`; Docker `TestFieldTypeSavePostgres`. Journal `TestBlogEditorDecode`, `TestBlogEditorNotices`, `TestLooksLikeHTML`, `TestBlogEditorOptions`, `TestBlogEditorListValue`, `TestBlogEditorUpload`, `TestStoreMedia`, extended `TestEditorOutputPassesPolicy` and `TestSanitizeHTMLReport`, `TestPostsEditorHooks`, Docker `TestCategoriesAdminBlogEditor`.
|
||||
- **JS (Task 3):** admin `fieldTypes.test.ts` (new) plus registry, formState, FormField, ListView and AppShell (View site: href `origin + "/"`, `_blank`, `noopener noreferrer`, en/pl label and phrasebook keys); editor `schema`, `serializer`, `embed` tests (new) next to the existing smoke, corpus and preview tests; layer embeds (order at the sinks, exact-serialization only, stable global regex, fixed src) and htmlGate (four rules accept, Go negatives stripped, corpus free of danger).
|
||||
- **Gate:** `scripts/check-phase16.2.sh` with stages go, admin, editor, layer, docs, postgres, `--self-test` (go, TAP and vitest-JSON detectors plus the corpus `cmp` on planted output) and `--all`.
|
||||
- **Validation map:** every row has real task IDs, commands and statuses; Docker suites are manual rows; `nyquist_compliant: true`, `wave_0_complete: true`.
|
||||
|
||||
## Test counts per repository
|
||||
|
||||
| Repository | Suite | Before | After |
|
||||
|---|---|---|---|
|
||||
| summercms.go | Go: new top-level tests in pact and cabana | — | 13 (12 run here + `TestFieldTypeSavePostgres` for Docker) |
|
||||
| summercms.go | admin vitest | 1190 tests (plan 01), 77 files | 1218 tests, 78 files |
|
||||
| sm-journal-plugin | Go classes/controllers new top-level tests | — | 9 (+2 extended in place) |
|
||||
| sm-journal-plugin | Go root (Docker) | — | `TestCategoriesAdminBlogEditor` new, `TestPostsAdminResaveKeepsHTML` rewritten |
|
||||
| sm-journal-plugin | editor vitest | 55 tests, 3 files | 220 tests, 7 files |
|
||||
| vue-journal-layer | node --test | 136 | 144 |
|
||||
|
||||
## Task Commits
|
||||
|
||||
**summercms.go**
|
||||
1. Task 1: `08a2002` test(16.2-06): pin the untouched-value skip in cabana and PluginField
|
||||
2. Task 2: `3404a4b` test(16.2-06): full unit coverage of the plugin field type framework
|
||||
3. Task 3: `9f5e807` test(16.2-06): admin SPA coverage of plugin field types and View site; `3da2fd6` test(16.2): phase gate; `7e11301` docs(16.2): complete the validation map
|
||||
|
||||
**sm-journal-plugin**
|
||||
1. Task 1: `d5a5413` test(16.2-06): pin the imported-post resave contract
|
||||
2. Task 2: `da7a073` test(16.2-06): full unit coverage of the Journal Blog Editor field types
|
||||
3. Task 3: `a578dda` test(16.2-06): Blog Editor schema, serializer and embed coverage; `85963b1` test(16.2-06): keep happy-dom iframe pages offline in the editor tests
|
||||
|
||||
**vue-journal-layer**
|
||||
3. Task 3: `782aa4d` test: pin embed expansion order and the editor policy mirror
|
||||
|
||||
No fix(...) commit: no test exposed a production defect. Nothing was pushed or tagged.
|
||||
|
||||
## Verification
|
||||
|
||||
- Task 1 verify: element vitest 15/15, PluginField 17/17, `--- PASS: TestFieldTypeUntouched`, `--- PASS: TestBlogEditorLoad`, `go -C ../sm-journal-plugin vet ./...` clean.
|
||||
- Task 2 verify: 13 of 13 named cabana tests pass, 10 of 10 named Journal classes tests pass, `TestPostsEditorHooks` passes, vet clean in both repositories; `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; Task 2's diffs (`08a2002..3404a4b`, `d5a5413..da7a073`) hold only `*_test.go` files.
|
||||
- Task 3 verify: admin 1218/1218, editor 220/220, layer `# fail 0` (144 pass); `check-phase16.2.sh --self-test` passes; `--all` ends with `Phase 16.2 gate passed` (go, admin, editor, layer, docs PASS; postgres NOT RUN).
|
||||
- Typecheck: `vue-tsc --noEmit` (admin) and `tsc --noEmit` (plugin) clean; `check-dist.sh` and `check-admin-dist.sh` report current bundles.
|
||||
- Full `go test ./...`: the only failures in summercms.go (beachcomber, bouncer, cabana, conga, lagoon, lagoon/attach, lighthouse, postcard, docs/examples/blog) and sm-journal-plugin (root, models, updates) are the testcontainers suites failing on `docker.sock: permission denied`; no other failing test line.
|
||||
|
||||
## Docker-host command list (not run here)
|
||||
|
||||
```
|
||||
bash scripts/check-phase16.2.sh postgres
|
||||
# which runs, fail-closed:
|
||||
go test -json -count=1 -timeout 15m ./modules/cabana # requires TestFieldTypeSavePostgres, TestListRelationSearchPostgres
|
||||
go -C ../sm-journal-plugin test -json -count=1 -timeout 20m ./... # requires TestPostsFormCompiles, TestPostsAdminCreateSmoke,
|
||||
# TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent,
|
||||
# TestCategoriesAdminBlogEditor, TestJournalSVGEmbed
|
||||
```
|
||||
|
||||
These cover WINDOWS entries 11, 12, 14, 15, 16, 20 and 21.
|
||||
|
||||
## Decisions Made
|
||||
|
||||
See key-decisions in the frontmatter.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Test isolation] happy-dom loaded remote iframe pages in the editor tests**
|
||||
- **Found during:** Task 1 (baseline run of the plugin vitest suite)
|
||||
- **Issue:** The quick-task preview tests put payloads with `https://example.test/` iframes into the preview frame's srcdoc; happy-dom tried to fetch them (12 `getaddrinfo ENOTFOUND example.test` per run). The threat model (T-16.2-28) says no test reaches the network.
|
||||
- **Fix:** `test.environmentOptions.happyDOM.settings.disableIframePageLoading: true` in the plugin's `vite.config.ts` (test block only; the build output is unchanged, `check-dist.sh` passes). The admin suite was checked and makes no such request.
|
||||
- **Files modified:** ../sm-journal-plugin/vite.config.ts
|
||||
- **Commit:** 85963b1
|
||||
|
||||
**2. [Plan text] Locales of the Postgres resave test**
|
||||
- **Issue:** The plan says "pl and en ... no en translation row ... en content_html translation" with pl as host; the harness (`bootJournal`) makes en the default locale and pl the translation (as plan 03 recorded).
|
||||
- **Fix:** host assertions on en; the untouched empty value and the translation assertions on pl. The untouched-empty check compares pl `content`/`content_html` presence and the translation row count before and after the PUT.
|
||||
- **Commit:** d5a5413
|
||||
|
||||
**3. [Plan text] Edited-block markup compared by shape**
|
||||
- **Issue:** schema tests that serialize a whole document from the model saw attribute order and style normalization differ from the source (`<a class="more" data-id="7" href="/a">`, `color: red;`). This is plan 02's documented behaviour for edited blocks, not a defect.
|
||||
- **Fix:** those cases compare element names, sorted attributes and text; untouched blocks keep byte comparisons everywhere.
|
||||
- **Commit:** a578dda
|
||||
|
||||
**4. Files outside the plan's list**
|
||||
- `admin/tests/shell/AppShell.test.ts` (the View site link test the orchestrator asked for), `../sm-journal-plugin/vite.config.ts` (deviation 1), `.planning/WINDOWS.md` (entries 20 and 21).
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 1 auto-fixed (test isolation) plus 2 plan-text adaptations and the listed extra files. **Impact:** no production code changed; no assertion that describes shipped behaviour was relaxed (the 16.1 policy test files are unchanged since c60abc6).
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
- No Docker on this host: the Postgres suites compile under go vet and fail rather than skip; see the Docker-host command list.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
- Run `bash scripts/check-phase16.2.sh postgres` on a Docker host, then /msd-verify-work 16.2 (WINDOWS 18 and 19 remain human UAT items from plan 05).
|
||||
- The orchestrator tags v0.1.19 separately.
|
||||
- STATE.md and ROADMAP.md updates are pending: another session holds uncommitted edits to them (see below).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Created files exist: modules/pact/field_type_test.go, modules/cabana/field_type_test.go, modules/cabana/field_type_postgres_test.go, admin/tests/app/fieldTypes.test.ts, scripts/check-phase16.2.sh, ../sm-journal-plugin/classes/blog_editor_test.go, ../sm-journal-plugin/controllers/posts_editor_test.go, ../sm-journal-plugin/categories_admin_editor_test.go, ../sm-journal-plugin/tests/blogeditor/{element,schema,serializer,embed}.test.ts.
|
||||
- Commits exist: summercms.go 08a2002, 3404a4b, 9f5e807, 3da2fd6, 7e11301; sm-journal-plugin d5a5413, da7a073, a578dda, 85963b1; vue-journal-layer 782aa4d.
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
fixed_at: 2026-10-10T14:46:56Z
|
||||
review_path: .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-REVIEW.md
|
||||
iteration: 1
|
||||
findings_in_scope: 8
|
||||
fixed: 8
|
||||
skipped: 0
|
||||
status: all_fixed
|
||||
---
|
||||
|
||||
# Phase 16.2: Code Review Fix Report
|
||||
|
||||
**Fixed at:** 2026-10-10T14:46:56Z
|
||||
**Source review:** .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-REVIEW.md
|
||||
**Iteration:** 1
|
||||
|
||||
**Summary:**
|
||||
- Findings in scope: 8 (CR-01, WR-01, WR-02, WR-03, WR-04, WR-05, WR-06, IN-04, as the orchestrator chose)
|
||||
- Fixed: 8
|
||||
- Skipped: 0 in scope. WR-07, WR-08, IN-01, IN-02, IN-03, IN-05, IN-06, IN-07 and IN-08 are out of scope by decision. Each is listed under Skipped Issues.
|
||||
|
||||
The fixes span three repositories, and each fix is committed in the repository that owns the file:
|
||||
- summercms.go: `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go`
|
||||
- sm-journal-plugin: `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin`
|
||||
- vue-journal-layer: no fix touched it.
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
### CR-01: Blog Editor hides a locale's existing `content_html` when its `content` is blank
|
||||
|
||||
**Repository:** sm-journal-plugin
|
||||
**Files modified:** `classes/blog_editor.go`, `classes/blog_editor_test.go`, `controllers/posts_editor_smoke_test.go`
|
||||
**Commit:** 1593231
|
||||
**Applied fix:** `loadBlogHTML` now reads the `htmlColumn` attribute when the stored content is blank and serves it whenever it is not empty. This applies to every locale and to the host row. When it is empty or missing, the blank value is served as stored. FormatHTML never runs on a blank value. An untouched save echoes the served value, which cabana skips. An edit writes the edited HTML to `content`, and FormAfterUpdate writes it to that locale's `content_html`.
|
||||
**Tests:**
|
||||
- Blank or nil content is served as `content_html`, in a translation and on the host row.
|
||||
- Blank content with an empty or missing `content_html` is still served as `""`.
|
||||
- A failing read is the Load error.
|
||||
- A field without `htmlColumn` never reads.
|
||||
- The controller smoke test covers the whole flow: Load serves the API-written body, Decode accepts the edit, and FormAfterUpdate writes `content_html` for that locale.
|
||||
|
||||
### WR-01: Field upload route skips the controller's create/update capability check
|
||||
|
||||
**Repository:** summercms.go
|
||||
**Files modified:** `modules/cabana/field_type_upload.go`, `modules/cabana/field_type_test.go`, `modules/cabana/field_type_smoke_test.go`, `modules/cabana/README.md`, `docs/backend/field-types.md`
|
||||
**Commit:** 3158bb5
|
||||
**Applied fix:** `fieldUpload` now calls `s.operationDeclared(w, r, cc, op)` right after parsing `{id}`, before it reads the body. The operation is `create` for id 0 and `update` otherwise, as on the save routes.
|
||||
**Tests:**
|
||||
- New `TestFieldUpload` case: a controller with no `create` toolbar button answers 403 for id 0 and never calls Upload. The same field with the button answers 201.
|
||||
- The test fixtures for upload (`upFS`, `ftMLPlugin`) now declare `toolbar.buttons: [create]`.
|
||||
**Docs:** the README route row and the field-types page now describe the 403.
|
||||
|
||||
### WR-02: Stored upload objects are orphaned when the route refuses after Upload succeeded
|
||||
|
||||
**Repository:** summercms.go
|
||||
**Files modified:** `modules/cabana/field_type_upload.go`, `modules/cabana/field_type_test.go`, `modules/cabana/README.md`, `docs/backend/field-types.md`
|
||||
**Commit:** c532529
|
||||
**Applied fix:**
|
||||
- The route reads the whole multipart body with `ReadForm` before Upload runs. Files over 8 MiB (`fieldUploadMemory`) spool to a temporary file, which `RemoveAll` deletes.
|
||||
- `singleUploadFile` requires exactly one part: a file part named `file` with a non-blank file name. Only then is Upload called, with the opened file.
|
||||
- An extra part, a value part or an oversized body is refused (422 or 413) without calling Upload.
|
||||
- Two windows remain, and the docs now name them: a record transaction that fails after Upload returns, and an element that stops waiting. In both cases `Upload` should store under a name that is safe to leave behind.
|
||||
**Tests:**
|
||||
- New cases check that a second file part, a later field, a value part before or after the file, and an oversized body never call Upload.
|
||||
- A file larger than the in-memory threshold reaches Upload byte for byte.
|
||||
|
||||
### WR-03: Media key uniqueness is check-then-write
|
||||
|
||||
**Repository:** sm-journal-plugin
|
||||
**Files modified:** `classes/media_store.go`, `classes/media_store_test.go`
|
||||
**Commit:** 2c961b8
|
||||
**Applied fix:**
|
||||
- `StoreMedia` reserves the chosen key in an in-process set, per bucket, while it writes. `uniqueMediaKey` treats reserved keys as taken.
|
||||
- The write uses `blob.WriterOptions{IfNotExist: true}`. A `gcerrors.FailedPrecondition` moves the upload on to the next free name, for at most 10 attempts.
|
||||
- Both parts are needed: fileblob's `IfNotExist` is stat-then-rename under a per-writer mutex, so on its own it would not close the race within one process. The reservation closes it within the process, and `IfNotExist` covers other processes on backends that enforce it atomically, such as S3.
|
||||
**Tests (run with `-race`):**
|
||||
- 16 concurrent uploads of `image.png` get distinct keys and keep their own bytes, and no reservations are left behind.
|
||||
- A test hook (`mediaBeforeWrite`) simulates another process writing the chosen keys. The upload moves to `image-2.png` and leaves the other objects untouched.
|
||||
- When the upload loses every attempt, it gives up with an opaque error.
|
||||
|
||||
### WR-04: `LooksLikeHTML` treats markdown that starts with a tag as HTML
|
||||
|
||||
**Repository:** sm-journal-plugin
|
||||
**Files modified:** `classes/blog_editor.go`, `classes/blog_editor_test.go`, `controllers/posts_editor_smoke_test.go`
|
||||
**Commit:** 5af746f
|
||||
**Status:** fixed: requires human verification (this changes a heuristic)
|
||||
**Applied fix:**
|
||||
- A value that starts with `<` is served as it is only when one of these holds:
|
||||
- `content_html` is empty or missing, or cannot be read (no translation writer).
|
||||
- `content_html` equals the value, ignoring white space at either end.
|
||||
- `content_html` equals `SanitizeHTML(value)`, ignoring white space at either end.
|
||||
- Otherwise the value is markdown that starts with a tag, and `content_html` is served.
|
||||
- `sameStoredHTML` holds the comparison.
|
||||
**Why `FormatHTML(value)` is not a match:** that render also matches markdown which starts with `<img>`, so counting it would bring the bug back.
|
||||
**Side effect:** an imported HTML post whose `content_html` differs from its `content` (for example, Winter's Markdown::parse split an HTML block) now opens with `content_html`. That is what the site renders, so an untouched save still skips and an edit writes both columns from it.
|
||||
**Tests:**
|
||||
- The HTML-verbatim cases now cover a `content_html` that is missing, empty, equal, sanitized, trimmed, or both sanitized and trimmed.
|
||||
- New fixtures cover markdown that starts with `<img>`, `<div>` and `<!-- comment -->`. Each is served as its `content_html`, with no `##` or `**` markers.
|
||||
- A read error on an HTML value is the Load error.
|
||||
- A controller smoke case covers the `<img>` hero.
|
||||
|
||||
### WR-05: Attribute preservation lets `id`, `name`, `style`, `srcset` into the live admin DOM
|
||||
|
||||
**Repository:** sm-journal-plugin
|
||||
**Files modified:** `assets/src/blogeditor/extensions.ts`, `assets/dist/blogeditor.js`, `tests/blogeditor/schema.test.ts`, `tests/blogeditor/smoke.test.ts`, `README.md`
|
||||
**Commit:** f1e6c11
|
||||
**Applied fix:**
|
||||
- `captureAttributes` and `renderCaptured` now use the conservative allowlist from the orchestrator (`KEPT_ATTRIBUTES`, `isKeptAttribute`): `class`, `title`, `alt`, `width`, `height`, `colspan`, `rowspan`, `start`, `rel`, `target` and `data-*`.
|
||||
- `href` and `src` stay modelled on the link and image nodes, which check them.
|
||||
- Every other attribute is never captured, so it is never rendered into the ProseMirror view. This covers `id`, `name`, `style`, `srcset`, `background`, `form`, `contenteditable`, `popover`, `itemprop`, `dir`, `lang` and `on*`. Such attributes drop out of a block once that block is edited.
|
||||
- A `span` is kept as a mark only when it has a kept attribute.
|
||||
- Untouched documents and untouched blocks still return their stored bytes (D-03).
|
||||
**Tests:**
|
||||
- The schema tests use view-safe attributes for the "keeps" cases.
|
||||
- New table cases drop `id`, `name`, `style`, `srcset`, `loading`, `background`, `form`/`contenteditable`/`popover`, `itemprop`/`dir`/`lang` and a cell's `style`.
|
||||
- A live-DOM test asserts that no stored `id`, `name`, `srcset`, `background` or style value reaches `.ProseMirror`, and that the untouched value is still the exact input.
|
||||
- The smoke expectation for edited blocks is updated.
|
||||
- A new test confirms that an untouched block keeps `id`, `style` and `itemprop` byte for byte after another block is edited.
|
||||
**Other changes:** the dist bundle is rebuilt and the plugin README's untouched-values paragraph is updated.
|
||||
|
||||
### WR-06: Translatable Decode can receive non-string values
|
||||
|
||||
**Repository:** summercms.go
|
||||
**Files modified:** `modules/cabana/field_type.go`, `modules/cabana/field_type_test.go`, `modules/pact/capabilities.go`, `modules/pact/README.md`, `docs/backend/field-types.md`
|
||||
**Commit:** f388d27
|
||||
**Applied fix:** before `decodeOne` handles a translatable field's default value, `decodePluginFields` now does the following:
|
||||
- A string passes through.
|
||||
- `nil` becomes `""`.
|
||||
- Any other value is a 422 on the field: "The X field must be text or an object of locale codes to text."
|
||||
**Docs:** the pact `FormFieldValue` doc comment, the pact README row and the field-types page now state the enforced contract.
|
||||
**Tests:** a probe Decode does `in.Value.(string)` and so panics on a broken contract.
|
||||
- A string and null each reach it as a string.
|
||||
- A number, a boolean and an int are refused with a 422 before Decode runs.
|
||||
|
||||
### IN-04: `crypto.randomUUID()` throws outside a secure context
|
||||
|
||||
**Repository:** sm-journal-plugin
|
||||
**Files modified:** `assets/src/blogeditor/element.ts`, `assets/dist/blogeditor.js`, `tests/blogeditor/smoke.test.ts`
|
||||
**Commit:** b997a41
|
||||
**Applied fix:** a new exported `uploadRequestId()` picks the request id in this order:
|
||||
- `crypto.randomUUID` when it exists.
|
||||
- Otherwise, a version 4 UUID built from `crypto.getRandomValues`, which works in insecure contexts.
|
||||
- Otherwise, a time-plus-counter id.
|
||||
The admin host (`PluginField.vue`) echoes any id back, so nothing else changes.
|
||||
**Tests:**
|
||||
- With `randomUUID` stubbed away, ids are unique v4 UUIDs and a full upload round trip still works.
|
||||
- With no `crypto` object, ids are unique counter ids.
|
||||
**Other changes:** the dist bundle is rebuilt.
|
||||
|
||||
## Skipped Issues
|
||||
|
||||
### WR-07: "View site" links to the admin's own origin
|
||||
|
||||
**File:** `admin/src/components/shell/AppShell.vue:25-27, 40-48`
|
||||
**Reason:** By explicit user decision, "View site" defaults to the admin origin. The `SUMMER_FRONTEND__URL` override is a tracked follow-up.
|
||||
**Original issue:** `siteURL` assumes the public site and the admin share an origin, which is wrong for the headless deployment.
|
||||
|
||||
### WR-08: The phase gate prints "Phase 16.2 gate passed" without running the data-loss regression suites
|
||||
|
||||
**File:** `scripts/check-phase16.2.sh:521-544`
|
||||
**Reason:** This is by design. The gate prints a loud NOT RUN block, and `PHASE162_REQUIRE_POSTGRES=1` enforces the Postgres stage. The Postgres run is a tracked Docker-host step.
|
||||
**Original issue:** `--all` exits 0 when Docker is unreachable, without the D-03/D-10 Postgres acceptance tests.
|
||||
|
||||
### IN-01: An embed placeholder that both policies accept may never expand
|
||||
|
||||
**File:** `../vue-journal-layer/app/utils/embeds.ts:41`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** `EMBED_PLACEHOLDER` matches only `data-embed` before `data-id`.
|
||||
|
||||
### IN-02: The expanded YouTube iframe has no `sandbox`, and the exported `/g` regex carries shared `lastIndex` state
|
||||
|
||||
**File:** `../vue-journal-layer/app/utils/embeds.ts:41, 56-60`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** There is no defence-in-depth `sandbox` on the iframe, and the exported global regex is stateful.
|
||||
|
||||
### IN-03: Plugin aliases can shadow list column types
|
||||
|
||||
**File:** `modules/cabana/field_type.go:104-109`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** A plugin alias `date`, `datetime` or `time` is accepted, but its `ListValue` is never called.
|
||||
|
||||
### IN-05: content_html translations are skipped silently when the application is unavailable
|
||||
|
||||
**File:** `../sm-journal-plugin/controllers/posts.go:263-270`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** `writeContentHTMLTranslations` returns nil without a writer, and the columns diverge.
|
||||
|
||||
### IN-06: Untouched detection cannot be byte-exact for legacy content with invalid UTF-8
|
||||
|
||||
**File:** `modules/cabana/field_type.go:641-651`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** JSON transport replaces invalid UTF-8, so an untouched save rewrites such rows.
|
||||
|
||||
### IN-07: "Edit as HTML" selects the first identical block
|
||||
|
||||
**File:** `../sm-journal-plugin/assets/src/blogeditor/element.ts:524-532`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** `indexOf(html)` highlights the first of two identical raw blocks.
|
||||
|
||||
### IN-08: Plugin-typed fields cannot declare `readOnly`/`hidden`/`disabled` in YAML
|
||||
|
||||
**File:** `modules/cabana/field_type.go:33-36`
|
||||
**Reason:** Deferred. Info findings other than IN-04 are out of scope for this fix pass.
|
||||
**Original issue:** `pluginFieldKeys` excludes the general keys that the SPA honours.
|
||||
|
||||
## Verification
|
||||
|
||||
All verification ran in the main checkouts. `workflow.use_worktrees` is `false`, so no worktree was created and the numbers can be reproduced from the trees as committed.
|
||||
|
||||
- **summercms.go:**
|
||||
- `go vet ./...` is clean.
|
||||
- The phase gate's framework set (`FRAMEWORK_RUN` over `./modules/pact ./modules/cabana`) passes.
|
||||
- `go test ./cmd/summer -run TestDocsTree` passes.
|
||||
- The other cabana failures are Docker-only (testcontainers). The failing subtest list is identical before and after these changes; this was checked with `git stash`.
|
||||
- **sm-journal-plugin:**
|
||||
- `go vet ./...` is clean.
|
||||
- `go test ./classes ./controllers ./controllers/api` passes. The root, `models` and `updates` packages need Docker.
|
||||
- vitest passes 232 of 232, and `tsc --noEmit` is clean.
|
||||
- `scripts/check-dist.sh` matches a fresh build.
|
||||
- **vue-journal-layer:** untouched. The gate's layer stage passes 144 of 144, and the corpus copy equals the plugin's.
|
||||
- **Full gate:** `bash scripts/check-phase16.2.sh --all` passes the go, admin (vitest 1218 of 1218), editor, layer and docs stages; docs includes check-admin-openapi, check-admin-dist and the Journal check-dist. Postgres is NOT RUN because no Docker daemon is reachable. Its suites (TestFieldTypeSavePostgres, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdmin*) still need a Docker-host run. That run matters most for CR-01 and WR-04, whose end-to-end untouched-save behavior those suites cover.
|
||||
|
||||
---
|
||||
|
||||
_Fixed: 2026-10-10T14:46:56Z_
|
||||
_Fixer: Claude (msd-code-fixer)_
|
||||
_Iteration: 1_
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
reviewed: 2026-10-10T14:29:09Z
|
||||
depth: standard
|
||||
files_reviewed: 73
|
||||
files_reviewed_list:
|
||||
- admin/src/api/schema.d.ts
|
||||
- admin/src/api/types.ts
|
||||
- admin/src/app/fieldTypes.ts
|
||||
- admin/src/app/pluginAssets.ts
|
||||
- admin/src/components/form/FieldRenderer.vue
|
||||
- admin/src/components/form/FormField.vue
|
||||
- admin/src/components/form/FormGrid.vue
|
||||
- admin/src/components/form/PreviewField.vue
|
||||
- admin/src/components/form/fields/MLField.vue
|
||||
- admin/src/components/form/fields/MLPluginField.vue
|
||||
- admin/src/components/form/fields/PluginField.vue
|
||||
- admin/src/components/form/formContext.ts
|
||||
- admin/src/components/form/formState.ts
|
||||
- admin/src/components/form/mlLocale.ts
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/components/relation/RelationChildModal.vue
|
||||
- admin/src/components/shell/AppShell.vue
|
||||
- admin/src/main.ts
|
||||
- admin/src/views/FormView.vue
|
||||
- admin/src/views/LoginView.vue
|
||||
- modules/cabana/admin_openapi.go
|
||||
- modules/cabana/contracts.go
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/extension.go
|
||||
- modules/cabana/field_ml.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_type_upload.go
|
||||
- modules/cabana/form_schema.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/list_schema.go
|
||||
- modules/cabana/query.go
|
||||
- modules/cabana/registry.go
|
||||
- modules/cabana/relation.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/relation_form.go
|
||||
- modules/cabana/schema_types.go
|
||||
- modules/cabana/settings.go
|
||||
- modules/cabana/tx_context.go
|
||||
- modules/pact/capabilities.go
|
||||
- modules/phrasebook/backend/lang/en/lang.yaml
|
||||
- modules/phrasebook/backend/lang/pl/lang.yaml
|
||||
- scripts/check-phase16.2.sh
|
||||
- ../sm-journal-plugin/admin.go
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/index.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/serializer.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
- ../sm-journal-plugin/classes/blog_editor.go
|
||||
- ../sm-journal-plugin/classes/format_html.go
|
||||
- ../sm-journal-plugin/classes/media_store.go
|
||||
- ../sm-journal-plugin/controllers/admin_registry.go
|
||||
- ../sm-journal-plugin/controllers/api/media.go
|
||||
- ../sm-journal-plugin/controllers/categories.go
|
||||
- ../sm-journal-plugin/controllers/posts.go
|
||||
- ../sm-journal-plugin/field_types.go
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/lang/en/lang.yaml
|
||||
- ../sm-journal-plugin/lang/pl/lang.yaml
|
||||
- ../sm-journal-plugin/models/category/fields.yaml
|
||||
- ../sm-journal-plugin/models/post/fields.yaml
|
||||
- ../sm-journal-plugin/scripts/check-dist.sh
|
||||
- ../sm-journal-plugin/vite.config.ts
|
||||
- ../vue-journal-layer/app/assets/css/journal-base.css
|
||||
- ../vue-journal-layer/app/components/Journal/CategoryHeader.vue
|
||||
- ../vue-journal-layer/app/components/Journal/PostBody.vue
|
||||
- ../vue-journal-layer/app/utils/embeds.ts
|
||||
- ../vue-journal-layer/app/utils/htmlGate.ts
|
||||
- ../vue-journal-layer/testing/expect-acme.txt
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 8
|
||||
info: 8
|
||||
total: 17
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 16.2: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-10T14:29:09Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 73 (summercms.go diff 0754a6e..HEAD, sm-journal-plugin diff c60abc6..HEAD, vue-journal-layer diff 294a8d8..HEAD)
|
||||
**Status:** issues_found
|
||||
|
||||
Paths starting with `../` are relative to the summercms.go repository root; they live in the sibling sm-journal-plugin and vue-journal-layer repositories.
|
||||
|
||||
## Summary
|
||||
|
||||
The review covered the new `pact.FormFieldType` API, cabana's compile, decode, untouched-skip, load, list and upload paths, the admin SPA host (PluginField, MLPluginField, the field-type loader, notices, View site), the Journal Blog Editor types (Decode/Load/ListValue/Upload, StoreMedia, the bluemonday extension and SanitizeHTMLReport), the TipTap custom element (schema, serializer, preview, toolbar), and the layer's sanitize-html mirror and embed expansion.
|
||||
|
||||
The security core holds up:
|
||||
- Decode runs inside the save transaction for every written locale. The untouched skip compares against the value the form was served, so it cannot be used to store unsanitized bytes that are not already stored.
|
||||
- SanitizeHTMLReport returns the input unchanged only when the token comparison matches.
|
||||
- The layer expands embeds only after sanitizing, with a fixed `src` and a validated id.
|
||||
- The preview iframe uses an empty sandbox, and stored HTML is parsed only into inert documents.
|
||||
- Upload sniffing plus StoreMedia's extension list refuses SVG and HTML.
|
||||
|
||||
Problems found:
|
||||
- **Blocker (data loss):** Journal `Load` serves an empty editor for a locale whose `content` is blank but whose `content_html` holds the body. The public API's `FormatTranslation` path supports that state. Any edit in that locale silently replaces the hidden translated body.
|
||||
- **Upload route:** it skips the controller's create/update capability check and can leave orphaned public objects. The Journal storage key check is racy, so concurrent uploads can overwrite each other.
|
||||
- **Live admin DOM:** the editor's attribute preservation is a deny-list (`on*` only). `id`, `name`, `style` and `srcset` from stored HTML reach the live admin DOM, which contradicts the threat-model claim.
|
||||
- Other warnings: the markdown/HTML heuristic, the translatable Decode contract, the View site URL, and a phase gate that reports success without its data-loss regression tests.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: Blog Editor hides a locale's existing `content_html` when its `content` is blank; editing that locale overwrites the hidden body
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/blog_editor.go:204-209`
|
||||
**Issue:** `loadBlogHTML` returns the stored text as-is when it is blank:
|
||||
|
||||
```go
|
||||
if column == "" || LooksLikeHTML(text) || strings.TrimSpace(text) == "" {
|
||||
return text, nil
|
||||
}
|
||||
```
|
||||
|
||||
`FormatTranslation` (`classes/format_html.go:292-316`) supports translations that carry only `content_html` with blank or missing `content`, for example API writes of a translated body. For such a locale, cabana's `hydratePluginFields` and `servedFieldValue` serve `""`. The administrator therefore sees an empty editor although the site renders a full translated post. The untouched skip protects only a save that leaves that locale empty. As soon as the administrator types anything there, for example believing no translation exists, `decodeOne` writes the new value. `FormAfterUpdate` (`controllers/posts.go:241-280`) then stores it as that locale's `content_html`, and the existing translated body is lost with no notice. This breaks D-07's intent ("a markdown post opens with its `content_html`"). The same applies to a host row whose `content` is blank but whose `content_html` is not.
|
||||
**Fix:** When `htmlColumn` is set, serve the HTML column whenever `content` is blank, not only when it is markdown:
|
||||
|
||||
```go
|
||||
text := storedText(in.Value)
|
||||
column, _ := in.Options["htmlColumn"].(string)
|
||||
if column == "" || (LooksLikeHTML(text) && strings.TrimSpace(text) != "") {
|
||||
return text, nil
|
||||
}
|
||||
if in.Translated != nil {
|
||||
html, found, err := in.Translated(column)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if found && html != "" {
|
||||
return html, nil
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return text, nil
|
||||
}
|
||||
html, err := FormatHTML(text)
|
||||
...
|
||||
```
|
||||
|
||||
Add a test: a pl translation with only `content_html` is served as that HTML, an untouched save keeps it, and an edit writes both columns from the edited value.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: Field upload route skips the controller's create/update capability check
|
||||
|
||||
**File:** `modules/cabana/field_type_upload.go:25-36, 80-81`
|
||||
**Issue:** `fieldUpload` only runs `protect`, which checks the controller's required permissions. Unlike `create` and `update` (`http.go:831, 860`), it never calls `s.operationDeclared(w, r, cc, "create"|"update")`. Consequences:
|
||||
- With `{id}` 0, any administrator holding the controller permission can write files to the public media bucket through a controller whose YAML declares no create capability (no toolbar `create`).
|
||||
- The `id == 0` path never applies the controller's `FormExtendQuery` scope, so an administrator scoped to a subset of records can still upload.
|
||||
- With `{id} > 0`, a record the administrator may view but whose form declares no update can still receive uploads.
|
||||
|
||||
**Fix:** Mirror the save routes before reading the body:
|
||||
|
||||
```go
|
||||
op := "update"
|
||||
if id == 0 {
|
||||
op = "create"
|
||||
}
|
||||
if !s.operationDeclared(w, r, cc, op) {
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
Add `TestFieldUpload` cases for a controller without the `create` toolbar button (403 for id 0).
|
||||
|
||||
### WR-02: Stored upload objects are orphaned when the route refuses after Upload succeeded
|
||||
|
||||
**File:** `modules/cabana/field_type_upload.go:58-78, 83-99`
|
||||
**Issue:** The type's `Upload` writes the object to the bucket first. Only then does `run` check that no second multipart part follows (`mr.NextPart()`), and that check returns 422. In the `id != 0` case, a later transaction error rolls back the DB transaction but not the blob write. The element's 60 s timeout (`element.ts:553-556`) also drops a late successful answer. Each case leaves a public, unreferenced object under `/journal/<folder>/` with no cleanup.
|
||||
**Fix:** Before calling `Upload`, check that the stream has exactly one part. For example, buffer the part up to the cap, then read `NextPart` before calling `Upload`. Alternatively, add an optional `pact.FormFieldType.Discard(ctx, url)` that cabana calls when it refuses after a successful Upload. At minimum, document that a refused upload may leave the object behind.
|
||||
|
||||
### WR-03: Media key uniqueness is check-then-write; concurrent uploads of the same name overwrite each other
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/media_store.go:79-85, 122-151`
|
||||
**Issue:** `uniqueMediaKey` calls `bucket.Exists` and then `bucket.NewWriter(ctx, key, nil)` writes without a precondition. Two concurrent uploads of the same name to the same folder both see the key as free, and the second write replaces the first. One post then shows the other's image. Clipboard and drag-and-drop images commonly share names such as `image.png`, and `safeUploadName` maps every nameless file to `image.<ext>`, so collisions are likely. The plan-03 fix (T-16.2-16, "never overwrites") covers only the 999-suffix exhaustion case.
|
||||
**Fix:** Write with `&blob.WriterOptions{IfNotExist: true}` (available in gocloud.dev v0.46) and retry with the next suffix on a `gcerrors.FailedPrecondition`. Alternatively, add a short random component to editor upload names.
|
||||
|
||||
### WR-04: `LooksLikeHTML` treats markdown that starts with a tag as HTML; the first edit stores raw markdown as HTML in both columns
|
||||
|
||||
**File:** `../sm-journal-plugin/classes/blog_editor.go:112-115, 207`
|
||||
**Issue:** A markdown post whose first non-space character is `<` is served as raw markdown in the WYSIWYG editor, not as its `content_html`. Examples: a hero `<img …>`, a leading `<div>`, or `<!-- comment -->` followed by `## Heading` and `**bold**`. Any edit makes `preparePostWrite` (`controllers/posts.go:212-224`) copy that string into `content_html`. From then on the site renders literal `##` and `**` markers. This is silent corruption on first edit; it is listed as assumption A1, but no safeguard backs it.
|
||||
**Fix:** Do not decide by the first character alone. For example, treat the value as HTML only when `content_html` is empty, equals `content`, or equals `SanitizeHTML(content)`; otherwise serve `content_html`. Add a fixture with a markdown post that starts with `<img>`.
|
||||
|
||||
### WR-05: Attribute preservation lets `id`, `name`, `style`, `srcset` and other stored attributes into the live admin DOM
|
||||
|
||||
**File:** `../sm-journal-plugin/assets/src/blogeditor/extensions.ts:100-125, 133-173`
|
||||
**Issue:**
|
||||
- `captureAttributes` and `renderCaptured` drop only `on*` names. Every other attribute of a paragraph, heading, list, table, cell, image, figure or link, and every attribute of a `span`, is rendered by ProseMirror into the contenteditable view in the admin document. This happens on the form and also on the read-only preview screen, which renders the element with `disabled`.
|
||||
- Legacy imported HTML is never sanitized by an untouched save, so its attributes reach the admin DOM unchanged:
|
||||
- `<img src=… name="querySelector">` clobbers `document.querySelector` (Document named properties override built-ins) and breaks the SPA while the post is open.
|
||||
- `style="position:fixed;inset:0;z-index:99999;…"` can overlay the admin UI with attacker text (UI redress).
|
||||
- `srcset` and `background` load remote resources.
|
||||
- The policy also allows `id` globally, so any administrator can plant window-level named elements.
|
||||
- Plan 02's statement "Stored HTML never reaches the live admin DOM" is inaccurate for attributes.
|
||||
|
||||
**Fix:** Switch to an allow-list that mirrors the server policy: `class`, `style` (filtered to the four allowed properties, as in htmlGate), `dir`, `lang`, `title`, `itemscope`/`itemtype`/`itemprop`, `data-message-*`, plus element-specific attributes. Keep any other attribute only as an opaque value on the node, and serialize it in the inert document without rendering it in the view. One way is to render `htmlAttrs` in the view through `addNodeView` or a `renderHTML` that filters by a view-safe list, while the serializer path emits everything. At minimum, drop `id`, `name`, `form`, `srcset`, `contenteditable`, `popover*` and `style` from the view rendering.
|
||||
|
||||
### WR-06: Translatable Decode can receive non-string values despite the documented pact contract
|
||||
|
||||
**File:** `modules/cabana/field_type.go:476-486`, `modules/pact/capabilities.go` (FormFieldValue doc: "it is a string for a translatable type")
|
||||
**Issue:** `liftMLValues` validates only object-shaped submissions. A scalar submitted for a translatable plugin field (`null`, `3`, `true`) passes through `projectOperation` and reaches `ft.def.Decode` as `nil`/`float64`/`bool`. The published API promises a string, so a third-party type that does `in.Value.(string)` will panic, and the save fails as an unexpected 500. Journal guards against this; the framework contract does not.
|
||||
**Fix:** Enforce the contract in `decodePluginFields` before calling `decodeOne` for a translatable default value:
|
||||
|
||||
```go
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
case nil:
|
||||
value = ""
|
||||
default:
|
||||
return pluginDecodeResult{}, &ValidationError{Details: fieldDetail(field.Name, "The "+field.Name+" field must be text or an object of locale codes to text.")}
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, change the pact doc to "any JSON scalar".
|
||||
|
||||
### WR-07: "View site" links to the admin's own origin, which is wrong for the headless deployment
|
||||
|
||||
**File:** `admin/src/components/shell/AppShell.vue:25-27, 40-48`
|
||||
**Issue:** `siteURL = ${window.location.origin}/` assumes the public site and the admin share an origin. v1 is a headless backend with a separate Nuxt frontend. When the admin is served from an API or admin host, the link opens the Go binary's root (the SPA shell or a 404), not the site. WinterCMS's "View site" uses the configured application URL. The link attributes themselves are safe (`noopener noreferrer`, fixed origin).
|
||||
**Fix:** Serve the site URL from configuration, for example an `app.url` or `backend.site_url` key exposed in `/navigation` or `/me`. Hide the link when the key is unset, or fall back to the origin only when it is explicitly configured as the same host. Update the module README and config docs in the same change.
|
||||
|
||||
### WR-08: The phase gate prints "Phase 16.2 gate passed" (exit 0) without running the data-loss regression suites
|
||||
|
||||
**File:** `scripts/check-phase16.2.sh:521-544`
|
||||
**Issue:** The header calls the script fail-closed. Yet `--all` (the default mode) exits 0 and prints "Phase 16.2 gate passed" when Docker is unreachable. The Postgres stage holds `TestPostsAdminResaveKeepsHTML`, `TestPostsAdminSaveSanitizesHTML`, `TestCategoriesAdminContent`, `TestCategoriesAdminBlogEditor` and `TestFieldTypeSavePostgres`, which are this phase's D-03/D-10 acceptance tests. Plan 06 records that this host has no Docker, so the green gate was produced without them. A CI job or a later reader that trusts the exit code will treat the phase as verified.
|
||||
**Fix:** Make the default fail closed. Exit non-zero, or at least print "Phase 16.2 gate INCOMPLETE" and exit 3, when Postgres did not run, unless `PHASE162_ALLOW_NO_POSTGRES=1` is set. This inverts the current `PHASE162_REQUIRE_POSTGRES` opt-in.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: An embed placeholder that both policies accept may never expand
|
||||
|
||||
**File:** `../vue-journal-layer/app/utils/embeds.ts:41`
|
||||
**Issue:** `EMBED_PLACEHOLDER` matches only `data-embed` before `data-id`, double-quoted, with an empty body. A placeholder written in the Source tab as `<figure data-id="…" data-embed="youtube"></figure>` passes the Go policy and the layer sanitizer. sanitize-html keeps attribute order, so it renders as an inert empty figure with no error anywhere. The editor's Embed node accepts either order (`extensions.ts:327-341`).
|
||||
**Fix:** In the layer, match either attribute order (or parse the figure's attributes after sanitizing). Alternatively, have Decode normalize the placeholder's attribute order.
|
||||
|
||||
### IN-02: The expanded YouTube iframe has no `sandbox`, and the exported `/g` regex carries shared `lastIndex` state
|
||||
|
||||
**File:** `../vue-journal-layer/app/utils/embeds.ts:41, 56-60`
|
||||
**Issue:** The iframe is fixed to youtube-nocookie with a validated id, which is fine, but it has no `sandbox` attribute as defence in depth. `EMBED_PLACEHOLDER` is an exported global regex; a consumer that calls `.test()` or `.exec()` on it gets stateful results.
|
||||
**Fix:** Add `sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"`. Export a factory or a non-global pattern and build the `/g` regex inside `expandEmbeds`.
|
||||
|
||||
### IN-03: Plugin aliases can shadow list column types, and ListValue is then silently ignored
|
||||
|
||||
**File:** `modules/cabana/field_type.go:104-109`, `modules/cabana/list_schema.go:22-24, 242-251`
|
||||
**Issue:** `checkFieldTypeDef` refuses only `formFieldTypes`. A plugin alias `date`, `datetime` or `time` is accepted at boot, but a list column of that type resolves to the built-in column type and never calls the plugin's `ListValue`.
|
||||
**Fix:** Also refuse aliases in `listColumnTypes`.
|
||||
|
||||
### IN-04: `crypto.randomUUID()` throws outside a secure context
|
||||
|
||||
**File:** `../sm-journal-plugin/assets/src/blogeditor/element.ts:551`
|
||||
**Issue:** On an admin served over plain HTTP from a non-localhost host (a LAN dev box), `crypto.randomUUID` is undefined and every upload throws. The status line still says "uploading".
|
||||
**Fix:** Fall back to `crypto.getRandomValues` or a counter for the request id.
|
||||
|
||||
### IN-05: content_html translations are skipped silently when the application is unavailable
|
||||
|
||||
**File:** `../sm-journal-plugin/controllers/posts.go:263-270`
|
||||
**Issue:** `writeContentHTMLTranslations` returns nil when `c.application()` or the writer is nil, while cabana has already written the `content` translation through its own writer. The two columns then diverge with no error.
|
||||
**Fix:** Return an error, which aborts the transaction, when a non-default write exists but no writer can be resolved.
|
||||
|
||||
### IN-06: Untouched detection cannot be byte-exact for legacy content with invalid UTF-8
|
||||
|
||||
**File:** `modules/cabana/field_type.go:641-651`
|
||||
**Issue:** JSON transport replaces invalid UTF-8 with U+FFFD, so the echoed value never equals the served value under `sameJSON`. An untouched save then rewrites such a row with replacement characters, which breaks D-03 for those rows.
|
||||
**Fix:** Document the limitation, or compare after normalizing the served value with `strings.ToValidUTF8(s, "<22>")`.
|
||||
|
||||
### IN-07: "Edit as HTML" selects the first identical block
|
||||
|
||||
**File:** `../sm-journal-plugin/assets/src/blogeditor/element.ts:524-532`
|
||||
**Issue:** `textarea.value.indexOf(html)` selects the first occurrence when two raw HTML blocks are identical, so the wrong block is highlighted.
|
||||
**Fix:** Pass the node position and map it to the block's source offset through `#blocks`.
|
||||
|
||||
### IN-08: Plugin-typed fields cannot declare `readOnly`/`hidden`/`disabled` in YAML
|
||||
|
||||
**File:** `modules/cabana/field_type.go:33-36`
|
||||
**Issue:** `pluginFieldKeys` excludes general keys such as `readOnly` (and `checkFieldTypeDef` forbids options with those names), but `PluginField.vue:50` honours `field.readOnly`. A plugin field can therefore never be made read-only from fields.yaml.
|
||||
**Fix:** Allow the general keys the SPA and server already understand (`readOnly`, `hidden`, `placeholder`, `cssClass`) for plugin fields, or document the restriction in `docs/backend/field-types.md`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-10T14:29:09Z_
|
||||
_Reviewer: Claude (msd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
status: testing
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
source: [16.2-VERIFICATION.md]
|
||||
started: 2026-10-10T14:54:46Z
|
||||
updated: 2026-10-10T14:54:46Z
|
||||
---
|
||||
|
||||
## Current Test
|
||||
|
||||
number: 1
|
||||
name: Postgres suites on a Docker host
|
||||
expected: |
|
||||
bash scripts/check-phase16.2.sh postgres passes, which closes WINDOWS 11, 12, 14-16, 20 and 21.
|
||||
awaiting: user response
|
||||
|
||||
## Tests
|
||||
|
||||
### 1. Postgres suites on a Docker host
|
||||
expected: bash scripts/check-phase16.2.sh postgres passes. This is the end-to-end proof for the CR-01 and WR-04 Load changes and for the category save.
|
||||
result: [pending]
|
||||
|
||||
### 2. D-19 category content on UAT (WINDOWS 18)
|
||||
expected: Change one word in a category body and save. No removal notice appears, and the site shows the change.
|
||||
result: [pending]
|
||||
|
||||
### 3. Untouched re-save after the next deploy (with the CR-01/WR-04 fixes)
|
||||
expected: Re-save posts 1 and 64 untouched. The md5 SELECT is unchanged (1: a026264105f900175b9770e637e5ec5c both columns; 64: 0e4be845390b1656871513957d471a7c:e785e14e80f6400534e16b4769749d3c) and updated_at moves.
|
||||
result: [pending]
|
||||
|
||||
### 4. Preview pane in a real browser (quick 261010-iwu)
|
||||
expected: 50/50 split; the pane wraps below the editor when the element is narrower than about 720px; a <script> in a raw HTML block stays inert; YouTube thumbnails load; relative /storage images resolve.
|
||||
result: [pending]
|
||||
|
||||
### 5. Accept the sandboxed preview iframe as an exception to plan 02's "no iframe in the admin"
|
||||
expected: The human accepts the empty-sandbox srcdoc preview, or asks for a change.
|
||||
result: [pending]
|
||||
|
||||
## Summary
|
||||
|
||||
total: 5
|
||||
passed: 0
|
||||
issues: 0
|
||||
pending: 5
|
||||
skipped: 0
|
||||
blocked: 0
|
||||
|
||||
## Gaps
|
||||
@@ -4,8 +4,8 @@ slug: "plugin-provided-field-types-and-the-blog-editor-formwidget"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
nyquist_compliant: true
|
||||
wave_0_complete: true
|
||||
created: "2026-10-09"
|
||||
revised: "2026-10-10"
|
||||
---
|
||||
@@ -41,39 +41,46 @@ revised: "2026-10-10"
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
|
||||
| TBD | — | — | D-11 | — | Alias collision fails boot | unit | `go test -short ./modules/cabana -run TestFieldTypeRegistry` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | D-12.1 | — | Unknown plugin key fails boot | unit | `go test -short ./modules/cabana -run TestFieldTypeOptions` | ❌ W0 | ⬜ pending |
|
||||
| TBD | — | — | D-12.2 / D-10 | XSS | 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; 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 |
|
||||
| 16.2-01-T2, 16.2-06-T2 | 01, 06 | 1, 4 | D-11 | T-16.2 boot | Alias pattern, core and cross-plugin clash (both IDs named), element prefix and reserved tag, missing Decode, missing AdminFS, asset path and extension fail boot | unit | `go test -count=1 -run '^TestFieldTypeRegistry$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-06-T2 | 01, 06 | 1, 4 | D-12.1 | — | Option key pattern, field-key clash and repeats fail boot; an undeclared fields.yaml key fails naming field and key | unit | `go test -count=1 -run '^TestFieldTypeOptions$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-06-T2 | 01, 06 | 1, 4 | D-12 | — | General keys accepted, core type keys refused, option values of every YAML kind decoded, CheckOptions error surfaced; CompileForm core-only; settings and relation forms refuse plugin types | unit | `go test -count=1 -run '^TestFieldTypeCompile$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-12.2 / D-10 | XSS | FormFieldError on the default or a non-default locale → 422 on the field, no write, no notices; other errors and a non-string translatable value → opaque 500 | unit | `go test -count=1 -run '^TestFieldTypeDecode$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-10 | — | Notices → meta.notices with field and locale, localized through phrasebook (Count plural, Params), ≤ 10 per field and locale, ≤ 500 runes; no notices key when none | unit | `go test -count=1 -run '^TestFieldTypeNotices$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-03-T1, 16.2-06-T2 | 03, 06 | 2, 4 | D-10 / D-02 | XSS | Journal Decode strips per locale and reports; clean HTML byte-identical with no notice; report empty exactly when nothing changed; notices only when stripped | unit | `go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorDecode\|TestBlogEditorNotices\|TestSanitizeHTMLReport)$' ./classes/` | ✅ | ✅ green |
|
||||
| 16.2-03-T3, 16.2-06-T2 | 03, 06 | 2, 4 | D-05 | XSS | Editor corpus passes SanitizeHTML byte for byte; the four editor rules admit nothing else (other providers, malformed ids, data attributes elsewhere, iframe stripped) | unit | `go -C ../sm-journal-plugin test -count=1 -run '^TestEditorOutputPassesPolicy$' ./classes/` | ✅ | ✅ green |
|
||||
| 16.2-01-T4, 16.2-06-T3 | 01, 06 | 1, 4 | D-10 | XSS | Save notices render as literal text under the field (aria-describedby) and in the toast; stored value reloaded into the element | vitest | `npm --prefix admin test -- tests/form/FormField.test.ts tests/form/formState.test.ts tests/form/PluginField.test.ts` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-06-T1 | 01, 06 | 1, 4 | D-03 | — | Untouched value writes and reports nothing; one changed locale writes only that locale; canonical JSON comparison; create never skips | unit | `go test -count=1 -run '^TestFieldTypeUntouched$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-12.3 | — | Load per locale; nil Load keeps the stored value; Load errors (FormFieldError included) are opaque 500 | unit | `go test -count=1 -run '^TestFieldTypeLoad$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-12.4 | — | List column uses ListValue with the request ctx; nil ListValue keeps the raw value | unit | `go test -count=1 -run '^TestFieldTypeListValue$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-14 | — | FieldWritesFromContext copies; false outside a save; Before and After hooks of create and update see the same writes | unit | `go test -count=1 -run '^TestFieldTypeWrites$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-06-T2 | 01, 06 | 1, 4 | D-13 | — | GET /field-types sorted, versioned asset URLs under the admin prefix, [] lists, 401 without a backend session | unit | `go test -count=1 -run '^TestFieldTypesRoute$' ./modules/cabana` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-01-T4 | 01 | 1 | D-13 / D-15 | — | Schema/OpenAPI/TS in sync; admin dist current | unit + script | `bash scripts/check-admin-openapi.sh --check && bash scripts/check-admin-dist.sh` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-01-T4, 16.2-06-T1, 16.2-06-T3 | 01, 06 | 1, 4 | D-13 / D-03 | — | PluginField binds value, takes summer-change, never re-sets an echoed value; fieldTypes loads each script once, global styles stay on, nothing rejects; registry renders PluginField / MLPluginField | vitest | `npm --prefix admin test -- tests/form/PluginField.test.ts tests/app/fieldTypes.test.ts tests/form/registry.test.ts tests/list/ListView.test.ts` | ✅ | ✅ green |
|
||||
| 16.2-01-T3, 16.2-03-T2, 16.2-06-T2 | 01, 03, 06 | 1, 2, 4 | D-18 | upload | Upload 201, 403, 401, 404 (core field, no Upload, unknown field, context refusal, unknown controller), 413, 422 (FormFieldError, missing/misnamed/extra part, not multipart), opaque 500; Journal sniffing refuses disguised files and svg; safe names; StoreMedia never overwrites | unit | `go test -count=1 -run '^TestFieldUpload$' ./modules/cabana && go -C ../sm-journal-plugin test -count=1 -run '^(TestBlogEditorUpload\|TestStoreMedia)$' ./classes/` | ✅ | ✅ green |
|
||||
| 16.2-03-T1, 16.2-06-T1 | 03, 06 | 2, 4 | D-07 | — | HTML served verbatim (leading white space, comment); markdown served as content_html, empty falls back to FormatHTML; nil and empty pointers give "" | unit | `go -C ../sm-journal-plugin test -count=1 -run '^TestBlogEditorLoad$' ./classes/` | ✅ | ✅ green |
|
||||
| 16.2-03-T1, 16.2-06-T2 | 03, 06 | 2, 4 | D-02 / D-14 | — | Posts hooks: default write copied to content_html, non-default writes through the TranslationWriter, nothing for untouched content or without a writer | unit | `go -C ../sm-journal-plugin test -count=1 -run '^TestPostsEditorHooks$' ./controllers/` | ✅ | ✅ green |
|
||||
| 16.2-02-T2, 16.2-02-T3, 16.2-06-T1, 16.2-06-T3 | 02, 06 | 1, 4 | D-03 / D-04 | XSS | Element returns an untouched value byte for byte through Source, tabs, preview and reattach; raw HTML cards are escaped text; every raw tag rule; figures; links limited to http, https, mailto, relative; block reuse after edit, deletion and insertion | vitest | `npm --prefix ../sm-journal-plugin test` | ✅ | ✅ green |
|
||||
| 16.2-02-T3 | 02 | 1 | D-05 | — | Committed editor corpus equals the editor's current output | vitest | `npm --prefix ../sm-journal-plugin test -- tests/blogeditor/corpus.test.ts` | ✅ | ✅ green |
|
||||
| 16.2-03-T1, 16.2-06-T1 | 03, 06 | 2, 4 | todo resave / D-03 | — | Untouched GET-then-PUT of an HTML and a markdown post byte-identical with no notices and no translated content added; a clean edited save stores equal content and content_html per locale | integration (Postgres) | `go -C ../sm-journal-plugin test -count=1 -run TestPostsAdminResaveKeepsHTML .` | ✅ | ⚠️ Docker host (manual) |
|
||||
| 16.2-03-T1 | 03 | 2 | 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 -count=1 -run TestPostsAdminSaveSanitizesHTML .` | ✅ | ⚠️ Docker host (manual) |
|
||||
| 16.2-03-T2, 16.2-06-T2 | 03, 06 | 2, 4 | D-19 / D-10 | XSS | Category content: untouched saves keep imported markup and NULL, clean edit as sent, unsafe edit stripped with notices (no 422), blank to NULL, editor upload under journal/categories | integration (Postgres) | `go -C ../sm-journal-plugin test -count=1 -run '^(TestCategoriesAdminContent\|TestCategoriesAdminBlogEditor)$' .` | ✅ | ⚠️ Docker host (manual) |
|
||||
| 16.2-01-T3, 16.2-06-T2 | 01, 06 | 1, 4 | D-12 / D-18 | upload | Real-database create, show, untouched and changed update, refused update rolls back, upload in and out of the FormExtendQuery scope | integration (Postgres) | `go test -count=1 -run '^TestFieldTypeSavePostgres$' ./modules/cabana` | ✅ | ⚠️ Docker host (manual) |
|
||||
| 16.2-04-T1, 16.2-06-T3 | 04, 06 | 2, 4 | D-17 / D-05 | iframe | Embed allowlist and id regex; sanitize first, expand last at each sink, never in payload transforms; stored iframe stripped; corpus safe under the mirror; Go negatives stripped the same way; 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` | ✅ | ✅ green |
|
||||
| 16.2-01-T2, 16.2-06-T3 | 01, 06 | 1, 4 | docs | — | README/docs identifiers exist; plugin dist current | unit + script | `go test -count=1 ./cmd/summer -run TestDocsTree && bash ../sm-journal-plugin/scripts/check-dist.sh` | ✅ | ✅ green |
|
||||
| 16.2-06-T3 | 06 | 4 | all | T-16.2-29 | Phase gate: every runnable stage fail-closed, detectors self-tested | script | `bash scripts/check-phase16.2.sh --self-test && bash scripts/check-phase16.2.sh --all` | ✅ | ✅ green |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky or manual (Docker host)*
|
||||
|
||||
---
|
||||
|
||||
## 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`
|
||||
- [x] `../sm-journal-plugin/package.json`, `package-lock.json`, `vite.config.ts`, vitest + happy-dom setup, `scripts/check-dist.sh` (plan 02)
|
||||
- [x] `../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)
|
||||
- [x] `../sm-journal-plugin/classes/editor_policy_test.go` (plan 03, extended in plan 06)
|
||||
- [x] `modules/cabana/field_type_test.go` — registry, options, compile, decode, notices, skip, load, list, writes, routes, upload (plan 06)
|
||||
- [x] `admin/tests/form/PluginField.test.ts` — stub custom element (plan 01, extended in plan 06)
|
||||
- [x] `../vue-journal-layer/tests/embeds.test.ts` (plan 04, extended in plan 06)
|
||||
|
||||
---
|
||||
|
||||
@@ -81,7 +88,7 @@ revised: "2026-10-10"
|
||||
|
||||
| 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 |
|
||||
| Testcontainers Postgres suites | todo resave, D-03, D-10, D-12, D-18, D-19 | No Docker on the dev machine | On a Docker host: `bash scripts/check-phase16.2.sh postgres` (cabana `./modules/cabana` incl. TestFieldTypeSavePostgres and TestListRelationSearchPostgres; sm-journal-plugin `./...` incl. TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor, TestJournalSVGEmbed) |
|
||||
| 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) |
|
||||
|
||||
@@ -89,11 +96,11 @@ revised: "2026-10-10"
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 120s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [x] Wave 0 covers all MISSING references
|
||||
- [x] No watch-mode flags
|
||||
- [x] Feedback latency < 120s (the gate's runnable stages; the Docker suites are the manual row)
|
||||
- [x] nyquist_compliant set to true in the frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
**Approval:** automated map complete (plan 06, 2026-10-10); the Docker-host suites and the UAT rows above stay manual
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
phase: 16.2-plugin-provided-field-types-and-the-blog-editor-formwidget
|
||||
verified: 2026-10-10T17:05:00Z
|
||||
status: human_needed
|
||||
score: 13/14 must-haves verified
|
||||
covered_files:
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-01-SUMMARY.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-03-SUMMARY.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-04-SUMMARY.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-05-SUMMARY.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-PLAN.md
|
||||
- .planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-06-SUMMARY.md
|
||||
- admin/src/app/fieldTypes.ts
|
||||
- admin/src/components/form/fields/MLPluginField.vue
|
||||
- admin/src/components/form/fields/PluginField.vue
|
||||
- admin/src/components/form/registry.ts
|
||||
- admin/src/views/FormView.vue
|
||||
- docs/backend/field-types.md
|
||||
- modules/cabana/crud.go
|
||||
- modules/cabana/field_type.go
|
||||
- modules/cabana/field_type_upload.go
|
||||
- modules/cabana/http.go
|
||||
- modules/cabana/relation_field.go
|
||||
- modules/cabana/tx_context.go
|
||||
- modules/pact/capabilities.go
|
||||
- scripts/check-phase16.2.sh
|
||||
covered_digest: "v2:sha256:9eb467f60a452579c8faedf8900a27909d5234623d4f6760fef9f5691bbf6d75"
|
||||
behavior_unverified: 1
|
||||
overrides_applied: 0
|
||||
behavior_unverified_items:
|
||||
- truth: "D-19 (amended): Journal category content uses blogeditor and is stripped and reported like post content; the 422 CheckHTML gate is retired"
|
||||
test: "On a Docker host run `bash scripts/check-phase16.2.sh postgres` (TestCategoriesAdminContent, TestCategoriesAdminBlogEditor); on UAT change one word of a category's content in the Blog Editor and save"
|
||||
expected: "Both Postgres tests pass; on UAT the save succeeds with no removal notice and the category page on the site shows the change"
|
||||
why_human: "The code is present and wired (fields.yaml type blogeditor, prepareCategoryWrite no longer refuses, the shared Decode is unit-tested), but the end-to-end category save only runs in the Docker-only harness and the UAT category step was never performed (WINDOWS entry 18)"
|
||||
human_verification:
|
||||
- test: "On a host with Docker, run `bash scripts/check-phase16.2.sh postgres` from summercms.go"
|
||||
expected: "TestFieldTypeSavePostgres, TestListRelationSearchPostgres, TestPostsFormCompiles, TestPostsAdminCreateSmoke, TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML, TestCategoriesAdminContent, TestCategoriesAdminBlogEditor and TestJournalSVGEmbed all pass (closes WINDOWS 11, 12, 14, 15, 16, 20, 21)"
|
||||
why_human: "No Docker daemon on this host; the suites compile under go vet but have never run. They are the only end-to-end proof of the CR-01 and WR-04 Load changes, which landed after the UAT acceptance"
|
||||
- test: "D-19 on UAT: change one word of a category's content in the Blog Editor and save"
|
||||
expected: "The save succeeds with no removal notice; the category page shows the change"
|
||||
why_human: "WINDOWS entry 18; not among the four UAT steps the human ran"
|
||||
- test: "After the next deploy (v0.1.19 with the review fixes), save post 1 and post 64 untouched again and re-run the read-only md5 SELECT"
|
||||
expected: "md5(content), md5(content_html) and the post 64 en translation md5 equal the 16.2-05 snapshot; updated_at moved"
|
||||
why_human: "The UAT D-03 evidence was taken on the v0.1.18 binary. The review fixes changed Load (CR-01: blank content served as content_html; WR-04: '<'-prefixed values compared with content_html). By reading the code the served value for posts 1 and 64 is unchanged, but the deployed proof predates the change"
|
||||
- test: "Blog Editor preview pane in a real browser (quick 261010-iwu): toggle Preview in light and dark mode, narrow the window, put a <script> in a raw HTML block, add a YouTube embed and a relative /storage image"
|
||||
expected: "50/50 split that wraps below about 720px; the script does nothing in the pane; the embed shows a thumbnail card; the relative image resolves"
|
||||
why_human: "happy-dom cannot enforce iframe sandboxing, CSP or layout"
|
||||
- test: "Judgment-tier prohibition (plan 02): 'The element renders no iframe and no live raw HTML in the admin'. Confirm the preview pane folded in from quick 261010-iwu is an accepted exception"
|
||||
expected: "Human accepts that the sandboxed srcdoc iframe (empty sandbox, CSP meta, active content dropped, no src) does not violate the intent, or asks for a change"
|
||||
why_human: "The preview pane renders the post HTML inside an iframe in the admin, which reads against the literal prohibition text; the LLM judgment is that its intent (no YouTube iframe, no script-capable raw HTML in the admin DOM) still holds"
|
||||
---
|
||||
|
||||
# Phase 16.2: Plugin-provided field types and the Blog Editor formwidget Verification Report
|
||||
|
||||
**Phase 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.
|
||||
**Verified:** 2026-10-10T17:05:00Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No, initial verification
|
||||
|
||||
ROADMAP lists no success criteria and REQUIREMENTS.md has none for this phase, so the must-haves are the six plans' frontmatter truths, keyed to CONTEXT decisions D-01 to D-19. They are merged into 14 truths below.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | D-11: a plugin registers a value-bearing field type under a short alias; duplicate aliases across plugins and clashes with core types fail boot | ✓ VERIFIED | `pact.HasFormFieldTypes` (capabilities.go:491). `compileFieldTypes` (field_type.go:151) checks the alias pattern and core-type clash, and names both owners on a cross-plugin clash. It is called from `Activate` (http.go:99) and passed to `compileRegistryWithTypes`. `TestFieldTypeRegistry` and `TestFieldTypeCompile` pass in the gate |
|
||||
| 2 | D-12.1: plugin fields accept the general keys plus the type's Options; other keys and CheckOptions errors fail boot | ✓ VERIFIED | `pluginFieldKeys` and `fieldKeyAllowed`, plus `compilePluginField` running CheckOptions. `TestFieldTypeOptions` passes |
|
||||
| 3 | D-12.2 and D-10 (framework): Decode runs inside the save transaction per locale; FormFieldError becomes 422 with no write; other errors become an opaque 500; notices become meta.notices only when present | ✓ VERIFIED | crud.go:609-636: lift, then loadRecord, then `decodePluginFields`, then Fill, all inside `s.transaction`. crud.go:733 calls `localizeNotices`, and `RecordMeta.Notices` is `json:"notices,omitempty"`. WR-06 adds a 422 for a non-string translatable value. `TestFieldTypeDecode` and `TestFieldTypeNotices` pass |
|
||||
| 4 | D-12.3 and D-12.4: Show and the save response serve plugin fields through Load (per locale); list columns use ListValue | ✓ VERIFIED | `hydratePluginFields` is called in Show (crud.go:447) and in save (crud.go:728). `projectRowCtx` calls `ListValue` (http.go:1114). `TestFieldTypeLoad` and `TestFieldTypeListValue` pass |
|
||||
| 5 | D-03 (server half): on update, a value equal to the served value is neither decoded nor written | ✓ VERIFIED | `decodeOne` compares with `servedFieldValue` (Load over the loaded record) through `sameJSON` and skips. An untouched default-locale value is deleted from Projected. The behavioural test `TestFieldTypeUntouched` passes |
|
||||
| 6 | D-14 and D-06: translatable plugin types use cabana's ML lift, translation writer and hydrate; Form hooks read writes through `FieldWritesFromContext` | ✓ VERIFIED | Translations are decoded in place on the `mlValue` items that `applyMLTranslations` writes. `withFieldWrites` (crud.go:636) feeds `FieldWritesFromContext`. `TestFieldTypeWrites` passes |
|
||||
| 7 | D-13 and D-15: the SPA loads field-type assets globally after login; PluginField/MLPluginField bind `value` and `summer-change`; the schema carries plugin fields generically; OpenAPI, TS types, dist and docs are regenerated | ✓ VERIFIED | `loadFieldTypes` is called in main.ts:26 and LoginView.vue:33. `registry.ts` routes any field with `element` to PluginField or MLPluginField. PluginField only re-sets `value` when it differs (echo guard). admin.json and schema.d.ts carry `element`, `translatable` and `typeOptions`. Gate docs stage: check-admin-openapi, check-admin-dist and TestDocsTree pass. Vitest 1218/1218 |
|
||||
| 8 | D-18: cabana-owned upload route (guard, X-Requested-With, permissions, declared op, record scope, body cap) answers 201 {data:{url}}; Journal Upload accepts only sniffed jpg/jpeg/png/gif/webp up to 10240 KiB | ✓ VERIFIED | Route at http.go:360, wrapped in `requireAjax`. `fieldUpload` calls `protect` and `operationDeclared` (WR-01), reads the whole body before Upload (WR-02) and scopes the record through `loadRecord`. Journal `uploadBlogImage` sniffs the bytes. `StoreMedia` enforces the extension allowlist, so a PNG named `.svg` is refused there, and caps the size. `TestFieldUpload`, `TestBlogEditorUpload` and `TestStoreMedia` pass. UAT edit-ok shows an image under journal/posts |
|
||||
| 9 | D-01, D-04, D-16: Blog Editor is a TipTap WYSIWYG custom element `golem15-journal-blogeditor` with a source tab, a toolbar (headings, marks, lists, links, blockquote, tables, images, embeds, raw HTML), unknown blocks shown as raw cards, and a committed and embedded bundle with a dist drift gate | ✓ VERIFIED | assets/src/blogeditor/*.ts and toolbar.ts provide H2-H4, bold, italic, lists, link, blockquote, table ops, image, embed and rawHtml. extensions.ts lists the raw block tags (footer, section, aside, details…). admin.go:22 embeds the dist. `check-dist.sh` (diff -r) passes in the gate. Editor vitest 232/232 |
|
||||
| 10 | D-03 (editor and Journal halves) and todo journal-imported-html-admin-resave: an untouched value round-trips byte-identically; Load serves HTML content verbatim and markdown as content_html; an untouched save leaves content and content_html byte-identical | ✓ VERIFIED | The element's `value` getter returns the stored string in clean mode and uses `serializeDocument` (top-level block reuse) only after an edit. It never calls `getHTML`. `loadBlogHTML` implements D-07 plus the CR-01 and WR-04 refinements. element.test.ts, `TestBlogEditorLoad` and `TestFieldTypeUntouched` pass. UAT: post 1 (HTML) and post 64 (markdown) kept the same md5 after untouched saves, and updated_at moved (16.2-05 SUMMARY, follow-up section). The Postgres `TestPostsAdminResaveKeepsHTML` did not run (human item 1) |
|
||||
| 11 | D-02, D-07, D-08, D-09: post content is `mlblogeditor` (excerpt unchanged); an admin save stores the same sanitized HTML in content and content_html per locale without goldmark; the public write API keeps markdown and FormatHTML | ✓ VERIFIED | models/post/fields.yaml: `type: mlblogeditor`, `htmlColumn: content_html`. `preparePostWrite` copies the default-locale write into `ContentHTML`. `writeContentHTMLTranslations` writes non-default locales through `WriteTranslated`. The only remaining FormatHTML callers are the API helpers, the import console and Load's markdown fallback. No migration or column was added. `TestPostsEditorHooks` passes. UAT edit-ok: content = content_html |
|
||||
| 12 | D-10 (amended) and D-05: Decode stores `SanitizeHTMLReport` output and reports removals as notices, never refusing on markup; the policy gains exactly four editor-output rules; every corpus entry passes byte for byte | ✓ VERIFIED | `decodeBlogHTML` and `removalNotices` are in blog_editor.go. Commit 2384a61 adds only `figure data-embed ^youtube$`, `figure data-id ^[A-Za-z0-9_-]{11}$`, `img alt` and `ol start ^[0-9]+$`. `TestEditorOutputPassesPolicy`, `TestSanitizeHTMLReport`, `TestBlogEditorDecode` and `TestBlogEditorNotices` pass. On UAT a style element and an onclick attribute were stripped and the notice was confirmed by the human |
|
||||
| 13 | D-17 and D-05 (layer): `expandEmbeds(sanitizeJournalHtml(html))` at the PostBody and CategoryHeader sinks, with payload transforms only sanitizing; htmlGate mirrors the four rules; corpus copies identical; v0.2.0 tagged and pinned | ✓ VERIFIED | PostBody.vue:13 and CategoryHeader.vue:11 expand last. postView.ts and CategoryPage.vue only sanitize. `expandEmbeds` builds the iframe only from an allowlisted provider, a validated id and a fixed host. `cmp` of the two corpus files is identical. Tag v0.2.0 exists on origin (759e684). The app's package.json pins `#v0.2.0`. Layer 144/144. UAT site-ok found 1 youtube-nocookie iframe |
|
||||
| 14 | D-19 (amended): category content uses blogeditor and is stripped and reported; the 422 CheckHTML gate is retired | ⚠️ PRESENT_BEHAVIOR_UNVERIFIED | models/category/fields.yaml has `type: blogeditor`. `prepareCategoryWrite` only nulls blank content, and no CheckHTML caller remains. The end-to-end category save is covered only by the Docker-only `TestCategoriesAdminContent` and `TestCategoriesAdminBlogEditor` (not run), and the UAT category step was never done |
|
||||
|
||||
**Score:** 13/14 truths verified (1 present, behavior-unverified)
|
||||
|
||||
Release truths from plan 05 are verified separately. Tag v0.1.18 is an annotated tag object 04dc5bd on origin, pointing at commit f1d8a75. The UAT binary sha256 matched the local build, and the on-server smoke passed on 98 URLs. These rest on SUMMARY records; the tag is confirmed directly with `git ls-remote`.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `modules/pact/capabilities.go` | FormFieldType family and HasFormFieldTypes | ✓ VERIFIED | All types are present, with Decode, Load, ListValue, Upload and CheckOptions |
|
||||
| `modules/cabana/field_type.go` | registry, compile and the decode/skip/load/list runner | ✓ VERIFIED | 877 lines, substantive, wired from http.go and crud.go |
|
||||
| `modules/cabana/field_type_upload.go` | upload route | ✓ VERIFIED | Mounted at http.go:360 |
|
||||
| `modules/cabana/tx_context.go` | FieldWritesFromContext | ✓ VERIFIED | Consumed by Journal posts.go |
|
||||
| `modules/cabana/relation_field.go` | `notices,omitempty` | ✓ VERIFIED | relation_field.go:99 |
|
||||
| `admin/src/components/form/fields/PluginField.vue` | element host | ✓ VERIFIED | Handles summer-change and summer-upload; passes no token or function to the element |
|
||||
| `admin/src/app/fieldTypes.ts` | global loader | ✓ VERIFIED | Wired in main.ts and LoginView.vue |
|
||||
| `docs/backend/field-types.md` | guide | ✓ VERIFIED | Names HasFormFieldTypes; no consuming application is named |
|
||||
| `../sm-journal-plugin/classes/blog_editor.go` | BlogEditorTypes | ✓ VERIFIED | Registered through field_types.go |
|
||||
| `../sm-journal-plugin/classes/format_html.go` | SanitizeHTMLReport and the four rules | ✓ VERIFIED | |
|
||||
| `../sm-journal-plugin/classes/media_store.go` | StoreMedia | ✓ VERIFIED | Shared with controllers/api/media.go:39 |
|
||||
| `../sm-journal-plugin/assets/dist/blogeditor.js` | committed bundle | ✓ VERIFIED | Embedded at admin.go:22; check-dist passes |
|
||||
| `../sm-journal-plugin/tests/blogeditor/fixtures/editor-corpus.json` | corpus | ✓ VERIFIED | Byte-identical to the layer copy |
|
||||
| `../vue-journal-layer/app/utils/embeds.ts` | expandEmbeds | ✓ VERIFIED | |
|
||||
| `../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` | `#v0.2.0` pin | ✓ VERIFIED | |
|
||||
| `modules/cabana/field_type_postgres_test.go` | Postgres test | ✓ EXISTS (not run) | Compiles under go vet; needs Docker |
|
||||
| `scripts/check-phase16.2.sh` | phase gate | ✓ VERIFIED | Re-run in this verification, exit 0 |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| http.go Activate | compileRegistryWithTypes | `compileFieldTypes(plugins)` passed as a parameter | ✓ WIRED |
|
||||
| crud.go save | Decode and Load | `decodePluginFields` between loadRecord and Fill, in the transaction | ✓ WIRED |
|
||||
| crud.go save | meta.notices | `localizeNotices` after commit | ✓ WIRED |
|
||||
| FormView.vue save | FormField notices and toast | `noticeLines(result.data.meta.notices)` | ✓ WIRED |
|
||||
| main.ts / LoginView.vue | GET /field-types | `loadFieldTypes` | ✓ WIRED |
|
||||
| PluginField.vue | upload route | `summer-upload` handled by `api.POST` | ✓ WIRED |
|
||||
| blog_editor.go decodeBlogHTML | SanitizeHTMLReport | direct call | ✓ WIRED |
|
||||
| posts.go preparePostWrite | FieldWritesFromContext | default-locale write sets ContentHTML | ✓ WIRED |
|
||||
| posts.go FormAfterUpdate | TranslationWriter.WriteTranslated | content_html per written locale | ✓ WIRED |
|
||||
| api/media.go UploadMedia | StoreMedia | media.go:39 | ✓ WIRED |
|
||||
| PostBody.vue / CategoryHeader.vue | expandEmbeds | `expandEmbeds(sanitizeJournalHtml(...))` | ✓ WIRED |
|
||||
| admin.go adminFS | assets/dist/blogeditor.js | `//go:embed` | ✓ WIRED |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| PluginField element `value` | form value | Show response from `hydratePluginFields`, which runs Load over the record loaded in the transaction | yes | ✓ FLOWING |
|
||||
| content_html (admin path) | sanitized HTML | Decode output, then FieldWrite, then the hooks | yes (UAT edit-ok shows content = content_html) | ✓ FLOWING |
|
||||
| Site post body | content_html | Journal API, then postView sanitize, then the PostBody sink expandEmbeds | yes (UAT site-ok found the iframe) | ✓ FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Full phase gate | `bash scripts/check-phase16.2.sh --all` (TMPDIR in the scratchpad) | self-test, go (19 framework + 29 Journal tests), admin 1218, editor 232, layer 144 plus corpus cmp, and docs all PASS; postgres NOT RUN; "Phase 16.2 gate passed", exit 0 | ✓ PASS |
|
||||
| go vet | `go vet ./...` in summercms.go and sm-journal-plugin | clean | ✓ PASS |
|
||||
| Release tag on origin | `git ls-remote --tags origin v0.1.18` | 04dc5bd, which points at f1d8a75 | ✓ PASS |
|
||||
| Layer tag on origin | `git ls-remote --tags origin` (vue-journal-layer) | v0.2.0, which points at 759e684 | ✓ PASS |
|
||||
| Postgres suites | `check-phase16.2.sh postgres` | no Docker daemon | ? SKIP (human item 1) |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
Step 7c: no `scripts/*/tests/probe-*.sh` is declared or present for this phase. The phase gate above serves as the runnable check.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
REQUIREMENTS.md lists no IDs for this phase. The CONTEXT decisions the plans cite:
|
||||
|
||||
| Decision | Status | Evidence |
|
||||
|----------|--------|----------|
|
||||
| D-01, D-04, D-16 | ✓ SATISFIED | Truth 9 |
|
||||
| D-02, D-07, D-08, D-09 | ✓ SATISFIED | Truth 11 |
|
||||
| D-03 | ✓ SATISFIED | Truths 5 and 10 (unit tests plus UAT; Postgres run pending) |
|
||||
| D-05, D-10 | ✓ SATISFIED | Truths 3, 12 and 13 |
|
||||
| D-06, D-14 | ✓ SATISFIED | Truth 6 |
|
||||
| D-11, D-12, D-13, D-15 | ✓ SATISFIED | Truths 1 to 4 and 7 |
|
||||
| D-17 | ✓ SATISFIED | Truth 13 |
|
||||
| D-18 | ✓ SATISFIED | Truth 8 |
|
||||
| D-19 | ? NEEDS HUMAN | Truth 14 |
|
||||
|
||||
### Prohibitions (judgment tier, non-authoritative LLM verdicts)
|
||||
|
||||
| Prohibition | Verdict |
|
||||
|-------------|---------|
|
||||
| No package-level mutable field type registry in cabana | Holds. The only package-level vars are regexes and a static key set. The registry is built per Activate |
|
||||
| The element never receives a token, cookie, function or Vue instance | Holds. PluginField sets only `value` and string attributes; uploads go through `api.POST` |
|
||||
| Core field types, the `type: widget` contract and CompileForm unchanged | Holds as far as the gate shows. TestPhase10CSRF, TestPhase09ContractInventory and ExampleCompileFormWithTypes pass, and settings and relation forms use a refusing registry |
|
||||
| Framework docs name no consuming application | Holds. grep for journal, grzyb, fonoteka and blogeditor in field-types.md, forms.md and the two READMEs finds nothing |
|
||||
| The editor never re-serializes an untouched document or uses getHTML() | Holds. The clean-mode getter returns the stored string |
|
||||
| No eval, no `new Function`, no style injection | Holds. `injectCSS: false`, and no eval in the sources |
|
||||
| The element renders no iframe and no live raw HTML in the admin | **Flagged for human review.** The preview pane folded in from quick 261010-iwu adds a lazily created iframe with an empty `sandbox`, `srcdoc` only and a CSP meta. Raw blocks in the editor itself still render as escaped text |
|
||||
| htmlPolicy gains only the four editor rules; the layer mirrors the same four | Holds. Diff 2384a61 and htmlGate.ts:122-127 |
|
||||
| Public write API and media route keep the PHP contract | Holds. Posts still go through FormatHTML in the API helpers, and media.go keeps its error mapping through MediaError |
|
||||
| No format flag, column or migration | Holds. No migration commit in updates/ during the phase |
|
||||
| Executor ran only SELECTs on UAT; no credentials handled; no tag moved | Taken from the SUMMARY; the tags are unmoved on origin |
|
||||
| Tests not weakened | Taken from the SUMMARY: the 16.1 policy test files are unchanged since c60abc6 |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (none) | | No TBD, FIXME, XXX, TODO or HACK marker in the phase's source files | | |
|
||||
|
||||
Known open review items, skipped by decision (16.2-REVIEW-FIX.md):
|
||||
- WR-07: View site uses the admin origin.
|
||||
- WR-08: the gate passes with postgres NOT RUN; `PHASE162_REQUIRE_POSTGRES=1` enforces it.
|
||||
- IN-01 to IN-03 and IN-05 to IN-08.
|
||||
|
||||
These are information only and do not block.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Postgres suites on a Docker host
|
||||
|
||||
**Test:** `bash scripts/check-phase16.2.sh postgres`
|
||||
**Expected:** Every named suite passes. This covers WINDOWS 11, 12, 14 to 16, 20 and 21.
|
||||
**Why human:** No Docker on this host. These suites are the only end-to-end proof of the CR-01 and WR-04 Load changes and of the category strip.
|
||||
|
||||
#### 2. D-19 category edit on UAT
|
||||
|
||||
**Test:** Change one word of a category's content in the Blog Editor and save.
|
||||
**Expected:** No removal notice; the site shows the change.
|
||||
**Why human:** WINDOWS 18; never performed.
|
||||
|
||||
#### 3. Re-confirm the untouched resave after the review-fix deploy
|
||||
|
||||
**Test:** After v0.1.19 is deployed, save posts 1 and 64 untouched and re-run the md5 SELECT.
|
||||
**Expected:** The md5 values equal the 16.2-05 snapshot.
|
||||
**Why human:** The UAT proof ran on v0.1.18, before the CR-01 and WR-04 Load changes. The code reading says the outcome is unchanged for these posts.
|
||||
|
||||
#### 4. Preview pane browser check
|
||||
|
||||
**Test:** Check the layout, the sandbox (script in a raw block), the embed card and relative images.
|
||||
**Expected:** As listed in the 261010-iwu SUMMARY.
|
||||
**Why human:** Real browser behaviour.
|
||||
|
||||
#### 5. Accept the preview iframe against the plan 02 prohibition
|
||||
|
||||
**Test:** Decide whether the sandboxed srcdoc preview is an accepted exception to "no iframe in the admin".
|
||||
**Expected:** Accept, or ask for a change.
|
||||
**Why human:** Judgment-tier prohibition.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No must-have failed. The framework API (pact.HasFormFieldTypes, the cabana registry, the decode/skip/load/list runner, meta.notices, the upload route), the SPA host, the Journal Blog Editor (element, Go types, policy, hooks, storage) and the layer's embed expansion all exist, are substantive and are wired. The fixed tree passes the phase gate here, and vet is clean in both Go repositories. On UAT (v0.1.18) the goal was proven on real data:
|
||||
- An imported HTML post and a markdown post re-saved untouched with identical md5.
|
||||
- An edited post stored identical content and content_html, with an uploaded figure and a YouTube placeholder.
|
||||
- Disallowed markup was stripped and reported.
|
||||
- The site rendered a youtube-nocookie iframe.
|
||||
|
||||
Status is `human_needed`, not `passed`, for three reasons:
|
||||
- The Docker-only Postgres suites have never run.
|
||||
- The D-19 category path has no executed end-to-end evidence: no Postgres run and no UAT step.
|
||||
- The review fixes (CR-01, WR-01 to WR-06, IN-04) are committed but not deployed or tagged, so the UAT evidence predates them. summercms.go master is 13 commits ahead of origin, and the sm-journal-plugin test and fix commits are not pushed either.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-10T17:05:00Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Phase 16.3: Backend admin personal API tokens with scopes - Context
|
||||
|
||||
**Gathered:** 2026-10-10
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Backend users can create personal API tokens (`g15_…`) with one or more scopes picked at creation time. The framework `backend` guard accepts such a token as well as the admin JWT, and the request runs as the token's owner. Access is the intersection of the token's scopes and the owner's own permissions. Tokens are managed in the admin SPA (`/account`, own tokens) and through `summer admin:token:*` CLI commands (any user). The Journal plugin registers its scopes and accepts tokens on its write routes, so an agent can manage blog posts as a backend author.
|
||||
|
||||
Repos: `summercms.go` (cabana/bouncer guard, token model and migration, scope registry, account API, admin SPA panel, CLI, READMEs and docs); `sm-journal-plugin` (scope registration, switch to the framework principal helper); host applications only for the version bump.
|
||||
|
||||
Out of scope: a backend-user management screen, a superuser view of all tokens, migrating Apparatus tokens, Payment and Fonoteka scopes (they register through the same API when their phases need them).
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Scope model
|
||||
- **D-01:** Scopes are named and registered by plugins through a new pact capability (working name `pact.HasTokenScopes`). Each scope has a name, a label and description (phrasebook keys), a list of permission patterns (Winter `.*` wildcard semantics, Phase 9 D-03) and a list of allowed HTTP methods. The naming convention is `<plugin>` for full access and `<plugin>:read` for read-only, e.g. `journal` → `golem15.journal.*` with all methods. A duplicate scope name fails boot loudly. — **Reversibility:** costly — plugins implement the capability and stored tokens reference scope names.
|
||||
- **D-02:** Read-only is enforced by the scope's method list. A `:read` scope allows only `GET`/`HEAD`, and the framework refuses any other method with 403 before the handler runs. Plugins do not change their handlers for this. A POST that only reads (search) is not reachable with a read scope; that is accepted.
|
||||
- **D-03:** A token holds several scopes. A request passes if any one scope allows it, meaning its method list includes the request method and its patterns match every required permission. Scopes are immutable after creation.
|
||||
- **D-04:** Framework scopes:
|
||||
- `*` means full access: all methods, and it behaves exactly like the owner.
|
||||
- `admin:read` means system-wide read-only: `GET`/`HEAD` on everything the owner can see.
|
||||
|
||||
Journal registers `journal` and `journal:read`. Payment (`payment:read`) and Fonoteka scopes come with their own phases through the same API.
|
||||
- **D-05:** Deny by default. A token reaches a route only when a scope covers it. When `cabana.Allows` is called with an empty permission list under a token, only `*` (and `admin:read` for GET/HEAD) passes; other scoped tokens are refused. `GET /auth/me` is always allowed to any valid token, so an agent can see who it is.
|
||||
- **D-06:** An explicit "interactive only" marker covers account changes (`PUT /account`, password, email, avatar), token management (create, list, revoke) and logout/refresh. These routes refuse every token, including `*`, and need an admin JWT session. A leaked token cannot take over the account or mint new tokens. — **Reversibility:** reversible.
|
||||
|
||||
### Token lifecycle
|
||||
- **D-07:** The format keeps the Apparatus shape: `g15_` + 40 lowercase hex chars (20 random bytes from `crypto/rand`, 160 bits). Only the sha256 hex is stored, in a unique indexed column, along with the last 4 characters as a display hint (`g15_…a1b2`). There is no checksum suffix. — **Reversibility:** one-way — issued tokens live in agent and integration configs.
|
||||
- **D-08:** Expiry is optional. The SPA picker offers 30 / 90 / 365 days / never, defaulting to 90 days. The CLI accepts `--expires=30d|90d|365d|never` or a date, also defaulting to 90 days. Expired tokens return 401.
|
||||
- **D-09:** Revoking is a soft revoke. It sets `revoked_at` and `revoked_by` and keeps the row for audit. Revoked tokens return 401 and are hidden from the default list, with an option to show them. Name, scopes and expiry cannot be edited: to change one, create a new token and revoke the old one.
|
||||
- **D-10:** The owner is loaded fresh on every request through the existing `BackendUsers.FindByID` path. Deactivating (`is_activated=false`) or soft-deleting the owner makes all their tokens fail with 401. A password reset and the JWT `tokens_valid_after` cutoff do **not** invalidate tokens, so routine password changes don't break agents.
|
||||
- **D-11:** `last_used_at` is stamped on use, at most once per minute per token, so every request isn't a write. Lists show name, scopes, hint, `created_at`, `last_used_at`, `expires_at`, and revoked state.
|
||||
- **D-12:** The table is framework-owned. It is appended to `BackendAdminMigrations` (history id `summercms.cabana`) as a raw-SQL migration with a dated ID. Columns are roughly: id, backend_user_id (FK), name, token_hash (unique), token_hint, scopes (JSON array of names), expires_at, last_used_at, revoked_at, revoked_by, timestamps. Apparatus `golem15_apparatus_personal_api_tokens` rows are **not** migrated.
|
||||
|
||||
### Guard and Journal wiring
|
||||
- **D-13:** The cabana `backend` guard becomes composite. `Authorization: Bearer g15_…` goes to the token lookup; everything else (Bearer JWT or the admin cookie) goes to the existing `NewBackendJWTGuard` path unchanged. Tokens are accepted only in the Bearer header, never from a cookie or query string. The token row is returned as the bouncer credential (`CredentialGuard`). — **Reversibility:** costly — every backend-authenticated route goes through it.
|
||||
- **D-14:** The permission side of the scope check lives in the principal. A token-authenticated `Principal` carries its scopes' permission patterns and methods, and `cabana.Allows` requires **both** an owner grant and a matching scope pattern. Every existing check (admin CRUD `RequiredPermissions`, actions, Journal's direct `Allows` calls) is restricted with no per-route annotation. A superuser holding a scoped token is still limited to the scope. The method check runs once, in the guard or middleware. — **Reversibility:** costly — `Allows` semantics change for every caller.
|
||||
- **D-15:** Cabana exports a principal resolver, working name `cabana.BackendPrincipal(r)` or similar, that runs the composite guard plus the scope and method checks for routes that don't sit under the `backend` middleware. Journal's `controllers/api/auth.go` uses it instead of building its own `NewBackendJWTGuard`. Journal's error bodies and status codes stay byte-identical (`{"error":"Authentication required"}` 401, its existing 403). The public read routes stay optional-auth, so a valid token with `journal` or `journal:read` also unlocks drafts where the owner may see them. This reverses Phase 15 D-15. — **Reversibility:** reversible.
|
||||
|
||||
### Admin SPA and CLI
|
||||
- **D-16:** The SPA panel is an "API tokens" section on `/account` (`AccountView.vue`). It lists, creates and revokes the current user's own tokens only. There is no superuser view of other users' tokens; superusers use the CLI. The plaintext is shown once after creation with a copy button and a clear "you won't see this again" notice, and is never retrievable afterwards.
|
||||
- **D-17:** The scope picker lists registered scopes with label and description. It hides scopes that grant the current user nothing (no overlap between the scope's patterns and their grants). `*` is always shown, labelled as full access ("everything you can do"). The server re-validates scope names and the overlap rule on create (422 on an unknown or empty scope).
|
||||
- **D-18:** Account token endpoints sit under the admin API (Phase 9 D-09/D-10 envelope), for example `GET/POST /_admin/api/v1/account/tokens`, `DELETE /account/tokens/{id}` (revoke) and `GET /account/tokens/scopes`. They carry swag annotations so the OpenAPI document and the SPA TS types cover them, and they are interactive-only (D-06).
|
||||
- **D-19:** CLI commands in cabana, beside `admin:create`:
|
||||
- `admin:token:create <login|email> --name=… --scope=journal [--scope=payment:read] [--expires=90d|never]` prints only the plaintext token to stdout so it can be piped.
|
||||
- `admin:token:list [<login|email>] [--revoked]`
|
||||
- `admin:token:revoke <id>`
|
||||
- `admin:token:scopes` lists registered scopes.
|
||||
|
||||
The CLI may create tokens for any backend user and applies the same overlap validation as D-17.
|
||||
- **D-20:** Module READMEs (cabana, bouncer if its API changes, pact for the new capability) and the `docs/` pages cover the token API, the scope registration capability, the framework scopes and the CLI commands, per the project documentation rules. The Journal README documents its scopes and token auth.
|
||||
|
||||
### Claude's Discretion
|
||||
- Exact names: the pact capability, the scope struct, the principal resolver, the table (`backend_api_tokens` or similar), and the interactive-only marker.
|
||||
- How scopes are stored (JSON array vs `text[]`), and what an unknown scope name does at request time after a plugin drops it. Recommended: it grants nothing and is shown as unknown in lists.
|
||||
- How the 403 for a scope refusal is reported in the framework envelope (e.g. `details.reason = "token_scope"`).
|
||||
- Whether token requests get their own throttle bucket; reuse an existing throttle shape if cheap.
|
||||
- How `last_used_at` is debounced (in-memory per process is fine).
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase origin and prior decisions
|
||||
- `.planning/ROADMAP.md` § Phase 16.3 — goal, repos, explore constraints, success criteria
|
||||
- `.planning/todos/pending/backend-admin-api-tokens.md` — the todo this phase was promoted from
|
||||
- `.planning/notes/apparatus-dissolved-into-framework.md` — why tokens are a framework feature, not an Apparatus port
|
||||
- `.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-CONTEXT.md` — D-01..D-04 (backend users, `backend` guard, permission semantics, `admin:create`), D-09/D-10 (admin API paths and envelope)
|
||||
- `.planning/phases/15-journal-plugin/15-CONTEXT.md` — D-14/D-15 (Journal write API and the backend-JWT-only rule this phase reverses)
|
||||
|
||||
### Reference implementation (behavioral, not ported 1:1)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/models/PersonalApiToken.php` — `g15_` + 40 hex, sha256 storage, expiry, last_used_at
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/middleware/TokenAuthenticate.php` — Bearer lookup, expiry, owner check
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/apparatus/Plugin.php` (~lines 335-400) — My Account "API Tokens" tab and create/revoke handlers
|
||||
|
||||
### Pattern to copy (Go)
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/middleware/api_token.go` — `APITokenGuard`: prefix check, sha256 lookup, last_used stamp, `Scope()` middleware
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/classes/api_tokens.go` — `MintAPIToken`, `HashAPIToken`
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/models/api_token.go` — `HasScope`
|
||||
- `../sm-summercmsio-app/plugins/golem15/user/updates/202610040001_create_api_tokens.go` — token table migration
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `modules/bouncer/guard.go`: `Guard`, `CredentialGuard`. `modules/bouncer/context.go`: `Principal` (`Backend`, `IsSuperuser`, `PermissionGrants`), `WithUser`, `WithCredential`.
|
||||
- `modules/bouncer/jwt.go`: `NewBackendJWTGuard` (:94), `Authenticate` (:106), `bearerToken` (:314). The JWT path stays as-is behind the composite guard.
|
||||
- `modules/cabana/auth.go`: `BackendUsers.FindByID` (:40) and `principalFrom` (:103). These are the owner-loading path, already merging role and user grants.
|
||||
- `modules/cabana/contracts.go`: `Allows` (:186) and `requiredOf` (:237). This is where scope intersection lands (D-14).
|
||||
- `modules/wristband/crypto.go:26-31`: `sha256Hex` and a constant-time compare.
|
||||
- `modules/cabana/commands.go`: `admin:create` (:18) and `admin:reset-password` (:107). The new `admin:token:*` commands go beside them.
|
||||
- `modules/cabana/account.go` plus `admin/src/views/AccountView.vue`: the existing account API and page that host the tokens panel.
|
||||
|
||||
### Established Patterns
|
||||
- Plugin capabilities are optional pact interfaces compiled by cabana at boot (`modules/pact/capabilities.go:494` `HasPermissions`, compiled in `modules/cabana/registry.go:182-200`). `HasTokenScopes` follows the same path, including fail-loud validation.
|
||||
- Framework-owned migrations are raw-SQL entries appended to `BackendAdminMigrations` (`modules/lagoon/backend_admin_migrations.go:21`).
|
||||
- Admin API handlers carry swag annotations; SPA types are generated from OpenAPI.
|
||||
- Permission checks happen in handlers, not in route middleware, which is why the scope check belongs in `Allows` and not in per-route annotations.
|
||||
|
||||
### Integration Points
|
||||
- `modules/cabana/http.go:145-159`: where the `backend` guard is built and registered. The composite guard wraps it here.
|
||||
- `modules/bouncer/registry.go:73-113`: `Registry.Middleware` already prefers `CredentialGuard`, so the token row arrives as the credential.
|
||||
- `modules/cabana/http.go:245-249`: `/auth/me` and the `/account` routes. The token endpoints and the interactive-only marker go here.
|
||||
- `../sm-journal-plugin/controllers/api/auth.go`: `newBackendGuard` (:58), `requireBackendPrincipal` (:99) and `requireAccessPosts` (:113) switch to the cabana resolver (D-15). `posts.go` Index/Show `optionalBackendPrincipal` (:32, :121) do the same.
|
||||
- `../sm-journal-plugin/admin_permissions.go:9`: the permission catalog the `journal` scopes map onto.
|
||||
|
||||
### Noted during scouting (verify, don't assume)
|
||||
- Journal `UploadFeaturedImage` / `DeleteFeaturedImage` (`posts.go:325, :393`) authenticate, but a permission check was not confirmed. Check it while wiring tokens, because a `journal:read` token must not reach them (the method rule covers this, but the permission gap would be a separate bug).
|
||||
- Journal's per-user throttle key (`plugin.go:88`) reads `bouncer.User` before any guard runs and likely always falls back to the client IP.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Main use case: an agent manages Journal blog posts as a backend author using a `journal`-scoped token. Later, a reporting agent reads Payment Gateway data with `payment:read`.
|
||||
- The token panel follows the Apparatus/Winter "My Account → API Tokens" placement.
|
||||
- CLI `admin:token:create` output is pipe-friendly (only the token on stdout).
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Backend-user management screen, including a superuser view and revoke of other users' tokens: its own phase.
|
||||
- `payment:read` scope: registered by the Payment Gateway phase (18.x) through D-01.
|
||||
- Fonoteka scopes: when a Fonoteka integration needs token access.
|
||||
- Secret-scanner-friendly checksum token format: only if a scanner partnership is pursued.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 16.3-backend-admin-personal-api-tokens-with-scopes*
|
||||
*Context gathered: 2026-10-10*
|
||||
@@ -0,0 +1,118 @@
|
||||
# Phase 16.3: Backend admin personal API tokens with scopes - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-10
|
||||
**Phase:** 16.3-backend-admin-personal-api-tokens-with-scopes
|
||||
**Areas discussed:** Scope model, Token lifecycle, Guard & Journal wiring, SPA panel & CLI
|
||||
|
||||
---
|
||||
|
||||
## Scope model
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Named scopes, plugin-registered | Plugins register scopes mapping to permission patterns; framework `*` | ✓ |
|
||||
| Scope = permission patterns | Token stores raw permission patterns | |
|
||||
| Coarse fixed set | read/write/* like user plugin tokens | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Scope declares methods + patterns | Read scopes allow GET/HEAD only, enforced before handler | ✓ |
|
||||
| Read-only permission subset | Read scope maps to view-type permissions | |
|
||||
| Both | Methods and permission subset | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Several | Token stores a list; any scope allowing passes | ✓ |
|
||||
| Exactly one | One scope per token | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Deny by default, except whoami | Only `/auth/me` open; account/token routes need interactive JWT even with `*` | ✓ |
|
||||
| `*` reaches everything | System-wide token can change account and mint tokens | |
|
||||
| Allow unchecked routes | Unguarded routes open to any token | |
|
||||
|
||||
## Token lifecycle
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Optional, presets | 30/90/365/never, default 90 days | ✓ |
|
||||
| Required, capped | Must expire, max 1 year | |
|
||||
| Optional, no default | Apparatus as-is | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Owner deactivated or deleted | Password reset / tokens_valid_after do not kill tokens | ✓ |
|
||||
| Also on password reset / session cutoff | Tokens respect tokens_valid_after | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Soft revoke, immutable | revoked_at kept for audit; no edits | ✓ |
|
||||
| Hard delete, immutable | Apparatus behavior | |
|
||||
| Soft revoke, editable name/expiry | Rename and extend allowed | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Keep Apparatus shape + hint | g15_ + 40 hex, sha256, last-4 hint | ✓ |
|
||||
| Add checksum | GitHub-style base62 + CRC32 | |
|
||||
|
||||
## Guard & Journal wiring
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Bearer header only | Composite guard; g15_ to token lookup, rest to JWT | ✓ |
|
||||
| Bearer + query param | Also ?token= | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Framework helper, keep Journal's envelope | cabana resolver replaces Journal's hand-built guard | ✓ |
|
||||
| Mount Journal writes under backend middleware | Changes Journal error shape | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Inside the principal / cabana.Allows | Allows intersects owner grants and scope patterns | ✓ |
|
||||
| Route middleware per scope | Routes declare RequireScope | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| `*` passes, scoped tokens denied | Empty permission list denied to scoped tokens; interactive-only marker | ✓ |
|
||||
| Scoped tokens pass too | Current Allows semantics | |
|
||||
|
||||
## SPA panel & CLI
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| /account only, own tokens | Superusers use CLI for others | ✓ |
|
||||
| /account + superuser view | New admin screen | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Only scopes their permissions intersect | Hide useless scopes; `*` always shown; server re-checks | ✓ |
|
||||
| All registered scopes | Simpler | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| admin:token:create/list/revoke (+scopes) | Plaintext to stdout once | ✓ |
|
||||
| Fewer commands | create and revoke only | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| `*`, journal, journal:read | payment:read later | |
|
||||
| Also a framework `admin:read` | System-wide read-only scope | ✓ |
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- Names of the capability, scope struct, resolver, table and interactive-only marker
|
||||
- Scope storage type and unknown-scope behavior
|
||||
- Scope-refusal 403 details
|
||||
- Token throttle bucket
|
||||
- last_used_at debounce mechanism
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Backend-user management screen with superuser token view
|
||||
- payment:read scope (Payment Gateway phase)
|
||||
- Fonoteka scopes
|
||||
- Checksum token format
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
---
|
||||
phase: quick-261010-iwu
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.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/preview.test.ts
|
||||
- ../sm-journal-plugin/README.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261010-iwu]
|
||||
|
||||
estimate:
|
||||
tokens: 70000
|
||||
raw_tokens: 70000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "The Blog Editor toolbar has a Preview toggle button (data-command preview) labelled 'Preview' for ui-locale en and 'Podgląd' for ui-locale pl through labelsFor. It is off by default with aria-pressed 'false', and each element instance has its own state. Pressing it sets aria-pressed 'true' and shows a preview pane that splits the editor 50/50, a grid with repeat(auto-fit, minmax(min(100%, 360px), 1fr)) columns. When the element is narrower than about 720px, the pane wraps below the editor. Pressing it again hides the pane. The button stays usable in the Source tab and when the element is disabled."
|
||||
- "The pane is an <iframe> whose sandbox attribute is exactly the empty string. It has no allow-scripts or allow-same-origin token, no src and no allow attribute, and it has referrerpolicy no-referrer. Its content is set only through srcdoc. The srcdoc head starts with meta charset, then a Content-Security-Policy meta with PREVIEW_CSP (default-src 'none'; img-src * data:; style-src 'unsafe-inline'), then a no-referrer meta."
|
||||
- "The preview renders immediately when it is switched on. After that, it re-renders PREVIEW_DEBOUNCE_MS (300 ms) after the last WYSIWYG edit, Source tab input or external value set, so it updates live in both modes. While it is off, nothing is scheduled or rendered."
|
||||
- "A YouTube placeholder <figure data-embed=\"youtube\" data-id=\"ID\"></figure>, with an id that matches the provider idPattern, shows as a thumbnail card in the preview: an img with src https://img.youtube.com/vi/ID/hqdefault.jpg and referrerpolicy no-referrer, plus the label '<embedCard>: YouTube ID'. The preview never contains an iframe. The preview copy also drops script, iframe, frame, frameset, object, embed, meta, base and link elements and every on* attribute."
|
||||
- "The preview uses post-body typography from an inline stylesheet in the srcdoc. Its colours and font are concrete values that getComputedStyle reads from the host's --c-* variables at render time and that pass safeCssValue, or neutral defaults otherwise. The srcdoc never contains var(--c-. In blogeditor.css, every selector stays under golem15-journal-blogeditor and every colour is a var(--c-*)."
|
||||
- "Toggling or rendering the preview never dispatches summer-change. It never calls serializeDocument for an untouched document, never dispatches an editor transaction and never writes the stored-value fields. An untouched imported post still returns the exact given string after any number of toggles and renders. smoke.test.ts, corpus.test.ts and the fixtures are not edited and stay green."
|
||||
- "assets/dist is rebuilt and committed, scripts/check-dist.sh prints 'check-dist: assets/dist matches a fresh build', and go vet ./... passes in sm-journal-plugin. The README Blog Editor and Admin sections describe the preview. Every commit is in sm-journal-plugin without co-author tags, and nothing is pushed or deployed."
|
||||
artifacts:
|
||||
- path: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
provides: "PREVIEW_SANDBOX, PREVIEW_CSP, PREVIEW_DEBOUNCE_MS, PREVIEW_DEFAULTS, safeCssValue, resolvePreviewColors, youtubeThumbnail, previewBody, buildPreviewDocument"
|
||||
contains: "export function buildPreviewDocument"
|
||||
- path: ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
provides: "showPreview(on), #renderPreview reading the value getter, #schedulePreview debounce, split panes and the sandboxed iframe"
|
||||
contains: "showPreview"
|
||||
- path: ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
provides: "the preview toggle button outside the tablist, ToolbarHost.togglePreview, Toolbar.setPreview"
|
||||
contains: "togglePreview"
|
||||
- path: ../sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
provides: "preview and previewFrame labels in en and pl"
|
||||
contains: "Podgląd"
|
||||
- path: ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
provides: "split-pane grid with the narrow-width wrap, preview pane and iframe styles, all under the tag with --c-* only"
|
||||
contains: "auto-fit"
|
||||
- path: ../sm-journal-plugin/tests/blogeditor/preview.test.ts
|
||||
provides: "vitest coverage: toggle and aria-pressed, debounced live update, sandbox attribute, embed card, active-content drop, colours, no summer-change, untouched value"
|
||||
contains: "PREVIEW_DEBOUNCE_MS"
|
||||
- path: ../sm-journal-plugin/assets/dist/blogeditor.js
|
||||
provides: "rebuilt committed bundle including the preview"
|
||||
contains: "img.youtube.com"
|
||||
key_links:
|
||||
- from: ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
to: ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
via: "button[data-command=preview] click -> host.togglePreview() -> showPreview(!#previewOn)"
|
||||
pattern: "togglePreview"
|
||||
- from: ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
to: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
via: "#renderPreview reads this.value and sets frame.srcdoc = buildPreviewDocument(...)"
|
||||
pattern: "buildPreviewDocument"
|
||||
- from: ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
to: "onUpdate, #sourceInput, value setter"
|
||||
via: "#schedulePreview() after each value-changing path"
|
||||
pattern: "schedulePreview"
|
||||
- from: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
to: ../sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
via: "previewBody accepts a placeholder only through isEmbedProvider plus EMBED_PROVIDERS[provider].idPattern"
|
||||
pattern: "idPattern"
|
||||
- from: ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
to: ../sm-journal-plugin/assets/src/blogeditor/extensions.ts
|
||||
via: "previewBody parses its copy in inert(), a document without a browsing context"
|
||||
pattern: "inert()"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add a toggleable live preview pane to the Journal Blog Editor element (`golem15-journal-blogeditor`) in the sibling repo `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin`, like the WinterCMS Blog plugin's preview. A localized "Preview"/"Podgląd" toolbar button splits the editor 50/50. The right pane shows the current HTML in a sandboxed `<iframe srcdoc>` with post-body typography, YouTube placeholders show as thumbnail cards, and the pane updates live with a debounce in both WYSIWYG and Source modes.
|
||||
|
||||
Purpose: editors see how the post body will read without leaving the form. The editor's exact-value contract stays intact: an untouched post still saves byte for byte, and previewing never counts as an edit.
|
||||
|
||||
Output: a new `preview.ts` module, changes to the element, toolbar, labels and CSS, a new vitest file, the rebuilt and committed `assets/dist`, and README updates. This is three commits in sm-journal-plugin. There is no summercms framework change, and the element contract published in summercms v0.1.18 (the value property, the attributes, summer-change, summer-upload and summer-upload-result) is unchanged.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@CLAUDE.md
|
||||
@.planning/phases/16.2-plugin-provided-field-types-and-the-blog-editor-formwidget/16.2-02-SUMMARY.md
|
||||
|
||||
Element sources (absolute paths; the repo is a sibling, not a submodule):
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/toolbar.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/labels.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/serializer.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/embed.ts
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/assets/src/blogeditor/blogeditor.css
|
||||
@/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/tests/blogeditor/helpers.ts
|
||||
|
||||
Facts the planner verified (no need to re-derive):
|
||||
- sm-journal-plugin is on branch `main` at `9d5bfc4` (chore(deps): require summercms v0.1.18). The only untracked file is `go.work.sum`, which is never committed. `node_modules/` is installed, so check-dist.sh does not run npm ci.
|
||||
- The `value` getter (element.ts) returns `#value` in `clean` mode, which is the exact given string with no serializer call. It returns `#sourceText` in `source` mode. In `wysiwyg` mode it returns `serializeDocument(editor, #blocks)`. `serializeDocument` (serializer.ts) only reads `editor.state.doc` and builds nodes in the inert document. It writes no element state. The preview reads through the getter and nothing else.
|
||||
- `inert()` (extensions.ts) is `document.implementation.createHTMLDocument('')`. Markup parsed there or in a `template` created from it loads nothing and runs nothing. RawHtml's renderHTML already uses this pattern.
|
||||
- The Embed node accepts a placeholder only when it has exactly two attributes (`data-embed`, `data-id`), no child nodes, `isEmbedProvider(provider)` and the provider's `idPattern` (`^[A-Za-z0-9_-]{11}$`). Its card text is `${labels.embedCard}: ${EMBED_PROVIDERS[provider].name} ${id}`.
|
||||
- The admin SPA's CSP (summercms boardwalk) is `frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'`. A srcdoc document inherits it, so img.youtube.com images and an inline `<style>` are allowed, and a pasted `<base>` is already blocked. A srcdoc document also inherits the parent's base URL, so relative image URLs such as `/storage/...` resolve against the admin origin.
|
||||
- happy-dom 20.11.6 (the vitest environment) returns inline custom properties from `getComputedStyle(el).getPropertyValue('--c-x')` (probed). It reflects `iframe.srcdoc` and `sandbox` to attributes. Tests read `getAttribute('srcdoc')` and `getAttribute('sandbox')` and never `contentDocument`.
|
||||
- The smoke test `toolbar > offers every D-04 feature` queries `button[data-command=...]` per command, and no test counts buttons or children, so an extra button and wrapper divs break nothing.
|
||||
|
||||
Executor runtime notes:
|
||||
- The user's login shell is fish. Run anything with bash syntax through `bash -c '...'`. Every verify command below is already wrapped.
|
||||
- Code commits go to sm-journal-plugin with `git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin ...`. Stage explicit paths only. Commit messages carry no co-author tags or trailers (user rule, overrides any default attribution). Do not push and do not deploy.
|
||||
- If npm or pnpm fails with a read-only store or cache error (`/tmp/.pnpm-store` belongs to another user), rerun with `TMPDIR` and the cache pointed into the session scratchpad, for example `bash -c 'S=/tmp/claude-1002/-media-nvme-dev-golem15-summercms-io-summercms-summercms-go/9d521e8a-c2b5-4e55-bdcd-429b1ab44bfc/scratchpad; mkdir -p $S/tmp $S/npm-cache; cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && TMPDIR=$S/tmp npm_config_cache=$S/npm-cache npm run build'`. For pnpm, use `--store-dir $S/pnpm-store`.
|
||||
- Use `node_modules/.bin/vitest` and `node_modules/.bin/vite`, never `npx` (no network installs). No package is added. package.json and package-lock.json stay byte-identical.
|
||||
- The orchestrator commits the planning docs (this PLAN, the SUMMARY and STATE). The executor commits only code in sm-journal-plugin.
|
||||
</context>
|
||||
|
||||
<source_coverage>
|
||||
| Source item (quick task description) | Covered by |
|
||||
|---|---|
|
||||
| "Podgląd"/"Preview" toolbar button through the existing ui-locale handling (labelsFor) | Task 1 (labels, toolbar) |
|
||||
| Splits the editor 50/50, with the right pane rendering the current HTML | Task 1 (panes, grid CSS, srcdoc) |
|
||||
| Works in WYSIWYG and Source modes and updates live with a debounce | Task 2 (#schedulePreview, PREVIEW_DEBOUNCE_MS); Task 1 makes the button usable in Source |
|
||||
| Sandboxed `<iframe srcdoc>` with no allow-scripts and no allow-same-origin | Task 1 (PREVIEW_SANDBOX '', srcdoc only) |
|
||||
| Post-body typography | Task 2 (srcdoc stylesheet) |
|
||||
| YouTube placeholders as thumbnail cards (img.youtube.com plus a label), never a live iframe | Task 2 (previewBody, youtubeThumbnail) |
|
||||
| Per element, off by default, aria-pressed | Task 1 (#previewOn per instance, aria-pressed via setPreview) |
|
||||
| Narrow widths: the pane collapses below the editor | Task 1 (auto-fit grid; checked by hand in a browser, noted in the SUMMARY) |
|
||||
| Toggling or previewing never emits summer-change and never re-serializes an untouched document; the value is read without a mutating serializer; the untouched string is returned exactly; existing untouched tests stay green | Task 1 (value-getter-only read, serializer spy tests, existing files unedited); Task 2 (the same after debounced renders) |
|
||||
| Only --c-* variables, scoped under the tag; srcdoc carries its own inline stylesheet with values resolved by getComputedStyle or neutral defaults | Tasks 1 and 2 (CSS gates, resolvePreviewColors, safeCssValue) |
|
||||
| Vitest: toggle and aria-pressed, debounced live update with fake timers, sandbox attribute, embed card, no summer-change, untouched value after toggling | Task 1 (toggle, sandbox, no summer-change, untouched) and Task 2 (debounce, embed card, colours, active content) |
|
||||
| Rebuild and commit assets/dist with check-dist green; go vet passes | Every task's verify; Task 3 final gate |
|
||||
| Update the plugin README's Admin section | Task 3 |
|
||||
| Do not push or deploy | Runtime notes; Task 3 done criteria |
|
||||
|
||||
No item is deferred or unplanned.
|
||||
</source_coverage>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: End-to-end preview toggle (toolbar button, element, sandboxed srcdoc frame)</name>
|
||||
<files>../sm-journal-plugin/assets/src/blogeditor/labels.ts, ../sm-journal-plugin/assets/src/blogeditor/toolbar.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/assets/src/blogeditor/preview.ts, ../sm-journal-plugin/assets/src/blogeditor/blogeditor.css, ../sm-journal-plugin/tests/blogeditor/preview.test.ts, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css</files>
|
||||
<read_first>
|
||||
- element.ts in full: the value getter and setter, connectedCallback, disconnectedCallback, showSource, #load, #sourceInput and #emitChange
|
||||
- toolbar.ts in full: ToolbarHost, Toolbar, the GROUPS buttons, the tabs and refresh()
|
||||
- labels.ts: the Labels interface, the en and pl tables, labelsFor
|
||||
- serializer.ts: the serializeDocument signature (read only, to confirm it is pure)
|
||||
- blogeditor.css: the tag-scoped selectors, the [hidden] rule and the aria-pressed rule
|
||||
- tests/blogeditor/helpers.ts (mount, button, click) and the first 105 lines of tests/blogeditor/smoke.test.ts, for the test style
|
||||
</read_first>
|
||||
<precondition>sm-journal-plugin HEAD is 9d5bfc4, `git status --porcelain` shows only `?? go.work.sum`, and node_modules/.bin/vitest exists.</precondition>
|
||||
<behavior>
|
||||
New file tests/blogeditor/preview.test.ts. At the top, vi.mock the serializer module ('../../assets/src/blogeditor/serializer') with importOriginal: re-export everything and wrap serializeDocument in vi.fn around the real function. afterEach calls vi.useRealTimers(), vi.mocked(serializeDocument).mockClear() and document.body.replaceChildren(). Reuse mount, button and click from ./helpers, and the fixture from './fixtures/imported-post.html?raw'.
|
||||
- Toggle: mount('<p>Hello</p>'). button(el, 'preview') has the text 'Preview', aria-label 'Preview' and aria-pressed 'false'. el.querySelector('.blogeditor__preview') is hidden. After a click, aria-pressed is 'true', the pane is not hidden, '.blogeditor__panes' has class 'blogeditor__panes--split', and the iframe's srcdoc already contains '<p>Hello</p>' with no timer advanced. Another click gives aria-pressed 'false', a hidden pane and no split class.
|
||||
- Locale: with ui-locale 'pl' the button text and aria-label are 'Podgląd' and the iframe title is 'Podgląd wpisu'. With en the title is 'Post preview'.
|
||||
- Per element: mount two elements and toggle the first. The second's button keeps aria-pressed 'false' and its pane stays hidden.
|
||||
- Usable everywhere: after el.showSource(true), and separately with the disabled attribute set, the preview button is not disabled and a click turns the preview on.
|
||||
- Sandbox: iframe.getAttribute('sandbox') is exactly ''. The attribute string matches neither /allow-scripts/ nor /allow-same-origin/. The iframe has no src or allow attribute, and referrerpolicy is 'no-referrer'. The srcdoc contains the PREVIEW_CSP meta (http-equiv Content-Security-Policy with content PREVIEW_CSP) and '<meta name="referrer" content="no-referrer">'.
|
||||
- Untouched value: mount(fixture) and keep const before = editor.state.doc. Toggle on, off and on again. Then changes is [], el.value toBe fixture, editor.state.doc toBe before (no transaction ran), serializeDocument was not called, and the srcdoc contains 'Jan Kowalski'. Setting el.value = fixture afterwards emits nothing.
|
||||
- Touched value: mount('<p>a</p>'), insert 'X' at position 1 (one user edit), then toggle on. changes has length 1, the srcdoc contains '<p>Xa</p>', serializeDocument was called (the touched path goes through the getter), and toggling off and on adds no summer-change.
|
||||
</behavior>
|
||||
<action>
|
||||
Work only in /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin (see the runtime notes). This task wires one path end to end: button, element, preview module, iframe. It also builds the final security shape of the frame. Task 2 adds the content transforms and live updates.
|
||||
|
||||
1. labels.ts: add `preview` and `previewFrame` to the Labels interface and to both tables. In en they are 'Preview' and 'Post preview'. In pl they are 'Podgląd' and 'Podgląd wpisu'. labelsFor and the element's ui-locale reading stay the only locale mechanism.
|
||||
|
||||
2. Create assets/src/blogeditor/preview.ts as a pure module with no element state. It exports:
|
||||
- `PREVIEW_SANDBOX = ''`: the iframe's sandbox attribute value, an empty token list, which is the most restrictive sandbox (no scripts, opaque origin, no forms, popups or top navigation).
|
||||
- `PREVIEW_CSP = "default-src 'none'; img-src * data:; style-src 'unsafe-inline'"`.
|
||||
- `PREVIEW_DEBOUNCE_MS = 300` (used from Task 2).
|
||||
- `interface PreviewOptions { labels: Labels; lang?: string | null }`.
|
||||
- `buildPreviewDocument(html: string, options: PreviewOptions): string`. It returns a full document: a doctype, then an html element, then a head with meta charset utf-8. The http-equiv Content-Security-Policy meta whose content is PREVIEW_CSP and the meta name=referrer content=no-referrer follow, and nothing else comes before them in head. The body wraps the HTML in `article class="post-body"`. In this task the HTML is inserted as given. Task 2 adds previewBody, the stylesheet and lang.
|
||||
- Doc comments state why: the sandbox blocks scripts from pasted HTML and gives the frame an opaque origin, and CSS variables do not cross into the frame.
|
||||
|
||||
3. toolbar.ts:
|
||||
- ToolbarHost gains `togglePreview: () => void`. Toolbar gains `setPreview: (on: boolean) => void`, which sets the preview button's aria-pressed to 'true' or 'false'.
|
||||
- After the tabs, append a `div.blogeditor__view` holding one button. Give it type button, data-command 'preview', textContent labels.preview, title and aria-label labels.preview, and an initial aria-pressed of 'false'. Its mousedown calls preventDefault (it keeps the editor selection, like the other buttons) and its click calls host.togglePreview().
|
||||
- It is a toggle, not a tab. Keep it out of the role=tablist div and out of the `buttons` array, so refresh() never disables it. The preview is read-only, so it works in the Source tab, with the disabled attribute and before an editor exists.
|
||||
|
||||
4. element.ts:
|
||||
- Add private state: `#previewOn = false` (per instance, off by default, never stored anywhere), `#panes`, `#previewPane`, `#previewFrame` and `#previewTimer`.
|
||||
- In connectedCallback, append the mount and the textarea inside `div.blogeditor__main`, which sits inside `div.blogeditor__panes`. Follow it with `div.blogeditor__preview`, created hidden and holding the iframe. Keep the existing append order otherwise: toolbar, forms, panes, status, file input.
|
||||
- Create the iframe with setAttribute('sandbox', PREVIEW_SANDBOX), referrerpolicy 'no-referrer' and title labels.previewFrame. Never add a sandbox token, src, allow or allowfullscreen.
|
||||
- Pass `togglePreview: () => this.showPreview(!this.#previewOn)` to buildToolbar. At the end of connectedCallback, call showPreview(true) when #previewOn is already true (a re-attached element).
|
||||
- Add a public `showPreview(on: boolean): void`. It sets #previewOn and returns if the panes are not built yet. Otherwise it sets the pane's hidden to !on, toggles the class `blogeditor__panes--split` on #panes and calls toolbar.setPreview(on). When on, it renders right away through #renderPreview(). When off, it clears the pending timer.
|
||||
- Add `#renderPreview()`. It clears the timer and returns unless #previewOn and the frame exist. It reads the HTML only through the value getter (`this.value`), then sets `frame.srcdoc = buildPreviewDocument(html, { labels: this.#labels })`. The preview path never calls editor.getHTML(), #load, setContent, captureBlocks or #emitChange. It never dispatches a transaction and never assigns #value, #mode, #sourceText, #parsed or #blocks. So an untouched document never reaches serializeDocument, and nothing in the stored-value path changes.
|
||||
- In disconnectedCallback, clear #previewTimer and keep #previewOn.
|
||||
|
||||
5. blogeditor.css: every new selector starts with golem15-journal-blogeditor, and every colour is a var(--c-*).
|
||||
- `.blogeditor__view` is a flex row with a 2px gap, like `.blogeditor__tabs`.
|
||||
- `.blogeditor__panes--split` is a grid with `grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr))`. That gives two equal 1fr columns (50/50) when the element is at least about 720px wide. Below that it gives one column, with the preview wrapping below the editor, because auto-fit collapses the empty tracks. No media query is needed, and the element's width drives it rather than the viewport's.
|
||||
- `.blogeditor__main` gets min-width 0.
|
||||
- `.blogeditor__preview` is a flex container with min-width 0, min-height 360px, background var(--c-surface) and a 1px var(--c-border) divider that reads in both arrangements, for example an outline.
|
||||
- Its iframe gets display block, flex 1 1 auto, width 100%, min-height 360px, border 0 and background var(--c-surface).
|
||||
- The existing [hidden] rule hides the pane when it is off. Existing rules stay as they are.
|
||||
|
||||
6. Write tests/blogeditor/preview.test.ts per the behavior block. Do not edit smoke.test.ts, corpus.test.ts, helpers.ts or anything under fixtures.
|
||||
|
||||
7. Run `npm run build`, then `bash scripts/check-dist.sh`, `npm test` and `go vet ./...`, all inside bash -c. Stage only this task's files and commit in sm-journal-plugin with the subject `feat(editor): toggleable preview pane in a sandboxed frame` and a two-line body (the toggle and split, the empty sandbox with srcdoc only and the value getter as the only read). Add no co-author trailer and do not stage go.work.sum.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test && bash scripts/check-dist.sh && go vet ./... && grep -qF "togglePreview" assets/src/blogeditor/toolbar.ts && grep -qF "showPreview" assets/src/blogeditor/element.ts && grep -qF "PREVIEW_SANDBOX" assets/src/blogeditor/element.ts && grep -qF "auto-fit" assets/src/blogeditor/blogeditor.css && grep -qF "Podgląd" assets/src/blogeditor/labels.ts && test "$(grep -E "^[^ @/*}].*\{" assets/src/blogeditor/blogeditor.css | grep -vc "^golem15-journal-blogeditor")" = 0 && test "$(grep -cE "#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(" assets/src/blogeditor/blogeditor.css)" = 0 && git diff --quiet 9d5bfc4 HEAD -- tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/helpers.ts tests/blogeditor/fixtures package.json package-lock.json && st=$(git status --porcelain -- assets tests) && test -z "$st" && files=$(git show --name-only --format= HEAD) && test -z "$(grep -vxF -e "" -e assets/src/blogeditor/preview.ts -e assets/src/blogeditor/element.ts -e assets/src/blogeditor/toolbar.ts -e assets/src/blogeditor/labels.ts -e assets/src/blogeditor/blogeditor.css -e assets/dist/blogeditor.js -e assets/dist/blogeditor.css -e tests/blogeditor/preview.test.ts <<< "$files")" && body=$(git log -1 --format=%B) && test -z "$(grep -i co-authored-by <<< "$body")" && subj=$(git log -1 --format=%s) && grep -q preview <<< "$subj"'</automated>
|
||||
</verify>
|
||||
<done>Clicking the localized Preview button splits the element and shows the current value in an iframe whose sandbox attribute is exactly empty and whose srcdoc carries the CSP and no-referrer metas. aria-pressed follows the toggle per element. Toggling an untouched imported post emits nothing, runs no transaction, never calls serializeDocument and keeps the exact string. The whole vitest suite passes, including the unedited smoke and corpus files. check-dist is green, go vet is clean, and one feat(editor) commit holds only the task's files.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Live debounced updates, YouTube thumbnail cards, post-body typography and resolved colours</name>
|
||||
<files>../sm-journal-plugin/assets/src/blogeditor/preview.ts, ../sm-journal-plugin/assets/src/blogeditor/element.ts, ../sm-journal-plugin/tests/blogeditor/preview.test.ts, ../sm-journal-plugin/assets/dist/blogeditor.js, ../sm-journal-plugin/assets/dist/blogeditor.css</files>
|
||||
<read_first>
|
||||
- preview.ts and the element.ts preview members as committed in Task 1
|
||||
- embed.ts (EMBED_PROVIDERS, isEmbedProvider, idPattern) and, in extensions.ts, the inert() function and the Embed node's parseHTML getAttrs and addNodeView card text (use grep -n to find the lines, then read only those ranges)
|
||||
- the .ProseMirror content rules in blogeditor.css (headings, lists, blockquote, code, table, figure), which the preview typography mirrors
|
||||
</read_first>
|
||||
<behavior>
|
||||
Add these to tests/blogeditor/preview.test.ts. Call vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) after mount and before the toggle, and advance only by explicit amounts.
|
||||
- WYSIWYG live update: mount('<p>a</p>') and toggle on, so the srcdoc has '<p>a</p>'. Insert 'X' at 1. The srcdoc is unchanged after advanceTimersByTime(PREVIEW_DEBOUNCE_MS - 1) and contains '<p>Xa</p>' after advancing 1 more ms.
|
||||
- Debounce reset: make an edit, advance 200, make a second edit, advance 200. The srcdoc still shows neither edit. After advancing 100 more it shows both.
|
||||
- Source mode: call showSource(true), set the textarea to '<h2>From source</h2>' and dispatch input. After PREVIEW_DEBOUNCE_MS the srcdoc contains '<h2>From source</h2>'. changes is exactly ['<h2>From source</h2>'] (the input only; the render adds nothing).
|
||||
- External value: with the preview on, set el.value = '<p>New</p>'. After the debounce the srcdoc contains '<p>New</p>' and changes is [].
|
||||
- Off means idle: toggle on, then off, then edit and advance 1000 ms. The srcdoc is unchanged from the last render.
|
||||
- Detach: make an edit with the preview on, call el.remove(), then advance 1000 ms. No error is thrown and the srcdoc is unchanged.
|
||||
- Untouched after renders: mount(fixture), toggle on, then advanceTimersByTime(1000). changes is [], el.value toBe fixture and serializeDocument was not called.
|
||||
- Embed card: mount('<figure data-embed="youtube" data-id="dQw4w9WgXcQ"></figure>') and toggle on. The srcdoc contains 'https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 'referrerpolicy="no-referrer"' and 'Embedded video: YouTube dQw4w9WgXcQ'. With ui-locale pl it contains 'Osadzone wideo: YouTube dQw4w9WgXcQ'. The srcdoc matches no /<iframe/i, and el.value equals the placeholder string exactly.
|
||||
- Invalid placeholder: '<figure data-embed="youtube" data-id="short"></figure>' gives a srcdoc with no 'img.youtube.com'.
|
||||
- Active content in the copy only: value '<p>a</p><script>alert(1)</script><iframe src="https://example.test/"></iframe><img src="/x.png" onerror="alert(1)"><meta http-equiv="refresh" content="0;url=https://example.test/">'. The srcdoc matches none of /<script/i, /<iframe/i, /onerror/i or /http-equiv="refresh"/i, and still contains '<p>a</p>' and 'src="/x.png"'. el.value equals the given string exactly and changes is [].
|
||||
- Colours: set el.style.setProperty('--c-text', 'rgb(1, 2, 3)') before toggling, and the srcdoc contains 'rgb(1, 2, 3)'. No srcdoc ever contains 'var(--c-'. With no variables set, the srcdoc contains PREVIEW_DEFAULTS.colors.text.
|
||||
- safeCssValue unit cases: an empty value, a value with '}', a value with '<', a value with ';' and a 201-character value all return the fallback. ' rgb(1, 2, 3) ' returns 'rgb(1, 2, 3)', and '"DM Sans", sans-serif' is returned unchanged.
|
||||
- Lang and typography: with locale 'pl' the srcdoc contains '<html lang="pl">'. With locale 'pl"><x' it has no lang attribute. The srcdoc has one style element with rules for '.post-body', 'blockquote', 'figcaption' and '.embed-card', and the body wraps the content in '<article class="post-body">'.
|
||||
</behavior>
|
||||
<action>
|
||||
TDD: add the behavior cases first and run `node_modules/.bin/vitest run tests/blogeditor/preview.test.ts`. Note in the SUMMARY which cases fail against Task 1, then implement until the whole suite is green.
|
||||
|
||||
1. preview.ts:
|
||||
- Export `interface PreviewColors` with the fields surface, text, muted, border, borderStrong, subtle and primary. Export `PREVIEW_DEFAULTS = { colors, fontFamily }`. Use neutral light-theme constants: a white surface, near-black text, a mid-grey muted colour, light-grey and darker-grey borders, an off-white subtle colour and a blue primary. The font stack is system-ui with sans-serif fallbacks.
|
||||
- Export `safeCssValue(value, fallback)`. It returns the trimmed value, or the fallback when the value is empty, longer than 200 characters, or contains a less-than, greater-than, curly brace, semicolon, backslash or a CSS comment opener. A host value can then never close the style element or inject a rule.
|
||||
- Export `resolvePreviewColors(host: Element): { colors: PreviewColors; fontFamily: string }`. It reads getComputedStyle(host).getPropertyValue for --c-surface, --c-text, --c-muted, --c-border, --c-border-strong, --c-subtle and --c-primary, plus the computed fontFamily, each through safeCssValue with its PREVIEW_DEFAULTS fallback.
|
||||
- Export `youtubeThumbnail(id)`, which returns the https://img.youtube.com/vi/ID/hqdefault.jpg URL. Call it only for ids that passed the provider idPattern, so no unchecked text reaches a URL.
|
||||
- Export `previewBody(html: string, labels: Labels): string`. It works on a copy:
|
||||
- Parse the copy into a template element created from inert().
|
||||
- Remove the script, iframe, frame, frameset, object, embed, meta, base and link elements, and every attribute whose lowercased name starts with 'on'. This is defense in depth on top of the sandbox, and the stored value never changes.
|
||||
- Replace each figure[data-embed] that is exactly the editor's placeholder with a card. "Exactly" means two attributes, no child nodes, isEmbedProvider(provider) and EMBED_PROVIDERS[provider].idPattern accepting the id, the same test as the Embed node's parse rule. The card is a figure with class embed-card holding an img and a figcaption. The img has src youtubeThumbnail(id), an empty alt, referrerpolicy no-referrer, loading lazy, width 480 and height 360. The figcaption's textContent is `${labels.embedCard}: ${EMBED_PROVIDERS[provider].name} ${id}`, the editor card's text, set through textContent so it is escaped.
|
||||
- Leave any other figure[data-embed] as it is, with no thumbnail request.
|
||||
- Return the copy's HTML through a div in the inert document.
|
||||
- Extend PreviewOptions with `colors?: PreviewColors` and `fontFamily?: string`. In buildPreviewDocument:
|
||||
- Put the html element's lang attribute only when options.lang matches /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/.
|
||||
- Add one style element after the two metas, holding the post-body typography built from the given colours and font, with PREVIEW_DEFAULTS for anything missing.
|
||||
- Wrap previewBody(html, labels) in the article.
|
||||
- The typography mirrors the editor's .ProseMirror content rules, so the preview reads like a post body:
|
||||
- body: margin 0, padding 16px 20px, background surface, colour text, a 16px font with 1.7 line-height in the resolved family, overflow-wrap anywhere.
|
||||
- .post-body: max-width 72ch with auto side margins.
|
||||
- Headings h1 to h4 at weight 600 with sizes 1.875em, 1.5em, 1.25em and 1.1em and margin 1.4em 0 0.5em.
|
||||
- p, ul, ol, blockquote, table, figure and pre: margin 0 0 1em.
|
||||
- ul uses disc and ol uses decimal, each with 1.5em left padding.
|
||||
- Links: primary colour, underlined.
|
||||
- blockquote: a 3px borderStrong left border, 1em left padding and the muted colour.
|
||||
- code and pre: on the subtle colour with a monospace stack. pre gets padding and an 8px radius.
|
||||
- hr: a borderStrong top border.
|
||||
- img: max-width 100%, height auto.
|
||||
- Tables: collapse borders at full width. td and th get a 1px borderStrong border, 4px 8px padding and vertical-align top. th gets the subtle background, weight 600 and left alignment.
|
||||
- figcaption: 13px, muted, margin-top 4px.
|
||||
- .embed-card: max-width 480px, a 1px border-colour border, an 8px radius, overflow hidden and the subtle background. Its img gets display block, width 100%, aspect-ratio 16/9 and object-fit cover. Its figcaption gets 8px 12px padding and margin 0.
|
||||
- The stylesheet uses only the concrete resolved values.
|
||||
|
||||
2. element.ts:
|
||||
- Add `#schedulePreview()`. It returns unless #previewOn and the frame exist. Otherwise it clears the pending timer and sets #previewTimer = setTimeout(() => this.#renderPreview(), PREVIEW_DEBOUNCE_MS).
|
||||
- Call it in four places:
|
||||
- in the editor's onUpdate after #emitChange, which covers WYSIWYG typing, toolbar commands, undo and redo
|
||||
- at the end of #sourceInput (the Source tab)
|
||||
- at the end of the value setter after #load (an external value; the echo early return stays first, so an echo schedules nothing)
|
||||
- in attributeChangedCallback (locale changes the preview lang)
|
||||
- #renderPreview now passes `lang: this.getAttribute('locale')` and the result of resolvePreviewColors(this) to buildPreviewDocument. It skips assigning srcdoc when the built document equals the current srcdoc attribute, which avoids a needless reload.
|
||||
- Nothing else changes in the value, mode or event logic. The preview still reads only through the value getter.
|
||||
|
||||
3. Run `npm run build`, `bash scripts/check-dist.sh`, `npm test` and `go vet ./...` through bash -c. Commit only this task's files with the subject `feat(editor): live preview with embed cards and post typography` and a short body (the 300 ms debounce in both modes, thumbnail cards with no player, the preview-copy element and attribute drop, and colours resolved from the host). Add no co-author trailer.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test && bash scripts/check-dist.sh && go vet ./... && grep -qF "PREVIEW_DEBOUNCE_MS" assets/src/blogeditor/element.ts && grep -qF "schedulePreview" assets/src/blogeditor/element.ts && grep -qF "resolvePreviewColors" assets/src/blogeditor/element.ts && grep -qF "idPattern" assets/src/blogeditor/preview.ts && grep -qF "inert()" assets/src/blogeditor/preview.ts && grep -qF "img.youtube.com" assets/dist/blogeditor.js && test "$(grep -cE "eval\(|new Function\(" assets/dist/blogeditor.js)" = 0 && test "$(grep -E "^[^ @/*}].*\{" assets/src/blogeditor/blogeditor.css | grep -vc "^golem15-journal-blogeditor")" = 0 && test "$(grep -cE "#[0-9a-fA-F]{3,8}|rgba?\(|hsla?\(" assets/src/blogeditor/blogeditor.css)" = 0 && git diff --quiet 9d5bfc4 HEAD -- tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/helpers.ts tests/blogeditor/fixtures package.json package-lock.json && st=$(git status --porcelain -- assets tests) && test -z "$st" && files=$(git show --name-only --format= HEAD) && test -z "$(grep -vxF -e "" -e assets/src/blogeditor/preview.ts -e assets/src/blogeditor/element.ts -e assets/src/blogeditor/toolbar.ts -e assets/src/blogeditor/labels.ts -e assets/src/blogeditor/blogeditor.css -e assets/dist/blogeditor.js -e assets/dist/blogeditor.css -e tests/blogeditor/preview.test.ts <<< "$files")" && body=$(git log -1 --format=%B) && test -z "$(grep -i co-authored-by <<< "$body")" && subj=$(git log -1 --format=%s) && grep -q preview <<< "$subj"'</automated>
|
||||
</verify>
|
||||
<done>The preview re-renders 300 ms after the last WYSIWYG edit, Source input or external value, never while it is off and never after detach. YouTube placeholders show as img.youtube.com thumbnail cards with the localized label and no iframe. Active elements and on* attributes are absent from the preview copy while the value keeps them byte for byte. The srcdoc carries post-body typography with resolved or default colours and no var(--c-. All vitest files pass, check-dist is green, go vet is clean, and one feat(editor) commit holds only the task's files.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: README Blog Editor and Admin sections, final gate</name>
|
||||
<files>../sm-journal-plugin/README.md</files>
|
||||
<read_first>
|
||||
- README.md, the "### Blog Editor" and "### Admin: Blog Editor field types" sections (grep -n for the headings, then read that range only)
|
||||
- preview.ts as committed, so every identifier and value the README names matches the code
|
||||
</read_first>
|
||||
<action>
|
||||
1. In "### Blog Editor", extend the toolbar sentence of the first paragraph so it also lists a Preview toggle.
|
||||
|
||||
2. Add a bullet that starts with "**Preview.**" after the "Embeds" bullet. It states:
|
||||
- The Preview button ('Podgląd' for ui-locale pl) is off by default and per element. It shows its state through aria-pressed.
|
||||
- It splits the editor 50/50, and the pane wraps below the editor when the element is narrower than about 720px.
|
||||
- The pane renders the current value from the WYSIWYG or Source tab 300 ms (`PREVIEW_DEBOUNCE_MS`) after the last change. Each update reloads the frame.
|
||||
- The frame is an `<iframe srcdoc>` with an empty `sandbox` attribute, so no scripts run and its origin is opaque. It has `referrerpolicy="no-referrer"` and a Content-Security-Policy meta (`PREVIEW_CSP`) that allows only inline styles and images.
|
||||
- The preview copy leaves out script, iframe, frame, frameset, object, embed, meta, base and link elements and event handler attributes.
|
||||
- YouTube placeholders show as thumbnail cards loaded from img.youtube.com, never as a player. This, with the post's own images, is the only network traffic the pane makes.
|
||||
- The styling is post-body typography, with `--c-*` values read from the element at render time or neutral defaults.
|
||||
- Previewing never fires `summer-change` and never changes the value, so an untouched post is still saved byte for byte.
|
||||
|
||||
3. In "### Admin: Blog Editor field types", add one sentence after the type table: both types show the element's Preview pane, and previewing never changes what a save stores.
|
||||
|
||||
4. Name no consuming application. Every identifier you name (`PREVIEW_DEBOUNCE_MS`, `PREVIEW_CSP`, `summer-change`) must exist in the code.
|
||||
|
||||
5. Commit README.md alone with the subject `docs(readme): blog editor preview pane` and no co-author trailer.
|
||||
|
||||
6. Run the final gate in the verify command, and do not push. In the SUMMARY:
|
||||
- Record the three commit SHAs.
|
||||
- Record the bundle sizes before and after (the 16.2-02 baseline was 466,051 bytes).
|
||||
- Note that the 50/50 split, the narrow-width wrap, real sandbox enforcement and thumbnail loading are a browser check for the user, which happy-dom cannot do.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test && bash scripts/check-dist.sh && go vet ./... && grep -qF "**Preview.**" README.md && grep -qF "Podgląd" README.md && grep -qF "sandbox" README.md && grep -qF "img.youtube.com" README.md && grep -qF "PREVIEW_DEBOUNCE_MS" README.md && grep -qF "PREVIEW_CSP" README.md && test "$(grep -Eic "grzyby|fonoteka|plytarium|płytarium" README.md)" = 0 && shas=$(git log --format=%H 9d5bfc4..HEAD) && test "$(grep -c . <<< "$shas")" -ge 3 && bodies=$(git log --format=%B 9d5bfc4..HEAD) && test -z "$(grep -i co-authored-by <<< "$bodies")" && changed=$(git diff --name-only 9d5bfc4 HEAD) && test -z "$(grep -vxF -e "" -e README.md -e assets/src/blogeditor/preview.ts -e assets/src/blogeditor/element.ts -e assets/src/blogeditor/toolbar.ts -e assets/src/blogeditor/labels.ts -e assets/src/blogeditor/blogeditor.css -e assets/dist/blogeditor.js -e assets/dist/blogeditor.css -e tests/blogeditor/preview.test.ts <<< "$changed")" && git diff --quiet 9d5bfc4 HEAD -- tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/helpers.ts tests/blogeditor/fixtures package.json package-lock.json go.mod go.sum && st=$(git status --porcelain) && test -z "$(grep -vxF -e "" -e "?? go.work.sum" <<< "$st")" && subj=$(git log -1 --format=%s) && grep -q "^docs(readme)" <<< "$subj"'</automated>
|
||||
</verify>
|
||||
<done>The README's Blog Editor section lists the Preview toggle and has a Preview bullet covering the toggle, layout, debounce, sandbox, CSP, embed cards, typography and the value contract. The Admin field types section notes that previewing never changes what is saved. The final gate is green: vitest, check-dist and go vet. The branch has at least three local commits since 9d5bfc4 that touch only the listed files, carry no co-author trailer and leave package.json, the lockfile, go.mod, go.sum and the existing tests unchanged. The working tree is clean apart from go.work.sum. Nothing is pushed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| stored or admin-pasted post HTML → preview iframe | Untrusted markup (raw HTML blocks, Source tab text, imported posts) is rendered as a live document for the first time in the admin |
|
||||
| preview iframe → admin origin | The admin page holds the session, the admin DOM and the SPA state; the preview document must not reach them |
|
||||
| preview iframe → third parties (img.youtube.com, image hosts) | Rendering triggers network requests from the administrator's browser |
|
||||
| host page CSS (--c-* values) → srcdoc stylesheet | Values computed from the admin's CSS are interpolated into the preview document |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-iwu-01 | Elevation of Privilege (script execution, admin XSS) | element.ts preview iframe, preview.ts buildPreviewDocument | high | mitigate | The iframe's sandbox attribute is exactly '' (PREVIEW_SANDBOX), so no scripts run. Content enters only through srcdoc, with no src and no allow. The srcdoc inherits the admin CSP (script-src 'self') and adds PREVIEW_CSP (default-src 'none'). previewBody also drops script, iframe, frame, frameset, object, embed, meta, base and link elements and every on* attribute from the copy. The Task 1 test pins sandbox === '' with no allow-scripts. The Task 2 test pins the active-content drop. |
|
||||
| T-iwu-02 | Information Disclosure (admin origin, session, DOM) | preview iframe origin | high | mitigate | There is no allow-same-origin token, so the srcdoc document has an opaque origin. It cannot read admin cookies, storage, the parent DOM or the SPA, and the parent never reaches into contentDocument. The Task 1 sandbox test pins this. |
|
||||
| T-iwu-03 | Information Disclosure (third-party requests) | YouTube thumbnail cards, post images | low | mitigate | A thumbnail is requested only while the preview is switched on (off by default), and only for ids that pass the provider idPattern. The thumbnail URL is youtubeThumbnail(id), so no free text reaches a URL. The img has referrerpolicy no-referrer, the srcdoc has a no-referrer meta, and the admin's same-origin Referrer-Policy is inherited, so the admin URL never leaks. PREVIEW_CSP limits network access to images: fonts, stylesheets, frames and media are blocked. Residual risk (accepted): the administrator's IP and user agent reach img.youtube.com, the same exposure every site visitor gets from the layer's player. |
|
||||
| T-iwu-04 | Tampering (stored value integrity) | element.ts #renderPreview and #schedulePreview | medium | mitigate | The preview reads only the value getter: clean returns the exact string, source returns the Source text, and wysiwyg calls the pure serializeDocument. It never calls getHTML, setContent, #load or #emitChange, never dispatches transactions and never writes value fields. Tests assert no summer-change, el.value === fixture, editor.state.doc identity and serializeDocument not called for an untouched post. smoke.test.ts and corpus.test.ts stay unedited and green. |
|
||||
| T-iwu-05 | Spoofing (navigation, phishing from previewed links) | preview iframe | low | mitigate | The empty sandbox blocks top navigation, popups and form submission, so a click on a previewed link can only navigate the sandboxed frame itself. The meta refresh element is dropped from the copy, and the inherited base-uri 'none' blocks a pasted base element. |
|
||||
| T-iwu-06 | Tampering (CSS injection into srcdoc) | preview.ts resolvePreviewColors and safeCssValue | low | mitigate | Every computed --c-* value and the font family pass safeCssValue. A value that contains characters able to close the style element or start a rule (angle or curly brackets, semicolon, backslash, comment opener), or that runs over 200 characters, falls back to PREVIEW_DEFAULTS. Unit cases cover each. |
|
||||
| T-iwu-07 | Denial of Service (render per keystroke on long posts) | element.ts #schedulePreview | low | mitigate | A 300 ms debounce applies, nothing runs while the preview is off, the timer is cleared on toggle off and on disconnect, and an identical srcdoc is not re-assigned. Tests cover the debounce reset, the idle-when-off case and detach. |
|
||||
| T-iwu-SC | Tampering | npm/pip/cargo installs | low | accept | No package is added. The Task 1 to Task 3 gates diff package.json and package-lock.json against 9d5bfc4. The build uses the committed lockfile through check-dist.sh. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && npm test'`: smoke.test.ts, corpus.test.ts and preview.test.ts all pass, and the first two are unedited.
|
||||
- `bash -c 'cd /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin && bash scripts/check-dist.sh'` prints `check-dist: assets/dist matches a fresh build`.
|
||||
- `go -C /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin vet ./...` is clean.
|
||||
- `git -C /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin log --oneline 9d5bfc4..HEAD` shows the three commits (two feat(editor), one docs(readme)), and nothing is pushed.
|
||||
- Browser check (human judgment, recorded in the SUMMARY and not blocking): in the admin post form, Preview splits the editor 50/50 in light and dark mode, and the pane wraps below the editor in a narrow window. A `<script>` in a raw HTML block does nothing in the pane. A YouTube embed shows its thumbnail card.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- The localized Preview toggle, with aria-pressed and per-element state that is off by default, splits the editor 50/50 and wraps the pane below the editor on narrow widths.
|
||||
- The pane is a srcdoc iframe with an empty sandbox attribute, a CSP meta and a no-referrer meta. It updates live with a 300 ms debounce in WYSIWYG and Source modes.
|
||||
- YouTube placeholders show as img.youtube.com thumbnail cards with a label, with no iframe. The preview uses post-body typography with resolved --c-* values or neutral defaults, and blogeditor.css stays tag-scoped with --c-* colours only.
|
||||
- The value contract holds: toggling or previewing never emits summer-change, never serializes an untouched document and never changes the value. The existing untouched-value tests pass unedited.
|
||||
- assets/dist is rebuilt and committed, check-dist is green, go vet is clean, the README Blog Editor and Admin sections are updated, there are no co-author tags, and nothing is pushed or deployed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/.planning/quick/261010-iwu-blog-editor-live-preview-pane-toggleable/261010-iwu-SUMMARY.md` when done. It records `commits_repo: ../sm-journal-plugin`, the three commit SHAs, the bundle size delta, the TDD RED evidence for Task 2 and the browser check as a human_judgment coverage item.
|
||||
</output>
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
phase: quick-261010-iwu
|
||||
plan: 01
|
||||
subsystem: journal-blogeditor
|
||||
status: complete
|
||||
tags: [blog-editor, preview, iframe, sandbox, csp, tiptap, journal]
|
||||
commits_repo: ../sm-journal-plugin
|
||||
requires:
|
||||
- "16.2-02 Blog Editor element (value contract, toolbar, labels, embed placeholder)"
|
||||
provides:
|
||||
- "Toggleable live preview pane in golem15-journal-blogeditor (sandboxed iframe srcdoc)"
|
||||
- "preview.ts: PREVIEW_SANDBOX, PREVIEW_CSP, PREVIEW_DEBOUNCE_MS, PREVIEW_DEFAULTS, safeCssValue, resolvePreviewColors, youtubeThumbnail, previewBody, buildPreviewDocument"
|
||||
affects:
|
||||
- "sm-journal-plugin assets/dist (embedded admin bundle)"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Read-only preview through the value getter only (no serializer for an untouched document)"
|
||||
- "Lazy iframe creation on first switch-on"
|
||||
- "Inert-document copy for preview transforms (active-content drop, embed thumbnail cards)"
|
||||
key-files:
|
||||
created:
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/preview.ts
|
||||
- ../sm-journal-plugin/tests/blogeditor/preview.test.ts
|
||||
modified:
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/element.ts
|
||||
- ../sm-journal-plugin/assets/src/blogeditor/toolbar.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/README.md
|
||||
decisions:
|
||||
- "The preview iframe is created on the first switch-on, not at connect, because the unedited smoke test asserts the element holds no iframe after mount; it also means an element that never previews has no extra browsing context"
|
||||
- "Task 2 shipped as one feat commit (no separate RED test commit) so npm test stays green at every commit; the RED evidence is recorded here"
|
||||
metrics:
|
||||
duration: "~20 min"
|
||||
completed: 2026-10-10
|
||||
tasks: 3
|
||||
files: 9
|
||||
estimate:
|
||||
tokens: 70000
|
||||
tasks: 3
|
||||
actuals:
|
||||
tokens: 10058 # chars/4 over the realized diff of sources, tests and README (40,233 chars; the regenerated assets/dist bundle excluded)
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 9d5bfc4a3597be8ba4abee26d80dc2b81d343da8
|
||||
plan_head_after: 2c78211cb90e27cf8515b104613c3eac1a0aca5f
|
||||
---
|
||||
|
||||
# Quick 261010-iwu Plan 01: Blog Editor Live Preview Pane Summary
|
||||
|
||||
A per-element Preview/Podgląd toggle on the Journal Blog Editor splits the editor 50/50 with an auto-fit grid. The second pane is an `<iframe srcdoc>` with an empty `sandbox` attribute, a CSP meta and a no-referrer meta. It re-renders 300 ms after the last change in WYSIWYG or Source mode, shows YouTube placeholders as img.youtube.com thumbnail cards and uses post-body typography with colours resolved from the host's `--c-*` variables. It reads only the value getter, so an untouched post still saves byte for byte.
|
||||
|
||||
## Commits (sm-journal-plugin, branch main, not pushed)
|
||||
|
||||
| Task | Commit | Message |
|
||||
|------|--------|---------|
|
||||
| 1 | `df67939` | feat(editor): toggleable preview pane in a sandboxed frame |
|
||||
| 2 | `c1f6a83` | feat(editor): live preview with embed cards and post typography |
|
||||
| 3 | `2c78211` | docs(readme): blog editor preview pane |
|
||||
|
||||
`git rev-list --count 9d5bfc4..HEAD` = 3. No commit has a co-author trailer. `go.work.sum` stays untracked.
|
||||
|
||||
## What was built
|
||||
|
||||
- **labels.ts:** `preview` / `previewFrame` labels: 'Preview' / 'Post preview' (en) and 'Podgląd' / 'Podgląd wpisu' (pl).
|
||||
- **toolbar.ts:** a `div.blogeditor__view` holding `button[data-command=preview]` with `aria-pressed`, placed outside the tablist and the `buttons` array so `refresh()` never disables it. Adds `ToolbarHost.togglePreview` and `Toolbar.setPreview`.
|
||||
- **element.ts:**
|
||||
- Per-instance `#previewOn` (off by default) and a public `showPreview(on)`.
|
||||
- The editor and the Source textarea now sit inside `div.blogeditor__main`, which is inside `div.blogeditor__panes`, next to a hidden `div.blogeditor__preview`.
|
||||
- The iframe is created lazily with `sandbox=""` and `referrerpolicy=no-referrer`.
|
||||
- `#renderPreview()` reads only `this.value` and skips an identical srcdoc.
|
||||
- `#schedulePreview()` debounces renders. It is called from onUpdate, `#sourceInput`, the value setter (after the echo early return) and attributeChangedCallback.
|
||||
- The timer is cleared on toggle-off and on disconnect.
|
||||
- **preview.ts:**
|
||||
- Constants and `safeCssValue`.
|
||||
- `resolvePreviewColors`, which uses getComputedStyle with fallbacks to `PREVIEW_DEFAULTS`.
|
||||
- `youtubeThumbnail`.
|
||||
- `previewBody`: an inert-document copy that drops script, iframe, frame, frameset, object, embed, meta, base and link elements and every `on*` attribute. It turns exact placeholders, checked with `isEmbedProvider` and `idPattern`, into `figure.embed-card` with an img and a figcaption.
|
||||
- `buildPreviewDocument`: charset, CSP and referrer metas, one `<style>`, `lang` only when it matches the BCP-47-ish pattern, and `article.post-body`.
|
||||
- **blogeditor.css:** `.blogeditor__view`, `.blogeditor__panes--split` (`repeat(auto-fit, minmax(min(100%, 360px), 1fr))`), `.blogeditor__main`, `.blogeditor__preview` and its iframe. All are tag-scoped and use only `var(--c-*)`.
|
||||
- **tests/blogeditor/preview.test.ts:** 23 cases covering the toggle and aria-pressed, the locale, per-element state, lazy frame creation, usability in Source and disabled, the sandbox and CSP, the untouched and touched value contract, debounced live updates (WYSIWYG, debounce reset, Source, external value, idle when off, detach, untouched after renders), embed cards (en and pl), the invalid placeholder, the active-content drop, colours, lang and typography, and safeCssValue unit cases.
|
||||
- **README.md:** the Blog Editor toolbar sentence, a "**Preview.**" bullet and an Admin field-types sentence.
|
||||
|
||||
## TDD Gate Compliance (Task 2)
|
||||
|
||||
RED: the 14 Task 2 cases were added first and run against the Task 1 code (`node_modules/.bin/vitest run tests/blogeditor/preview.test.ts`). 10 of them failed:
|
||||
- live updates: re-renders a WYSIWYG edit after the debounce; restarts the debounce on every change; re-renders Source tab input; re-renders an external value
|
||||
- content: YouTube thumbnail card; active-content drop; colours; lang and typography
|
||||
- safeCssValue: both cases (export missing)
|
||||
|
||||
4 passed already: schedules nothing while off, drops a pending render on detach, untouched after renders, and no thumbnail for an invalid placeholder. They hold trivially when there is no scheduling or no card. They are guards, not drivers.
|
||||
|
||||
GREEN: after the preview.ts and element.ts changes, all 55 tests in the three files pass.
|
||||
|
||||
## Bundle size
|
||||
|
||||
| File | 16.2-02 baseline / before | After |
|
||||
|------|---------------------------|-------|
|
||||
| assets/dist/blogeditor.js | 466,051 B | 471,714 B (+5,663 B; 149.65 kB gzip) |
|
||||
| assets/dist/blogeditor.css | 7,480 B | 8,036 B (+556 B) |
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm test`: 55/55 pass (smoke 31, corpus, preview 23). smoke.test.ts, corpus.test.ts, helpers.ts and the fixtures are unchanged against 9d5bfc4.
|
||||
- `bash scripts/check-dist.sh` prints `check-dist: assets/dist matches a fresh build`.
|
||||
- `go vet ./...` is clean.
|
||||
- package.json, package-lock.json, go.mod and go.sum are byte-identical to 9d5bfc4.
|
||||
- All three task verify commands passed, including the Task 1 tracer re-run before expansion.
|
||||
|
||||
## Human judgment (browser check, not blocking)
|
||||
|
||||
happy-dom cannot check these, so they need a look in a real browser in the admin post form:
|
||||
- Preview splits the editor 50/50 in light and dark mode, and the pane wraps below the editor in a narrow window (under about 720px element width).
|
||||
- A `<script>` in a raw HTML block does nothing in the pane (real sandbox enforcement).
|
||||
- A YouTube embed shows its thumbnail card, and the image loads under the admin CSP plus PREVIEW_CSP.
|
||||
- Relative image URLs (`/storage/...`) resolve in the srcdoc against the admin origin.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Iframe created lazily instead of at connect**
|
||||
- **Found during:** Task 1
|
||||
- **Issue:** The plan had connectedCallback create the iframe up front. The unedited smoke test `embeds > round-trips the exact placeholder and shows a card, never an iframe` asserts `el.querySelector('iframe')` is null after mount, so it failed.
|
||||
- **Fix:** `#previewFrameOf()` creates the iframe in the pane on the first `showPreview(true)` and reuses it afterwards. The pane div still exists, hidden, from connect. `#schedulePreview` stays idle until a frame exists. The locale test toggles on before reading the frame title, and a new case asserts there is no iframe before the first switch-on and exactly one after repeated toggles.
|
||||
- **Files modified:** element.ts, preview.test.ts
|
||||
- **Commit:** df67939
|
||||
|
||||
## Out-of-scope observation
|
||||
|
||||
The active-content test value holds `<iframe src="https://example.test/">`. When that value is mounted, happy-dom logs `ENOTFOUND example.test` fetch errors (stderr only, and the tests pass). A probe showed the source is `mount()` alone, meaning TipTap's existing `elementFromString` parses through `window.DOMParser`. Neither `previewBody` (inert document) nor the sandboxed srcdoc frame triggers it. Browsers do not load subresources in DOMParser documents, so this is a happy-dom artifact in the existing editor parse path. It predates this task and was not changed.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None. The only new surface is the sandboxed srcdoc iframe and thumbnail requests to img.youtube.com, both covered by the plan's threat model (T-iwu-01 to T-iwu-07).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: assets/src/blogeditor/preview.ts and tests/blogeditor/preview.test.ts
|
||||
- FOUND commits: df67939, c1f6a83, 2c78211 (in sm-journal-plugin `git log`)
|
||||
@@ -141,8 +141,8 @@
|
||||
],
|
||||
"next": {
|
||||
"command": "/msd:progress --next",
|
||||
"label": "Advance to the next step",
|
||||
"reason": "Phase 16.2 of 34 · executing"
|
||||
"label": "Advance to the next step (verify)",
|
||||
"reason": "Phase 16.2 of 35 · ready to verify"
|
||||
},
|
||||
"updated_at": "2026-10-10T11:07:18.598Z"
|
||||
"updated_at": "2026-10-10T14:21:45.523Z"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
---
|
||||
title: Backend admin personal API tokens (deferred Apparatus PersonalApiToken)
|
||||
title: Backend admin personal API tokens (Phase 16.3)
|
||||
date: 2026-09-28
|
||||
priority: low
|
||||
priority: high
|
||||
area: summercms.go admin auth
|
||||
---
|
||||
|
||||
Apparatus provides personal API tokens for backend admins (`PersonalApiToken` model, `TokenAuthenticate` + `ForceJsonResponse` middleware, token create/revoke on the backend user form). Nothing in Płytarium calls it, so it is deferred past v1. The Phase 9 `backend` guard already accepts `Authorization: Bearer` for CLI and tests.
|
||||
|
||||
Revisit when a Golem15 project needs scripted access to the admin API. See `.planning/notes/apparatus-dissolved-into-framework.md`.
|
||||
|
||||
**2026-10-10:** Promoted to Phase 16.3 (scoped tokens, Journal agent use case). No migration of Apparatus tokens.
|
||||
|
||||
@@ -6,8 +6,10 @@ import SectionPanel from './SectionPanel.vue'
|
||||
import Breadcrumbs from './Breadcrumbs.vue'
|
||||
import UserMenu from './UserMenu.vue'
|
||||
import Toast from '../ui/Toast.vue'
|
||||
import { ExternalLink } from '@lucide/vue'
|
||||
import { activeEntry } from '../../state/useNavigation'
|
||||
import { useSidebar } from '../../state/useSidebar'
|
||||
import { t } from '../../app/i18n'
|
||||
|
||||
// Navigation shell (design: Navigation shell and Top header; D-06, D-10):
|
||||
// the plugin rail, the active plugin's section panel (hidden when the
|
||||
@@ -20,6 +22,10 @@ const { collapsed, watchViewport } = useSidebar()
|
||||
const stopViewport = watchViewport()
|
||||
onBeforeUnmount(stopViewport)
|
||||
|
||||
// "View site" (WinterCMS parity) opens the public site in a new tab: the
|
||||
// admin page's own origin, without the admin path prefix.
|
||||
const siteURL = `${window.location.origin}/`
|
||||
|
||||
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
|
||||
</script>
|
||||
|
||||
@@ -30,7 +36,20 @@ const active = computed(() => activeEntry(String(route.params.vendor ?? ''), Str
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<header class="flex h-header shrink-0 items-center justify-between gap-4 border-b border-border bg-surface pr-6 pl-8">
|
||||
<Breadcrumbs />
|
||||
<UserMenu />
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<a
|
||||
data-view-site
|
||||
:href="siteURL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="t('backend::lang.nav.view_site')"
|
||||
:aria-label="t('backend::lang.nav.view_site')"
|
||||
class="flex size-input items-center justify-center rounded-inner text-muted transition-colors duration-150 ease-out hover:bg-hover hover:text-text"
|
||||
>
|
||||
<ExternalLink class="size-[18px]" aria-hidden="true" />
|
||||
</a>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
<main class="min-w-0 flex-1 px-8 py-7">
|
||||
<slot />
|
||||
|
||||
155
admin/tests/app/fieldTypes.test.ts
Normal file
155
admin/tests/app/fieldTypes.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// Plugin field type loader (Phase 16.2 D-13): GET {api}/field-types once,
|
||||
// every script URL loaded once as a module script, styles enabled globally
|
||||
// on every screen, and nothing ever rejects. Files never load in happy-dom:
|
||||
// appended scripts and links go into a detached sandbox the test inspects,
|
||||
// and the test fires each script's load or error event itself. The asset
|
||||
// loader's state lives for the whole file, so every test uses its own URLs.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fieldTypeScripts, loadFieldTypes, resetFieldTypes } from '../../src/app/fieldTypes'
|
||||
import { activateStyles, GLOBAL_STYLE_OWNER, loadStyles, OWNER_ATTRIBUTE } from '../../src/app/pluginAssets'
|
||||
import { API, mockApi, type Route } from '../helpers'
|
||||
|
||||
let sandbox: HTMLDivElement
|
||||
|
||||
function captureHead(): void {
|
||||
sandbox = document.createElement('div')
|
||||
const head = document.head
|
||||
const append = head.appendChild.bind(head)
|
||||
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
|
||||
if (node instanceof HTMLScriptElement || node instanceof HTMLLinkElement) {
|
||||
return sandbox.appendChild(node)
|
||||
}
|
||||
return append(node)
|
||||
})
|
||||
}
|
||||
|
||||
function scripts(src: string): HTMLScriptElement[] {
|
||||
return Array.from(sandbox.querySelectorAll('script')).filter((script) => script.getAttribute('src') === src)
|
||||
}
|
||||
|
||||
function links(href: string): HTMLLinkElement[] {
|
||||
return Array.from(sandbox.querySelectorAll('link')).filter((link) => link.getAttribute('href') === href)
|
||||
}
|
||||
|
||||
const asset = (file: string) => `/admin-test/assets/acme/demo/${file}`
|
||||
|
||||
function types(body: unknown): Record<string, Route> {
|
||||
return { [`GET ${API}/field-types`]: { body } }
|
||||
}
|
||||
|
||||
function entry(name: string, scriptUrls: string[], styleUrls: string[] = []) {
|
||||
return { name, element: `acme-demo-${name}`, translatable: false, scripts: scriptUrls, styles: styleUrls }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetFieldTypes()
|
||||
captureHead()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
sandbox.remove()
|
||||
})
|
||||
|
||||
describe('loadFieldTypes', () => {
|
||||
it('loads each script URL once, even across two runs and two types', async () => {
|
||||
const shared = asset('js/shared-once.js?v=111111111111')
|
||||
const own = asset('js/own-once.js?v=222222222222')
|
||||
const calls = mockApi(types({ data: [entry('alpha', [shared]), entry('beta', [shared, own])], meta: {} }))
|
||||
const first = await loadFieldTypes()
|
||||
const second = await loadFieldTypes()
|
||||
expect(first.map((t) => t.name)).toEqual(['alpha', 'beta'])
|
||||
expect(second).toEqual(first)
|
||||
expect(calls.map((request) => new URL(request.url).pathname)).toEqual([`${API}/field-types`, `${API}/field-types`])
|
||||
expect(scripts(shared)).toHaveLength(1)
|
||||
expect(scripts(own)).toHaveLength(1)
|
||||
expect(scripts(shared)[0]!.type).toBe('module')
|
||||
|
||||
scripts(shared)[0]!.dispatchEvent(new Event('load'))
|
||||
scripts(own)[0]!.dispatchEvent(new Event('load'))
|
||||
await expect(fieldTypeScripts('acme-demo-alpha')).resolves.toEqual([])
|
||||
await expect(fieldTypeScripts('acme-demo-beta')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('does not wait for the scripts, and reports the ones that failed', async () => {
|
||||
const ok = asset('js/ok-fail.js')
|
||||
const broken = asset('js/broken-fail.js')
|
||||
mockApi(types({ data: [entry('gamma', [ok, broken])], meta: {} }))
|
||||
const loaded = await loadFieldTypes()
|
||||
expect(loaded).toHaveLength(1)
|
||||
let settled = false
|
||||
const pending = fieldTypeScripts('acme-demo-gamma').then((failed) => {
|
||||
settled = true
|
||||
return failed
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
scripts(ok)[0]!.dispatchEvent(new Event('load'))
|
||||
scripts(broken)[0]!.dispatchEvent(new Event('error'))
|
||||
await expect(pending).resolves.toEqual([broken])
|
||||
})
|
||||
|
||||
it('refuses URLs outside the asset prefix without appending them', async () => {
|
||||
const outside = 'https://cdn.example.test/admin-test/assets/acme/demo/js/x.js'
|
||||
const climb = '/admin-test/assets/../api/v1/x.js'
|
||||
const style = 'https://cdn.example.test/x.css'
|
||||
mockApi(types({ data: [entry('delta', [outside, climb], [style])], meta: {} }))
|
||||
await loadFieldTypes()
|
||||
expect(sandbox.querySelectorAll('script')).toHaveLength(0)
|
||||
expect(links(style)).toHaveLength(0)
|
||||
await expect(fieldTypeScripts('acme-demo-delta')).resolves.toEqual([outside, climb])
|
||||
})
|
||||
|
||||
it('keeps field type styles enabled on every screen', async () => {
|
||||
const global = asset('css/global-type.css')
|
||||
const own = asset('css/controller-only.css')
|
||||
mockApi(types({ data: [entry('epsilon', [], [global])], meta: {} }))
|
||||
await loadFieldTypes()
|
||||
loadStyles('acme.demo.widgets', [own])
|
||||
const [globalLink] = links(global)
|
||||
const [ownLink] = links(own)
|
||||
expect(globalLink!.getAttribute(OWNER_ATTRIBUTE)).toBe(GLOBAL_STYLE_OWNER)
|
||||
expect(globalLink!.rel).toBe('stylesheet')
|
||||
for (const active of ['acme.demo.widgets', 'acme.demo.other', '']) {
|
||||
activateStyles(active)
|
||||
expect(globalLink!.disabled).toBe(false)
|
||||
expect(ownLink!.disabled).toBe(active !== 'acme.demo.widgets')
|
||||
}
|
||||
// A second load adds no second link.
|
||||
await loadFieldTypes()
|
||||
expect(links(global)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each<[string, () => void]>([
|
||||
['a 500 answer', () => mockApi({ [`GET ${API}/field-types`]: { status: 500, body: { error: { code: 'error', message: 'down', details: {} } } } })],
|
||||
['a 401 answer', () => mockApi({ [`GET ${API}/field-types`]: { status: 401, body: { error: { code: 'unauthenticated', message: 'no', details: {} } } } })],
|
||||
['a network failure', () => vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))],
|
||||
])('resolves an empty list on %s', async (_name, setup) => {
|
||||
setup()
|
||||
await expect(loadFieldTypes()).resolves.toEqual([])
|
||||
expect(sandbox.querySelectorAll('script, link')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('tolerates a type without script or style lists', async () => {
|
||||
mockApi(types({ data: [{ name: 'zeta', element: 'acme-demo-zeta', translatable: true }], meta: {} }))
|
||||
const loaded = await loadFieldTypes()
|
||||
expect(loaded.map((t) => t.name)).toEqual(['zeta'])
|
||||
await expect(fieldTypeScripts('acme-demo-zeta')).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fieldTypeScripts', () => {
|
||||
it('resolves at once with no failures for an element no type defines', async () => {
|
||||
await expect(fieldTypeScripts('acme-demo-unknown')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('forgets the loaded types after resetFieldTypes', async () => {
|
||||
const url = asset('js/reset.js')
|
||||
mockApi(types({ data: [entry('eta', [url])], meta: {} }))
|
||||
await loadFieldTypes()
|
||||
scripts(url)[0]!.dispatchEvent(new Event('error'))
|
||||
await expect(fieldTypeScripts('acme-demo-eta')).resolves.toEqual([url])
|
||||
resetFieldTypes()
|
||||
await expect(fieldTypeScripts('acme-demo-eta')).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue'
|
||||
import FormField from '../../src/components/form/FormField.vue'
|
||||
import { clone, extensionFormSchemaFixture, formField } from '../fixtures/typed'
|
||||
import { mockApi, resetState } from '../helpers'
|
||||
import type { FormField as FormFieldSchema } from '../../src/api/types'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
@@ -32,6 +33,40 @@ describe('save notices', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin field rows (Phase 16.2)', () => {
|
||||
const plugin: FormFieldSchema = { name: 'content', type: 'blogeditor', element: 'acme-demo-never-defined', label: 'Content', comment: 'Edited in the plugin element' }
|
||||
|
||||
it('labels the plugin element group with a span, never a label for an input', () => {
|
||||
const wrapper = mount(FormField, { props: { field: plugin, modelValue: '', idPrefix: 'f' } })
|
||||
expect(wrapper.find('label').exists()).toBe(false)
|
||||
expect(wrapper.find('span#f-content-label').text()).toBe('Content')
|
||||
const group = wrapper.find('[data-plugin-field]')
|
||||
expect(group.attributes('aria-labelledby')).toBe('f-content-label')
|
||||
expect(group.attributes('aria-describedby')).toBe('f-content-comment')
|
||||
})
|
||||
|
||||
it('renders plugin notices as literal text linked to the group', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: { field: plugin, modelValue: '', notices: ['EN: 1 element was removed: <script>', 'PL: Usunięto 1 atrybut: onclick'], idPrefix: 'f' },
|
||||
})
|
||||
const line = wrapper.find('#f-content-notice')
|
||||
expect(line.attributes('role')).toBe('status')
|
||||
expect(line.findAll('p').map((p) => p.text())).toEqual(['EN: 1 element was removed: <script>', 'PL: Usunięto 1 atrybut: onclick'])
|
||||
expect(line.find('script').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-plugin-field]').attributes('aria-describedby')).toBe('f-content-comment f-content-notice')
|
||||
})
|
||||
|
||||
it('shows notices and errors together, notices first', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: { field: plugin, modelValue: '', notices: ['Trimmed'], errors: ['Required'], idPrefix: 'f' },
|
||||
})
|
||||
expect(wrapper.find('[data-plugin-field]').attributes('aria-describedby')).toBe('f-content-comment f-content-error f-content-notice')
|
||||
const html = wrapper.html()
|
||||
expect(html.indexOf('id="f-content-notice"')).toBeLessThan(html.indexOf('id="f-content-error"'))
|
||||
expect(html.indexOf('id="f-content-notice"')).toBeGreaterThan(-1)
|
||||
})
|
||||
})
|
||||
|
||||
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' } })
|
||||
|
||||
@@ -245,6 +245,81 @@ describe('stored value reload', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** A stub element that re-emits its current value when it connects, as an
|
||||
* element that normalizes nothing but announces its value on mount does. */
|
||||
class AcmeDemoEcho extends AcmeDemoNotes {
|
||||
connectedCallback(): void {
|
||||
this.dispatchEvent(new CustomEvent(FIELD_CHANGE_EVENT, { bubbles: true, detail: { value: this.value } }))
|
||||
}
|
||||
}
|
||||
if (!customElements.get('acme-demo-echo')) {
|
||||
customElements.define('acme-demo-echo', AcmeDemoEcho)
|
||||
}
|
||||
|
||||
describe('untouched value (D-03)', () => {
|
||||
// The form: every update:modelValue becomes the next modelValue, as
|
||||
// FormView's v-model does.
|
||||
function mountEcho(modelValue: unknown) {
|
||||
const wrapper: VueWrapper = mount(PluginField, {
|
||||
props: {
|
||||
field: notesField({ element: 'acme-demo-echo' }),
|
||||
modelValue,
|
||||
controlId: 'f-notes',
|
||||
recordId: 7,
|
||||
source: SOURCE,
|
||||
'onUpdate:modelValue': (value: unknown) => wrapper.setProps({ modelValue: value }),
|
||||
},
|
||||
global: { provide: { [FORM_LOCALE as symbol]: ref('pl') } },
|
||||
// Attached, so the element's connectedCallback runs.
|
||||
attachTo: document.body,
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function echoElement(wrapper: VueWrapper): AcmeDemoEcho {
|
||||
const found = wrapper.find('acme-demo-echo').element
|
||||
if (!(found instanceof AcmeDemoEcho)) {
|
||||
throw new Error('echo element not mounted')
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
it('keeps the served HTML identical when the element echoes it on connect', async () => {
|
||||
const served = '\n<!-- imported -->\n<p>Grzyby <b>rosną</b></p>\n<footer class="author-footer"><p>Autor</p></footer>\n'
|
||||
const wrapper = mountEcho(served)
|
||||
await flushPromises()
|
||||
const el = echoElement(wrapper)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[served]])
|
||||
expect((wrapper.props() as { modelValue: unknown }).modelValue).toBe(served)
|
||||
expect(el.value).toBe(served)
|
||||
// The property was written once, before connect; the echo wrote nothing.
|
||||
expect(el.writes).toEqual([served])
|
||||
})
|
||||
|
||||
it('does not write an equal object value a second time', async () => {
|
||||
const served = { blocks: [{ type: 'p', text: 'a' }], version: 1 }
|
||||
const wrapper = mountEcho(served)
|
||||
await flushPromises()
|
||||
const el = echoElement(wrapper)
|
||||
expect((wrapper.props() as { modelValue: unknown }).modelValue).toEqual(served)
|
||||
// An equal copy from the server (a reload) is not written either.
|
||||
await wrapper.setProps({ modelValue: JSON.parse(JSON.stringify(served)) })
|
||||
await flushPromises()
|
||||
expect(el.writes).toEqual([served])
|
||||
})
|
||||
|
||||
it('writes a value that really differs exactly once', async () => {
|
||||
const wrapper = mountEcho('<p>a</p>')
|
||||
await flushPromises()
|
||||
const el = echoElement(wrapper)
|
||||
await wrapper.setProps({ modelValue: '<p>b</p>' })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ modelValue: '<p>b</p>' })
|
||||
await flushPromises()
|
||||
expect(el.writes).toEqual(['<p>a</p>', '<p>b</p>'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MLPluginField', () => {
|
||||
class AcmeDemoNotesml extends AcmeDemoNotes {}
|
||||
if (!customElements.get('acme-demo-notesml')) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
fieldErrors,
|
||||
focusField,
|
||||
initialValues,
|
||||
isMLField,
|
||||
mergeMLValue,
|
||||
noticeLines,
|
||||
panelDomId,
|
||||
@@ -324,3 +325,45 @@ describe('noticeLines', () => {
|
||||
expect(noticeLines([{ field: 'x', message: '' }])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin field values (Phase 16.2)', () => {
|
||||
const plain: FormField = { name: 'notes', type: 'notes', element: 'acme-demo-notes' }
|
||||
const translatable: FormField = { name: 'body', type: 'notesml', element: 'acme-demo-notesml', translatable: true }
|
||||
|
||||
it('treats only a translatable plugin field as a locale map', () => {
|
||||
expect(isMLField(translatable)).toBe(true)
|
||||
expect(isMLField(plain)).toBe(false)
|
||||
expect(isMLField({ type: 'notesml', translatable: true })).toBe(false)
|
||||
expect(isMLField({ type: 'mltext' })).toBe(true)
|
||||
expect(isMLField(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('seeds a translatable plugin field with every enabled locale, a plain one with nothing', () => {
|
||||
expect(initialValues([translatable, plain], ['pl', 'en'])).toEqual({ body: { pl: '', en: '' } })
|
||||
expect(initialValues([{ ...plain, default: '<p>x</p>' }], ['pl'])).toEqual({ notes: '<p>x</p>' })
|
||||
})
|
||||
|
||||
it('sends a translatable value as a locale record and a plain value as it is', () => {
|
||||
const values = { body: { pl: '<p>P</p>', en: '<p>E</p>', de: 7 }, notes: { blocks: [1] }, other: 'x' }
|
||||
expect(editablePayload([translatable, plain], values)).toEqual({ body: { pl: '<p>P</p>', en: '<p>E</p>' }, notes: { blocks: [1] } })
|
||||
expect(editablePayload([{ ...plain, readOnly: true }], values)).toEqual({})
|
||||
expect(editablePayload([plain], {})).toEqual({})
|
||||
})
|
||||
|
||||
it('groups notices per field, keeping their order, and skips malformed ones', () => {
|
||||
const lines = noticeLines([
|
||||
{ field: 'body', locale: 'en', message: 'second locale first' },
|
||||
{ field: 'body', locale: 'pl', message: '1 element was removed: style' },
|
||||
{ field: 'body', message: 'no locale' },
|
||||
{ field: 'notes', locale: '', message: 'empty locale' },
|
||||
{ field: '', message: 'no field name' },
|
||||
null as unknown as { field: string; message: string },
|
||||
{ field: 'notes', message: 42 as unknown as string },
|
||||
])
|
||||
expect(lines).toEqual({
|
||||
body: ['EN: second locale first', 'PL: 1 element was removed: style', 'no locale'],
|
||||
notes: ['empty locale'],
|
||||
'': ['no field name'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,16 +15,21 @@ import PasswordField from '../../src/components/form/fields/PasswordField.vue'
|
||||
import PermissionEditorField from '../../src/components/form/fields/PermissionEditorField.vue'
|
||||
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
|
||||
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
|
||||
import MLPluginField from '../../src/components/form/fields/MLPluginField.vue'
|
||||
import PluginField from '../../src/components/form/fields/PluginField.vue'
|
||||
import RelationManager from '../../src/components/relation/RelationManager.vue'
|
||||
import {
|
||||
controlAttributes,
|
||||
controlClass,
|
||||
groupLabelled,
|
||||
isPluginField,
|
||||
isRegistered,
|
||||
isRegisteredField,
|
||||
needsRecord,
|
||||
ownsLabel,
|
||||
RELATION_MANAGER,
|
||||
rendererFor,
|
||||
rendererForField,
|
||||
toggleOn,
|
||||
toggleValue,
|
||||
} from '../../src/components/form/registry'
|
||||
@@ -220,3 +225,32 @@ describe('markdown and multilingual field types (Phase 14.2.1)', () => {
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('plugin field types (Phase 16.2, D-13)', () => {
|
||||
const plugin = { name: 'notes', type: 'notes', element: 'acme-demo-notes' }
|
||||
|
||||
it('renders a field that names an element with PluginField, a translatable one with MLPluginField', () => {
|
||||
expect(rendererForField(plugin)).toBe(PluginField)
|
||||
expect(rendererForField({ ...plugin, translatable: true })).toBe(MLPluginField)
|
||||
expect(rendererForField({ ...plugin, translatable: false })).toBe(PluginField)
|
||||
})
|
||||
|
||||
it('keeps the core renderer for a core type, and the unsupported box for an unknown type without an element', () => {
|
||||
expect(rendererForField({ name: 'title', type: 'text' })).toBe(TextField)
|
||||
expect(rendererForField({ name: 'body', type: 'markdown' })).toBe(MarkdownField)
|
||||
expect(rendererForField({ name: 'x', type: 'notes' })).toBe(UnsupportedField)
|
||||
expect(rendererForField({ name: 'x', type: 'notes', element: '' })).toBe(UnsupportedField)
|
||||
})
|
||||
|
||||
it('counts a plugin field as registered and editable whatever its alias', () => {
|
||||
expect(isPluginField(plugin)).toBe(true)
|
||||
expect(isPluginField({ element: '' })).toBe(false)
|
||||
expect(isPluginField({})).toBe(false)
|
||||
expect(isRegisteredField(plugin)).toBe(true)
|
||||
expect(isRegisteredField({ name: 'x', type: 'text' })).toBe(true)
|
||||
expect(isRegisteredField({ name: 'x', type: 'notes' })).toBe(false)
|
||||
// The alias is not a core type: only the element makes it a plugin field.
|
||||
expect(isRegistered('notes')).toBe(false)
|
||||
expect(groupLabelled('notes')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -143,6 +143,21 @@ describe('list view', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin field type columns (Phase 16.2)', () => {
|
||||
it('renders the row value of a column type it does not know as plain text', async () => {
|
||||
const schema = schemaWith((data) => {
|
||||
const code = data.columns.find((column) => column.key === 'code')!
|
||||
;(code as { type?: string }).type = 'blogeditor'
|
||||
})
|
||||
const rows = clone(listFixture)
|
||||
;(rows.data[0] as Record<string, unknown>).code = '<b>Grzyby</b> rosną'
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', routes({ [`GET ${BASE}/schema/list`]: schema, [`GET ${BASE}`]: { body: rows } }))
|
||||
const cell = wrapper.findAll('tbody tr')[0]!.findAll('td').find((td) => td.text().includes('Grzyby'))
|
||||
expect(cell?.text()).toBe('<b>Grzyby</b> rosną')
|
||||
expect(cell?.find('b').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('list view extension points (Phase 10.1, D-03, D-11, D-12, D-14)', () => {
|
||||
const STATS = `${BASE}/partials/stats`
|
||||
const RECOUNT = `${BASE}/toolbar/recount`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import AppShell from '../../src/components/shell/AppShell.vue'
|
||||
@@ -5,7 +7,9 @@ import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { NARROW_QUERY, setCollapsed } from '../../src/state/useSidebar'
|
||||
import { showToast } from '../../src/state/useToasts'
|
||||
import { navigationFixture } from '../fixtures/typed'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { fakeMedia, resetState, routerAt, signIn } from '../helpers'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
|
||||
async function mountShell(path: string) {
|
||||
await signIn()
|
||||
@@ -77,3 +81,39 @@ describe('app shell', () => {
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('Broken')
|
||||
})
|
||||
})
|
||||
|
||||
describe('View site link (Phase 16.2 quick follow-up)', () => {
|
||||
it('opens the site root in a new tab without an opener or referrer', async () => {
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
const link = wrapper.find('header a[data-view-site]')
|
||||
expect(link.exists()).toBe(true)
|
||||
expect(link.attributes('href')).toBe(`${window.location.origin}/`)
|
||||
expect(link.attributes('target')).toBe('_blank')
|
||||
expect(link.attributes('rel')?.split(' ').sort()).toEqual(['noopener', 'noreferrer'])
|
||||
// It sits next to the user menu, inside the header.
|
||||
expect(link.element.nextElementSibling?.matches('[data-user-menu], :has([data-user-menu])')).toBe(true)
|
||||
expect(link.find('svg').attributes('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['pl', 'Zobacz stronę'],
|
||||
['en', 'View site'],
|
||||
])('labels the link from the phrasebook in %s', async (locale, label) => {
|
||||
setBundle({ ...langFixture.data, 'backend::lang.nav.view_site': { other: label } }, locale)
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
const link = wrapper.find('a[data-view-site]')
|
||||
expect(link.attributes('aria-label')).toBe(label)
|
||||
expect(link.attributes('title')).toBe(label)
|
||||
})
|
||||
|
||||
it('has the label key in the backend phrasebook for en and pl', () => {
|
||||
for (const [locale, label] of [
|
||||
['en', 'View site'],
|
||||
['pl', 'Zobacz stronę'],
|
||||
]) {
|
||||
// vitest runs from the admin directory.
|
||||
const yaml = readFileSync(resolve(process.cwd(), '../modules/phrasebook/backend/lang', locale!, 'lang.yaml'), 'utf8')
|
||||
expect(yaml).toMatch(new RegExp(`^nav:[\\s\\S]*?^ view_site: ${label}$`, 'm'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -100,7 +100,7 @@ A notice is how a type that changes an accepted value tells the administrator wh
|
||||
|
||||
## 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.
|
||||
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` of a translatable type always receives a string: a client that submits a plain value instead of a locale map has it decoded for the default locale, `null` as the empty string, and any other JSON value (a number, a boolean) is a 422 on the field before `Decode` runs.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -118,7 +118,7 @@ A column in `columns.yaml` may name a plugin alias as its `type`. The list then
|
||||
|
||||
## 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.
|
||||
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 controller declares the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise; a 403 otherwise, as the save routes answer), 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`. It reads the whole body and checks that it holds exactly that one part before it calls `Upload` (a large file is spooled to a temporary file), so a malformed or oversized request never stores anything. A stored file can still be left unreferenced when the record's transaction fails after `Upload` returned or the element stops waiting for the answer, so `Upload` should store under a name that is safe to leave behind. `{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
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-DhIJo93I.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-DhIJo93I.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -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-CuK_LdRk.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DgNySjE5.css">
|
||||
<script type="module" crossorigin src="./assets/index-Bqh6wnN4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-DhIJo93I.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -52,7 +52,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
|
||||
| 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`. |
|
||||
| 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). 403 when the controller does not declare the save the upload belongs to (create for `{id}` 0, which needs the list's `create` toolbar button, update otherwise), as the save routes answer. 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`. The body is read and checked whole before `Upload` runs, so a refused request stores nothing. |
|
||||
| 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. |
|
||||
|
||||
@@ -474,6 +474,16 @@ func decodePluginFields(ctx context.Context, in pluginDecodeInput) (pluginDecode
|
||||
return pluginDecodeResult{}, err
|
||||
}
|
||||
if value, ok := in.Projected[field.Name]; ok {
|
||||
// pact.FormFieldValue promises a translatable type a string: a
|
||||
// scalar submitted instead of a locale map is text, null is
|
||||
// empty, anything else is refused before Decode runs (WR-06).
|
||||
switch value.(type) {
|
||||
case string:
|
||||
case nil:
|
||||
value = ""
|
||||
default:
|
||||
return pluginDecodeResult{}, &ValidationError{Details: fieldDetail(field.Name, "The "+field.Name+" field must be text or an object of locale codes to text.")}
|
||||
}
|
||||
stored, keep, err := decodeOne(ctx, in, field, def, true, value, &out)
|
||||
if err != nil {
|
||||
return pluginDecodeResult{}, err
|
||||
|
||||
234
modules/cabana/field_type_postgres_test.go
Normal file
234
modules/cabana/field_type_postgres_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The plugin field type save, show and upload paths on a real Postgres
|
||||
// database (testcontainers, like newListService). Without Docker the test
|
||||
// fails rather than skips; it runs on a Docker host before /msd-verify-work.
|
||||
|
||||
// pgFTController scopes the notes form to rows whose title is not "hidden".
|
||||
type pgFTController struct{ ftController }
|
||||
|
||||
func (pgFTController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||
return db.Where("title <> ?", "hidden")
|
||||
}
|
||||
|
||||
// pgFTPlugin serves pgFTController instead of ftController.
|
||||
type pgFTPlugin struct{ ftPlugin }
|
||||
|
||||
func (pgFTPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{pgFTController{}}
|
||||
}
|
||||
|
||||
// pgFTCall runs one admin record request against the notes controller.
|
||||
func pgFTCall(t *testing.T, svc *service, method, id string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var raw []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
if raw, err = json.Marshal(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, "/", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for name, value := range map[string]string{"vendor": "acme", "plugin": "demo", "controller": "notes"} {
|
||||
req.SetPathValue(name, value)
|
||||
}
|
||||
if id != "" {
|
||||
req.SetPathValue("id", id)
|
||||
}
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), superUser()))
|
||||
rec := httptest.NewRecorder()
|
||||
switch method {
|
||||
case http.MethodPost:
|
||||
svc.create(rec, req)
|
||||
case http.MethodGet:
|
||||
svc.show(rec, req)
|
||||
case http.MethodPut:
|
||||
svc.update(rec, req)
|
||||
default:
|
||||
t.Fatalf("method %s", method)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// pgFTBody is one admin record response.
|
||||
type pgFTBody struct {
|
||||
Data map[string]any `json:"data"`
|
||||
Meta map[string]json.RawMessage `json:"meta"`
|
||||
}
|
||||
|
||||
func pgFTDecode(t *testing.T, rec *httptest.ResponseRecorder) pgFTBody {
|
||||
t.Helper()
|
||||
var out pgFTBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("body %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pgFTNotices(t *testing.T, body pgFTBody) []FieldNotice {
|
||||
t.Helper()
|
||||
raw, ok := body.Meta["notices"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []FieldNotice
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFieldTypeSavePostgres(t *testing.T) {
|
||||
_, db := newListService(t)
|
||||
if err := db.Migrator().DropTable(&ftNote{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&ftNote{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Migrator().DropTable(&ftNote{}) })
|
||||
|
||||
fsys := ftFS(ftNotesField, ftBodyField)
|
||||
fsys["controllers/notes/config_list.yaml"] = &fstest.MapFile{Data: []byte(ftListConfig + "toolbar:\n buttons: [create]\n")}
|
||||
reg, err := ftCompile(pgFTPlugin{ftPlugin{id: "acme.demo", fsys: fsys, types: []pact.FormFieldType{ftNotesType(), ftNotesMLType()}}})
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
writer := ftWriter()
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Publish[TranslationWriter](writer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service{app: app, reg: reg, uploadBytes: 4096}
|
||||
|
||||
row := func(id uint) ftNote {
|
||||
t.Helper()
|
||||
var out ftNote
|
||||
if err := db.First(&out, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Create: plain and translatable values decoded, notices per locale.
|
||||
rec := pgFTCall(t, svc, http.MethodPost, "", map[string]any{"title": "T", "notes": " n ", "body": map[string]any{"pl": " P ", "en": " E "}})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
created := pgFTDecode(t, rec)
|
||||
idNum, _ := created.Data["id"].(float64)
|
||||
id := uint(idNum)
|
||||
if id == 0 {
|
||||
t.Fatalf("create data=%v", created.Data)
|
||||
}
|
||||
idText := strconv.FormatUint(uint64(id), 10)
|
||||
if got := row(id); got.Notes != "n" || got.Body != "P" || got.Title != "T" {
|
||||
t.Fatalf("created row=%+v", got)
|
||||
}
|
||||
if writer.attrs["en"]["body"] != "E" {
|
||||
t.Fatalf("en translation=%v", writer.attrs)
|
||||
}
|
||||
notices := pgFTNotices(t, created)
|
||||
if len(notices) != 3 {
|
||||
t.Fatalf("create notices=%+v", notices)
|
||||
}
|
||||
if created.Data["notes"] != "served:n" {
|
||||
t.Fatalf("create response serves notes=%v, want it through Load", created.Data["notes"])
|
||||
}
|
||||
|
||||
// Show serves every plugin field through Load, per locale.
|
||||
rec = pgFTCall(t, svc, http.MethodGet, idText, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("show status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
shown := pgFTDecode(t, rec)
|
||||
body, _ := shown.Data["body"].(map[string]any)
|
||||
if shown.Data["notes"] != "served:n" || body["pl"] != "P" || body["en"] != "E" {
|
||||
t.Fatalf("show data=%v", shown.Data)
|
||||
}
|
||||
|
||||
// An untouched update writes nothing and reports nothing.
|
||||
before := row(id)
|
||||
writes := len(writer.ids)
|
||||
rec = pgFTCall(t, svc, http.MethodPut, idText, map[string]any{"title": "T", "notes": shown.Data["notes"], "body": shown.Data["body"]})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("untouched update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := pgFTDecode(t, rec).Meta["notices"]; ok {
|
||||
t.Fatalf("untouched update reports notices: %s", rec.Body.String())
|
||||
}
|
||||
if after := row(id); after != before {
|
||||
t.Fatalf("untouched update changed the row: %+v -> %+v", before, after)
|
||||
}
|
||||
if len(writer.ids) != writes {
|
||||
t.Fatalf("untouched update wrote translations: %v", writer.ids[writes:])
|
||||
}
|
||||
|
||||
// A changed update writes and reports only what changed.
|
||||
rec = pgFTCall(t, svc, http.MethodPut, idText, map[string]any{"title": "T", "notes": "m ", "body": map[string]any{"pl": "P", "en": " E2"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("changed update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
changed := pgFTNotices(t, pgFTDecode(t, rec))
|
||||
if fmt.Sprint(changed) != fmt.Sprint([]FieldNotice{{Field: "notes", Message: "acme.demo::lang.trimmed"}, {Field: "body", Locale: "en", Message: "trimmed en"}}) {
|
||||
t.Fatalf("changed notices=%+v", changed)
|
||||
}
|
||||
if got := row(id); got.Notes != "m" || got.Body != "P" {
|
||||
t.Fatalf("changed row=%+v", got)
|
||||
}
|
||||
if writer.attrs["en"]["body"] != "E2" {
|
||||
t.Fatalf("en translation=%v", writer.attrs)
|
||||
}
|
||||
|
||||
// A FormFieldError is a 422 and the transaction writes nothing.
|
||||
before = row(id)
|
||||
rec = pgFTCall(t, svc, http.MethodPut, idText, map[string]any{"title": "Changed", "notes": "bad"})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"notes":["acme.demo::lang.bad"]`) {
|
||||
t.Fatalf("refused update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if after := row(id); after != before {
|
||||
t.Fatalf("refused update changed the row: %+v -> %+v", before, after)
|
||||
}
|
||||
|
||||
// Upload with a real record: the type sees it; a record outside the
|
||||
// FormExtendQuery scope is 404.
|
||||
admin := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
|
||||
up := upCall(t, svc, idText, "notes", []upPart{{"file", "a.txt", []byte("hello")}}, admin)
|
||||
if up.Code != http.StatusCreated || !strings.Contains(up.Body.String(), "record=set") {
|
||||
t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String())
|
||||
}
|
||||
hidden := ftNote{Title: "hidden"}
|
||||
if err := db.Create(&hidden).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up = upCall(t, svc, strconv.FormatUint(uint64(hidden.ID), 10), "notes", []upPart{{"file", "a.txt", []byte("hello")}}, admin)
|
||||
if up.Code != http.StatusNotFound {
|
||||
t.Fatalf("out-of-scope upload status=%d body=%s", up.Code, up.Body.String())
|
||||
}
|
||||
up = upCall(t, svc, "999999", "notes", []upPart{{"file", "a.txt", []byte("hello")}}, admin)
|
||||
if up.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing record upload status=%d body=%s", up.Code, up.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -401,9 +401,11 @@ func ftNotesMLType() pact.FormFieldType {
|
||||
|
||||
const ftBodyField = " body:\n type: notesml\n label: Body\n"
|
||||
|
||||
// ftMLPlugin has both types, a translatable body field and typed columns.
|
||||
// ftMLPlugin has both types, a translatable body field, typed columns and
|
||||
// the create toolbar button (the create form may upload).
|
||||
func ftMLPlugin() ftPlugin {
|
||||
fsys := ftFS(ftNotesField, ftBodyField)
|
||||
fsys["controllers/notes/config_list.yaml"] = &fstest.MapFile{Data: []byte(ftListConfig + "toolbar:\n buttons: [create]\n")}
|
||||
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()}}
|
||||
}
|
||||
|
||||
1461
modules/cabana/field_type_test.go
Normal file
1461
modules/cabana/field_type_test.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -16,10 +17,16 @@ import (
|
||||
// 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
|
||||
// operation the form would save (create for {id} 0, update otherwise), which
|
||||
// the controller must declare as the save routes require (403 otherwise), 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
|
||||
// outside it is 404. The body is one multipart part named file; it is read
|
||||
// and checked whole before Upload runs, so a malformed or oversized body
|
||||
// never reaches Upload. A stored object can still be left unreferenced when
|
||||
// the record transaction fails after Upload or the element stops waiting;
|
||||
// Upload stores under a name that is safe to leave behind. 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) {
|
||||
@@ -29,6 +36,14 @@ func (s *service) fieldUpload(w http.ResponseWriter, r *http.Request) {
|
||||
writeCRUDError(w, err)
|
||||
return
|
||||
}
|
||||
// The upload belongs to a save: refuse it where the save route would.
|
||||
op := "update"
|
||||
if id == 0 {
|
||||
op = "create"
|
||||
}
|
||||
if !s.operationDeclared(w, r, cc, op) {
|
||||
return
|
||||
}
|
||||
field, ok := uploadField(cc, r.PathValue("field"), id)
|
||||
if !ok {
|
||||
writeNotFound(w, r)
|
||||
@@ -46,34 +61,38 @@ func (s *service) fieldUpload(w http.ResponseWriter, r *http.Request) {
|
||||
writeCRUDError(w, uploadInvalid(field.Name))
|
||||
return
|
||||
}
|
||||
part, err := mr.NextPart()
|
||||
// The whole body is read and checked before Upload runs, so a
|
||||
// refused body never leaves a stored object behind (WR-02): a file
|
||||
// past fieldUploadMemory is spooled to a temporary file.
|
||||
form, err := mr.ReadForm(fieldUploadMemory)
|
||||
if err != nil {
|
||||
s.writeFieldUploadError(w, body, field, uploadInvalid(field.Name))
|
||||
return
|
||||
}
|
||||
if part.FormName() != "file" || strings.TrimSpace(part.FileName()) == "" {
|
||||
defer func() { _ = form.RemoveAll() }()
|
||||
header, ok := singleUploadFile(form)
|
||||
if !ok {
|
||||
writeCRUDError(w, uploadInvalid(field.Name))
|
||||
return
|
||||
}
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
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,
|
||||
Filename: header.Filename,
|
||||
Body: file,
|
||||
})
|
||||
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
|
||||
@@ -128,6 +147,23 @@ func uploadField(cc *CompiledController, name string, id uint) (FormField, bool)
|
||||
return FormField{}, false
|
||||
}
|
||||
|
||||
// fieldUploadMemory is how much of a field upload's file is kept in memory;
|
||||
// the rest is spooled to a temporary file until the request ends.
|
||||
const fieldUploadMemory = 8 << 20
|
||||
|
||||
// singleUploadFile is the one file of a field upload body: exactly one part,
|
||||
// a file part named file with a non-blank file name.
|
||||
func singleUploadFile(form *multipart.Form) (*multipart.FileHeader, bool) {
|
||||
if form == nil || len(form.Value) != 0 || len(form.File) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
files := form.File["file"]
|
||||
if len(files) != 1 || files[0] == nil || strings.TrimSpace(files[0].Filename) == "" {
|
||||
return nil, false
|
||||
}
|
||||
return files[0], true
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -121,7 +121,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `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.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 (always a string for a translatable type) 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. |
|
||||
|
||||
@@ -416,7 +416,9 @@ type FormFieldType struct {
|
||||
// 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,
|
||||
// for a translatable type (the framework refuses any other submitted value,
|
||||
// null aside, which is the empty string, before Decode runs) 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.
|
||||
|
||||
88
modules/pact/field_type_test.go
Normal file
88
modules/pact/field_type_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package pact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormFieldErrorMessage(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
messages []string
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{[]string{}, ""},
|
||||
{[]string{"acme.demo::lang.bad"}, "acme.demo::lang.bad"},
|
||||
{[]string{"first", "second", "third"}, "first; second; third"},
|
||||
} {
|
||||
err := &FormFieldError{Messages: tc.messages}
|
||||
if got := err.Error(); got != tc.want {
|
||||
t.Fatalf("Error(%q) = %q, want %q", tc.messages, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("a nil pointer does not panic", func(t *testing.T) {
|
||||
var err *FormFieldError
|
||||
if got := err.Error(); got != "" {
|
||||
t.Fatalf("nil Error() = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("it is found through a wrapped error", func(t *testing.T) {
|
||||
wrapped := fmt.Errorf("decode: %w", &FormFieldError{Messages: []string{"a", "b"}})
|
||||
var refused *FormFieldError
|
||||
if !errors.As(wrapped, &refused) || refused.Error() != "a; b" {
|
||||
t.Fatalf("errors.As = %v, %v", refused, wrapped)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormFieldTypeContract pins the plugin-facing shape: the funcs are
|
||||
// never serialized, and a plugin implements HasFormFieldTypes.
|
||||
func TestFormFieldTypeContract(t *testing.T) {
|
||||
typ := FormFieldType{
|
||||
Name: "notes",
|
||||
Element: "acme-demo-notes",
|
||||
Translatable: true,
|
||||
Scripts: []string{"assets/js/notes.js"},
|
||||
Styles: []string{"assets/css/notes.css"},
|
||||
Options: []string{"rows"},
|
||||
CheckOptions: func(map[string]any) error { return nil },
|
||||
Decode: func(context.Context, FormFieldValue) (FormFieldDecoded, error) {
|
||||
return FormFieldDecoded{}, nil
|
||||
},
|
||||
Load: func(context.Context, FormFieldValue) (any, error) { return nil, nil },
|
||||
ListValue: func(context.Context, any) string { return "" },
|
||||
Upload: func(context.Context, FormFieldUpload) (FormFieldUploadResult, error) {
|
||||
return FormFieldUploadResult{}, nil
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(typ)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var keys map[string]any
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, fn := range []string{"CheckOptions", "Decode", "Load", "ListValue", "Upload"} {
|
||||
if _, ok := keys[fn]; ok {
|
||||
t.Fatalf("%s is serialized: %s", fn, raw)
|
||||
}
|
||||
}
|
||||
if keys["Name"] != "notes" || keys["Element"] != "acme-demo-notes" || keys["Translatable"] != true {
|
||||
t.Fatalf("json = %s", raw)
|
||||
}
|
||||
|
||||
var src HasFormFieldTypes = fieldTypePlugin{types: []FormFieldType{typ}}
|
||||
if got := src.FormFieldTypes(); len(got) != 1 || got[0].Name != "notes" {
|
||||
t.Fatalf("FormFieldTypes() = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type fieldTypePlugin struct{ types []FormFieldType }
|
||||
|
||||
func (p fieldTypePlugin) FormFieldTypes() []FormFieldType { return p.types }
|
||||
@@ -18,6 +18,7 @@ nav:
|
||||
collapse: Collapse menu
|
||||
expand: Expand menu
|
||||
user_menu: User menu
|
||||
view_site: View site
|
||||
page:
|
||||
not_found: Page not found.
|
||||
list:
|
||||
|
||||
@@ -20,6 +20,7 @@ nav:
|
||||
collapse: Zwiń menu
|
||||
expand: Rozwiń menu
|
||||
user_menu: Menu użytkownika
|
||||
view_site: Zobacz stronę
|
||||
page:
|
||||
not_found: Nie znaleziono strony.
|
||||
list:
|
||||
|
||||
557
scripts/check-phase16.2.sh
Executable file
557
scripts/check-phase16.2.sh
Executable file
@@ -0,0 +1,557 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase 16.2 fail-closed gate: plugin-provided admin field types (summercms.go
|
||||
# pact and cabana, the admin SPA) and the Journal Blog Editor
|
||||
# (sm-journal-plugin Go and editor element, vue-journal-layer embeds).
|
||||
#
|
||||
# Stages:
|
||||
# go go vet in both Go repositories; the framework's field type tests
|
||||
# and the Journal classes and controllers tests (including the
|
||||
# editor-corpus policy cross-check); the 16.1 policy test files
|
||||
# must be unchanged since c60abc6.
|
||||
# admin the admin SPA typecheck and full vitest suite.
|
||||
# editor the Blog Editor typecheck and full vitest suite (smoke, corpus,
|
||||
# preview, element, schema, serializer, embed).
|
||||
# layer the vue-journal-layer node --test suite and the editor corpus
|
||||
# copy, which must equal the plugin's byte for byte (cmp).
|
||||
# docs TestDocsTree, the admin OpenAPI and dist drift gates and the
|
||||
# plugin's assets/dist drift gate.
|
||||
# postgres Docker host only: the testcontainers suites (cabana, including
|
||||
# TestFieldTypeSavePostgres; the Journal admin harness, including
|
||||
# TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML,
|
||||
# TestCategoriesAdminContent and TestCategoriesAdminBlogEditor).
|
||||
# Without a reachable Docker daemon this stage fails.
|
||||
#
|
||||
# Every stage exits non-zero on a failing command; a go test -json run that
|
||||
# fails, skips, matches zero tests, prints "no tests to run" or misses a named
|
||||
# required test; a vitest JSON report with a failed, skipped or todo test, a
|
||||
# failed file, no passing test or a missing required file or test; or a TAP
|
||||
# run with "not ok", a non-zero "# fail", a skip, no pass or a missing named
|
||||
# test. --self-test proves the detectors and the corpus comparison fail on
|
||||
# planted output.
|
||||
#
|
||||
# --all runs self-test, go, admin, editor, layer and docs, then postgres when
|
||||
# a Docker daemon answers. When none does, --all says so loudly (NOT RUN
|
||||
# postgres) and the Docker suites stay a manual step for a Docker host:
|
||||
# bash scripts/check-phase16.2.sh postgres
|
||||
# Set PHASE162_REQUIRE_POSTGRES=1 to make --all fail instead.
|
||||
#
|
||||
# Environment overrides: PHASE162_ROOT (this repository), JOURNAL (default
|
||||
# ../sm-journal-plugin), LAYER (default ../vue-journal-layer).
|
||||
set -euo pipefail
|
||||
|
||||
unset FORCE_COLOR
|
||||
export NO_COLOR=1
|
||||
|
||||
ROOT="${PHASE162_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
JOURNAL="${JOURNAL:-$ROOT/../sm-journal-plugin}"
|
||||
LAYER="${LAYER:-$ROOT/../vue-journal-layer}"
|
||||
POLICY_BASE="c60abc6"
|
||||
|
||||
# Framework tests (modules/pact, modules/cabana) that must pass by name.
|
||||
FRAMEWORK_RUN='^(TestFormFieldErrorMessage|TestFormFieldTypeContract|TestFieldTypeRegistry|TestFieldTypeOptions|TestFieldTypeCompile|TestFieldTypeDecode|TestFieldTypeNotices|TestFieldTypeUntouched|TestFieldTypeLoad|TestFieldTypeListValue|TestFieldTypeWrites|TestFieldTypesRoute|TestFieldUpload|TestFieldTypeTracerSmoke|TestFieldTypeExpansionSmoke|TestPhase10CSRF|TestPhase09ContractInventory|TestPhase09PermissionMatrix|ExampleCompileFormWithTypes)$'
|
||||
FRAMEWORK_REQUIRE=(
|
||||
TestFormFieldErrorMessage TestFormFieldTypeContract
|
||||
TestFieldTypeRegistry TestFieldTypeOptions TestFieldTypeCompile TestFieldTypeDecode
|
||||
TestFieldTypeNotices TestFieldTypeUntouched TestFieldTypeLoad TestFieldTypeListValue
|
||||
TestFieldTypeWrites TestFieldTypesRoute TestFieldUpload
|
||||
TestFieldTypeTracerSmoke TestFieldTypeExpansionSmoke
|
||||
TestPhase10CSRF TestPhase09ContractInventory TestPhase09PermissionMatrix
|
||||
ExampleCompileFormWithTypes
|
||||
)
|
||||
# Journal classes and controllers tests that must pass by name (the packages
|
||||
# run whole; these are the phase's and the 16.1 policy's).
|
||||
JOURNAL_REQUIRE=(
|
||||
TestBlogEditorDecode TestBlogEditorNotices TestBlogEditorLoad TestLooksLikeHTML
|
||||
TestBlogEditorOptions TestBlogEditorListValue TestBlogEditorUpload TestStoreMedia
|
||||
TestEditorOutputPassesPolicy TestSanitizeHTMLReport
|
||||
TestFormatHTMLSanitizesUnsafeHTML TestFormatHTMLDuplicateAttributes TestCheckHTML TestSanitizeHTML
|
||||
TestMediaObjectPath TestMediaFolderPatternRejectsDotDot
|
||||
TestPostsEditorHooks TestBlogEditorTracerSmoke TestBlogEditorUploadSmoke
|
||||
TestWriteMediaStoreError
|
||||
)
|
||||
# Docker-host suites.
|
||||
PG_FRAMEWORK_REQUIRE=(TestFieldTypeSavePostgres TestListRelationSearchPostgres)
|
||||
PG_JOURNAL_REQUIRE=(
|
||||
TestPostsFormCompiles TestPostsAdminCreateSmoke TestPostsAdminResaveKeepsHTML
|
||||
TestPostsAdminSaveSanitizesHTML TestCategoriesAdminContent TestCategoriesAdminBlogEditor
|
||||
TestJournalSVGEmbed
|
||||
)
|
||||
|
||||
ADMIN_FILES=(
|
||||
tests/form/PluginField.test.ts tests/form/FormField.test.ts tests/form/registry.test.ts
|
||||
tests/form/formState.test.ts tests/app/fieldTypes.test.ts tests/app/pluginAssets.test.ts
|
||||
tests/list/ListView.test.ts tests/list/CellValue.test.ts tests/shell/AppShell.test.ts
|
||||
)
|
||||
ADMIN_REQUIRE=(
|
||||
"untouched value (D-03) keeps the served HTML identical when the element echoes it on connect"
|
||||
"PluginField does not write back the value the element just emitted"
|
||||
"loadFieldTypes loads each script URL once, even across two runs and two types"
|
||||
"loadFieldTypes keeps field type styles enabled on every screen"
|
||||
"View site link (Phase 16.2 quick follow-up) opens the site root in a new tab without an opener or referrer"
|
||||
"plugin field type columns (Phase 16.2) renders the row value of a column type it does not know as plain text"
|
||||
)
|
||||
EDITOR_FILES=(
|
||||
tests/blogeditor/smoke.test.ts tests/blogeditor/corpus.test.ts tests/blogeditor/preview.test.ts
|
||||
tests/blogeditor/element.test.ts tests/blogeditor/schema.test.ts tests/blogeditor/serializer.test.ts
|
||||
tests/blogeditor/embed.test.ts
|
||||
)
|
||||
EDITOR_REQUIRE=(
|
||||
"untouched value (D-03) returns the imported HTML post unchanged after a Source tab round trip"
|
||||
"block reuse keeps every other block, separator and comment byte for byte after one edit"
|
||||
"editor corpus (D-05) matches the committed editor-corpus.json"
|
||||
"isPolicyUrl refuses \"java\\tscript:alert(1)\""
|
||||
)
|
||||
LAYER_REQUIRE=(
|
||||
"the sink turns a stored placeholder into the iframe"
|
||||
"a raw iframe in stored HTML is stripped; only the placeholder becomes an iframe"
|
||||
"the sinks expand after sanitizing and the payload transforms never expand"
|
||||
"the Go policy negatives are stripped the same way here"
|
||||
"the editor corpus has the entries the mirror rules are for"
|
||||
)
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage:
|
||||
check-phase16.2.sh --self-test
|
||||
check-phase16.2.sh go | admin | editor | layer | docs | postgres
|
||||
check-phase16.2.sh --go | --admin | --editor | --layer | --docs | --postgres
|
||||
check-phase16.2.sh --all
|
||||
EOF
|
||||
exit 2
|
||||
}
|
||||
|
||||
# detect_go reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests,
|
||||
# 4 non-JSON, 5 missing required name.
|
||||
detect_go() {
|
||||
python3 - "$1" <<'PY'
|
||||
import json, os, sys
|
||||
path = sys.argv[1]
|
||||
require = [n for n in os.environ.get("REQUIRE_TESTS", "").split() if n]
|
||||
passed = set()
|
||||
failed = []
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
for raw in fh:
|
||||
line = raw.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
ev = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
print("refuse: non-json test output", file=sys.stderr)
|
||||
sys.exit(4)
|
||||
action = ev.get("Action")
|
||||
test = ev.get("Test") or ""
|
||||
pkg = ev.get("Package") or ev.get("ImportPath") or ""
|
||||
if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")):
|
||||
print(f"refuse: build failed {pkg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
text = ev.get("Output") or ""
|
||||
if action == "output" and "no tests to run" in text:
|
||||
print(f"refuse: no tests to run in {pkg}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
if action == "skip" and test:
|
||||
print(f"refuse: skipped {pkg} {test}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if action == "fail":
|
||||
failed.append(f"{pkg} {test}".strip())
|
||||
if action == "pass" and test:
|
||||
passed.add(test)
|
||||
if failed:
|
||||
print("refuse: failed " + ", ".join(failed), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not passed:
|
||||
print("refuse: zero tests", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
missing = [n for n in require if n not in passed]
|
||||
if missing:
|
||||
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
|
||||
sys.exit(5)
|
||||
PY
|
||||
}
|
||||
|
||||
# detect_tap reads node --test --test-reporter=tap output. Exit 1 "not ok" or
|
||||
# a fail/cancelled count, 2 skip/todo, 3 no pass, 5 missing required name.
|
||||
# REQUIRE_NAMES holds one test name per line.
|
||||
detect_tap() {
|
||||
python3 - "$1" <<'PY'
|
||||
import os, re, sys
|
||||
path = sys.argv[1]
|
||||
require = [n for n in os.environ.get("REQUIRE_NAMES", "").split("\n") if n.strip()]
|
||||
passed = set()
|
||||
counts = {}
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
for raw in fh:
|
||||
line = raw.rstrip("\n")
|
||||
body = line.lstrip()
|
||||
if body.startswith("not ok"):
|
||||
print(f"refuse: {body}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
m = re.match(r"ok \d+ - (.*?)(\s+#\s*(SKIP|TODO)\b.*)?$", body, re.I)
|
||||
if m:
|
||||
if m.group(2):
|
||||
print(f"refuse: {body}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
passed.add(m.group(1))
|
||||
continue
|
||||
m = re.match(r"# (tests|suites|pass|fail|cancelled|skipped|todo) (\d+)$", line)
|
||||
if m:
|
||||
counts[m.group(1)] = int(m.group(2))
|
||||
for key in ("fail", "cancelled"):
|
||||
if counts.get(key, 0) > 0:
|
||||
print(f"refuse: {counts[key]} {key}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for key in ("skipped", "todo"):
|
||||
if counts.get(key, 0) > 0:
|
||||
print(f"refuse: {counts[key]} {key}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if counts.get("pass", 0) < 1 or not passed:
|
||||
print("refuse: no passing tests", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
missing = [n for n in require if n not in passed]
|
||||
if missing:
|
||||
print("refuse: required tests did not pass: " + "; ".join(missing), file=sys.stderr)
|
||||
sys.exit(5)
|
||||
PY
|
||||
}
|
||||
|
||||
# detect_vitest reads a vitest --reporter=json report. Exit 1 a failed test
|
||||
# or file or an unsuccessful run, 2 a skipped or todo test, 3 no passing
|
||||
# test, 4 an unreadable report, 5 a missing required file or test. Required
|
||||
# files (REQUIRE_FILES, one path suffix per line) must each have a passing
|
||||
# test; required names (REQUIRE_NAMES, one full test name per line) must
|
||||
# pass.
|
||||
detect_vitest() {
|
||||
python3 - "$1" <<'PY'
|
||||
import json, os, sys
|
||||
path = sys.argv[1]
|
||||
files = [n for n in os.environ.get("REQUIRE_FILES", "").split("\n") if n.strip()]
|
||||
names = [n for n in os.environ.get("REQUIRE_NAMES", "").split("\n") if n.strip()]
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
report = json.load(fh)
|
||||
except (OSError, ValueError) as err:
|
||||
print(f"refuse: unreadable vitest report: {err}", file=sys.stderr)
|
||||
sys.exit(4)
|
||||
results = report.get("testResults") or []
|
||||
bad = [r.get("name", "?") for r in results if r.get("status") != "passed"]
|
||||
if bad or report.get("numFailedTests", 0) or report.get("numFailedTestSuites", 0) or report.get("success") is not True:
|
||||
print("refuse: failed " + ", ".join(bad or ["run"]), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if report.get("numPendingTests", 0) or report.get("numTodoTests", 0):
|
||||
print(f"refuse: {report.get('numPendingTests', 0)} skipped, {report.get('numTodoTests', 0)} todo", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
passed = set()
|
||||
by_file = {}
|
||||
for r in results:
|
||||
for a in r.get("assertionResults") or []:
|
||||
status = a.get("status")
|
||||
if status != "passed":
|
||||
print(f"refuse: {status} {a.get('fullName')}", file=sys.stderr)
|
||||
sys.exit(2 if status in ("skipped", "pending", "todo") else 1)
|
||||
passed.add(a.get("fullName", ""))
|
||||
by_file[r.get("name", "")] = by_file.get(r.get("name", ""), 0) + 1
|
||||
if report.get("numPassedTests", 0) < 1 or not passed:
|
||||
print("refuse: no passing tests", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
missing = [f for f in files if not any(name.endswith("/" + f) and count > 0 for name, count in by_file.items())]
|
||||
missing += [n for n in names if n not in passed]
|
||||
if missing:
|
||||
print("refuse: required files or tests did not pass: " + "; ".join(missing), file=sys.stderr)
|
||||
sys.exit(5)
|
||||
print(f"vitest: {report.get('numPassedTests')} passed in {len(results)} files")
|
||||
PY
|
||||
}
|
||||
|
||||
go_json() {
|
||||
local dir="$1"
|
||||
shift
|
||||
local log err rc=0 dc=0
|
||||
log="$(mktemp)"
|
||||
err="$(mktemp)"
|
||||
(cd "$dir" && go test -json "$@") >"$log" 2>"$err" || rc=$?
|
||||
detect_go "$log" || dc=$?
|
||||
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
|
||||
cat "$err" >&2 || true
|
||||
grep -v '^{' "$log" | tail -n 40 >&2 || true
|
||||
python3 -c 'import json,sys
|
||||
for raw in open(sys.argv[1], errors="replace"):
|
||||
try: ev = json.loads(raw)
|
||||
except ValueError: continue
|
||||
if ev.get("Action") == "output" and ("FAIL" in ev.get("Output", "") or "_test.go:" in ev.get("Output", "")):
|
||||
sys.stderr.write(ev["Output"])' "$log" 2>&1 | tail -n 40 >&2 || true
|
||||
rm -f "$log" "$err"
|
||||
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "go: $(grep -c '"Action":"pass","Package":"[^"]*","Test":"[^"/]*","Elapsed"' "$log") top-level tests passed in $dir ($*)"
|
||||
rm -f "$log" "$err"
|
||||
}
|
||||
|
||||
# vitest_json runs a repository's vitest with the JSON reporter and checks it.
|
||||
vitest_json() {
|
||||
local dir="$1" report out rc=0 dc=0
|
||||
report="$(mktemp)"
|
||||
out="$(mktemp)"
|
||||
(cd "$dir" && ./node_modules/.bin/vitest run --reporter=json --outputFile="$report") >"$out" 2>&1 || rc=$?
|
||||
detect_vitest "$report" || dc=$?
|
||||
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
|
||||
grep -E 'FAIL|Error|×' "$out" | tail -n 40 >&2 || true
|
||||
rm -f "$report" "$out"
|
||||
echo "refuse: vitest in $dir (test=$rc detect=$dc)" >&2
|
||||
return 1
|
||||
fi
|
||||
rm -f "$report" "$out"
|
||||
}
|
||||
|
||||
# node_tap runs `npm --prefix <dir> test` (node --test, TAP) and checks it.
|
||||
node_tap() {
|
||||
local dir="$1" log rc=0 dc=0
|
||||
log="$(mktemp)"
|
||||
npm --prefix "$dir" test >"$log" 2>&1 || rc=$?
|
||||
detect_tap "$log" || dc=$?
|
||||
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
|
||||
grep -E '^\s*(not ok|# )|Error' "$log" | tail -n 40 >&2 || true
|
||||
rm -f "$log"
|
||||
echo "refuse: npm test in $dir (test=$rc detect=$dc)" >&2
|
||||
return 1
|
||||
fi
|
||||
grep -E '^# (tests|pass|fail) ' "$log"
|
||||
rm -f "$log"
|
||||
}
|
||||
|
||||
# same_corpus fails unless the two editor corpus copies are byte-identical.
|
||||
same_corpus() {
|
||||
local plugin_copy="$1" layer_copy="$2"
|
||||
if [[ ! -s "$plugin_copy" || ! -s "$layer_copy" ]]; then
|
||||
echo "refuse: editor corpus missing or empty: $plugin_copy $layer_copy" >&2
|
||||
return 1
|
||||
fi
|
||||
if ! cmp -s "$plugin_copy" "$layer_copy"; then
|
||||
echo "refuse: the layer's editor corpus differs from the plugin's; copy $plugin_copy to $layer_copy" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
expect_detector() {
|
||||
local detector="$1" name="$2" want="$3" payload="$4" log dc=0
|
||||
log="$(mktemp)"
|
||||
printf '%s\n' "$payload" >"$log"
|
||||
"$detector" "$log" >/dev/null 2>&1 || dc=$?
|
||||
rm -f "$log"
|
||||
if [[ "$dc" -ne "$want" ]]; then
|
||||
echo "refuse: self-test $detector $name: exit $dc, want $want" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_self_test() {
|
||||
bash -n "${BASH_SOURCE[0]}"
|
||||
|
||||
expect_detector detect_go pass 0 '{"Action":"pass","Package":"p","Test":"TestFieldTypeUntouched"}'
|
||||
expect_detector detect_go fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
|
||||
{"Action":"fail","Package":"p","Test":"TestFieldUpload"}'
|
||||
expect_detector detect_go package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
|
||||
{"Action":"fail","Package":"p"}'
|
||||
expect_detector detect_go build 1 '{"Action":"build-fail","ImportPath":"p"}'
|
||||
expect_detector detect_go skip 2 '{"Action":"pass","Package":"p","Test":"TestA"}
|
||||
{"Action":"skip","Package":"p","Test":"TestPostsAdminResaveKeepsHTML"}'
|
||||
expect_detector detect_go zero 3 '{"Action":"pass","Package":"p"}'
|
||||
expect_detector detect_go no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"}
|
||||
{"Action":"pass","Package":"p"}'
|
||||
expect_detector detect_go nonjson 4 '{"Action":"pass",'
|
||||
REQUIRE_TESTS="TestFieldTypeUntouched TestBlogEditorLoad" expect_detector detect_go missing-named 5 \
|
||||
'{"Action":"pass","Package":"p","Test":"TestFieldTypeUntouched"}'
|
||||
REQUIRE_TESTS="TestFieldType" expect_detector detect_go prefix-is-not-the-test 5 \
|
||||
'{"Action":"pass","Package":"p","Test":"TestFieldTypeUntouched"}'
|
||||
|
||||
local ok='TAP version 13
|
||||
ok 1 - the sink turns a stored placeholder into the iframe
|
||||
1..1
|
||||
# tests 1
|
||||
# pass 1
|
||||
# fail 0
|
||||
# cancelled 0
|
||||
# skipped 0
|
||||
# todo 0'
|
||||
expect_detector detect_tap pass 0 "$ok"
|
||||
expect_detector detect_tap not-ok 1 'ok 1 - a
|
||||
not ok 2 - b
|
||||
# pass 1
|
||||
# fail 1'
|
||||
expect_detector detect_tap nested-not-ok 1 ' not ok 1 - sub
|
||||
ok 1 - a
|
||||
# pass 1
|
||||
# fail 0'
|
||||
expect_detector detect_tap fail-count 1 'ok 1 - a
|
||||
# pass 1
|
||||
# fail 1'
|
||||
expect_detector detect_tap skip 2 'ok 1 - a # SKIP later
|
||||
# pass 1'
|
||||
expect_detector detect_tap todo-count 2 'ok 1 - a
|
||||
# pass 1
|
||||
# todo 1'
|
||||
expect_detector detect_tap zero 3 '# tests 0
|
||||
# pass 0
|
||||
# fail 0'
|
||||
REQUIRE_NAMES=$'the sink turns a stored placeholder into the iframe\nmissing' expect_detector detect_tap missing-named 5 "$ok"
|
||||
|
||||
local vok='{"success":true,"numPassedTests":2,"numFailedTests":0,"numFailedTestSuites":0,"numPendingTests":0,"numTodoTests":0,"testResults":[{"name":"/r/tests/form/PluginField.test.ts","status":"passed","assertionResults":[{"fullName":"PluginField a","status":"passed"},{"fullName":"PluginField b","status":"passed"}]}]}'
|
||||
expect_detector detect_vitest pass 0 "$vok"
|
||||
REQUIRE_FILES='tests/form/PluginField.test.ts' REQUIRE_NAMES='PluginField a' expect_detector detect_vitest named 0 "$vok"
|
||||
expect_detector detect_vitest failed-test 1 '{"success":false,"numPassedTests":1,"numFailedTests":1,"testResults":[{"name":"/r/a.test.ts","status":"failed","assertionResults":[{"fullName":"x","status":"failed"}]}]}'
|
||||
expect_detector detect_vitest failed-file 1 '{"success":false,"numPassedTests":0,"numFailedTests":0,"numFailedTestSuites":1,"testResults":[{"name":"/r/a.test.ts","status":"failed","assertionResults":[]}]}'
|
||||
expect_detector detect_vitest unsuccessful 1 '{"success":false,"numPassedTests":1,"numFailedTests":0,"testResults":[{"name":"/r/a.test.ts","status":"passed","assertionResults":[{"fullName":"x","status":"passed"}]}]}'
|
||||
expect_detector detect_vitest skipped 2 '{"success":true,"numPassedTests":1,"numPendingTests":1,"testResults":[{"name":"/r/a.test.ts","status":"passed","assertionResults":[{"fullName":"x","status":"passed"},{"fullName":"y","status":"skipped"}]}]}'
|
||||
expect_detector detect_vitest todo 2 '{"success":true,"numPassedTests":1,"numTodoTests":1,"testResults":[{"name":"/r/a.test.ts","status":"passed","assertionResults":[{"fullName":"x","status":"passed"}]}]}'
|
||||
expect_detector detect_vitest zero 3 '{"success":true,"numPassedTests":0,"testResults":[]}'
|
||||
expect_detector detect_vitest unreadable 4 'not json'
|
||||
REQUIRE_FILES='tests/blogeditor/element.test.ts' expect_detector detect_vitest missing-file 5 "$vok"
|
||||
REQUIRE_NAMES='PluginField c' expect_detector detect_vitest missing-name 5 "$vok"
|
||||
|
||||
# The corpus comparison fails on a planted difference and on a missing copy.
|
||||
local a b
|
||||
a="$(mktemp)"
|
||||
b="$(mktemp)"
|
||||
printf '[{"name":"x","html":"<p>a</p>"}]\n' >"$a"
|
||||
cp "$a" "$b"
|
||||
same_corpus "$a" "$b" || {
|
||||
echo "refuse: self-test corpus: identical copies refused" >&2
|
||||
rm -f "$a" "$b"
|
||||
return 1
|
||||
}
|
||||
printf '[{"name":"x","html":"<p>b</p>"}]\n' >"$b"
|
||||
if same_corpus "$a" "$b" 2>/dev/null; then
|
||||
echo "refuse: self-test corpus: a planted difference passed" >&2
|
||||
rm -f "$a" "$b"
|
||||
return 1
|
||||
fi
|
||||
: >"$b"
|
||||
if same_corpus "$a" "$b" 2>/dev/null; then
|
||||
echo "refuse: self-test corpus: an empty copy passed" >&2
|
||||
rm -f "$a" "$b"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$a" "$b"
|
||||
|
||||
local mode
|
||||
for mode in self-test go admin editor layer docs postgres all; do
|
||||
grep -q -- "^ --$mode" "${BASH_SOURCE[0]}" || {
|
||||
echo "refuse: missing mode --$mode" >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
echo "phase16.2 self-test passed"
|
||||
}
|
||||
|
||||
require_dir() {
|
||||
[[ -d "$1" ]] || {
|
||||
echo "refuse: $2 $1 not found" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
run_go() {
|
||||
require_dir "$JOURNAL" "Journal plugin"
|
||||
(cd "$ROOT" && go vet ./...)
|
||||
REQUIRE_TESTS="${FRAMEWORK_REQUIRE[*]}" go_json "$ROOT" -count=1 -run "$FRAMEWORK_RUN" ./modules/pact ./modules/cabana
|
||||
go -C "$JOURNAL" vet ./...
|
||||
REQUIRE_TESTS="${JOURNAL_REQUIRE[*]}" go_json "$JOURNAL" -count=1 ./classes/ ./controllers/...
|
||||
if ! git -C "$JOURNAL" diff --quiet "$POLICY_BASE" -- classes/format_html_test.go classes/check_html_test.go classes/format_translation_test.go; then
|
||||
echo "refuse: the 16.1 policy tests changed since $POLICY_BASE" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "phase16.2 go passed"
|
||||
}
|
||||
|
||||
run_admin() {
|
||||
(cd "$ROOT/admin" && ./node_modules/.bin/vue-tsc --noEmit)
|
||||
REQUIRE_FILES="$(printf '%s\n' "${ADMIN_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${ADMIN_REQUIRE[@]}")" vitest_json "$ROOT/admin"
|
||||
echo "phase16.2 admin passed"
|
||||
}
|
||||
|
||||
run_editor() {
|
||||
require_dir "$JOURNAL" "Journal plugin"
|
||||
(cd "$JOURNAL" && ./node_modules/.bin/tsc --noEmit)
|
||||
REQUIRE_FILES="$(printf '%s\n' "${EDITOR_FILES[@]}")" REQUIRE_NAMES="$(printf '%s\n' "${EDITOR_REQUIRE[@]}")" vitest_json "$JOURNAL"
|
||||
echo "phase16.2 editor passed"
|
||||
}
|
||||
|
||||
run_layer() {
|
||||
require_dir "$LAYER" "layer repository"
|
||||
REQUIRE_NAMES="$(printf '%s\n' "${LAYER_REQUIRE[@]}")" node_tap "$LAYER"
|
||||
same_corpus "$JOURNAL/tests/blogeditor/fixtures/editor-corpus.json" "$LAYER/tests/fixtures/editor-corpus.json"
|
||||
echo "corpus: the layer copy equals the plugin's"
|
||||
echo "phase16.2 layer passed"
|
||||
}
|
||||
|
||||
run_docs() {
|
||||
require_dir "$JOURNAL" "Journal plugin"
|
||||
REQUIRE_TESTS="TestDocsTree" go_json "$ROOT" -count=1 -run '^TestDocsTree$' ./cmd/summer
|
||||
bash "$ROOT/scripts/check-admin-openapi.sh" --check
|
||||
bash "$ROOT/scripts/check-admin-dist.sh"
|
||||
bash "$JOURNAL/scripts/check-dist.sh"
|
||||
echo "phase16.2 docs passed"
|
||||
}
|
||||
|
||||
docker_up() {
|
||||
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
|
||||
}
|
||||
|
||||
run_postgres() {
|
||||
require_dir "$JOURNAL" "Journal plugin"
|
||||
if ! docker_up; then
|
||||
echo "refuse: the postgres stage needs a reachable Docker daemon (testcontainers); run it on a Docker host" >&2
|
||||
return 1
|
||||
fi
|
||||
REQUIRE_TESTS="${PG_FRAMEWORK_REQUIRE[*]}" go_json "$ROOT" -count=1 -timeout 15m ./modules/cabana
|
||||
REQUIRE_TESTS="${PG_JOURNAL_REQUIRE[*]}" go_json "$JOURNAL" -count=1 -timeout 20m ./...
|
||||
echo "phase16.2 postgres passed"
|
||||
}
|
||||
|
||||
run_all() {
|
||||
local stage
|
||||
for stage in self-test go admin editor layer docs; do
|
||||
if bash "${BASH_SOURCE[0]}" "--$stage"; then
|
||||
echo "PASS $stage"
|
||||
else
|
||||
echo "FAIL $stage"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
if docker_up; then
|
||||
if bash "${BASH_SOURCE[0]}" --postgres; then
|
||||
echo "PASS postgres"
|
||||
else
|
||||
echo "FAIL postgres"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ "${PHASE162_REQUIRE_POSTGRES:-0}" == "1" ]]; then
|
||||
echo "FAIL postgres: no reachable Docker daemon and PHASE162_REQUIRE_POSTGRES=1"
|
||||
exit 1
|
||||
else
|
||||
cat >&2 <<'EOF'
|
||||
************************************************************************
|
||||
NOT RUN postgres: no reachable Docker daemon on this host.
|
||||
The testcontainers suites (TestFieldTypeSavePostgres, the Journal admin
|
||||
harness incl. TestPostsAdminResaveKeepsHTML, TestPostsAdminSaveSanitizesHTML,
|
||||
TestCategoriesAdminContent, TestCategoriesAdminBlogEditor) did NOT run.
|
||||
Run on a Docker host before /msd-verify-work:
|
||||
bash scripts/check-phase16.2.sh postgres
|
||||
************************************************************************
|
||||
EOF
|
||||
echo "NOT RUN postgres (Docker-host only; see the notice above)"
|
||||
fi
|
||||
echo "Phase 16.2 gate passed"
|
||||
}
|
||||
|
||||
case "${1:---all}" in
|
||||
--self-test | self-test) run_self_test ;;
|
||||
--go | go) run_go ;;
|
||||
--admin | admin) run_admin ;;
|
||||
--editor | editor) run_editor ;;
|
||||
--layer | layer) run_layer ;;
|
||||
--docs | docs) run_docs ;;
|
||||
--postgres | postgres) run_postgres ;;
|
||||
--all | all) run_all ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user