From c29ad45313df7a73915b642da1f56202ea266397 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 09:45:37 +0000 Subject: [PATCH 1/7] feat(cli): kind:'react' pages are off unless the deployment turns them on WIP: console serving reads OS_PAGE_REACT_ENABLED (legacy OS_PAGE_REACT), defaults the tier off, and writes the capability global in both directions. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- .../console.content-security-policy.test.ts | 79 ++++++++++++++----- packages/cli/src/utils/console.ts | 75 ++++++++++++++---- 2 files changed, 118 insertions(+), 36 deletions(-) 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..668c331299a 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,57 @@ 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', async () => { + process.env.OS_PAGE_REACT = 'on'; + expect((await serve()).scriptSrc).toContain("'unsafe-eval'"); 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 +902,19 @@ 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.', + ); }; // A runtime-config read that fails leaves the shell's head as the build @@ -904,11 +948,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 +958,9 @@ 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(reportUnrecognizedReactPages); const html = readIndexHtml(reactPages, nonce); const shell = await readShellConfig(app, c, reportShellConfigReadFailure); const policy = consoleContentSecurityPolicy({ From c93774755b8de934caa7b5b69cedb10d8e239dc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 09:48:12 +0000 Subject: [PATCH 2/7] feat(cli): react pages default off; docs, ADR-0081 amendment, showcase opt-in Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- .changeset/react-pages-default-off.md | 25 +++++++++++++++++++ content/docs/protocol/objectui/layout-dsl.mdx | 6 ++--- content/docs/ui/pages.mdx | 5 ++-- content/docs/ui/react-pages.mdx | 25 ++++++++++++------- docs/adr/0081-trusted-react-page-tier.md | 4 ++- .../areas/studio-authoring.json | 21 ++++++++-------- examples/app-showcase/.env | 5 ++++ packages/spec/src/ui/page.zod.ts | 4 +-- skills/objectstack-ui/rules/pages.md | 6 ++--- 9 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 .changeset/react-pages-default-off.md create mode 100644 examples/app-showcase/.env diff --git a/.changeset/react-pages-default-off.md b/.changeset/react-pages-default-off.md new file mode 100644 index 00000000000..5eb5e3d871e --- /dev/null +++ b/.changeset/react-pages-default-off.md @@ -0,0 +1,25 @@ +--- +'@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. + +### Migration: FROM → TO + +```bash +# FROM — react pages ran unless the deployment set OS_PAGE_REACT=off +# TO — they run only when the deployment opts in +OS_PAGE_REACT_ENABLED=true +``` + +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/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/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. From e6bbf96915b09df459ad007cb9acfc0531211a21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 09:48:37 +0000 Subject: [PATCH 3/7] chore(changeset): record the ADR-0087 disposition for the react-pages default Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- .changeset/react-pages-default-off.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.changeset/react-pages-default-off.md b/.changeset/react-pages-default-off.md index 5eb5e3d871e..93cd55733ab 100644 --- a/.changeset/react-pages-default-off.md +++ b/.changeset/react-pages-default-off.md @@ -10,13 +10,9 @@ 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. -### Migration: FROM → TO +To keep react pages running after the upgrade, set `OS_PAGE_REACT_ENABLED=true` on the deployment. Stored pages need no change. -```bash -# FROM — react pages ran unless the deployment set OS_PAGE_REACT=off -# TO — they run only when the deployment opts in -OS_PAGE_REACT_ENABLED=true -``` + 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. From e6125f233fdc7a88142823ea546a5f0a40b334e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 09:58:45 +0000 Subject: [PATCH 4/7] test(cli): the served shell carries the react-pages capability script by default Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- .../src/utils/console.branded-head.test.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) 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); }); }); From e6df92d29f802030a41df85370941a74fcfeb9e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 10:05:25 +0000 Subject: [PATCH 5/7] docs(spec): regenerate the page reference for the react-pages default Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- content/docs/references/ui/page.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. | From 69036206d985a93610d000ae7bacafbddb880ba9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 11:52:12 +0000 Subject: [PATCH 6/7] fix(cli): the react-pages reader in console.ts imports Node built-ins only `utils/console.ts` is behind the published `./console` subpath, which the packed-tarball pin runs with no other package installed, so the import of `@objectstack/types` failed there with ERR_MODULE_NOT_FOUND. The legacy `OS_PAGE_REACT` fallback is now spelled in the module, and its deprecation warning goes through the plugin logger once. The reader and its constants are no longer exported, so the module's export census is unchanged. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- .../console.content-security-policy.test.ts | 6 ++- packages/cli/src/utils/console.ts | 51 ++++++++++++++----- 2 files changed, 43 insertions(+), 14 deletions(-) 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 668c331299a..1c2482da67a 100644 --- a/packages/cli/src/utils/console.content-security-policy.test.ts +++ b/packages/cli/src/utils/console.content-security-policy.test.ts @@ -213,9 +213,11 @@ describe('\'unsafe-eval\' follows the react-pages tier, which is off unless the expect(html).not.toContain('__OBJECTUI_CAPABILITIES_DISABLED__'); }); - it('reads the legacy OS_PAGE_REACT in both directions while the new name is unset', async () => { + 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'; - expect((await serve()).scriptSrc).toContain("'unsafe-eval'"); + 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'; expect((await serve()).scriptSrc).not.toContain("'unsafe-eval'"); }); diff --git a/packages/cli/src/utils/console.ts b/packages/cli/src/utils/console.ts index 639939cc3a2..a8f5457820d 100644 --- a/packages/cli/src/utils/console.ts +++ b/packages/cli/src/utils/console.ts @@ -48,7 +48,6 @@ import fs from 'fs'; import { createHash, randomBytes } from 'crypto'; import { createRequire } from 'module'; import { pathToFileURL, fileURLToPath } from 'url'; -import { readEnvWithDeprecation } from '@objectstack/types'; // ─── Constants ────────────────────────────────────────────────────── @@ -796,28 +795,44 @@ function consoleContentSecurityPolicy(input: { } /** The variable a deployment sets to turn the `kind:'react'` page tier on. */ -export const REACT_PAGES_ENV = 'OS_PAGE_REACT_ENABLED'; -/** Its earlier name, read when the new one is unset (`readEnvWithDeprecation`). */ +const REACT_PAGES_ENV = 'OS_PAGE_REACT_ENABLED'; +/** Its earlier name, read when the new one is unset. */ const REACT_PAGES_LEGACY_ENV = 'OS_PAGE_REACT'; const REACT_PAGES_ON = new Set(['true', '1', 'on', 'yes', 'enabled']); const REACT_PAGES_OFF = new Set(['', 'false', '0', 'off', 'no', 'disabled']); +interface ReactPagesTierReports { + /** The value is neither an on nor an off spelling. */ + unrecognized?: (name: string, value: string) => void; + /** Only the legacy name is set. */ + legacyName?: (legacy: string, preferred: string) => void; +} + /** * Whether this deployment serves the `kind:'react'` page tier (ADR-0081, as * amended: off by default). That tier runs the page author's code in every * viewer's Console with no sandbox, so it is on only when the operator says so * with `OS_PAGE_REACT_ENABLED=true` (or the legacy `OS_PAGE_REACT=on`). A value - * that is neither an on nor an off spelling leaves the tier off and is reported - * through `onUnrecognized`, so a typo never reads as a silent refusal. + * that is neither an on nor an off spelling leaves the tier off and is reported, + * so a typo never reads as a silent refusal. + * + * The legacy fallback is spelled here rather than through + * `readEnvWithDeprecation` (`@objectstack/types`): this module is behind the + * published `./console` subpath, which imports Node built-ins only + * (`test/published-subpath-console.pin.test.ts` runs it from the packed + * tarball with no other package installed). */ -export function readReactPagesTier(onUnrecognized?: (name: string, value: string) => void): boolean { - const raw = readEnvWithDeprecation(REACT_PAGES_ENV, REACT_PAGES_LEGACY_ENV); +function readReactPagesTier(reports: ReactPagesTierReports = {}): boolean { + let name = REACT_PAGES_ENV; + let raw = process.env[REACT_PAGES_ENV]; + if (raw === undefined && process.env[REACT_PAGES_LEGACY_ENV] !== undefined) { + name = REACT_PAGES_LEGACY_ENV; + raw = process.env[REACT_PAGES_LEGACY_ENV]; + reports.legacyName?.(REACT_PAGES_LEGACY_ENV, REACT_PAGES_ENV); + } const value = String(raw ?? '').trim().toLowerCase(); if (REACT_PAGES_ON.has(value)) return true; - if (!REACT_PAGES_OFF.has(value)) { - const name = process.env[REACT_PAGES_ENV] !== undefined ? REACT_PAGES_ENV : REACT_PAGES_LEGACY_ENV; - onUnrecognized?.(name, String(raw)); - } + if (!REACT_PAGES_OFF.has(value)) reports.unrecognized?.(name, String(raw)); return false; } @@ -916,6 +931,15 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: + '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 // shipped it, which is a smaller page, not a broken one: said once, at @@ -960,7 +984,10 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?: const nonce = randomBytes(16).toString('base64'); // One read per document, so the injected capability global and the // policy's `'unsafe-eval'` always agree. - const reactPages = readReactPagesTier(reportUnrecognizedReactPages); + const reactPages = readReactPagesTier({ + unrecognized: reportUnrecognizedReactPages, + legacyName: reportLegacyReactPagesName, + }); const html = readIndexHtml(reactPages, nonce); const shell = await readShellConfig(app, c, reportShellConfigReadFailure); const policy = consoleContentSecurityPolicy({ From 87ff23f9652a044bc3be4d551f00e8e86a33d90d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 11:52:12 +0000 Subject: [PATCH 7/7] fix(spec/liveness): drop the comment-only engine.ts pointer from the translation/flows.screens row The same ledger edit as #25751, carried here because `check:liveness` and `check-liveness.test.ts` are red on main for every PR that reaches packages/spec. The hunk is identical, so either landing order merges clean. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- packages/spec/liveness/translation.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index b6f6dda7e5c..9af318f0d61 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -146,9 +146,9 @@ "status": "live", "verifiedAt": "2026-10-10", "evidenceScope": "cross-repo", - "evidence": "packages/services/service-automation/src/builtin/screen-nodes.ts#registerScreenNodes (the `screen` executor asks for `flows..screens..title` and, where the screen authors body text, `.description`, spelled by `flowScreenCopyKey`, before it renders either template); packages/services/service-automation/src/engine.ts#renderFlowTextSlot (picks the translated template through the `i18n` service in the run's `AutomationContext.locale`, negotiated by `resolveBundleLocale`, and renders it through `renderTextSlot` in place of the authored one; a miss echoes the key and the authored template renders); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#localizeScreen (the heading through `resolveFlowScreenTitle`, and each field's copy from `bundle[language]?.flows?.[flowName]?.screens?.[screen.nodeId]?.fields`); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#overlayFieldCopy (walks the spec's imported `FLOW_SCREEN_FIELD_COPY_KEYS`, which are `label` / `placeholder` / `inlineHelpText`, and overlays each key on its own, falling back to the authored string); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#activeFlowsBundle (reads the active language's `flows` group out of the i18next `translation` resource tree); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (draws `shown.title` as the dialog title and hands the localized screen to `ScreenView`); objectui @31971ff1e: packages/app-shell/src/views/ScreenView.tsx#ScreenView (draws each field's `label`, hands `placeholder` to the control, and draws `inlineHelpText` under the control, which names it in `aria-describedby`)", + "evidence": "packages/services/service-automation/src/builtin/screen-nodes.ts#registerScreenNodes (the `screen` executor asks for `flows..screens..title` and, where the screen authors body text, `.description`, spelled by `flowScreenCopyKey`, before it renders either template); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#localizeScreen (the heading through `resolveFlowScreenTitle`, and each field's copy from `bundle[language]?.flows?.[flowName]?.screens?.[screen.nodeId]?.fields`); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#overlayFieldCopy (walks the spec's imported `FLOW_SCREEN_FIELD_COPY_KEYS`, which are `label` / `placeholder` / `inlineHelpText`, and overlays each key on its own, falling back to the authored string); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#activeFlowsBundle (reads the active language's `flows` group out of the i18next `translation` resource tree); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (draws `shown.title` as the dialog title and hands the localized screen to `ScreenView`); objectui @31971ff1e: packages/app-shell/src/views/ScreenView.tsx#ScreenView (draws each field's `label`, hands `placeholder` to the control, and draws `inlineHelpText` under the control, which names it in `aria-describedby`)", "producer": "packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `flows` included); packages/runtime/src/domains/i18n.ts#handleI18nRequest (the translations route answers `getTranslations(locale)` with no group filter); objectui @31971ff1e: apps/console/src/loadLanguage.ts#loadLanguage (fetches the locale's translations and runs `transformSpecTranslations`); objectui @31971ff1e: packages/i18n/src/utils/spec-translations.ts#transformSpecTranslations (forwards every group it does not flatten, `flows` among them, verbatim under the `app` namespace); objectui @31971ff1e: packages/i18n/src/provider.tsx#I18nProvider (adds the loaded payload to the `translation` resource bundle that `activeFlowsBundle` reads)", - "note": "FLIPPED planned → live 2026-09-27 (#20296). It was PLANNED with its container under the #7646 contract-first split: the spec declared the vocabulary and the screen-flow runner half was a downstream objectui card. That half has landed client-side, the side #11287 picked (objectui#5920, FlowRunner's `localizeScreen`), and the `.objectui-sha` pin f8a9d0fb carries it, so every objectui pointer above was read at the pin this repo builds against. Each read is unchanged at objectui main 256b4c9e. Per-screen heading + per-field copy, keyed by `FlowNode.id` / `ScreenFieldConfig.name`, the identifiers the client already holds as `ScreenSpec.nodeId` / `ScreenFieldSpec.name`. Each key falls back to the authored string on its own. The screen `description` and the runner chrome stay untranslated here by ruling. Deeper conventions (`screens..title`, `screens..fields..{label,placeholder,inlineHelpText}`) are governed by the runner and the spec's `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`, not by ledger rows: the one-drill-level boundary this ledger's type note states. MOUNT CHAIN, closed by hand at the pin: `FlowRunner` is mounted by `useConsoleActionRuntime`'s dialogs (the console root `ConsoleShell` renders them, and ObjectView / DeclaredActionsBar each run their own runtime), by `RecordDetailView`, and by the console's `developer/flow-runs` route (`FlowRunsPage`). A `type: 'flow'` action whose run pauses at a screen node opens it with `{ flowName, runId, screen }`. The bundle reaches it through the console root's `I18nProvider loadLanguage` (apps/console main.tsx). ⚠️ NOT flipped with it: the container `flows` keeps `planned` + `authorWarn` for its `label` child, which nothing reads yet (#20318), and its `authorHint` now says that `screens` is read and the flow label is not. That bit is group-level and has two readers: @objectstack/lint's warn map, and the CLI i18n coverage gate (`authorWarnedTranslationGroups` in packages/cli i18n-extract.ts), which holds back the whole `flows.*` demand while it is set. Dropping it before the label has a reader would switch on demand for `flows..label` too, so it drops when #20318 lands (seat ruling on #20296). RE-VERIFIED 2026-10-02 (#17306) at the `.objectui-sha` pin 31971ff1e, which carries objectui 81778b955 (REST compare 81778b955...31971ff1e answers `ahead`, `behind_by 0`): the per-field face gained `inlineHelpText` beside `label` and `placeholder`, the screen field's own help-text key. Every objectui pointer in `evidence` and `producer` was re-read at that pin and repinned from f8a9d0fb. `overlayFieldCopy` imports the spec's key list rather than retyping it, so the new key needed no objectui edit, and `ScreenView` draws the help text under the control (objectui#9248). The status is unchanged: this row was already `live`. 2026-10-05: the `⚠️ NOT flipped with it` clause above is superseded. The `label` child is `live` now (objectui's FlowRunner reads it at the pin 0abd4f9f8), so the container flipped to `live` and dropped `authorWarn` and `authorHint`; see the container note. 2026-10-09 (#22507): the per-field face gained `options.` — a screen field's option labels, keyed by each option's value read as text (`flowScreenFieldOptionKey`) and resolved by `resolveFlowScreenFieldOptions`. It is NOT in `FLOW_SCREEN_FIELD_COPY_KEYS` (a map over `options[]`, not one string), so `overlayFieldCopy` does not reach it, and no objectui code reads it at main 20c6d351a: the option labels are declared ahead of their reader, the downstream runner change. The row stays `live` for the heading and the three string keys, which are unchanged; the one-drill-level boundary keeps the option map off a row of its own. 2026-10-10 (#22507, maintainer ruling A): the screen face gained `description`, and the screen's heading and body text moved to the ENGINE. Both are `{{ }}` templates the executor renders per run, so the `screen` executor picks each translated template in the run's locale through the one `i18n` channel the refusal message is read through, before it fills the holes; the served `ScreenSpec` arrives translated, and the authored template renders when the bundle has none. The body text is translated only where the screen authors one. objectui's `localizeScreen` still overlays the heading through `resolveFlowScreenTitle` at the `.objectui-sha` pin 20c6d351a; that overlay is retired by the ruling's consumer half (filed into objectui#12075). Until it lands the runner replaces a served heading with the translated template, which draws a hole literally exactly where it did before this change, so the window adds no regression. The per-field copy stays client-side, read by `overlayFieldCopy` as cited. The row stays `live`: the engine reads both screen keys in-repo, and objectui reads the field keys. objectui's `FlowRunner` overlay of `flows..screens` on the screen it draws was read at objectui @f8a9d0fb (the citation moved out of packages/spec/src/system/i18n-resolver.ts, #23434)." + "note": "FLIPPED planned → live 2026-09-27 (#20296). It was PLANNED with its container under the #7646 contract-first split: the spec declared the vocabulary and the screen-flow runner half was a downstream objectui card. That half has landed client-side, the side #11287 picked (objectui#5920, FlowRunner's `localizeScreen`), and the `.objectui-sha` pin f8a9d0fb carries it, so every objectui pointer above was read at the pin this repo builds against. Each read is unchanged at objectui main 256b4c9e. Per-screen heading + per-field copy, keyed by `FlowNode.id` / `ScreenFieldConfig.name`, the identifiers the client already holds as `ScreenSpec.nodeId` / `ScreenFieldSpec.name`. Each key falls back to the authored string on its own. The screen `description` and the runner chrome stay untranslated here by ruling. Deeper conventions (`screens..title`, `screens..fields..{label,placeholder,inlineHelpText}`) are governed by the runner and the spec's `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`, not by ledger rows: the one-drill-level boundary this ledger's type note states. MOUNT CHAIN, closed by hand at the pin: `FlowRunner` is mounted by `useConsoleActionRuntime`'s dialogs (the console root `ConsoleShell` renders them, and ObjectView / DeclaredActionsBar each run their own runtime), by `RecordDetailView`, and by the console's `developer/flow-runs` route (`FlowRunsPage`). A `type: 'flow'` action whose run pauses at a screen node opens it with `{ flowName, runId, screen }`. The bundle reaches it through the console root's `I18nProvider loadLanguage` (apps/console main.tsx). ⚠️ NOT flipped with it: the container `flows` keeps `planned` + `authorWarn` for its `label` child, which nothing reads yet (#20318), and its `authorHint` now says that `screens` is read and the flow label is not. That bit is group-level and has two readers: @objectstack/lint's warn map, and the CLI i18n coverage gate (`authorWarnedTranslationGroups` in packages/cli i18n-extract.ts), which holds back the whole `flows.*` demand while it is set. Dropping it before the label has a reader would switch on demand for `flows..label` too, so it drops when #20318 lands (seat ruling on #20296). RE-VERIFIED 2026-10-02 (#17306) at the `.objectui-sha` pin 31971ff1e, which carries objectui 81778b955 (REST compare 81778b955...31971ff1e answers `ahead`, `behind_by 0`): the per-field face gained `inlineHelpText` beside `label` and `placeholder`, the screen field's own help-text key. Every objectui pointer in `evidence` and `producer` was re-read at that pin and repinned from f8a9d0fb. `overlayFieldCopy` imports the spec's key list rather than retyping it, so the new key needed no objectui edit, and `ScreenView` draws the help text under the control (objectui#9248). The status is unchanged: this row was already `live`. 2026-10-05: the `⚠️ NOT flipped with it` clause above is superseded. The `label` child is `live` now (objectui's FlowRunner reads it at the pin 0abd4f9f8), so the container flipped to `live` and dropped `authorWarn` and `authorHint`; see the container note. 2026-10-09 (#22507): the per-field face gained `options.` — a screen field's option labels, keyed by each option's value read as text (`flowScreenFieldOptionKey`) and resolved by `resolveFlowScreenFieldOptions`. It is NOT in `FLOW_SCREEN_FIELD_COPY_KEYS` (a map over `options[]`, not one string), so `overlayFieldCopy` does not reach it, and no objectui code reads it at main 20c6d351a: the option labels are declared ahead of their reader, the downstream runner change. The row stays `live` for the heading and the three string keys, which are unchanged; the one-drill-level boundary keeps the option map off a row of its own. 2026-10-10 (#22507, maintainer ruling A): the screen face gained `description`, and the screen's heading and body text moved to the ENGINE. Both are `{{ }}` templates the executor renders per run, so the `screen` executor picks each translated template in the run's locale through the one `i18n` channel the refusal message is read through, before it fills the holes; the served `ScreenSpec` arrives translated, and the authored template renders when the bundle has none. The body text is translated only where the screen authors one. objectui's `localizeScreen` still overlays the heading through `resolveFlowScreenTitle` at the `.objectui-sha` pin 20c6d351a; that overlay is retired by the ruling's consumer half (filed into objectui#12075). Until it lands the runner replaces a served heading with the translated template, which draws a hole literally exactly where it did before this change, so the window adds no regression. The per-field copy stays client-side, read by `overlayFieldCopy` as cited. The row stays `live`: the engine reads both screen keys in-repo, and objectui reads the field keys. objectui's `FlowRunner` overlay of `flows..screens` on the screen it draws was read at objectui @f8a9d0fb (the citation moved out of packages/spec/src/system/i18n-resolver.ts, #23434). 2026-10-25 (#25742): the `evidence` pointer at service-automation's `engine.ts#renderFlowTextSlot` is dropped. That (entry, file) pair anchored only through a comment, which #25734 moved to `run-resume.ts`; `engine.ts` now keeps a delegating `renderFlowTextSlot` over `node-text-rendering.ts`. The `screen-nodes.ts#registerScreenNodes` pointer names the key and still anchors. `verifiedAt` is unchanged: nothing else on this row was re-read." }, "refusals": { "status": "live",