diff --git a/.changeset/react-pages-default-off.md b/.changeset/react-pages-default-off.md new file mode 100644 index 00000000000..93cd55733ab --- /dev/null +++ b/.changeset/react-pages-default-off.md @@ -0,0 +1,21 @@ +--- +'@objectstack/cli': patch +'@objectstack/spec': patch +--- + +feat: `kind:'react'` pages are off unless the deployment turns them on + +Clause-②: no +Metadata-protocol: none + +**BREAKING**: the `kind:'react'` page tier, which runs page source in the Console, now defaults to **off** (ADR-0081, amended). A deployment that serves react pages turns the tier on with `OS_PAGE_REACT_ENABLED=true`, in the environment or the project's `.env` file. With it off, each react page shows a notice naming that switch instead of running, and the Console document's content-security-policy no longer allows `'unsafe-eval'`. `kind:'html'` pages are unaffected. + +To keep react pages running after the upgrade, set `OS_PAGE_REACT_ENABLED=true` on the deployment. Stored pages need no change. + + + +The earlier name is still read while the new one is unset: `OS_PAGE_REACT=on` turns the tier on and `OS_PAGE_REACT=off` keeps it off, each with a one-time deprecation warning. `true`, `1`, `on`, `yes` and `enabled` turn it on; unset, `false`, `0`, `off`, `no` and `disabled` leave it off; any other value leaves it off and is reported once in the server log. + +The server now writes the Console's capability global in both directions, so a Console build with either default follows the server's setting. `examples/app-showcase` opts in through its `.env`, so its three react pages keep running. The `PageSchema.kind` description in `@objectstack/spec` now states the new default. + +Operators who collect content-security-policy reports may see one `script-src` eval refusal per Console load while the tier is off: the Console's schema library probes for `eval` once at boot and falls back without it. Nothing on the page is affected, and the probe is removed on the objectui side. diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index b032149ab1a..8b18dfd8a6f 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -39,9 +39,9 @@ write the page body as a string. Pick by what the page needs: For `html` / `react`, the page body is the `source` string (it is the source-of-truth; `regions` is ignored). `html` is **safe by construction** (it is parsed into the same SDUI tree this document describes, never executed). -`react` executes author code, so it is gated by a host capability that is **ON -by default** and disabled per-deployment with the `OS_PAGE_REACT=off` env -variable. Both tiers can embed the platform's real data components +`react` executes author code, so it is gated by a host capability that is **OFF +by default** and turned on per deployment with the `OS_PAGE_REACT_ENABLED=true` +env variable. Both tiers can embed the platform's real data components (``, ``, charts, metrics). > **Learn more:** [ADR-0080](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0080-ai-authored-ui-jsx-source.md) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 1c8da8abc93..b4705fc8c7e 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -215,7 +215,7 @@ View filter rule | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | | **print** | `{ paperSize?: Enum<'A4' \| 'A5' \| 'Letter' \| 'Legal'>; orientation?: Enum<'portrait' \| 'landscape'>; margins?: object; repeatHeader?: boolean; … }` | optional | Print declaration — makes this page a print page, a document authored in the page's own blocks: paper size and orientation, margins in millimetres, the running header and footer (the page's own `header` / `footer` regions), page numbers and page-break hints, each mapped to print CSS. A print page is a `kind: 'full'` page of `type: 'record'`, `'home'` or `'app'` with its blocks in `regions`, and every block in it must come from the printable block subset: containers that draw every child (`page:section`, `page:card`, `page:footer`), field blocks (`record:details`, `record:highlights`), the child-record table `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`; `os validate` / `os build` / `os lint` and the metadata save door refuse any other block inside it. Applied by the console's browser print rendering, which prints the page through the browser's own print dialog (where it can be saved as PDF); the server-side PDF renderer is to follow. A list view's print button is `allowPrinting`, not this. | -| **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it html), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via the OS_PAGE_REACT=off env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). | +| **kind** | `Enum<'full' \| 'slotted' \| 'html' \| 'react'>` | optional (default: `"full"`) | Page override mode. full \| slotted = structured authoring; html = author-written constrained JSX compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it html), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors; react = real-React source executed at render by the runtime (ADR-0081), styled by inline `style` with the same token colors; it runs author JS, so it is gated by a host capability that defaults OFF and is turned on server-side via the OS_PAGE_REACT_ENABLED=true env toggle. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). | | **slots** | `{ header?: object \| object[]; actions?: object \| object[]; alerts?: object \| object[]; highlights?: object \| object[]; … }` | optional | Slot override map for slotted pages | | **source** | `string` | optional | Page source text. For kind==='html' it is constrained JSX compiled to the tree by @objectstack/sdui-parser at save time (parse, never execute), styled by the registered components' structured props plus a JSON `style` object with hsl(var(--token)) theme colors. For kind==='react' it is real React/JSX executed at render by @object-ui/react-runtime (trusted tier), styled by inline `style` with the same token colors. Do not author Tailwind classes in page source in either tier: `source` is runtime metadata the build-time Tailwind never scans, so utility classNames silently produce no CSS (ADR-0065; ADR-0080). Authoritative over `regions` in both. | | **requires** | `string[]` | optional | Plugin namespaces the page's source uses, derived from the source at save — omit it. The key exists only on a kind==='html' page, the kind whose source is compiled at save; on a 'react', 'full' or 'slotted' page — and a page that omits kind, which is 'full' — it is refused at parse. On a server that has the deployment's SDUI component manifest, saving an html page compiles its source and stores the namespaces it uses here; a written list that disagrees with the source is refused (422 INVALID_METADATA, page-requires-disagrees-with-source) — on a draft save it is kept until the draft's publish, which refuses it. At load, a stored page whose list names a plugin no component in that manifest carries is reported, page and plugin named, and is still served. A server with no manifest checks neither and says so once at boot. | diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 01066b94003..acec9c7cb09 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -108,8 +108,9 @@ is rejected by the schema rather than rendering empty. `'jsx'` keep loading as `html` pages. Because `kind: 'react'` executes author JavaScript, it is gated by a host capability that -defaults **ON** and is disabled server-side per deployment with `OS_PAGE_REACT=off`. The -`html` tier is unaffected by that switch — it is never executed. +defaults **OFF** and is turned on server-side per deployment with +`OS_PAGE_REACT_ENABLED=true`. The `html` tier is unaffected by that switch — it is never +executed. Neither source tier is styled with Tailwind utility classes. A page's `source` is diff --git a/content/docs/ui/react-pages.mdx b/content/docs/ui/react-pages.mdx index bf2ea9e754a..e37ff6aa264 100644 --- a/content/docs/ui/react-pages.mdx +++ b/content/docs/ui/react-pages.mdx @@ -58,20 +58,27 @@ The two tiers also differ in what they give you for layout, in opposite directio ## The security gate -Because the react tier runs author JavaScript, it is gated by a host capability named -`react-pages`, which defaults **ON** — the platform's assumption is that page authors -are reviewed and draft-gated. +Because the react tier runs author JavaScript in every viewer's session, with no sandbox, +it is gated by a host capability named `react-pages`, which defaults **OFF**. The tier is +for deployments whose page authors are trusted and reviewed. -A deployment that does not trust its page authors turns the tier off **server-side**: +A deployment that trusts its page authors turns the tier on **server-side**: ```bash -OS_PAGE_REACT=off objectstack start +OS_PAGE_REACT_ENABLED=true objectstack start ``` -The server then injects the console's capability-disable flag, and every `kind:'react'` -page renders an explanatory notice instead of executing. `off`, `0`, `false`, `no` and -`disabled` are all accepted spellings. `kind:'html'` pages are unaffected — they were -never executed in the first place, which is the whole point of the split. +or with the same line in the project's `.env` file. `true`, `1`, `on`, `yes` and `enabled` +turn it on; unset, `false`, `0`, `off`, `no` and `disabled` leave it off. Any other value +leaves it off and is reported once in the server log. The earlier name `OS_PAGE_REACT` +(`on` / `off`) is still read when `OS_PAGE_REACT_ENABLED` is unset, with a deprecation +warning. + +With the tier off, every `kind:'react'` page renders a notice that names the switch +instead of executing, and the Console's content-security-policy refuses `eval`. With it +on, the server tells the Console to run react pages and the policy allows the `Function` +call the runtime compiles them with. `kind:'html'` pages are unaffected either way — they +are never executed, which is the whole point of the split. ## What is in scope diff --git a/docs/adr/0081-trusted-react-page-tier.md b/docs/adr/0081-trusted-react-page-tier.md index a7556cb9aea..b566d52a391 100644 --- a/docs/adr/0081-trusted-react-page-tier.md +++ b/docs/adr/0081-trusted-react-page-tier.md @@ -1,6 +1,6 @@ # ADR-0081: A trusted `kind:'react'` page tier — real React executed in the main tree, gated by a host capability; and renaming `kind:'jsx'` → `kind:'html'` -**Status**: Accepted (2026-06-30) — framework scope implemented (`html`/`react` page kinds + `jsx` alias, lint split `validate-jsx-pages`/`validate-react-pages`, `OS_PAGE_REACT=off` server toggle); the `@object-ui/react-runtime` executor + `CAP_REACT_PAGES` gate live in objectui. +**Status**: Accepted (2026-06-30) — framework scope implemented (`html`/`react` page kinds + `jsx` alias, lint split `validate-jsx-pages`/`validate-react-pages`, `OS_PAGE_REACT=off` server toggle); the `@object-ui/react-runtime` executor + `CAP_REACT_PAGES` gate live in objectui. **Amended** (2026-10-25 — the `react` tier defaults **OFF**; a deployment opts in with `OS_PAGE_REACT_ENABLED=true`. See "Amendment (2026-10-25): default off, opt in per deployment".) **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0080](./0080-ai-authored-ui-jsx-source.md) (AI authors a *constrained* JSX text, parsed-never-executed, into the SDUI tree; the component registry is the contract; capability ≠ contract), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (AI writes metadata via draft-gated `saveMetaItem` — the human-review boundary), [ADR-0077](./0077-authoring-surface-boundary-hook-flow-validation.md) (route by intent; verifiability tier = friction tier; **code is the flagged escape hatch**), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently-inert metadata). **Consumers**: `@objectstack/spec` (`packages/spec/src/ui/page.zod.ts` — `kind` gains `'html'` and `'react'`; `'jsx'` kept as a deprecated alias), `@objectstack/lint` (`validate-jsx-pages` — lints `html`/`jsx`, **skips** `react`), `../objectui` `@object-ui/react-runtime` (**new** — the trusted runtime-React executor) + `@object-ui/core` (the host capability gate) + `@object-ui/components` (PageRenderer routing + the full HTML tag set). @@ -13,6 +13,8 @@ > **Amendment (2026-06-30 — ADR-0065 styling correction).** The "HTML + **Tailwind**" framing for page `source` is **superseded on styling**. A page's `source` is *runtime metadata*, so the console's build-time Tailwind never scans it — authored utility `className`s silently produce no CSS (the exact failure ADR-0065 was written to prevent; the Task Desk modal's `bg-black/50` backdrop rendered transparent). The tiers themselves stand; only the styling **primitive** changes: `kind:'html'` styles via the registered components' structured props + a JSON `style` object; `kind:'react'` styles via inline `style={{}}` with `hsl(var(--token))` theme colors and renders drawer/modal overlays through `` (never a hand-rolled `fixed inset-0`). **Do not author Tailwind classes in page source.** See [ADR-0065](./0065-sdui-styling-model.md). +> **Amendment (2026-10-25): default off, opt in per deployment.** Decision 3 and TL;DR 4 are **superseded on the default**; the tier, its gate and its host-only control stand. `CAP_REACT_PAGES` now defaults **OFF** in `@object-ui/core`, and the ObjectStack console serving (`cli/utils/console.ts`, `readReactPagesTier`) turns it on only when the operator sets `OS_PAGE_REACT_ENABLED=true` (`OS_PAGE_REACT=on`, the earlier name, is still read through `readEnvWithDeprecation`). The server writes the capability global in **both** directions — the enable list when on, the disable list when off — so its answer holds against a Console build with either default. The Console document's content-security-policy keeps `'unsafe-eval'` tied to the same per-request read. Off, a react page renders the named "React pages are disabled on this deployment" notice. **Why the default moved:** "trust + review" names a property of one deployment's page authors, which only that deployment's operator can vouch for, so the platform default no longer vouches for it on every deployment; the operator makes the trust decision explicitly. Reference deployments that ship react pages opt in in their own configuration (`examples/app-showcase/.env`). + ## TL;DR 1. **[rename] `kind:'jsx'` → `kind:'html'`.** The constrained, parse-never-execute tier (ADR-0080) is renamed to match what it is: author-written **HTML + Tailwind** (expressed as constrained JSX) compiled to the SDUI tree. `'jsx'` stays as a **deprecated alias** (already-saved pages keep loading); all authored examples/docs move to `'html'`. diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index 5fdc0334136..08659b0092f 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -1105,7 +1105,7 @@ "title": "Source-authored page tiers render by the RIGHT path: kind:'html' source is PARSED-never-executed (static, in-browser sdui-parser compile), kind:'react' source is EXECUTED behind its capability gate (live data + interactivity) — command-center-jsx / capability-map (html) vs crm-workbench (react)", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "mixed", "personas": ["admin (seeded admin@objectos.ai) driving the published showcase app as an end user"], @@ -1113,7 +1113,7 @@ "app": "showcase", "requires": [ "seeded showcase_project rows (the react page's live KPI + read them)", - "the react-pages capability ON — CAP_REACT_PAGES defaults ON; a boot with OS_PAGE_REACT=off flips it OFF (the both-sides gate check)" + "the react-pages capability ON — CAP_REACT_PAGES defaults OFF and the showcase turns it on in its committed .env (OS_PAGE_REACT_ENABLED=true); a boot with OS_PAGE_REACT_ENABLED=false flips it OFF (the both-sides gate check)" ], "knownGaps": [ "NO manual build step is needed to VIEW either tier in the running showcase (contra the task's guess): a kind:'html' page compiles IN-BROWSER via @object-ui/sdui-parser compile(source, manifest) at render, against a whitelist manifest built from the LIVE ComponentRegistry (packages/components/src/renderers/layout/page.tsx); a kind:'react' page is transpiled IN-BROWSER by a lazily-loaded Sucrase @object-ui/react-runtime chunk. The only build-shaped risk is the vendored /_console bundle being stale (stale-console-bundle) — a stale sdui-parser/react-runtime mis-compiles a page that is fine on a fresh build. ⚠ A FAULTING html source never reaches the RENDERED page on a stock boot: the metadata save door compiles an html page's source against the deployment's manifest with the same compiler `os validate` uses (packages/metadata-protocol/src/runtime-authoring-gate.ts#HTML_PAGE_SOURCE_COMPILE), so an active save or a publish of a faulting html page answers 422 INVALID_METADATA carrying the compile diagnostics, and nothing is served. The 'HTML page failed to compile (N)' panel is therefore reachable only in the page designer's DRAFT preview, where a ?mode=draft save of the faulting source is admitted and rendered (QA run #23166)", @@ -1127,7 +1127,7 @@ "crm-workbench (kind:'react'): open /_console/apps/showcase_app/page/showcase_crm_workbench; screenshot the workbench — the KPI strip (Total / Active) + the real and master/detail over showcase_project", "prove the react tier is EXECUTED against LIVE data: the 'Total projects' KPI equals GET /api/v1/data/showcase_project's count (the page computes it via useAdapter().find('showcase_project'), reading the QueryResult .data envelope), and a showcase_project find query fires on the network — the number is computed at render, not authored", "prove react interactivity (executed handlers, not a parsed tree): click a ListView row → the ObjectForm binds THAT record (React useState); click '+ New project' → the form switches to create mode", - "both-sides gate: on a boot with OS_PAGE_REACT=off, re-open crm-workbench — it must render the NAMED 'React pages are disabled on this deployment' notice (CapabilityDisabledNotice), never a blank and never an executed page; the html pages still render (html is not gated). If the env toggle cannot be flipped on the stock fixture, record this clause blocked(environment)", + "both-sides gate: on a boot with OS_PAGE_REACT_ENABLED=false, re-open crm-workbench — it must render the NAMED 'React pages are disabled on this deployment' notice (CapabilityDisabledNotice), never a blank and never an executed page; the html pages still render (html is not gated). If the env toggle cannot be flipped on the stock fixture, record this clause blocked(environment)", "degradation: (a) html — save a faulting html source at the ACTIVE door (or publish one) and capture the 422 INVALID_METADATA with its compile diagnostics; then save the same source as a ?mode=draft draft IN A WRITABLE PACKAGE (?package=PKG) that also holds an app whose navigation references the page, and open it in Studio at /_console/studio/PKG/interfaces?surface=page:NAME, where the draft preview's 'HTML page failed to compile (N)' panel renders with the diagnostic messages. An env-wide (package-less) draft has no Studio surface, and the save gate keeps a faulting html page off the rendered route, so the panel cannot be met anywhere else (knownGaps[0]); (b) react — a react runtime error shows the 'React page error' fallback on the rendered page. Neither may be a blank page" ], "acceptance": [ @@ -1156,9 +1156,9 @@ "evidence": "before/after screenshots + the form DOM read" }, { - "clause": "the react capability gate is real and BOTH-sided: with CAP_REACT_PAGES on, crm-workbench executes; with OS_PAGE_REACT=off it renders the NAMED 'React pages are disabled on this deployment' notice — never a blank, never a bypassed execution (the html pages are unaffected)", + "clause": "the react capability gate is real and BOTH-sided: with CAP_REACT_PAGES on, crm-workbench executes; with OS_PAGE_REACT_ENABLED=false it renders the NAMED 'React pages are disabled on this deployment' notice — never a blank, never a bypassed execution (the html pages are unaffected)", "oracle": "screenshot", - "verify": "the on-state executes; the off-state shows CapabilityDisabledNotice with the OS_PAGE_REACT=off text; record blocked(environment) if the toggle cannot be exercised on the stock fixture", + "verify": "the on-state executes; the off-state shows CapabilityDisabledNotice naming OS_PAGE_REACT_ENABLED; record blocked(environment) if the toggle cannot be exercised on the stock fixture", "evidence": "the on-state + off-state screenshots (or the blocked-environment note)" }, { @@ -1170,7 +1170,7 @@ ], "negative": [ "an html page whose source is EXECUTED (author JS runs) rather than parsed is a security FAIL — ADR-0080's html tier is parse-never-execute; execution is the react tier's gated privilege only", - "a react page that executes with OS_PAGE_REACT=off (the gate bypassed) is a FAIL — the capability is the whole safety boundary for the trusted-execution tier", + "a react page that executes with OS_PAGE_REACT_ENABLED=false or unset (the gate bypassed) is a FAIL — the capability is the whole safety boundary for the trusted-execution tier", "a react KPI stuck at 0 while the beside it shows rows is the crm-workbench QueryResult-envelope bug regressed (reading .records instead of .data) — FAIL, cross-checked against the live count", "a compile or runtime error swallowed to a blank page (no named panel) is a FAIL — the author must SEE why the page did not render" ], @@ -1180,17 +1180,18 @@ ], "traps": ["stale-console-bundle", "hydration-race", "single-datapoint", "automation-input"], "source": [ - "packages/spec/src/ui/page.zod.ts#PageSchema (PageSchema.kind = full|slotted|html|react|jsx; html = constrained JSX/HTML+Tailwind compiled by @objectstack/sdui-parser 'parse, never execute' ADR-0080; react = real React executed at render, gated by a host capability defaulting ON, disabled via OS_PAGE_REACT=off, ADR-0081; the superRefine that fails an html/react page with no `source`)", + "packages/spec/src/ui/page.zod.ts#PageSchema (PageSchema.kind = full|slotted|html|react|jsx; html = constrained JSX/HTML+Tailwind compiled by @objectstack/sdui-parser 'parse, never execute' ADR-0080; react = real React executed at render, gated by a host capability defaulting OFF, turned on via OS_PAGE_REACT_ENABLED=true, ADR-0081 as amended; the superRefine that fails an html/react page with no `source`)", "objectui: packages/components/src/renderers/layout/page.tsx (PageRenderer kind dispatch — compile(src, getJsxManifest()) + SchemaRenderer for html with the 'HTML page failed to compile' panel; ReactKindPage for react; the manifest whitelist is built from the LIVE registry's known types)", - "objectui: packages/components/src/renderers/layout/react-page.tsx (ReactKindPage — CAP_REACT_PAGES gate + CapabilityDisabledNotice for OS_PAGE_REACT=off; lazy Sucrase @object-ui/react-runtime transpile; useAdapter/ListView/ObjectForm scope; the 'React page error' fallback)", + "objectui: packages/components/src/renderers/layout/react-page.tsx (ReactKindPage — CAP_REACT_PAGES gate + CapabilityDisabledNotice while the tier is off; lazy Sucrase @object-ui/react-runtime transpile; useAdapter/ListView/ObjectForm scope; the 'React page error' fallback)", "objectui: packages/sdui-parser/src/index.ts#compile (compile(source, manifest) — the pure in-browser parse used at render, no build step)", "examples/app-showcase/src/ui/pages/command-center-jsx.page.ts (kind:'html', static sample KPIs — 'parsed, never executed'), capability-map.page.ts (kind:'html' landing), crm-workbench.page.ts (kind:'react' — useAdapter().find reads the .data envelope, note the #… KPIs-stuck-at-0 fix)", - "objectui: content/docs/guide/react-pages.md (react tier: transpiled + evaluated in-app, react-pages capability, OS_PAGE_REACT=off disable)" + "objectui: content/docs/guide/react-pages.md (react tier: transpiled + evaluated in-app, react-pages capability, off by default, OS_PAGE_REACT_ENABLED=true enables)" ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: the html (parse-never-execute, in-browser sdui-parser compile, static) vs react (executed at render, capability-gated, live data + interactivity) source-page tiers — the objectui renderer-path split grounded in PageRenderer's kind dispatch + react-page.tsx's gate, distinguishing command-center-jsx/capability-map (html) from crm-workbench (react); records the honest build-step finding (none needed to view — in-browser compile/transpile) as a knownGap", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-10-13", "change": "acceptance[5] (clause and verify), steps[7] and knownGaps[0] name where an html compile error is observable (assertion defect, minor, #23166 A5; parked on #23121, carried by #23169). The save door compiles an html page's source against the deployment's manifest (runtime-authoring-gate.ts HTML_PAGE_SOURCE_COMPILE), so a faulting html page is refused 422 INVALID_METADATA at save / publish and never publishes, and the compile-error panel is reachable only in the designer's draft preview. QA run #23166 met the 422 and the draft-preview panel. The react half is unchanged", "ref": "#23169" }, - { "revision": 3, "date": "2026-10-16", "change": "steps[7](a) names how to reach the draft preview (checklist-accuracy finding 8 of QA run #23942, wave #23878). The compile panel is reachable only for a page staged in a writable package that an app's navigation references, opened at /_console/studio/PKG/interfaces?surface=page:NAME; an env-wide draft has no Studio surface, which is what #23942 met. The clause is unchanged", "ref": "#24036" } + { "revision": 3, "date": "2026-10-16", "change": "steps[7](a) names how to reach the draft preview (checklist-accuracy finding 8 of QA run #23942, wave #23878). The compile panel is reachable only for a page staged in a writable package that an app's navigation references, opened at /_console/studio/PKG/interfaces?surface=page:NAME; an env-wide draft has no Studio surface, which is what #23942 met. The clause is unchanged", "ref": "#24036" }, + { "revision": 4, "date": "2026-10-25", "change": "the react-pages tier defaults OFF (ADR-0081 amendment): fixtures.requires names the showcase's committed .env opt-in OS_PAGE_REACT_ENABLED=true, and the both-sides gate's off-state is a boot with OS_PAGE_REACT_ENABLED=false (steps[6], acceptance[4], negative[1], source rows). The clauses are unchanged", "ref": "claude/project-thread-80dotk" } ] }, { diff --git a/examples/app-showcase/.env b/examples/app-showcase/.env new file mode 100644 index 00000000000..6ef2a929efb --- /dev/null +++ b/examples/app-showcase/.env @@ -0,0 +1,5 @@ +# The showcase ships three kind:'react' pages (crm-workbench, renewals-pipeline, +# task-desk). That tier runs page source in the browser and is off unless a +# deployment turns it on, so this example opts in. `os dev`, `os serve` and +# `os start` read this file from the project root. +OS_PAGE_REACT_ENABLED=true diff --git a/packages/cli/src/utils/console.branded-head.test.ts b/packages/cli/src/utils/console.branded-head.test.ts index 6cb68ab70df..4f38aea0520 100644 --- a/packages/cli/src/utils/console.branded-head.test.ts +++ b/packages/cli/src/utils/console.branded-head.test.ts @@ -250,25 +250,34 @@ describe('the branding read is the visitor\'s own request, re-addressed', () => }); describe('with no branding to read, the document is served as before', () => { - /** The shell as it was served before this change: the base tag added, nothing else. */ - const BEFORE = SHELL.replace('', '\n '); + /** + * The shell as it was served before this change: the base tag and the + * react-pages capability script added (off by default), nothing else. The + * script's per-response nonce is read off by `served`. + */ + const BEFORE = SHELL.replace( + '', + '\n ' + + '\n ', + ); + const served = async (res: Response) => (await res.text()).replace(/ nonce="[^"]*"/g, ''); it('a composition with no branding door serves the shell unchanged, and says nothing', async () => { const app = await mount({}); - const html = await (await app.request('/_console/')).text(); + const html = await served(await app.request('/_console/')); expect(html).toBe(BEFORE); expect(app.warnings).toEqual([]); }); it('a door that answers no branding block serves the shell unchanged', async () => { const app = await mount({ stub: { kind: 'json', body: { cloudUrl: '' } } }); - expect(await (await app.request('/_console/')).text()).toBe(BEFORE); + expect(await served(await app.request('/_console/'))).toBe(BEFORE); }); it('a door that fails serves the shell unchanged and warns once', async () => { const app = await mount({ stub: { kind: 'status', status: 500 } }); - expect(await (await app.request('/_console/')).text()).toBe(BEFORE); - expect(await (await app.request('/_console/apps/x')).text()).toBe(BEFORE); + expect(await served(await app.request('/_console/'))).toBe(BEFORE); + expect(await served(await app.request('/_console/apps/x'))).toBe(BEFORE); expect(app.warnings).toHaveLength(1); expect(app.warnings[0]).toContain('/api/v1/runtime/config'); expect(app.warnings[0]).toContain('500'); @@ -278,7 +287,7 @@ describe('with no branding to read, the document is served as before', () => { const app = await mount({ stub: { kind: 'throw' } }); const res = await app.request('/_console/'); expect(res.status).toBe(200); - expect(await res.text()).toBe(BEFORE); + expect(await served(res)).toBe(BEFORE); expect(app.warnings).toHaveLength(1); }); }); diff --git a/packages/cli/src/utils/console.content-security-policy.test.ts b/packages/cli/src/utils/console.content-security-policy.test.ts index 5dcbde60450..1c2482da67a 100644 --- a/packages/cli/src/utils/console.content-security-policy.test.ts +++ b/packages/cli/src/utils/console.content-security-policy.test.ts @@ -39,6 +39,7 @@ const SHELL = [ /** The environment this suite sets; each test starts with all of it unset and leaves it as it found it. */ const ENV = [ + 'OS_PAGE_REACT_ENABLED', 'OS_PAGE_REACT', 'OS_CONSOLE_CSP_CONNECT_SRC', 'OS_CLOUD_URL', @@ -73,10 +74,18 @@ afterEach(() => { } }); -async function mount(opts: { runtimeConfig?: ConstructorParameters[0] } = {}) { +async function mount(opts: { + runtimeConfig?: ConstructorParameters[0]; + warnings?: string[]; +} = {}) { const server = new HonoHttpServer(0); server.installNotFoundSeam(); - const logger = { warn: () => {}, info: () => {}, debug: () => {}, error: () => {} }; + const logger = { + warn: (message: string) => { opts.warnings?.push(message); }, + info: () => {}, + debug: () => {}, + error: () => {}, + }; if (opts.runtimeConfig) { const hooks: Array<() => Promise> = []; await new RuntimeConfigPlugin(opts.runtimeConfig).start({ @@ -140,10 +149,11 @@ describe('the console document is served with a nonce-based content-security-pol const nonce = headerNonce(res.headers.get('content-security-policy')); const html = await res.text(); + // The build's three, and the server's react-pages capability global. const tags = scriptTags(html); - expect(tags).toHaveLength(3); + expect(tags).toHaveLength(4); for (const tag of tags) expect(tag).toContain(` nonce="${nonce}"`); - expect(html.match(/ nonce="/g)).toHaveLength(3); + expect(html.match(/ nonce="/g)).toHaveLength(4); }); it('draws a fresh nonce for every response', async () => { @@ -175,26 +185,59 @@ describe('the console document is served with a nonce-based content-security-pol }); }); -describe('\'unsafe-eval\' follows the react-pages tier', () => { - it('is allowed while the tier is on, which is the default', async () => { - const request = await mount(); +describe('\'unsafe-eval\' follows the react-pages tier, which is off unless the deployment turns it on', () => { + /** The served document's policy, its nonce, and its html. */ + async function serve(warnings?: string[]) { + const request = await mount({ warnings }); const res = await request('/_console/'); - expect(directives(res.headers.get('content-security-policy')).get('script-src')).toContain("'unsafe-eval'"); + const policy = res.headers.get('content-security-policy'); + return { scriptSrc: directives(policy).get('script-src')!, nonce: headerNonce(policy), html: await res.text() }; + } + + it('is refused by default, and the document carries the disable global under the nonce', async () => { + const { scriptSrc, nonce, html } = await serve(); + expect(scriptSrc).not.toContain("'unsafe-eval'"); + expect(html).toContain( + ``, + ); + expect(html).not.toContain('window.__OBJECTUI_CAPABILITIES__='); }); - it('is refused with OS_PAGE_REACT=off, and the injected disable global carries the nonce', async () => { + it('is allowed with OS_PAGE_REACT_ENABLED=true, and the document carries the enable global under the nonce', async () => { + process.env.OS_PAGE_REACT_ENABLED = 'true'; + const { scriptSrc, nonce, html } = await serve(); + expect(scriptSrc).toContain("'unsafe-eval'"); + expect(html).toContain( + ``, + ); + expect(html).not.toContain('__OBJECTUI_CAPABILITIES_DISABLED__'); + }); + + it('reads the legacy OS_PAGE_REACT in both directions while the new name is unset, and names the new one', async () => { + process.env.OS_PAGE_REACT = 'on'; + const warnings: string[] = []; + expect((await serve(warnings)).scriptSrc).toContain("'unsafe-eval'"); + expect(warnings.filter((w) => w.includes('`OS_PAGE_REACT` is deprecated; rename it to `OS_PAGE_REACT_ENABLED`'))).toHaveLength(1); process.env.OS_PAGE_REACT = 'off'; - const request = await mount(); - const res = await request('/_console/'); - const policy = res.headers.get('content-security-policy'); - expect(directives(policy).get('script-src')).not.toContain("'unsafe-eval'"); + expect((await serve()).scriptSrc).not.toContain("'unsafe-eval'"); + }); - const nonce = headerNonce(policy); - const html = await res.text(); - const tags = scriptTags(html); - expect(tags).toHaveLength(4); - for (const tag of tags) expect(tag).toContain(`nonce="${nonce}"`); - expect(html).toContain(``; +} + /** * Every `` and escaped text do not. @@ -863,14 +917,28 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: } // The `kind:'react'` page tier (executes author JS in the main React - // tree) is ON by default. A deployment that does not trust its page - // authors turns it off with `OS_PAGE_REACT=off`; we then inject the - // disable global the console's capability gate reads. Read per request - // (env can change without a rebuild — index.html is re-read on every - // fallback hit too). - const reactPagesDisabled = (): boolean => { - const v = String(process.env.OS_PAGE_REACT ?? '').trim().toLowerCase(); - return v === 'off' || v === '0' || v === 'false' || v === 'no' || v === 'disabled'; + // tree) is OFF unless the operator turns it on (`readReactPagesTier`); + // either way the document carries the global the console's capability + // gate reads. Read per request (env can change without a rebuild — + // index.html is re-read on every fallback hit too). + let reportedUnrecognizedReactPages = false; + const reportUnrecognizedReactPages = (name: string, value: string) => { + if (reportedUnrecognizedReactPages) return; + reportedUnrecognizedReactPages = true; + ctx.logger?.warn?.( + `Console static: ${name}=${JSON.stringify(value)} is not an on or off value, so kind:'react' pages ` + + `stay off and render a notice instead of running. Set ${REACT_PAGES_ENV}=true to turn them on, or ` + + 'unset it to keep them off. Reported once.', + ); + }; + let reportedLegacyReactPagesName = false; + const reportLegacyReactPagesName = (legacy: string, preferred: string) => { + if (reportedLegacyReactPagesName) return; + reportedLegacyReactPagesName = true; + ctx.logger?.warn?.( + `Console static: env var \`${legacy}\` is deprecated; rename it to \`${preferred}\`. ` + + 'The legacy name still works for now but will be removed in a future major release.', + ); }; // A runtime-config read that fails leaves the shell's head as the build @@ -904,11 +972,8 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: const baseTag = ``; html = html.replace(/]*)?>/i, (m) => `${m}\n ${baseTag}`); } - if (!reactPages) { - const capTag = - ``; - html = html.replace(/]*)?>/i, (m) => `${m}\n ${capTag}`); - } + const capTag = reactPagesCapabilityScript(reactPages); + html = html.replace(/]*)?>/i, (m) => `${m}\n ${capTag}`); // Stamped while the document holds only the build's markup and the // server's own tags: nothing read at request time is written yet, so // no runtime value can ever carry the nonce. @@ -917,9 +982,12 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: const serveDocument = async (c: any, reqPath: string): Promise => { const nonce = randomBytes(16).toString('base64'); - // One read per document, so the injected disable global and the + // One read per document, so the injected capability global and the // policy's `'unsafe-eval'` always agree. - const reactPages = !reactPagesDisabled(); + const reactPages = readReactPagesTier({ + unrecognized: reportUnrecognizedReactPages, + legacyName: reportLegacyReactPagesName, + }); const html = readIndexHtml(reactPages, nonce); const shell = await readShellConfig(app, c, reportShellConfigReadFailure); const policy = consoleContentSecurityPolicy({ diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index bf978a24342..496d3d07db1 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -2068,8 +2068,8 @@ export const PageSchema = lazySchema(() => strictObject({ "object with hsl(var(--token)) theme colors; " + "react = real-React source executed at render by the runtime (ADR-0081), styled " + "by inline `style` with the same token colors; it " + - "runs author JS, so it is gated by a host capability that defaults ON and is " + - "disabled server-side via the OS_PAGE_REACT=off env toggle. " + + "runs author JS, so it is gated by a host capability that defaults OFF and is " + + "turned on server-side via the OS_PAGE_REACT_ENABLED=true env toggle. " + "Do not author Tailwind classes in page source in either tier: `source` is " + "runtime metadata the build-time Tailwind never scans, so utility classNames " + "silently produce no CSS (ADR-0065; ADR-0080).", diff --git a/skills/objectstack-ui/rules/pages.md b/skills/objectstack-ui/rules/pages.md index 2daa91a907d..bb0496c174f 100644 --- a/skills/objectstack-ui/rules/pages.md +++ b/skills/objectstack-ui/rules/pages.md @@ -223,9 +223,9 @@ function Page() { ``` **Safety / availability.** `kind:'react'` executes author code in the app, so it is gated -by the host capability `react-pages` — **ON by default** (the platform trusts reviewed, -draft-gated authors). A deployment that does not trust its authors turns it off server-side -with `OS_PAGE_REACT=off`; the page then shows a "disabled" notice instead of executing. +by the host capability `react-pages` — **OFF by default**: a react page's source is code +every viewer runs. A deployment that trusts its authors turns it on server-side +with `OS_PAGE_REACT_ENABLED=true`; while off, the page shows a "disabled" notice instead. `os build` does NOT lint react source (it is real JS, not constrained JSX) — errors surface at render behind an error boundary, so always test a react page in the browser.