Files
summercms/.planning/quick/261006-sne-render-markdown-field-preview-via-saniti/261006-sne-PLAN.md

34 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
quick-261006-sne 01 execute 1
modules/cabana/field_markdown.go
modules/cabana/http.go
modules/cabana/admin_openapi.go
modules/cabana/markdown_preview_test.go
modules/cabana/markdown_preview_route_test.go
modules/cabana/security_coverage_test.go
modules/cabana/phase10_csrf_test.go
modules/cabana/openapi_conformance_test.go
modules/cabana/README.md
admin/openapi/admin.json
admin/src/api/schema.d.ts
admin/src/components/form/fields/MarkdownField.vue
admin/src/styles/main.css
admin/tests/form/MarkdownField.test.ts
admin/tests/form/MLFields.test.ts
docs/backend/forms.md
modules/boardwalk/dist/
scripts/check-phase10.sh
scripts/check-phase12.1.sh
scripts/check-phase12.2.sh
scripts/check-phase14.2.1.sh
true
QUICK-261006-sne
tokens raw_tokens tasks confidence
90000 90000 3 low
truths artifacts key_links
Clicking Preview on a type: markdown field shows rendered HTML (`# Title` becomes a heading), not the raw source
The preview HTML comes only from the server: the SPA binds data.html of a 2xx answer from POST {prefix}/api/v1/markdown/preview, never the markdown source
POST /markdown/preview answers 401 without a backend admin session, 403 for a cookie session without X-Requested-With, and renders through cabana.RenderMarkdown
No 200 answer of the preview route contains a script or iframe tag, an event handler, or a javascript/vbscript/data URL; output the RenderMarkdown gate refuses is a 422 validation_failed on `markdown`, shown in the SPA as a text notice
On a type: mlmarkdown field the preview renders the active locale's text and re-renders when the locale changes while Preview is open
modules/boardwalk/dist matches a fresh build; admin.json and schema.d.ts match a fresh generation; go vet ./... and go test ./... stay green
The raw-HTML hygiene gates allow exactly one binding (the sanitized preview line in MarkdownField.vue) and still refuse every other raw-HTML sink
path provides
modules/cabana/field_markdown.go service.markdownPreview handler, AdminMarkdownPreviewRequest, AdminMarkdownPreviewResult
path provides
modules/cabana/http.go POST /markdown/preview mounted in the backend-guarded group behind requireAjax
path provides
modules/cabana/admin_openapi.go AdminMarkdownPreview swag annotation
path provides
admin/src/components/form/fields/MarkdownField.vue Preview that fetches server-sanitized HTML (on toggle, debounced on source change) and binds only that answer
path provides
modules/boardwalk/dist Rebuilt embedded admin SPA
from to via pattern
admin/src/components/form/fields/MarkdownField.vue modules/cabana/field_markdown.go api.POST('/markdown/preview') typed by admin/src/api/schema.d.ts markdown/preview
from to via pattern
modules/cabana/http.go modules/cabana/field_markdown.go g.Post("/markdown/preview", requireAjax(s.markdownPreview)) inside the r.GroupRaw(api, []string{"backend"}, ...) block markdownPreview
from to via pattern
modules/cabana/admin_openapi.go admin/openapi/admin.json scripts/check-admin-openapi.sh (swag -> swagger2openapi -> openapi-typescript) /markdown/preview
from to via pattern
modules/cabana/security_coverage_test.go modules/cabana/http.go phase09Routes inventory checked against service.mount by TestPhase09PermissionMatrix and against admin.json by TestPhase09ContractInventory POST /markdown/preview
Make the admin SPA's markdown Preview show rendered markdown. The HTML must come from the server's sanitizing renderer, never from the browser. Add an authenticated, CSRF-protected admin route, `POST {prefix}/api/v1/markdown/preview`. It takes `{markdown}`, runs `cabana.RenderMarkdown` (goldmark without unsafe HTML, plus the reject gate) and answers `{html}`. MarkdownField.vue fetches the answer when Preview is shown, fetches again (debounced) when the source changes while Preview is open, and binds only that answer as HTML. mlmarkdown reuses MarkdownField with the active locale's text, so its preview follows the locale.

Purpose: Journal UAT bug. The Preview tab now shows the raw source (for example # Title) inside a <pre>. Output: cabana route, handler, swag annotation and Go tests; regenerated admin.json and schema.d.ts; SPA component, styles and vitest tests; cabana README and docs/backend/forms.md; rebuilt modules/boardwalk/dist; raw-HTML hygiene gates taught the one sanctioned binding. Three code commits, one per task.

Source coverage (from the bug report; there is no CONTEXT/RESEARCH in quick mode):

Item Task
Preview renders markdown instead of raw source 2
HTML comes only from cabana.RenderMarkdown on the server; no unsanitized HTML in the SPA 1, 2
Authenticated admin endpoint; reuse one if it exists (none exists: RenderMarkdown has no production caller) 1
Fetch on Preview toggle, debounced while Preview is open; raw-HTML binding only on the server's answer 2
Admin auth and CSRF conventions for POST (backend guard group + requireAjax) 1
mlmarkdown preview follows the active locale 2
Go tests: auth required, unsafe HTML stripped 1
SPA vitest tests 2
Rebuild and commit modules/boardwalk/dist with the SPA change 2
README + docs/ for the new route; TestDocsTree green 1, 2

<execution_context> @/.claude/gsd-core/workflows/execute-plan.md @/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/STATE.md @CLAUDE.md @modules/cabana/field_markdown.go @modules/cabana/csrf.go @admin/src/components/form/fields/MarkdownField.vue @admin/src/components/form/fields/MLMarkdownField.vue @scripts/check-admin-openapi.sh @scripts/check-admin-dist.sh

Facts gathered while planning (verified against the tree at 0ff928d):

  • cabana.RenderMarkdown(src) (string, error) in modules/cabana/field_markdown.go is the only renderer. Goldmark without html.WithUnsafe turns raw HTML into <!-- raw HTML omitted --> and empties the href of dangerous link URLs. A regex gate then returns an error if the output still contains script/iframe/object/embed tags, on*= handlers, or javascript:/vbscript:/data: anywhere in the output. Text that merely mentions data: is also refused, so the route must turn a refusal into a 422 rather than a 500. Do NOT change RenderMarkdown or its gate: plugins (the Journal FormatHTML mirror) depend on the same contract.
  • Admin routes are mounted in service.mount (modules/cabana/http.go). Guarded routes live in r.GroupRaw(api, []string{"backend"}, ...). Every state-changing route is wrapped in requireAjax (csrf.go): Bearer, or X-Requested-With: XMLHttpRequest, else 403 forbidden before the body is read. Handlers re-check bouncer.User(ctx) with principal.Backend and answer 401 unauthenticated (see service.navigation).
  • Body decoding: s.decodeStrictBody(w, r, &dest) (field_file.go) caps the body at s.jsonCap() (http.body_limits.default_bytes, else 1 MiB) and refuses unknown keys and trailing data. writeCRUDError maps a *http.MaxBytesError to 413 payload_too_large and *ValidationError to 422 validation_failed. WriteErrorDetails / WriteData (contracts.go) write the D-10 envelopes.
  • Route inventory tests that must learn the new route: phase09Routes in security_coverage_test.go (TestPhase09PermissionMatrix compares it with service.mount and requires the backend guard; TestPhase09ContractInventory requires admin.json to document the path with 200, 401 and BackendBearer security; AdminAPIRoutes() in export_test.go feeds it to TestPhase10OpenAPIConformance, which requires exactly one conformance case per route). TestPhase10CSRF in phase10_csrf_test.go walks every POST/PUT/DELETE and asserts the count unsafe != 25, which becomes 26.
  • OpenAPI: swag annotations live in admin_openapi.go (one func AdminX() {} per route, request/response types in the same package). scripts/check-admin-openapi.sh regenerates admin/openapi/admin.json and admin/src/api/schema.d.ts. With --check it is the drift gate. Swag names generic envelopes cabana.Envelope-cabana_<Type>.
  • The SPA makes every HTTP call through the typed api client (admin/src/api/client.ts; it already sets X-Requested-With and handles 401 refresh). The phase-10 hygiene rule forbids a direct fetch outside client.ts and any new file under admin/src/api.
  • MLMarkdownField.vue already renders <MarkdownField :model-value="activeText"> with the active locale's text, so a preview that re-fetches when its modelValue text changes follows the locale with no ML-specific code.
  • Four gate scripts refuse any raw-HTML sink in admin/src: scripts/check-phase10.sh (hygiene_checks, run from the tree root, output prefix admin/src/..., pattern without =), check-phase12.1.sh and check-phase12.2.sh (run_hygiene, from ROOT, prefix admin/src/..., pattern without =) and check-phase14.2.1.sh (run_forbidden, run from admin/src with grep -RInE ... ., prefix ./..., pattern with =). check-phase12.1/12.2 --hygiene fail TODAY only because the MarkdownField.vue line-5 comment contains the directive's name; check-phase14.2.1 --forbidden passes today. check-phase10 --hygiene also has unrelated pre-existing refusals (admin/src/api/files.ts, untested modules) that this task does not touch. Its --self-test plants a binding in admin/src/__plant/Plant.vue, which must still be refused.
  • Tailwind preflight strips heading, list and margin styles, so rendered HTML needs a small style kit. main.css already has the precedent .summer-partial in @layer components, reading only --c-* variables; --font-mono exists.
  • SPA tests: vitest + happy-dom. mockApi / API (/admin-test/api/v1) / json / requestsTo in admin/tests/helpers.ts mock fetch keyed by "METHOD pathname". MarkdownField.test.ts and MLFields.test.ts ("does not execute raw HTML in the markdown preview") currently assert that the pane shows the source text, and must be rewritten.
  • Framework hygiene: no consuming-application names (and no Polish catalogue words such as the ones in check-phase10's app-name regex) in admin/src, admin/tests, modules/cabana, the README or docs/. Use neutral acme/Hello fixtures.
  • Commits: one logical change each, no co-author trailers, no .planning files in code commits.
Task 1: Server route POST /markdown/preview, end-to-end through guard, CSRF, renderer and OpenAPI contract modules/cabana/field_markdown.go, modules/cabana/http.go, modules/cabana/admin_openapi.go, modules/cabana/markdown_preview_test.go, modules/cabana/markdown_preview_route_test.go, modules/cabana/security_coverage_test.go, modules/cabana/phase10_csrf_test.go, modules/cabana/openapi_conformance_test.go, admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/cabana/README.md, docs/backend/forms.md modules/cabana/field_markdown.go, modules/cabana/csrf.go, modules/cabana/http.go (mount, navigation), modules/cabana/field_file.go (jsonCap, decodeStrictBody, invalidBody, AdminFileCaptionRequest), modules/cabana/crud.go (writeCRUDError), modules/cabana/admin_openapi.go (AdminNavigation, AdminSettingsPut, AdminWidgetAction annotations; request type conventions), modules/cabana/security_coverage_test.go (phase09Routes), modules/cabana/phase10_csrf_test.go, modules/cabana/openapi_conformance_test.go (conformCase list, newConformEnv, send), modules/cabana/README.md (Admin API routes table, Features bullet on markdown, API reference rows near cabana.RenderMarkdown), docs/backend/forms.md (Markdown and multilingual fields) The Docker daemon is reachable (`docker info` succeeds), because the conformance and route tests use testcontainers Postgres. - Handler with no principal in context: 401, error code `unauthenticated`. Frontend principal (Backend false): 401. - Backend principal, body {"markdown":"# Hello"}: 200, decodes strictly into Envelope[AdminMarkdownPreviewResult], data.html contains `

Hello

`. - Backend principal, body {"markdown":""}: 200 with data.html "". - Unsafe sources, each as its own subtest: a script tag, an iframe tag, an img with an onerror handler, and javascript:, vbscript: and data: link URLs. Either the answer is 200 and the lowercased data.html contains none of `<script`, ` Implements the bug report's server requirement: preview HTML comes only from cabana.RenderMarkdown, behind the existing admin auth and CSRF conventions. No route like this exists; RenderMarkdown has no production caller.
  1. modules/cabana/field_markdown.go: add the exported types AdminMarkdownPreviewRequest (field Markdown string with json tag markdown) and AdminMarkdownPreviewResult (field HTML string with json tag html), each with a doc comment. Add func (s *service) markdownPreview(w http.ResponseWriter, r *http.Request):
    • Re-check bouncer.User(r.Context()) exactly as service.navigation does, answering 401 unauthenticated / msgUnauthenticated.
    • Decode with s.decodeStrictBody(w, r, &body). On error, writeCRUDError(w, err) (gives 413 or 422).
    • Call RenderMarkdown(body.Markdown). On error, answer WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", map[string]any{"markdown": []string{<fixed message>}}). The fixed message says the rendered HTML contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL and cannot be previewed. Never write err.Error().
    • On success, WriteData(w, http.StatusOK, AdminMarkdownPreviewResult{HTML: html}, nil).
    • No permission check: any signed-in backend administrator may render markdown. It reads and writes no data (see threat T-261006sne-05).
    • Leave RenderMarkdown and rejectUnsafeMarkdownHTML unchanged.
  2. modules/cabana/http.go service.mount: inside the r.GroupRaw(api, []string{"backend"}, ...) block, right after g.Get("/navigation", s.navigation), add g.Post("/markdown/preview", requireAjax(s.markdownPreview)). Add a one-line comment that it renders form previews through RenderMarkdown. No Where constraints: the path has two literal segments and conflicts with no other pattern.
  3. modules/cabana/admin_openapi.go: add // AdminMarkdownPreview documents POST /markdown/preview. followed by swag lines mirroring AdminSettingsPut:
    • @Summary "Render a markdown preview", and a @Description saying it renders through cabana.RenderMarkdown (goldmark without unsafe HTML), answers the sanitized HTML, gives 422 on markdown when the output is refused, and needs any backend session.
    • @Tags admin, @Accept json, @Produce json, @Security BackendBearer.
    • @Param body body AdminMarkdownPreviewRequest true "Markdown source".
    • @Success 200 {object} Envelope[AdminMarkdownPreviewResult].
    • @Failure 401, 403, 404, 413 and 422 {object} ErrorEnvelope.
    • @Router /markdown/preview [post], then func AdminMarkdownPreview() {}. Then run scripts/check-admin-openapi.sh (no args) to regenerate admin/openapi/admin.json and admin/src/api/schema.d.ts. Never hand-edit those two files.
  4. Tests (write them first, RED, then implement):
    • New modules/cabana/markdown_preview_test.go (package cabana): call (&service{}).markdownPreview directly with httptest requests and bouncer.WithUser, covering every non-router behavior bullet. Use &service{defaultBytes: 64} for the 413 case.
    • New modules/cabana/markdown_preview_route_test.go (package cabana_test): TestMarkdownPreviewRoute, built on newConformEnv(t); log in via e.send(t, http.MethodPost, "/auth/login", ...) with adminTestPassword, covering the two router bullets.
    • security_coverage_test.go: add {key: "POST /markdown/preview"} to phase09Routes after PUT /settings/{code}.
    • phase10_csrf_test.go: change the expected count from 25 to 26 in both the condition and the message, and add "markdown preview" to the comment list.
    • openapi_conformance_test.go: add one case to TestPhase10OpenAPIConformance: key "POST /markdown/preview", status 200, ref "cabana.Envelope-cabana_AdminMarkdownPreviewResult", sending {"markdown":"# Conform"} with auth, decoded via into[cabana.Envelope[cabana.AdminMarkdownPreviewResult]]().
  5. Docs, same commit, per CLAUDE.md:
    • modules/cabana/README.md: add a row to "Admin API routes": POST /markdown/preview renders {markdown} through cabana.RenderMarkdown for the form preview, answers {html}, gives 422 validation_failed on markdown when the output is refused, and needs any backend session.
    • README API reference: add rows for cabana.AdminMarkdownPreviewRequest, cabana.AdminMarkdownPreviewResult and cabana.AdminMarkdownPreview (swag annotation).
    • README Features bullet on markdown fields: add one clause naming the preview route.
    • docs/backend/forms.md, "Markdown and multilingual fields": add one sentence that POST <prefix>/api/v1/markdown/preview renders a source through cabana.RenderMarkdown for any signed-in administrator and answers the HTML, or a 422 on markdown when the output is refused.
    • Name no consuming application.
  6. gofmt, then commit: feat(cabana): add markdown preview admin route (Go, tests, admin.json, schema.d.ts, README, forms.md). No .planning files, no trailers. cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && bash -c 'test -z "$(gofmt -l modules/cabana)"' && go vet ./modules/cabana/ && go test ./modules/cabana -short -run 'MarkdownPreview|TestMarkdownRejectsUnsafeHTML|TestPhase09|TestPhase10CSRF' && go test ./modules/cabana -run 'TestMarkdownPreviewRoute|TestPhase10OpenAPIConformance' && scripts/check-admin-openapi.sh --check && go test ./cmd/summer -run TestDocsTree An authenticated POST to {prefix}/api/v1/markdown/preview with {"markdown":"# Hello"} answers {data:{html:"

    Hello

    \n"},meta:{}}. Without a session it answers 401; a cookie session without the CSRF header gets 403. Unsafe constructs never survive into a 200 answer. A refused output is 422 on markdown. The route is in the inventory, admin.json, schema.d.ts, README and forms.md. One commit.
Task 2: MarkdownField Preview renders the server-sanitized HTML (markdown and mlmarkdown), rebuilt dist admin/src/components/form/fields/MarkdownField.vue, admin/src/styles/main.css, admin/tests/form/MarkdownField.test.ts, admin/tests/form/MLFields.test.ts, docs/backend/forms.md, modules/boardwalk/dist/ admin/src/components/form/fields/MarkdownField.vue, admin/src/components/form/fields/MLMarkdownField.vue, admin/src/components/form/control.ts, admin/src/api/client.ts, admin/src/api/schema.d.ts (the /markdown/preview entry from Task 1), admin/src/styles/main.css (the .summer-partial block in @layer components), admin/tests/helpers.ts (mockApi, API, json, requestsTo), admin/tests/form/MarkdownField.test.ts, admin/tests/form/MLFields.test.ts (the markdown preview case), admin/src/components/relation/RelationManager.vue (searchTimer debounce pattern) - Clicking [data-markdown-preview] with modelValue "# Hello" sends exactly one POST to `${API}/markdown/preview` with JSON body {"markdown":"# Hello"} and the X-Requested-With header. When the mock answers {data:{html:"

Hello

\n"},meta:{}}, the [data-markdown-preview-pane] contains an h1 with text "Hello" and the textarea is gone. - The bound HTML is the server's answer, not the source. Source `<script>window.__md_xss = 1</script>` with the mock answering `

` leaves no img or script element in the wrapper and window.__md_xss undefined. - A 422 answer (validation_failed, details.markdown ["...refused..."]) shows [data-markdown-preview-error] with that message as text. The pane holds no element children and nothing from the source is rendered as markup. - A network failure (fetch rejects) shows a generic "Preview unavailable." notice. A whitespace-only source sends no request and leaves the pane empty. - Typing in the textarea while Preview is closed sends no request. - With Preview open, a modelValue change triggers one debounced re-fetch (about 300 ms; use fake timers) with the new text. When an older response resolves after a newer one, the older is ignored and the pane shows the newer HTML. - mlmarkdown: with locales en/pl, Preview open on en, switching [data-ml-locale] to pl sends a POST whose body is the pl text, and the pane shows the pl answer. - Clicking Preview again returns to the textarea with the unchanged source. Implements the bug report's SPA requirement: Preview renders the server-sanitized HTML, and the preview of an mlmarkdown field follows the active locale.
  1. MarkdownField.vue script:
    • Import api from '../../../api/client'. Keep rows, text, attrs and preview.
    • Add refs: sanitizedHtml (string, ''), previewError (string, ''), loading (boolean). Add a request sequence counter and a debounce timer, using the ReturnType<typeof setTimeout> pattern from RelationManager.vue.
    • renderPreview():
      • If text is empty after trim, clear sanitizedHtml and previewError, send nothing and return.
      • Otherwise bump the sequence, set loading, and await api.POST('/markdown/preview', { body: { markdown: text.value } }) inside try/catch.
      • Ignore any answer whose sequence is no longer current.
      • On data, set sanitizedHtml to data.data.html and clear previewError.
      • On an error answer, clear sanitizedHtml and set previewError. Use the first string of error.error.details.markdown when it is a string array; otherwise use the fixed text "Preview unavailable.".
      • On a thrown error, do the same with the fixed text.
      • Clear loading in finally.
    • Toggling Preview on calls renderPreview() immediately. Toggling off cancels the timer.
    • watch(text, ...): when Preview is open, restart a 300 ms timer that calls renderPreview(). When Preview is closed, do nothing.
    • onBeforeUnmount clears the timer.
    • sanitizedHtml is assigned ONLY from data.data.html. Never assign the source, an error message or any concatenation to it.
  2. MarkdownField.vue template:
    • Keep the Preview button (data-markdown-preview, aria-pressed).
    • Replace the <pre> with a v-if="preview" wrapper holding a sibling <p data-markdown-preview-error role="status"> (text-[13px], danger colour, shown when previewError is set, text interpolation only) and the pane <div data-markdown-preview-pane>.
    • The pane keeps :class="controlClass(invalid)" plus summer-markdown min-h-input overflow-auto px-3.5 py-2.5 and :aria-busy="loading ? 'true' : 'false'". It binds the raw-HTML directive to sanitizedHtml. Put that attribute on a line of its own containing exactly the attribute v-html="sanitizedHtml" and nothing else, because Task 3's gate exemption matches that exact line.
    • The textarea branch stays as is (v-else).
  3. Rewrite the file's head comment. It must say the preview renders only the HTML that POST /markdown/preview answers (cabana.RenderMarkdown: goldmark without unsafe HTML, refused output becomes a 422 notice), and that the markdown source is never bound as HTML. Describe the binding as "the raw-HTML binding". The comment must NOT contain the directive's name or the DOM property names the hygiene gates search for. The current line-5 comment is exactly what makes check-phase12.1/12.2 hygiene fail today.
  4. main.css, inside @layer components after the .summer-partial block: add a .summer-markdown kit, using :where() selectors so specificity stays low and reading only --c-* variables and --font-mono, in the same style as .summer-partial. It covers:
    • h1 to h4 sizes and weights;
    • p/ul/ol/blockquote/pre/table margins, with the last child at 0;
    • ul disc and ol decimal with left padding (preflight removes them);
    • inline code and pre in --font-mono on --c-subtle with a radius;
    • blockquote with a left border in --c-border-strong and --c-muted text;
    • links underlined in --c-text with a focus-visible ring;
    • hr and table cell borders in --c-border;
    • images capped at max-width 100%.
  5. Tests. Rewrite admin/tests/form/MarkdownField.test.ts to cover every behavior bullet with mockApi routes keyed POST ${API}/markdown/preview (route functions may read await request.json() to answer per locale). Use vi.useFakeTimers() plus vi.advanceTimersByTimeAsync and flushPromises for the debounce and race cases, and restore real timers afterwards. In admin/tests/form/MLFields.test.ts, rewrite "does not execute raw HTML in the markdown preview" to mock the route answering <p><!-- raw HTML omitted --></p> and assert that no script or img element exists and the window flag is undefined. Drop the assertion that the pane text contains the source. Neutral fixture text only.
  6. docs/backend/forms.md, "Markdown and multilingual fields": replace "The admin SPA shows a source editor and may preview HTML from cabana.RenderMarkdown" with the actual behavior. Preview posts the field's source (the active locale's text for mlmarkdown) to the preview route when it opens and again shortly after the source changes. It renders only the server's answer and shows the server's message as text when the output is refused. Keep the existing safety sentence.
  7. Run npm --prefix admin run build and include modules/boardwalk/dist. Commit: fix(admin): render markdown preview from server-sanitized HTML (SPA, styles, tests, forms.md, dist). No .planning files, no trailers. cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && npm --prefix admin test -- tests/form/MarkdownField.test.ts tests/form/MLFields.test.ts && npm --prefix admin test && npm --prefix admin run typecheck && scripts/check-admin-dist.sh && go test ./cmd/summer -run TestDocsTree In the admin SPA, Preview on a markdown field shows rendered headings, lists and links from the server's sanitized HTML. On an mlmarkdown field it shows the active locale and re-renders on a locale switch. A refused output shows a text notice. The source is never bound as HTML. The full vitest suite and typecheck pass, dist matches a fresh build, docs are green. One commit.
Task 3: Teach the raw-HTML hygiene gates the single sanctioned preview binding; full suite green scripts/check-phase10.sh, scripts/check-phase12.1.sh, scripts/check-phase12.2.sh, scripts/check-phase14.2.1.sh scripts/check-phase10.sh (hygiene_checks raw-HTML rule near the "raw-HTML directive" refusal, and the vhtml plant in run_self_test), scripts/check-phase12.1.sh (run_hygiene raw-HTML rule), scripts/check-phase12.2.sh (run_hygiene raw-HTML rule), scripts/check-phase14.2.1.sh (run_forbidden raw-HTML rule) Keeps the raw-HTML gates honest now that one server-sanitized binding exists (bug report: "only then uses v-html on the server-sanitized output").
  1. In each of the four scripts, keep the existing grep. Pipe its output through one grep -vE exclusion that drops ONLY the MarkdownField.vue line consisting of optional whitespace, then exactly v-html="sanitizedHtml", then optional whitespace. Insert the exclusion before the || true, matching how check-phase10 already excludes client.ts from the fetch rule.
    • check-phase10.sh, check-phase12.1.sh and check-phase12.2.sh: anchor at ^admin/src/components/form/fields/MarkdownField\.vue:[0-9]+:.
    • check-phase14.2.1.sh: it greps from admin/src with ., so anchor at ^\./components/form/fields/MarkdownField\.vue:[0-9]+:. Use [[:space:]]* for the whitespace and escape the dots.
  2. Add a one-line comment above each exclusion: the markdown preview binds only the HTML that POST /markdown/preview answers (cabana.RenderMarkdown), and every other raw-HTML sink is still refused.
  3. Leave the patterns themselves, every other rule and check-phase10's self-test plant (admin/src/__plant/Plant.vue) unchanged. The plant must still be refused because the exclusion is file- and line-exact.
  4. Do not try to fix check-phase10's unrelated pre-existing hygiene refusals (admin/src/api/files.ts, untested modules). Report them in the SUMMARY as pre-existing.
  5. Commit: chore(scripts): allow the sanitized markdown preview binding in raw-HTML gates. No .planning files, no trailers. cd /media/nvme/dev/golem15/summercms.io/summercms/summercms.go && bash -n scripts/check-phase10.sh scripts/check-phase12.1.sh scripts/check-phase12.2.sh scripts/check-phase14.2.1.sh && scripts/check-phase12.1.sh --hygiene && scripts/check-phase12.2.sh --hygiene && scripts/check-phase14.2.1.sh --forbidden && bash -c "! scripts/check-phase10.sh --hygiene 2>&1 | grep -q 'raw-HTML directive'" && go vet ./... && go test ./... check-phase12.1 --hygiene and check-phase12.2 --hygiene pass; they failed before this change. check-phase14.2.1 --forbidden still passes. check-phase10 --hygiene no longer reports a raw-HTML directive; its other refusals are pre-existing and listed in the SUMMARY. go vet ./... and go test ./... are green. One commit.

<threat_model>

Trust Boundaries

Boundary Description
browser → admin API Untrusted markdown source (an administrator's input, often translated or pasted) crosses into POST /markdown/preview
admin API → SPA DOM Server-rendered HTML is bound as HTML in the admin origin, the first raw-HTML sink in admin/src
cross-site page → admin API A foreign page may try to drive the cookie session (CSRF)

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-261006sne-01 Tampering (XSS) MarkdownField.vue preview pane high mitigate Only data.data.html of a 2xx answer from POST /markdown/preview is bound. That HTML comes from cabana.RenderMarkdown (goldmark without unsafe HTML plus the reject gate). The source, error messages and concatenations are never bound. Vitest proves that script/img-onerror sources create no elements and do not set the window flag.
T-261006sne-02 Tampering (XSS) service.markdownPreview high mitigate The route always goes through RenderMarkdown. A refused output is a 422 with a fixed message, never the HTML. Go tests cover script, iframe, onerror, javascript:, vbscript: and data: inputs, each asserted either absent from a 200 answer or refused with 422.
T-261006sne-03 Spoofing / Elevation POST /markdown/preview medium mitigate Mounted in the backend-guarded group, and the handler re-checks principal.Backend. TestPhase09PermissionMatrix requires the guard, and the handler and router tests assert 401 without a session.
T-261006sne-04 Tampering (CSRF) POST /markdown/preview medium mitigate Wrapped in requireAjax. TestPhase10CSRF walks it (count 26) and asserts 403 for cookie-only requests with the body unread.
T-261006sne-05 Information disclosure POST /markdown/preview low accept No permission beyond a backend session. The route is stateless: it reads no records and writes nothing, and answers only a rendering of the caller's own input.
T-261006sne-06 Denial of service POST /markdown/preview low mitigate Body capped at http.body_limits.default_bytes (1 MiB default) by decodeStrictBody, giving 413. The SPA fetches on toggle and with a 300 ms debounce only while Preview is open.
T-261006sne-07 Tampering (gate erosion) scripts/check-phase10/12.1/12.2/14.2.1 medium mitigate The exemption matches one file and one exact attribute line. Every other raw-HTML sink, including check-phase10's planted self-test binding, is still refused.
T-261006sne-SC Tampering npm/go installs low accept No new npm or Go dependency. The work uses the existing goldmark pin, openapi-fetch, the pinned swag v1.16.6 tool run and the committed lockfile.
</threat_model>
- go vet ./... and go test ./... (Task 3 runs the full suite; testcontainers needs Docker) - go test ./modules/cabana -run 'MarkdownPreview|TestPhase09|TestPhase10CSRF|TestPhase10OpenAPIConformance' - scripts/check-admin-openapi.sh --check - npm --prefix admin test and npm --prefix admin run typecheck - scripts/check-admin-dist.sh - go test ./cmd/summer -run TestDocsTree - scripts/check-phase12.1.sh --hygiene, scripts/check-phase12.2.sh --hygiene, scripts/check-phase14.2.1.sh --forbidden - Manual UAT (not gating): rebuild and restart the proof host, open a markdown field at /backend and click Preview. `# Title` should render as a heading. On an mlmarkdown field, switch the locale with Preview open.

<success_criteria>

  • Preview on markdown and mlmarkdown fields shows rendered markdown produced by the server's sanitizing renderer.
  • No unsanitized HTML reaches the SPA DOM, and the source is never bound as HTML.
  • The new route follows admin auth (backend guard, 401) and CSRF (requireAjax, 403) conventions. It is documented in OpenAPI, README and docs/, and covered by Go and vitest tests.
  • modules/boardwalk/dist is rebuilt and committed with the SPA change. All drift gates and go vet / go test ./... are green. </success_criteria>
Create `.planning/quick/261006-sne-render-markdown-field-preview-via-saniti/261006-sne-SUMMARY.md` when done