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

137 lines
7.5 KiB
Markdown

---
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 `<div v-html="raw" />` and against the variants `<div v-html="sanitizedHtml" />` 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