--- phase: quick-261006-sne plan: 01 status: complete subsystem: cabana admin API + admin SPA tags: [markdown, preview, xss, csrf, admin-spa, openapi] requires: [cabana.RenderMarkdown, requireAjax, decodeStrictBody] provides: - POST {prefix}/api/v1/markdown/preview (AdminMarkdownPreviewRequest -> Envelope[AdminMarkdownPreviewResult]) - MarkdownField Preview rendering server-sanitized HTML (markdown + mlmarkdown) - .summer-markdown style kit affects: [admin/openapi/admin.json, admin/src/api/schema.d.ts, modules/boardwalk/dist, raw-HTML hygiene gates] tech-stack: added: [] patterns: - "Stateless backend-guarded POST behind requireAjax with strict capped body decode" - "SPA request sequence counter + 300 ms debounce; raw-HTML binding fed only from a 2xx data field" - "Hygiene gate exemption matched by file and exact attribute line" key-files: created: - modules/cabana/markdown_preview_test.go - modules/cabana/markdown_preview_route_test.go modified: - modules/cabana/field_markdown.go - modules/cabana/http.go - modules/cabana/admin_openapi.go - modules/cabana/security_coverage_test.go - modules/cabana/phase10_csrf_test.go - modules/cabana/phase10_coverage_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 decisions: - "Markdown preview is a stateless POST /markdown/preview open to any backend session (no permission code); refused output is a 422 on markdown with a fixed message, never the renderer error text" - "The SPA binds only data.data.html of a 2xx preview answer; refusals and network errors are text notices" - "Raw-HTML hygiene gates exempt exactly one line: MarkdownField.vue `v-html=\"sanitizedHtml\"`" metrics: duration: 11m completed: 2026-10-06 tasks: 3 files: 25 actuals: tokens: 15800 tasks: 3 commits: 3 commits: 3 plan_head_before: 0ff928d6cf7ee6f43b2457c99ef401cb1855f535 plan_head_after: 27711b7c21a8545594fa9d548925d599bb91a237 --- # Quick 261006-sne: Render markdown field preview via sanitized server HTML Summary The admin markdown Preview now renders headings, lists and links. The HTML comes from a new backend-guarded, CSRF-protected `POST {prefix}/api/v1/markdown/preview` route that runs `cabana.RenderMarkdown`. MarkdownField binds only that answer, and on mlmarkdown fields the preview follows the active locale. ## Tasks | Task | Name | Commit | Key files | |------|------|--------|-----------| | 1 | Server route POST /markdown/preview (tracer) | b492e79 | field_markdown.go, http.go, admin_openapi.go, Go tests, admin.json, schema.d.ts, README, forms.md | | 2 | MarkdownField Preview renders server-sanitized HTML, rebuilt dist | a0116df | MarkdownField.vue, main.css, MarkdownField.test.ts, MLFields.test.ts, forms.md, modules/boardwalk/dist | | 3 | Raw-HTML gates allow the single sanctioned binding | 27711b7 | check-phase10/12.1/12.2/14.2.1.sh | ## What was built - **Route:** `service.markdownPreview` re-checks the backend principal and answers 401 `unauthenticated` without one. It decodes `{markdown}` through `decodeStrictBody`: unknown keys, malformed JSON and trailing data get 422, and a body over the cap gets 413. It renders through `RenderMarkdown` and answers `{html}`. When the gate refuses the output, the answer is 422 `validation_failed` with a fixed message on `markdown`. The route is mounted in the `backend` guard group behind `requireAjax`. `RenderMarkdown` and its gate are unchanged. - **Tracer gate:** the route ran end to end through guard, CSRF, renderer and OpenAPI conformance (`TestMarkdownPreviewRoute`, `TestPhase10OpenAPIConformance`) before the SPA work started. - **SPA:** Preview fetches when it opens. While it is open, a source change triggers one re-fetch after 300 ms. A sequence counter drops stale answers. Blank sources send nothing. Error answers show the server's `details.markdown[0]` as text, and failed requests show "Preview unavailable.". `aria-busy` reflects loading. - **Styles:** `.summer-markdown` style kit with low-specificity `:where()` selectors that read only `--c-*` variables and `--font-mono`. ## Verification - `go vet ./...` and `go test ./...` passed before each of the three commits (testcontainers Postgres included). - `scripts/check-admin-openapi.sh --check`, `scripts/check-admin-dist.sh` and `go test ./cmd/summer -run TestDocsTree` all pass. - `npm --prefix admin test` passes (73 files, 1054 tests), and `npm --prefix admin run typecheck` is clean. - `check-phase12.1.sh --hygiene` and `check-phase12.2.sh --hygiene` now pass; both failed before this change. `check-phase14.2.1.sh --forbidden` passes. `check-phase10.sh --hygiene` no longer reports a raw-HTML directive. - The exemption was tested against the planted `
` and against the variants `` and `v-html="other"` in MarkdownField.vue, using the real GNU grep. All three are still refused. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Second route inventory in phase10_coverage_test.go** - **Found during:** Task 1 (full `go test ./...`) - **Issue:** `TestPhase10Coverage/every_unsafe_mounted_route_is_CSRF-walked` keeps its own sorted list of unsafe routes and says "expects 25". The plan did not list this file. - **Fix:** Added `POST /markdown/preview` to the list in sort order and changed the message to 26. - **Files modified:** modules/cabana/phase10_coverage_test.go - **Commit:** b492e79 **2. [Rule 1 - Bug] vue-tsc noUncheckedIndexedAccess in the new vitest file** - **Found during:** Task 2 typecheck - **Fix:** Added non-null assertions on indexed `sent[n]` / `pending[n]` accesses in MarkdownField.test.ts. - **Commit:** a0116df Small additions beyond the plan's behavior list: - A trailing-data case in the Go invalid-body test. - Closing Preview or unmounting bumps the sequence, so an answer still in flight is dropped. ## Pre-existing issues (not touched) These `scripts/check-phase10.sh --hygiene` refusals predate this task and are out of scope: - `admin/src/api/files.ts:150` uses `new XMLHttpRequest()`, which the direct-fetch rule catches. - `admin/src/api/files.ts` is an unexpected file in `admin/src/api`. - `admin/src/components/form/fields/FileCaptionModal.vue` is imported by no test. - `admin/src/components/form/mlLocale.ts` is imported by no test. Because of them, `scripts/check-phase10.sh --self-test` stops with "rejected the clean scratch copy" before it plants anything. The plant refusal was checked by hand instead (see Verification). ## Threat Flags None beyond the plan's threat model. The one new surface, the T-261006sne-01..07 route and the raw-HTML sink, is mitigated as the plan specifies. ## Known Stubs None. ## Manual UAT (not gating) 1. Rebuild and restart the proof host. 2. Open a markdown field at /backend and click Preview. `# Title` should render as a heading. 3. Open an mlmarkdown field, click Preview and switch the locale. The preview should follow the locale. ## Self-Check: PASSED - FOUND: modules/cabana/markdown_preview_test.go, modules/cabana/markdown_preview_route_test.go, admin/src/components/form/fields/MarkdownField.vue - FOUND commits: b492e79, a0116df, 27711b7