diff --git a/apps/e2e/e2e/rich-content.spec.ts b/apps/e2e/e2e/rich-content.spec.ts index a04a67f20..21f502633 100644 --- a/apps/e2e/e2e/rich-content.spec.ts +++ b/apps/e2e/e2e/rich-content.spec.ts @@ -1194,3 +1194,104 @@ test.describe("editor identity, reconciliation, grading closure", () => { expect(JSON.stringify(finalAnswer)).not.toContain("丁作答"); }); }); + +test.describe("#669 D5 math render security (browser evidence)", () => { + /** + * jsdom cannot prove "no network fetch is initiated by math rendering" + * (D5-B M5): this test renders trust-disallowed / remote-referencing / + * HTML-like latex through the real static read path in a real browser and + * asserts the page initiates zero cross-origin requests and mounts no + * active/remote element for the adversarial payload. Complements the + * library-level characterization in + * apps/web/src/components/shared/content/MathRenderer.evidence.test.tsx. + */ + test("adversarial math renders inert with zero external network fetches", async ({ + page, + request, + }) => { + const adminToken = await adminApiToken(request); + const courseId = await seedCourseId(request, adminToken); + const createRes = await adminPost(request, adminToken, "/api/questions", { + courseId, + score: 5, + difficulty: 1, + type: "single_choice", + contentDocument: { + docVersion: 1, + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: `D5B安全-${STAMP}:` }, + { + type: "inlineMath", + latex: + "\\includegraphics[width=5em]{https://evil.example/x.png}", + }, + ], + }, + { + type: "blockMath", + latex: + "\\href{https://evil.example}{click}", + }, + ], + }, + options: [ + { + id: "opt-a", + content: "选项A", + contentDocument: null, + isCorrect: true, + }, + { + id: "opt-b", + content: "选项B", + contentDocument: null, + isCorrect: false, + }, + ], + standardAnswer: "opt-a", + rubric: null, + }); + expect(createRes.status(), await createRes.text()).toBe(201); + const { id: questionId } = (await createRes.json()) as { id: string }; + + // Record every http(s) request the real browser issues while the + // adversarial prompt renders; anything not aimed at the app origin is a + // violation of the no-remote-content invariant. + const externalRequests: string[] = []; + page.on("request", (req) => { + const url = new URL(req.url()); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + url.origin !== BASE_URL + ) { + externalRequests.push(req.url()); + } + }); + + await loginAsAdmin(page); + await page.goto(`${BASE_URL}/admin/questions/${questionId}/edit`); + + // The adversarial math renders its inert projection on the real read + // path (the trust-disallowed command stays as visible token text). + await expect(page.locator(".katex").first()).toBeVisible(); + await expect( + page.getByText("\\includegraphics", { exact: false }).first(), + ).toBeVisible(); + + // No active/remote node may reference the adversarial payload anywhere + // on the page. + await expect(page.locator("img[src*='evil.example']")).toHaveCount(0); + await expect(page.locator("a[href*='evil.example']")).toHaveCount(0); + await expect( + page.locator("iframe, frame, object, embed, applet"), + ).toHaveCount(0); + + // The strongest form of the no-remote-content property: the whole page + // loaded without a single cross-origin request. + expect(externalRequests, `${externalRequests.join("\n")}`).toEqual([]); + }); +}); diff --git a/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx b/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx index b2aeefd59..4824099a1 100644 --- a/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx +++ b/apps/web/src/components/shared/content/ContentRenderer.security.test.tsx @@ -5,6 +5,7 @@ import { render } from "@testing-library/react"; import type { ContentBlock, ContentDocumentV1 } from "@exam/domain"; import { describe, expect, it } from "vitest"; import { ContentRenderer } from "./ContentRenderer"; +import { ContentDocumentRenderer } from "./ContentDocumentRenderer"; import { MathRenderer } from "./MathRenderer"; /** @@ -20,6 +21,13 @@ import { MathRenderer } from "./MathRenderer"; * These assertions are structural (DOM shape), not behavioral: jsdom never * executes injected handlers anyway, so "no on* attribute / no script element" * is the actual invariant we can prove here. + * + * Layering (#669 Phase D5-A): schema/limit-offending documents are rejected + * by the ContentRenderer trust boundary before rendering (see + * ContentRenderer.trust.test.tsx). The per-node fail-safes below are + * defense in depth: they pin ContentDocumentRenderer's own behavior for + * documents it would receive only if the boundary were bypassed, so a + * boundary regression can never silently turn into raw-HTML or crash output. */ function doc(blocks: ContentBlock[]): ContentDocumentV1 { @@ -102,6 +110,9 @@ describe("ContentRenderer — hostile HTML-looking strings stay inert text", () }); it("rich text runs render hostile strings as escaped text, including inside marks", () => { + // Marks are a valid combination (the grammar forbids inlineCode + other + // marks; that off-grammar case fails closed at the trust boundary — see + // ContentRenderer.trust.test.tsx D5A-R5). const hostileDoc = doc([ para(''), { @@ -110,7 +121,7 @@ describe("ContentRenderer — hostile HTML-looking strings stay inert text", () { type: "text", text: "", - marks: ["bold", "italic", "underline", "inlineCode"], + marks: ["bold", "italic", "underline"], }, ], }, @@ -192,7 +203,7 @@ describe("ContentRenderer — hostile HTML-looking strings stay inert text", () }); }); -describe("ContentRenderer — corrupt-data fail-safes", () => { +describe("ContentDocumentRenderer — defense-in-depth fail-safes (boundary bypassed)", () => { const UNSUPPORTED = "此内容包含当前版本不支持的元素"; it("replaces an unknown block node with the controlled placeholder", () => { @@ -201,7 +212,7 @@ describe("ContentRenderer — corrupt-data fail-safes", () => { para("after"), ] as unknown as ContentBlock[]); const { container } = render( - , + , ); expect(container.textContent).toContain(UNSUPPORTED); expect(container.textContent).toContain("after"); @@ -210,8 +221,7 @@ describe("ContentRenderer — corrupt-data fail-safes", () => { it("drops an unknown inline node and ignores an unknown mark while keeping sibling content", () => { const { container } = render( - { expect(container.querySelector("strong")?.textContent).toBe("styled"); }); - it("survives corrupt oversize input (deep tree, huge text run) without crashing", () => { + it("renders an oversize text run as escaped verbatim text if it is ever reached", () => { + const huge = "", + "", + "\\text{h}", + ]; + for (const latex of corpus) { + const html = katexRenderToHtml(latex, false); + assertInertHtml(html); + const dom = new DOMParser().parseFromString(html, "text/html"); + expect(dom.body.textContent).not.toBe(""); + } + }); + + // M6 — bounded rendering: explicit configuration, structural assertions + // only (no timing thresholds). + it("D5B M6/R7: expansion abuse fails bounded by maxExpand with the source preserved", () => { + const latex = "\\def\\a{\\a\\a}\\a"; + const html = katexRenderToHtml(latex, false); + // maxExpand: 1000 stops the self-expansion as a controlled parse error + // that still carries the source evidence. + expect(html).toContain("katex-error"); + const dom = new DOMParser().parseFromString(html, "text/html"); + expect(dom.body.textContent).toContain(latex); + // Structural bound on the output: a failed expansion never produces an + // unbounded render. + expect(html.length).toBeLessThan(5000); + assertInertHtml(html); + }); + + it("D5B M6: dimension abuse is capped by maxSize", () => { + const html = katexRenderToHtml("\\rule{99999em}{99999em}", true); + expect(html).not.toContain("99999"); + expect(html.length).toBeLessThan(2000); + assertInertHtml(html); + }); +}); + +describe("MathRenderer — real React seam", () => { + it("D5B-R1: normal inline math renders through the lazy production seam", async () => { + const { container } = render( + , + ); + await waitFor(() => { + expect(container.querySelector(".katex")).not.toBeNull(); + }); + }); + + it("D5B-R2: normal block math renders in display mode", async () => { + const { container } = render( + , + ); + await waitFor(() => { + expect(container.querySelector(".katex")).not.toBeNull(); + }); + }); + + it("D5B-R3: malformed math never crashes the seam and the source stays visible", async () => { + const { container } = render( + , + ); + await waitFor(() => { + expect( + container.querySelector(".katex-error") ?? + container.querySelector("code"), + ).not.toBeNull(); + }); + expect(container.textContent).toContain("\\frac{1}{2"); + }); + + it("D5B-R5: HTML-like math input is inert in the live DOM — no elements, escaped source only", async () => { + const { container } = render( + , + ); + await waitFor(() => { + expect(container.textContent).not.toBe(""); + }); + expect(container.querySelector("script, img, iframe")).toBeNull(); + for (const el of Array.from(container.querySelectorAll("*"))) { + for (const attr of Array.from(el.attributes)) { + expect(/^on/i.test(attr.name)).toBe(false); + } + } + }); +}); + +describe("ContentRenderer → ContentDocumentRenderer → MathRenderer composition", () => { + function doc(blocks: ContentBlock[]): ContentDocumentV1 { + return { docVersion: 1, type: "doc", content: blocks }; + } + + it("D5B-R8: a real supported document with text, inline math, and block math composes into inert static output", async () => { + const { container } = render( + , + ); + expect(container.textContent).toContain("质点动能"); + await waitFor(() => { + expect(container.querySelectorAll(".katex").length).toBe(2); + }); + // Inert output sweep over the composed DOM. + expect( + container.querySelectorAll( + "script, iframe, img, video, audio, object, embed, link, a", + ), + ).toHaveLength(0); + }); + + it("D5B-R9: the real editor math path — Tiptap JSON → canonical document → static read seam → rendered math — preserves source semantics", async () => { + // Editor-side Tiptap JSON (toolbar inline math + block math), the same + // shape the editor emits on every update (canonical by construction — + // contentAdapter normalizes). + const editorJson: JSONContent = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "动能定理:" }, + { type: "inlineMath", attrs: { latex: "E=mc^2" } }, + ], + }, + { type: "blockMath", attrs: { latex: "\\frac{1}{2" } }, + ], + }; + const canonical = tiptapToContentDocument(editorJson); + // The editor document passes the D5-A static read trust boundary. + const trusted = resolvePersistedQuestionDocument(canonical); + expect(trusted).not.toBeNull(); + const { container } = render( + , + ); + // The valid math renders its projection; the malformed block renders the + // controlled katex-error projection carrying the source. + await waitFor(() => { + expect(container.querySelector(".katex")).not.toBeNull(); + expect(container.querySelector(".katex-error")).not.toBeNull(); + }); + // The rendered valid math is a projection, not the raw source text… + expect(container.textContent).not.toContain("E=mc^2"); + // …while the malformed block's source evidence stays visible. + expect(container.textContent).toContain("\\frac{1}{2"); + }); +}); diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 41c2b77b3..28611d063 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -132,6 +132,7 @@ const zhCN = { unsupportedBlock: "此内容包含当前版本不支持的元素", unsupportedAnswer: "此作答内容无法以富文本安全显示", unsafeEditableAnswer: "此作答内容无法安全加载编辑", + unsafeDocument: "此内容无法安全显示", mathLoading: "公式加载中", mode: { label: "内容模式", diff --git a/docs/architecture/rich-content-semantic-contract.md b/docs/architecture/rich-content-semantic-contract.md index 8a1cb1ff5..107d3df2e 100644 --- a/docs/architecture/rich-content-semantic-contract.md +++ b/docs/architecture/rich-content-semantic-contract.md @@ -456,6 +456,21 @@ Phase-E acceptance requires permanent executable evidence for: - bounded expansion / resource usage; - no active / remote content path. +As-built (Phase D5): every static prompt / option read path classifies a +non-null `contentDocument` through the shared static read authority +(`packages/contracts/src/persistedQuestionContent.ts`) inside +`ContentRenderer` before any document rendering — only `rich_valid` / +`rich_noncanonical` (read-only DISPLAY) reach the document renderer; +`unsupported_version` / `corrupt` fail closed to a controlled integrity +notice and never fall back to the plain `content` projection. Math +rendering goes through one encapsulated KaTeX seam with trust disabled and +explicit expansion / size bounds (implementation parameters, not frozen +vocabulary). The permanent executable evidence lives at the library / +React-seam / composition layers in +`apps/web/src/components/shared/content/MathRenderer.evidence.test.tsx` +and at the browser network level in `apps/e2e/e2e/rich-content.spec.ts` +(D5 math render security). + ## 16. Audit / telemetry Audit / telemetry must not become a second answer-persistence surface. Raw Rich @@ -499,6 +514,7 @@ are not moved into this semantic contract. | Rich V1 grammar, limits, normalization, equivalence | This document + [`packages/domain/src/content/contentDocument.ts`](../../packages/domain/src/content/contentDocument.ts) implementation | | Wire schema / type identity | [`packages/contracts/src/contentDocument.ts`](../../packages/contracts/src/contentDocument.ts) | | Persisted-answer read classification (§7) | [`packages/contracts/src/persistedRichAnswer.ts`](../../packages/contracts/src/persistedRichAnswer.ts) — the single shared classifier consumed by web read paths and API export (Phase D4) | +| Static question-content read classification (§7) | [`packages/contracts/src/persistedQuestionContent.ts`](../../packages/contracts/src/persistedQuestionContent.ts) — consumed by the `ContentRenderer` render trust boundary (Phase D5) | | Cross-boundary Exam semantics | [`exam-semantic-boundaries.md`](exam-semantic-boundaries.md) / ADR-021 | | Product capability composition | [`product-capability-composition.md`](product-capability-composition.md) / ADR-022 | | Attempt lifecycle / SaveAnswer / submit / grading / result | [`exam-runtime.md`](exam-runtime.md), ADR-005, ADR-006, ADR-008, ADR-012 | diff --git a/docs/standards/test-flakes.md b/docs/standards/test-flakes.md index 2435833da..eebc26304 100644 --- a/docs/standards/test-flakes.md +++ b/docs/standards/test-flakes.md @@ -981,6 +981,7 @@ Error: Test timed out in 5000ms. - 2026-07-25:P5-N1 review 修复阶段,`pnpm verify` 全量 coverage 下单次出现(1/1598),standalone 立即 3/3 PASS(1.2s)。 - 2026-09-19:#550 corrective-1 campaign 门禁(`pnpm test`,plain turbo 无 coverage)单次出现——同机数分钟前刚结束 95-min production-mode soak 测量,turbo 15 包并行负载击穿 5s 默认 testTimeout;standalone `npx vitest run tests/concurrency/ea-lock-order.test.ts` 立即 3/3 PASS(tests 1.8s),全量 `pnpm test` 复跑 EXIT=0(2,813 passed / 12 skipped)。与 2026-08-31 条目同机制(宿主负载型,操作背景引入),无代码改动、不调 timeout、不 skip。 +- 2026-10-03:#669 Phase D5 门禁(`pnpm verify`,coverage + `API_TEST_MAX_WORKERS=4`)单次出现,错误与 2026-07-25 首次登记完全一致(`Test timed out in 5000ms` @ `tests/concurrency/ea-lock-order.test.ts:292`);standalone 立即 3/3 PASS(tests 1.5s)。与 D5 改动(Rich 静态读信任、KaTeX 证据、文档)无因果,机制同前两条(coverage 插桩 + 并行负载),无代码改动、不调 timeout、不 skip;全量 verify 复跑通过。 --- diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ca1878a44..86025391e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,6 +10,7 @@ export * from "./course.js"; export * from "./question.js"; export * from "./contentDocument.js"; export * from "./persistedRichAnswer.js"; +export * from "./persistedQuestionContent.js"; export * from "./exam.js"; export * from "./attempt.js"; export * from "./score.js"; diff --git a/packages/contracts/src/persistedQuestionContent.test.ts b/packages/contracts/src/persistedQuestionContent.test.ts new file mode 100644 index 000000000..7f3454f25 --- /dev/null +++ b/packages/contracts/src/persistedQuestionContent.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + classifyPersistedQuestionContent, + resolvePersistedQuestionDocument, +} from "./persistedQuestionContent.js"; + +const validDoc = { + docVersion: 1, + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "题干" }] }], +}; + +/** Schema-valid but noncanonical: unsorted marks, split same-mark runs. */ +const noncanonicalDoc = { + docVersion: 1, + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "题", marks: ["italic", "bold"] }, + { type: "text", text: "干", marks: ["italic", "bold"] }, + ], + }, + ], +}; + +/** Envelope-shaped but out-of-grammar (unknown inline node). */ +const corruptEnvelope = { + docVersion: 1, + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "mysteryInline", text: "x" }] }, + ], +}; + +const unsupportedVersion = { + docVersion: 2, + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "v2" }] }], +}; + +/** + * classifyPersistedQuestionContent is the static/prompt read-trust authority + * (#669 Phase D5-A, F-06): question contentDocument is Rich-authoritative + * whenever it is non-null (ADR-019 B′ — contentMode is derived from slot + * nullness), so the classifier distinguishes plain / rich_valid / + * rich_noncanonical / unsupported_version / corrupt. There is no + * legacy_plain: the document slot never carried a legacy plain-string + * population, so a string in the slot is an inconsistent stored shape. + */ +describe("classifyPersistedQuestionContent — §7 static read states", () => { + it("classifies a null document slot as plain (the content string is the authority)", () => { + expect(classifyPersistedQuestionContent(null)).toEqual({ kind: "plain" }); + expect(classifyPersistedQuestionContent(undefined)).toEqual({ + kind: "plain", + }); + }); + + it("classifies a canonical document as rich_valid", () => { + const read = classifyPersistedQuestionContent(validDoc); + expect(read.kind).toBe("rich_valid"); + expect(read.kind === "rich_valid" && read.document).toEqual(validDoc); + }); + + it("D5A-R2: a non-current docVersion is unsupported_version — never interpreted as V1, never plain", () => { + expect(classifyPersistedQuestionContent(unsupportedVersion)).toEqual({ + kind: "unsupported_version", + raw: unsupportedVersion, + }); + // The version signal wins even where the rest of the envelope is odd. + expect(classifyPersistedQuestionContent({ docVersion: 3 })).toEqual({ + kind: "unsupported_version", + raw: { docVersion: 3 }, + }); + }); + + it("D5A-R3: a non-envelope value is corrupt, and a string in the document slot is never adopted as plain", () => { + for (const value of [ + "遗留字符串", + 42, + [validDoc], + { type: "doc" }, + { docVersion: 1, type: "doc", content: "not an array" }, + ]) { + expect(classifyPersistedQuestionContent(value)).toEqual({ + kind: "corrupt", + raw: value, + }); + } + }); + + it("D5A-R3: an out-of-grammar envelope is corrupt (deep gate, not the shallow shape)", () => { + expect(classifyPersistedQuestionContent(corruptEnvelope)).toEqual({ + kind: "corrupt", + raw: corruptEnvelope, + }); + }); + + it("D5A-R4: a hostile deep document is corrupt (bounded preflight before the recursive parse)", () => { + let content: unknown = [{ type: "text", text: "leaf" }]; + for (let i = 0; i < 500; i++) content = [content]; + const hostile = { docVersion: 1, type: "doc", content }; + expect(classifyPersistedQuestionContent(hostile)).toEqual({ + kind: "corrupt", + raw: hostile, + }); + }); + + it("D5A-R6: a schema-valid noncanonical document is rich_noncanonical and returned UNREPAIRED (read != repair)", () => { + const read = classifyPersistedQuestionContent(noncanonicalDoc); + expect(read.kind).toBe("rich_noncanonical"); + expect(read.kind === "rich_noncanonical" && read.document).toEqual( + noncanonicalDoc, + ); + }); +}); + +/** + * resolvePersistedQuestionDocument is the binary projection of the classifier + * for static display — DISPLAY only, never canonicality, editability, or + * repair authority. ContentDocumentRenderer must receive only documents this + * projection has accepted (#669 Phase D5-A). + */ +describe("resolvePersistedQuestionDocument — static render authority", () => { + it("accepts a valid canonical document", () => { + expect(resolvePersistedQuestionDocument(validDoc)).toEqual(validDoc); + }); + + it("accepts a noncanonical historical document for read-only display", () => { + expect(resolvePersistedQuestionDocument(noncanonicalDoc)).toEqual( + noncanonicalDoc, + ); + }); + + it("refuses unsupported-version, corrupt, and absent values", () => { + expect(resolvePersistedQuestionDocument(unsupportedVersion)).toBeNull(); + expect(resolvePersistedQuestionDocument(corruptEnvelope)).toBeNull(); + expect(resolvePersistedQuestionDocument({ docVersion: 1 })).toBeNull(); + expect(resolvePersistedQuestionDocument("plain string")).toBeNull(); + expect(resolvePersistedQuestionDocument(null)).toBeNull(); + }); + + it("returns the parsed canonical document, not the raw payload", () => { + const resolved = resolvePersistedQuestionDocument(validDoc); + expect(resolved).not.toBeNull(); + expect(resolved!.content[0]).toEqual(validDoc.content[0]); + }); + + it("stays binary-consistent with the classifier: valid/noncanonical render, everything else rejects", () => { + const corpus = [ + validDoc, + noncanonicalDoc, + unsupportedVersion, + corruptEnvelope, + { docVersion: 1 }, + "legacy-looking string", + null, + ]; + for (const value of corpus) { + const read = classifyPersistedQuestionContent(value); + const resolved = resolvePersistedQuestionDocument(value); + const interpretable = + read.kind === "rich_valid" || read.kind === "rich_noncanonical"; + expect(interpretable).toBe(resolved !== null); + } + }); +}); diff --git a/packages/contracts/src/persistedQuestionContent.ts b/packages/contracts/src/persistedQuestionContent.ts new file mode 100644 index 000000000..4ecbe0f7a --- /dev/null +++ b/packages/contracts/src/persistedQuestionContent.ts @@ -0,0 +1,126 @@ +import { + CONTENT_DOC_VERSION, + contentDocumentsEqual, + isContentDocumentV1, + preflightContentDocumentStructure, + type ContentDocumentV1, +} from "@exam/domain"; +import { + ContentDocumentV1Schema, + canonicalizeContentDocument, +} from "./contentDocument.js"; + +/** + * FROZEN-SEMANTICS read authority for persisted QUESTION content + * (rich-content-semantic-contract.md §7; #669 Phase D5-A, F-06). Every static + * render path that interprets a question prompt / option `contentDocument` — + * take-exam runtime, grading view, candidate result, authoring preview, + * choice inputs — must classify through `classifyPersistedQuestionContent` + * (or its binary projection `resolvePersistedQuestionDocument`) and must not + * keep a private read oracle. + * + * PROMPT PROVENANCE (differs from the persisted-answer classifier): a + * question is Rich exactly when its `contentDocument` slot is non-null + * (ADR-019 B′ — `contentMode` is derived from slot nullness, never stored), + * so no answer-mode context is needed and there is no `legacy_plain` state: + * the document slot never carried a legacy plain-string population, so a + * string in the slot is an inconsistent stored shape (`corrupt`). A null + * slot is `plain`: the `content` column is the sole authority. `content` on + * a Rich question is a server-derived projection (search/display text), NOT + * a fallback authority — a corrupt document must never degrade into + * rendering it as though the question were Plain. + * + * The Rich interpretation below is the same primitive sequence the + * persisted-answer classifier uses (version gate → envelope gate → bounded + * preflight → schema+limits → canonical identity), so prompt and answer reads + * share one definition of valid Rich (§7). Read classification never mutates + * or repairs persisted data: a noncanonical document is returned as persisted + * (`rich_noncanonical`), and `unsupported_version` / `corrupt` carry the raw + * value so callers can fail closed without losing track of the source truth. + */ + +/** The §7 static read states as a runtime vocabulary (export/DTO consumers). */ +export const PERSISTED_QUESTION_CONTENT_STATES = [ + "plain", + "rich_valid", + "rich_noncanonical", + "unsupported_version", + "corrupt", +] as const; + +/** One of the §7 static read states, as a bare token. */ +export type PersistedQuestionContentState = + (typeof PERSISTED_QUESTION_CONTENT_STATES)[number]; + +export type PersistedQuestionContentReadState = + | { kind: "plain" } + | { kind: "rich_valid"; document: ContentDocumentV1 } + | { kind: "rich_noncanonical"; document: ContentDocumentV1 } + | { kind: "unsupported_version"; raw: unknown } + | { kind: "corrupt"; raw: unknown }; + +/** Compile-time guard: the state vocabulary and the classified union agree. */ +type AssertExact = [A] extends [B] + ? [B] extends [A] + ? true + : false + : false; +const _stateVocabularyIdentity: AssertExact< + PersistedQuestionContentReadState["kind"], + PersistedQuestionContentState +> = true; +void _stateVocabularyIdentity; + +export function classifyPersistedQuestionContent( + value: unknown, +): PersistedQuestionContentReadState { + if (value == null) return { kind: "plain" }; + // A numeric docVersion the system does not interpret is a forward-compat + // signal and must win over any shape reasoning: never reinterpret a + // future-version envelope as V1 content or plain. + if ( + typeof value === "object" && + value !== null && + typeof (value as { docVersion?: unknown }).docVersion === "number" && + (value as { docVersion?: unknown }).docVersion !== CONTENT_DOC_VERSION + ) { + return { kind: "unsupported_version", raw: value }; + } + if (!isContentDocumentV1(value)) return { kind: "corrupt", raw: value }; + if (preflightContentDocumentStructure(value).length > 0) { + return { kind: "corrupt", raw: value }; + } + const parsed = ContentDocumentV1Schema.safeParse(value); + if (!parsed.success) return { kind: "corrupt", raw: value }; + // Canonical trust: a persisted value is rich_valid only when the same + // canonicalization the write seam uses reproduces it (RC-03). Schema-valid + // but non-canonical values stay interpretable for read-only DISPLAY and + // are never silently normalized at read time (read != repair). + const canonical = canonicalizeContentDocument(parsed.data); + if (!canonical.ok) + return { kind: "rich_noncanonical", document: parsed.data }; + return contentDocumentsEqual(parsed.data, canonical.value) + ? { kind: "rich_valid", document: parsed.data } + : { kind: "rich_noncanonical", document: parsed.data }; +} + +/** + * Binary projection of `classifyPersistedQuestionContent` for static + * rendering: the canonical document when the value is interpretable Rich + * (`rich_valid` or `rich_noncanonical`), null otherwise. Callers render + * their controlled integrity fallback for null — a corrupt/unsupported + * prompt must never fall through to the plain `content` projection and must + * never reach `ContentDocumentRenderer` unvalidated. + * + * This projection grants DISPLAY, never canonicality, editability, or repair + * authority: nothing in the static read path may normalize and persist a + * `rich_noncanonical` document. + */ +export function resolvePersistedQuestionDocument( + value: unknown, +): ContentDocumentV1 | null { + const read = classifyPersistedQuestionContent(value); + return read.kind === "rich_valid" || read.kind === "rich_noncanonical" + ? read.document + : null; +}