diff --git a/.changeset/6972-markdown-inert-keys-retired.md b/.changeset/6972-markdown-inert-keys-retired.md new file mode 100644 index 000000000..1f19c51e0 --- /dev/null +++ b/.changeset/6972-markdown-inert-keys-retired.md @@ -0,0 +1,115 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-markdown': minor +--- + +**Breaking for authored metadata:** `MarkdownSchema.sanitize` and +`MarkdownSchema.components` are RETIRED (objectui#6972, ADR-0049 +enforce-or-remove). A `markdown` node that authors either key no longer +validates: the parse fails loudly on that key's path with the explanation in +the message, and both TS members are `?: never` tombstones, so the same document +is refused at compile time. The two keys do not share a disposition — triage +recorded the asymmetry — so each is argued below. + +## `sanitize` + +**What was measured, on this branch's base.** `sanitize` was declared +`?: boolean` with `@default true` on both published faces — `data-display.ts` +and the Zod mirror — documented, and read by NOTHING. Worse than an ordinary +inert key, it implied a switch that does not exist: sanitization is +**unconditional**. `rehypePlugins` in `plugin-markdown/src/MarkdownImpl.tsx` is +a module-level `const` array whose last link is `[rehypeSanitize, sanitizeSchema]`, +handed to `ReactMarkdown` as-is — no ternary, no `if`, no runtime assembly. +`MarkdownRenderer` forwards exactly `content` and `className`, and +`MarkdownImplProps` accepts only those two. A repo-wide grep for +`schema.sanitize` over `packages/` and `apps/` returns nothing, against a +control of 20 `.tsx` files reading `schema.content` in the same query shape, so +the zero is a reading, not a blind query. An author writing `sanitize: false` +believed they turned XSS filtering off; one writing `sanitize: true` believed +they turned it on. Neither was true. + +**Why remove and not enforce.** The enforce arm of enforce-or-remove for this +key is a switch that DISABLES XSS sanitization, which is not an acceptable +outcome; for `sanitize` the ruling collapses to remove (triage on +objectui#6972). + +**Who is affected — a `sanitize` authored onto a `markdown` node:** + +```json +{ "type": "markdown", + "content": "# Hello", + "sanitize": false } // ← was tolerated, changed nothing +``` + +now fails validation with: + +> RETIRED (objectui#6972) — sanitization is unconditional: rehype-sanitize is a +> fixed last link of the markdown renderer's rehype chain, and no value of this +> key ever switched it. There is no authored spelling that disables XSS +> sanitization; delete the key. + +## `components` + +**What was measured, on this branch's base.** `components` was declared +`?: Record` ("custom components for markdown elements") on both +faces and read by NOTHING: the `components` map `MarkdownImpl` hands to +`ReactMarkdown` is its own module-level `mdComponents` (the mermaid / metadata +fence overrides), never merged with anything off the schema, and +`grep -rn "schema.components"` over `packages/` and `apps/` returns nothing +against the same `schema.content` control. The premise the PM declared +falsifiable — *no host path consumes a `components` map* — was re-measured +before this half was written: `MarkdownImplProps` has no such prop, `LazyMarkdown` +receives only `content` and `className`, and no plugin API, app-shell or runner +site passes one. + +**Why remove and not wire, and why not a runtime slot.** A map of React +component overrides is not a value a JSON document can author — the same shape +as the handler keys objectui#6124 retired ("JSON has no function value"). The +`runtime-slot` disposition keeps a TypeScript twin callable when a host-supplied +value actually reaches a renderer; nothing reaches this one, so there is no twin +to keep and the TS face refuses it outright. This half is the PM's disposition +under a declared veto window on objectui#6972, not a maintainer ruling; the PR +stays draft for contract review. A real override slot must arrive as a proposal +WITH its enforcing reader, not by reviving this key. + +```json +{ "type": "markdown", + "content": "# Hello", + "components": { "h1": "h2" } } // ← was tolerated, changed nothing +``` + +now fails validation with: + +> RETIRED (objectui#6972) — never read: the markdown renderer forwards only +> `content` and `className`, and a map of React component overrides is not a +> JSON-authorable value. Delete the key; the fenced mermaid / metadata block +> overrides are the renderer's own fixed map, not an authoring surface. + +## Both keys + +**Two published faces.** `@object-ui/plugin-markdown` re-exports `MarkdownSchema` +from `@object-ui/types` (one authority since objectui#6172) rather than +declaring a copy, so the retirement reaches its consumers through the same +declaration — which is why this changeset names the plugin as well: no plugin +source changes, but the type its published face exposes narrows, and its own +test now pins that the refusal arrives there. + +**Who is NOT affected.** A document that never wrote either key is untouched +(`absent` stays valid), `content` and `className` are unchanged, and the +renderer's behaviour is byte-identical — it sanitized unconditionally before +and does now, and its fenced-block overrides are the same fixed map. One +in-repo fixture authored either key +(`packages/types/examples/data-display-examples.json#examples.markdown`, +`"sanitize": true`); the key is deleted from it and the fixture is now pinned +to parse green. No fixture, catalog entry, doc snippet, skill or app in this +repository authored `components` on a markdown node. + +**Migration:** delete the keys. There is nothing to replace either with — the +behaviour `sanitize` claimed to control is always on, and no authored spelling +overrides markdown elements. + +Graded `minor`, not `patch`: this narrows the accepted input set, which is +breaking for any author who wrote the tolerated key. It is not `major` per +this repo's fixed-group convention (objectui's own breaking changes ship as +`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略, +mechanically enforced by `scripts/check-changeset-no-major.mjs`). diff --git a/content/docs/plugins/plugin-markdown.mdx b/content/docs/plugins/plugin-markdown.mdx index 4fb75dbc0..9e7dd0475 100644 --- a/content/docs/plugins/plugin-markdown.mdx +++ b/content/docs/plugins/plugin-markdown.mdx @@ -72,8 +72,19 @@ renderer's own props type takes a non-optional `string`, and the Zod mirror |----------|------|---------|-------------| | `content` | string | — (required) | Markdown content to render | | `className` | string | `''` | Additional Tailwind CSS classes | -| `sanitize` | boolean | `true` | Declared on the schema; the renderer sanitizes unconditionally | -| `components` | Record | — | Declared on the schema; not read by this renderer | + +> **Retired (objectui#6972):** `sanitize` and `components` used to be listed +> here. Neither was ever read. `sanitize` (a boolean defaulting to `true`) +> implied a switch that does not exist: sanitization is **unconditional** — +> `rehype-sanitize` is a fixed last link of the renderer's rehype chain, and no +> value of the key ever switched it, so `sanitize: false` changed nothing while +> reading as a security control. `components` (a `Record` of React component +> overrides) is not a value a JSON document can author, and the renderer's own +> fenced-block overrides (mermaid / metadata) are a fixed internal map, not an +> authoring surface. Both keys are now `never` on the TypeScript face and are +> refused **by name** by the Zod mirror, with that explanation in the parse +> message. There is no authored spelling that disables XSS sanitization, and no +> authored spelling that overrides markdown elements; delete the keys. ## Supported Markdown Features diff --git a/packages/plugin-markdown/src/index.test.ts b/packages/plugin-markdown/src/index.test.ts index 50978070d..b772b7dab 100644 --- a/packages/plugin-markdown/src/index.test.ts +++ b/packages/plugin-markdown/src/index.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect } from 'vitest'; import { ComponentRegistry } from '@object-ui/core'; import type { ComponentInput } from '@object-ui/types'; +import type { MarkdownSchema } from './types'; // Imports all renderers to register them. Module scope, NOT awaited inside a // `beforeAll` — there the cold transform of the renderer graph is billed to the // hook, against `hookTimeout`. That is what made the sibling plugin-kanban test @@ -97,4 +98,42 @@ describe('Plugin Markdown', () => { expect(defaults?.content).toContain('#'); }); }); + + describe('MarkdownSchema retirements reach this package\'s published face (objectui#6972)', () => { + // `./types` re-exports `MarkdownSchema` from `@object-ui/types` — ONE + // authority (objectui#6172), not a local copy — so an ADR-0049 tombstone + // declared there must be a `tsc` error through THIS package's import + // spelling too. That is the "two published faces" half of the retirement, + // pinned where the second face lives. REAL enforcement: `type-check` runs + // `tsc -p tsconfig.test.json`, so an unused directive fails the build — + // if the plugin ever re-declared a local `MarkdownSchema` that carried the + // key, this leg goes red before anything else does. + it('`sanitize` is a `tsc` error through the re-exported authority — sanitization is unconditional', () => { + const node: MarkdownSchema = { + type: 'markdown', + content: '# Hello', + // @ts-expect-error `sanitize` is an ADR-0049 retirement tombstone (objectui#6972): the renderer sanitizes unconditionally, no value switches it + sanitize: false, + }; + expect(node.content).toBe('# Hello'); + }); + + it('`components` is a `tsc` error through the re-exported authority — nothing reads an override map', () => { + const node: MarkdownSchema = { + type: 'markdown', + content: '# Hello', + // @ts-expect-error `components` is an ADR-0049 retirement tombstone (objectui#6972): the renderer forwards only content and className + components: { h1: 'h2' }, + }; + expect(node.content).toBe('# Hello'); + }); + + it('the two values the renderer DOES forward stay writable — the non-vacuity control', () => { + // `content` and `className` are exactly what `MarkdownRenderer` hands to + // `MarkdownImpl`; without this leg the directive above could be satisfied + // by a face that lost the whole interface. + const node: MarkdownSchema = { type: 'markdown', content: '# Hello', className: 'prose-lg' }; + expect(node.className).toBe('prose-lg'); + }); + }); }); diff --git a/packages/types/examples/data-display-examples.json b/packages/types/examples/data-display-examples.json index 73e3e63dd..a5793f2ad 100644 --- a/packages/types/examples/data-display-examples.json +++ b/packages/types/examples/data-display-examples.json @@ -234,8 +234,7 @@ "markdown": { "type": "markdown", "id": "readme", - "content": "# Welcome\n\nThis is a **markdown** component.\n\n- Feature 1\n- Feature 2\n- Feature 3", - "sanitize": true + "content": "# Welcome\n\nThis is a **markdown** component.\n\n- Feature 1\n- Feature 2\n- Feature 3" }, "html": { "type": "html", diff --git a/packages/types/src/__tests__/markdown-inert-keys-retired-6972.test.ts b/packages/types/src/__tests__/markdown-inert-keys-retired-6972.test.ts new file mode 100644 index 000000000..e66476feb --- /dev/null +++ b/packages/types/src/__tests__/markdown-inert-keys-retired-6972.test.ts @@ -0,0 +1,332 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Retirement pin — `MarkdownSchema.sanitize` and `MarkdownSchema.components` + * are REFUSED, not silently ignored (objectui#6972, ADR-0049 + * enforce-or-remove). The two keys do NOT share a disposition — triage recorded + * the asymmetry — so each is argued on its own below, then pinned by one + * mechanism. + * + * ## The failure this pin exists to prevent — `sanitize` + * + * `sanitize` was declared `?: boolean` with `@default true` on both published + * faces (`data-display.ts` and the Zod mirror; `@object-ui/plugin-markdown` + * re-exports the same authority since objectui#6172), documented, and read by + * NOTHING. Worse than an ordinary inert key, it implied a switch that does not + * exist: sanitization is UNCONDITIONAL. `rehypePlugins` in + * `plugin-markdown/src/MarkdownImpl.tsx` is a module-level `const` array whose + * last link is `[rehypeSanitize, sanitizeSchema]`, handed to `ReactMarkdown` + * as-is — no ternary, no `if`, no runtime assembly. `MarkdownRenderer` forwards + * exactly `content` and `className`; `MarkdownImplProps` accepts only those + * two. So `sanitize: false` type-checked, parsed green and changed nothing + * while reading as a security-relevant control, and `sanitize: true` promised + * a gate the author never controlled either. Both readings lied. + * + * The enforce arm of enforce-or-remove would be a switch that DISABLES XSS + * sanitization — not an acceptable outcome — so for this key the ruling + * collapses to remove (triage on objectui#6972). The deliverable is therefore + * not "sanitize works"; it is: **an authored `sanitize` is refused loudly at + * the authoring boundary, and the refusal says why** (sanitization is + * unconditional; there is no spelling that disables it). + * + * ## The failure this pin exists to prevent — `components` + * + * `components` was declared `?: Record` ("custom components for + * markdown elements") and read by NOTHING: the `components` map `MarkdownImpl` + * hands to `ReactMarkdown` is its OWN module-level `mdComponents` (the mermaid + * / metadata fence overrides), never merged with anything off the schema, and + * no host path — no `MarkdownImpl` prop, no plugin API, no app-shell or runner + * site — consumes such a map. Triage left both arms open here; the PM's + * disposition (declared veto window on objectui#6972) is remove: a map of + * React component overrides is not a JSON-authorable value, the same shape as + * the handler keys objectui#6124 retired, and with no host consumer there is + * no `runtime-slot` twin to keep callable. The falsifiable premise — "no host + * path consumes a `components` map" — was re-measured on the retiring PR's + * base before this half was written. + * + * ## Why the tombstone, and not simply deleting the keys + * + * `BaseSchema` is `.passthrough()` on the Zod side and carries a + * `[key: string]: any` index signature on the TS side. An UNDECLARED key is + * accepted by both halves, unvalidated — deleting `sanitize` outright would + * hand the authored spelling exactly the silent no-op this card exists to + * close (and would hand `components` the same). `?: never` / + * `retirementTombstone()` is this package's convention — + * {@link StaticTableColumn} (objectui#5474), `DataTableSchema.toolbar` + * (objectui#6881), `ComponentInput.inputType` (objectui#5905) — and it is + * lockstep: both halves or neither. The "deleted" row is pinned live below as + * a control, so the contrast cannot rot into prose. + * + * The `@ts-expect-error` directives are REAL enforcement: this package + * type-checks its tests through `tsconfig.test.json`, so re-widening the + * declaration fails the build on the unused directive. A green `vitest` run is + * NOT evidence about them — type assertions are erased before it runs. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { MarkdownSchema } from '../zod/data-display.zod.js'; +import type { MarkdownSchema as MarkdownSchemaTS } from '../data-display.js'; + +/** + * The retired keys, each with the values an author would plausibly have + * written, the FULL guidance string (pinned as a literal so the derived + * assertions below cannot all drift together), and the prescriptive half — + * the sentence that tells the author what is true and what to do. + */ +const RETIRED = { + sanitize: { + values: [false, true] as const, + guidance: + 'RETIRED (objectui#6972) — sanitization is unconditional: rehype-sanitize is a fixed last link of the ' + + 'markdown renderer\'s rehype chain, and no value of this key ever switched it. There is no authored ' + + 'spelling that disables XSS sanitization; delete the key.', + prescriptive: 'There is no authored spelling that disables XSS sanitization; delete the key.', + }, + components: { + // The shapes an author reaching for react-markdown's `components` prop + // would write: a tag remap, and an empty map. + values: [{ h1: 'h2' }, {}] as const, + guidance: + 'RETIRED (objectui#6972) — never read: the markdown renderer forwards only `content` and `className`, ' + + 'and a map of React component overrides is not a JSON-authorable value. Delete the key; the fenced ' + + 'mermaid / metadata block overrides are the renderer\'s own fixed map, not an authoring surface.', + prescriptive: 'Delete the key; the fenced mermaid / metadata block overrides are the renderer\'s own fixed map, not an authoring surface.', + }, +} as const; + +type RetiredKey = keyof typeof RETIRED; + +/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */ +const VALID_MARKDOWN = { + type: 'markdown', + content: '# Hello\n\nSome **markdown**.', +} as const; + +const shapeOf = (schema: unknown): Record => + (schema as { shape: Record }).shape; + +const describeOf = (schema: unknown, key: string): string | undefined => + (shapeOf(schema)[key] as { description?: string } | undefined)?.description; + +/* ── the Zod half: refused BY NAME, with the guidance in the message ─────── */ + +describe.each(Object.keys(RETIRED) as RetiredKey[])( + 'MarkdownSchema.%s is RETIRED — the Zod half of the tombstone (objectui#6972)', + (key) => { + const { values, guidance, prescriptive } = RETIRED[key]; + + it.each(values.map((v) => [JSON.stringify(v), v] as const))( + 'REFUSES `%s`, naming the retired key in the path — every value, not one spelling', + (_label, value) => { + // The pin. Before the retirement this document parsed GREEN (`sanitize` + // was `z.boolean().optional()`, `components` was + // `z.record(z.string(), z.any()).optional()`), measured ACCEPTED on the + // retiring PR's base. Asserting the ENVELOPE — not merely + // `success:false` — so the pin cannot be satisfied by an unrelated + // rejection. + const result = MarkdownSchema.safeParse({ ...VALID_MARKDOWN, [key]: value }); + expect( + result.success, + `an authored \`${key}: ${JSON.stringify(value)}\` was ACCEPTED — it parses green and changes nothing`, + ).toBe(false); + if (result.success) return; + + const issue = result.error.issues.find((i) => i.path[0] === key); + expect(issue, `parse failed, but not on the \`${key}\` path`).toBeTruthy(); + // The accept-set contract: same address, same code a bare `z.never()` + // reports — `retirementTombstone()` customises the MESSAGE only. + expect(issue?.code).toBe('invalid_type'); + expect((issue as { expected?: string } | undefined)?.expected).toBe('never'); + expect(issue?.path).toEqual([key]); + }, + ); + + it('the refusal CARRIES the guidance — the prescriptive sentence, not zod\'s generic message', () => { + const result = MarkdownSchema.safeParse({ ...VALID_MARKDOWN, [key]: values[0] }); + expect(result.success).toBe(false); + if (result.success) return; + + const issue = result.error.issues.find((i) => i.path[0] === key); + expect(issue?.message).not.toContain('Invalid input: expected never, received '); + // The half an author acts on: what is TRUE (sanitization is unconditional; + // nothing reads an override map) and what to DO (delete the key). Pinned + // as text, because the wording is the contract here — a message that only + // said "retired" would leave the security-shaped misreading of `sanitize` + // in place. + expect(issue?.message).toContain(prescriptive); + expect(issue?.message).toBe(guidance); + // ONE string, BOTH channels — asserted derived, so the parse message and + // the generated-docs metadata cannot drift apart (objectui#6931). + expect(issue?.message).toBe(describeOf(MarkdownSchema, key)); + }); + + it('keeps the key DECLARED — a tombstone, not a deletion', () => { + // The route guard. `BaseSchema` is `.passthrough()`, so removing the key + // from the mirror would make the authored spelling parse green again and + // do nothing — the silent no-op reintroduced by the very edit meant to + // remove it. + expect( + Object.keys(MarkdownSchema.shape), + `${key} left the mirror — under .passthrough() the retired key becomes a SILENT no-op again`, + ).toContain(key); + expect(describeOf(MarkdownSchema, key)).toContain('RETIRED (objectui#6972)'); + }); + }, +); + +/* ── the inside of the boundary: everything else is untouched ────────────── */ + +describe('the retirement narrows exactly the retired keys and nothing else (objectui#6972)', () => { + it('a document that never wrote either key parses GREEN — `absent` stays valid', () => { + // `.optional()` on both tombstones. The retirement narrows exactly two keys. + const result = MarkdownSchema.safeParse(VALID_MARKDOWN); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('still ACCEPTS `className` — the live sibling the renderer actually forwards', () => { + // `className` is the OTHER value `MarkdownRenderer` forwards (with + // `content`). Without this leg the refusals above would be satisfied by a + // schema that refuses every optional key — a narrowing that refuses too + // much would pass a refusal-only test. + const result = MarkdownSchema.safeParse({ ...VALID_MARKDOWN, className: 'prose-lg' }); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('still REFUSES a wrong `content` — the mirror did not stop validating', () => { + // Counter-probe in the other direction: the schema is not `z.any()` in + // disguise, so the green results above are readings. + const result = MarkdownSchema.safeParse({ type: 'markdown', content: 42 }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.find((i) => i.path[0] === 'content')).toBeTruthy(); + }); + + it('the shipped example document parses GREEN — the fixture that used to author the key', () => { + // `packages/types/examples/data-display-examples.json#examples.markdown` + // authored `"sanitize": true` — the one in-repo write of either key, dropped + // by the retiring PR because the tombstone refuses it. Pinned here so the + // fixture cannot silently regress into a document the published mirror + // rejects: it is documentation consumers copy from. + const ROOT = resolve(__dirname, '../../../..'); + const doc = JSON.parse( + readFileSync(resolve(ROOT, 'packages/types/examples/data-display-examples.json'), 'utf8'), + ) as { examples: { markdown: Record } }; + const markdown = doc.examples.markdown; + expect(markdown.type).toBe('markdown'); + for (const key of Object.keys(RETIRED)) { + expect(markdown, `the fixture still authors the retired \`${key}\``).not.toHaveProperty(key); + } + const result = MarkdownSchema.safeParse(markdown); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('an UNDECLARED key still rides `.passthrough()` — the DELETED row, measured live', () => { + // This is the contrast that justifies `?: never` over deletion, pinned + // rather than argued: a key the mirror does not declare is neither refused + // nor stripped, it is KEPT. Had either key been deleted instead of + // tombstoned, an authored value would sit exactly where this one sits — + // green, forwarded, and read by nothing. + const result = MarkdownSchema.safeParse({ ...VALID_MARKDOWN, notAKeyAtAll: 'anything' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toHaveProperty('notAKeyAtAll', 'anything'); + }); +}); + +/* ── the TS half: the `tsc` channel ──────────────────────────────────────── */ + +describe('MarkdownSchema.sanitize is RETIRED — the TS half of the tombstone (objectui#6972)', () => { + it('refuses the retired key at compile time', () => { + // On the pre-fix tree `sanitize` is `boolean | undefined`, so the + // assignment is LEGAL, the directive below is unused, and `tsc` fails the + // build with TS2578 naming the key — this leg is red before the fix in + // `type-check`, not in vitest, which strips types. + + // @ts-expect-error — `sanitize` is RETIRED (objectui#6972): declared `?: never`, so no value is authorable. + const retired: MarkdownSchemaTS['sanitize'] = false; + + // Counter-probe on the same surface: the live sibling still accepts its + // value, so the directive above pins the KEY's retirement and not a + // blanket narrowing of the interface. + const sibling: MarkdownSchemaTS['content'] = '# Hello'; + + expect([retired, sibling]).toHaveLength(2); + }); + + it('refuses the retired key in the form authors actually write', () => { + // The leg that proves the tombstone survives `BaseSchema`'s + // `[key: string]: any`: if the index signature won, `sanitize` would widen + // back to `any` here and the directive would go unused (TS2578). + const retiredDocument: MarkdownSchemaTS = { + type: 'markdown', + content: '# Hello', + // @ts-expect-error — `sanitize` is RETIRED (objectui#6972); sanitization is unconditional, there is no spelling that disables it. + sanitize: false, + }; + + // The migrated document — the key simply deleted — still type-checks. + const migratedDocument: MarkdownSchemaTS = { + type: 'markdown', + content: '# Hello', + className: 'prose-lg', + }; + + expect([retiredDocument, migratedDocument]).toHaveLength(2); + }); + + it('refuses it through a WIDENED value too — the half a deletion would have missed', () => { + // Excess-property checking only reaches a FRESH literal (objectui#7654 + // measured the contrast): a deleted key would ride a widened value + // silently. The declared `never` makes the assignment itself ill-typed, + // so freshness stops mattering. + const raw = { type: 'markdown' as const, content: '# Hello', sanitize: true }; + // @ts-expect-error — `sanitize` is RETIRED (objectui#6972), reached through a non-fresh value. + const document: MarkdownSchemaTS = raw; + expect(document.type).toBe('markdown'); + }); +}); + +describe('MarkdownSchema.components is RETIRED — the TS half of the tombstone (objectui#6972)', () => { + it('refuses the retired key at compile time', () => { + // On the pre-fix tree `components` is `Record | undefined`, + // so this assignment is LEGAL and the directive is unused (TS2578) — + // red before the fix in `type-check`. + + // @ts-expect-error — `components` is RETIRED (objectui#6972): declared `?: never`, so no value is authorable. + const retired: MarkdownSchemaTS['components'] = { h1: 'h2' }; + + const sibling: MarkdownSchemaTS['className'] = 'prose-lg'; + expect([retired, sibling]).toHaveLength(2); + }); + + it('refuses the retired key in the form authors actually write', () => { + // Survives `BaseSchema`'s `[key: string]: any`: if the index signature + // won, `components` would widen back to `any` here and the directive + // would go unused (TS2578). + const retiredDocument: MarkdownSchemaTS = { + type: 'markdown', + content: '# Hello', + // @ts-expect-error — `components` is RETIRED (objectui#6972); the renderer forwards only `content` and `className`. + components: { h1: 'h2' }, + }; + expect(retiredDocument.type).toBe('markdown'); + }); + + it('refuses it through a WIDENED value too — and there is NO runtime-slot twin to keep callable', () => { + // The `runtime-slot` arm (`handlerKeyRefusal`, objectui#6124) keeps a TS + // twin callable when a host-supplied value actually REACHES a renderer. + // Nothing reaches this one — `MarkdownImplProps` has no such prop — so the + // TS face refuses it outright, on the widened path as well. + const raw = { type: 'markdown' as const, content: '# Hello', components: { h1: 'h2' } }; + // @ts-expect-error — `components` is RETIRED (objectui#6972), reached through a non-fresh value. + const document: MarkdownSchemaTS = raw; + expect(document.type).toBe('markdown'); + }); +}); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 9b356dc97..e3ef905ac 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -1161,14 +1161,71 @@ export interface MarkdownSchema extends BaseSchema { */ content: string; /** - * Whether to sanitize HTML - * @default true - */ - sanitize?: boolean; - /** - * Custom components for markdown elements - */ - components?: Record; + * ADR-0049 RETIREMENT TOMBSTONE — `sanitize` (objectui#6972). + * + * Declared `?: boolean` with `@default true` and read by NOTHING — and, worse + * than an ordinary inert key, it implied a switch that does not exist. + * Sanitization is UNCONDITIONAL: `rehypePlugins` in + * `packages/plugin-markdown/src/MarkdownImpl.tsx` is a module-level `const` + * array whose last link is `[rehypeSanitize, sanitizeSchema]`, handed to + * `ReactMarkdown` as-is — no ternary, no `if`, no runtime assembly. So + * `sanitize: false` type-checked, passed the Zod mirror and changed nothing + * while reading as a security-relevant control, and `sanitize: true` + * promised a gate the author never controlled either. Both readings lied. + * The enforce arm of enforce-or-remove would be a switch that DISABLES XSS + * sanitization, which is not an acceptable outcome, so for this key the + * ruling collapses to remove (triage on objectui#6972). + * + * Measured on the retiring PR's base: `MarkdownRenderer` + * (`plugin-markdown/src/index.tsx`) forwards exactly `content` and + * `className` to `MarkdownImpl`, whose props type accepts only those two; + * `grep -rn "schema.sanitize"` over `packages/` and `apps/` returns nothing, + * against a control of 20 `.tsx` files reading `schema.content` in the same + * query shape — the zero is a reading, not a blind query. + * + * `?: never` is this package's tombstone convention (see `crud.ts` + * `confirm`, {@link StaticTableColumn}, `DataTableSchema.toolbar` above), + * NOT a deletion: `BaseSchema`'s `[key: string]: any` would admit a deleted + * key as `any` again — the same silence one layer over. The Zod twin refuses + * it loudly via `retirementTombstone()` (`zod/data-display.zod.ts`). Both + * published faces carry the refusal: `@object-ui/plugin-markdown` re-exports + * this one authority (objectui#6172), so its consumers meet the same + * declaration. + * + * RETIRED (objectui#6972, ADR-0049) — sanitization is unconditional; there + * is no authored spelling that disables it. Delete the key. + * @deprecated Not part of `MarkdownSchema`'s contract — the value was inert. + */ + sanitize?: never; + /** + * ADR-0049 RETIREMENT TOMBSTONE — `components` (objectui#6972). + * + * Declared `?: Record` ("custom components for markdown + * elements") and read by NOTHING: `MarkdownRenderer` forwards only `content` + * and `className`, `MarkdownImplProps` accepts only those two, and the + * `components` map `MarkdownImpl` hands to `ReactMarkdown` is its OWN + * module-level `mdComponents` (the mermaid / metadata fence overrides), + * never merged with anything off the schema. `grep -rn "schema.components"` + * over `packages/` and `apps/` returns nothing, against the same + * `schema.content` control as `sanitize` above. + * + * Removed rather than wired, under the PM's declared veto window on + * objectui#6972: a map of React component overrides is not a + * JSON-authorable value — the same shape as the handler keys objectui#6124 + * retired ("JSON has no function value"). It is NOT a `runtime-slot` + * either: no host path (no `MarkdownImpl` prop, no plugin API, no app-shell + * or runner site) consumes such a map, so there is no TypeScript twin to + * keep callable for hosts. A real override slot must arrive as a proposal + * WITH its enforcing reader, not by reviving this key. + * + * Same convention as `sanitize` above: `?: never` here, + * `retirementTombstone()` on the Zod twin, both published faces. + * + * RETIRED (objectui#6972, ADR-0049) — never read by the markdown renderer. + * Delete the key. + * @deprecated Not part of `MarkdownSchema`'s contract — the value was inert. + */ + components?: never; } /** diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 011cf6818..5657e60c8 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -285,12 +285,34 @@ export const DataTableSchema = BaseSchema.extend({ /** * Markdown Schema - Markdown content renderer + * + * `sanitize` / `components` are ADR-0049 tombstones (objectui#6972). + * `sanitize` implied a switch that does not exist: sanitization is + * UNCONDITIONAL — the `rehypeSanitize` link is a fixed last member of a + * module-level `const` chain in `plugin-markdown/src/MarkdownImpl.tsx`, with + * no conditional path — so the enforce arm (an XSS-off switch) was refused + * and the key retired. `components` was a `Record` of React + * overrides nothing read — not a JSON-authorable value, and no host path + * consumes such a map either, so there is no runtime slot to keep. Both + * refuse BY NAME through `retirementTombstone()` (objectui#6931), with the + * remedy in the message, rather than parsing green and doing nothing. The TS + * twins are `?: never` in `../data-display.ts`; both published faces carry the + * refusal (`@object-ui/types`, and `@object-ui/plugin-markdown`'s re-export of + * the same authority — objectui#6172). */ export const MarkdownSchema = BaseSchema.extend({ type: z.literal('markdown'), content: z.string().describe('Markdown content'), - sanitize: z.boolean().optional().describe('Sanitize HTML'), - components: z.record(z.string(), z.any()).optional().describe('Custom component overrides'), + sanitize: retirementTombstone( + 'RETIRED (objectui#6972) — sanitization is unconditional: rehype-sanitize is a fixed last link of the ' + + 'markdown renderer\'s rehype chain, and no value of this key ever switched it. There is no authored ' + + 'spelling that disables XSS sanitization; delete the key.', + ), + components: retirementTombstone( + 'RETIRED (objectui#6972) — never read: the markdown renderer forwards only `content` and `className`, ' + + 'and a map of React component overrides is not a JSON-authorable value. Delete the key; the fenced ' + + 'mermaid / metadata block overrides are the renderer\'s own fixed map, not an authoring surface.', + ), }); /** diff --git a/scripts/__tests__/check-doc-snippet-types.test.ts b/scripts/__tests__/check-doc-snippet-types.test.ts index 0c89fb634..519be59c7 100644 --- a/scripts/__tests__/check-doc-snippet-types.test.ts +++ b/scripts/__tests__/check-doc-snippet-types.test.ts @@ -97,7 +97,9 @@ const FENCE = '```'; * `npm install project-name`. */ const README_SAMPLE_DOC = 'content/docs/plugins/plugin-markdown.mdx'; -const README_SAMPLE_FENCE_LINE = 195; +// 195 until objectui#6972 replaced two prop-table rows above it with a +// retirement blockquote (+11 lines); re-declared here, as this pin intends. +const README_SAMPLE_FENCE_LINE = 206; /** * The regex reader objectui#7555 removed from both gates, kept HERE and only