Skip to content

Commit 3b7b040

Browse files
objectstack-fleet[bot]claudeos-zhuang
authored
feat(core): the react-pages capability defaults off; a host turns it on (#13526)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6JaoBY54KcuzDWcTYXW18gG3bJ)_ ## What changes The `react-pages` capability in `@object-ui/core` (it gates `kind: 'react'` pages, whose source runs in the application with no sandbox) now defaults to **off**. A host turns it on explicitly. - `packages/core/src/runtime/capabilities.ts`: the default flips to `false`. The two page globals keep their meaning: `__OBJECTUI_CAPABILITIES__` turns a capability on, `__OBJECTUI_CAPABILITIES_DISABLED__` turns it off and wins over a stale enable list. - `ReactKindPage` (`packages/components/src/renderers/layout/react-page.tsx`) already drew a named notice instead of a blank page while the capability is off. Its text now says the tier is off unless the deployment turns it on and names the switch, `OS_PAGE_REACT_ENABLED=true` (or `enableCapability('react-pages')` for a host that embeds the renderer). The title "React pages are disabled on this deployment" is unchanged. - Studio's new-page dialog (`offeredPageSourceKinds`) already offered `react` only while the capability is on; only its comment changes. - `apps/console/src/sdui-workbench-preview.tsx` (a dev preview harness) now sets the enable global itself, the way the server does, and `?disable=1` leaves it unset. - `content/docs/guide/react-pages.md`: the security-gate section states the new default and how the server and an embedding host turn it on. ## Landing order The ObjectStack server half is #25789: it reads `OS_PAGE_REACT_ENABLED` and writes the enable global when on and the disable global when off, so the Console follows the server whichever default its build carries. Land that first. After it, this PR changes nothing a server-hosted Console does; it changes the default for a host that embeds the renderer without saying either way, and for a Console served by an older server, where react pages stay off until the server is upgraded. This PR does not move `.objectstack-sha`: nothing here reads an objectstack symbol the current pin lacks, and the pin move before the merge window is already in flight on its own card. ## Known side effect until a follow-up lands With the tier off, the server's content-security-policy refuses `eval`, and zod's one-time `eval` probe at Console boot is reported as one violation per load (nothing breaks). objectui#13511 sets zod `jitless` in the Console boot and removes it. ## Tests - New `packages/components/src/__tests__/react-page-capability-gate.test.tsx`: a real react page through the real `type: 'home'` PageRenderer draws the notice naming `OS_PAGE_REACT_ENABLED=true` by default and never runs its source; with the capability enabled the same source runs; disabled again, it is closed. - `packages/core/src/runtime/__tests__/capabilities.test.ts`, rewritten to load a fresh module per case (the default is only observable before any override): default off; the enable global turns it on; the disable global wins over an enable list; host enable and disable; unknown capabilities off. - Nine test files that render react pages relied on the old default and now enable the capability at module scope: `react-page-adapter`, `react-page-content-identity`, `react-page-invalidation`, `react-page-scope`, `react-page-state` (components), `interfaceCreate` and `StudioDesignSurface.interfacesCreatePage-11823` (app-shell), `ListView.objectProviderBinding-7477` (plugin-list), `sdui-workbench-preview-listview-binding-8510` (console). Measured: before the opt-in those nine files failed (27 tests) and nothing else failed, in a run of 695 files across core runtime, components, app-shell studio-design and previews, console, and the two plugin files. - After: `pnpm exec vitest run packages/core/src/runtime/ packages/components/src/__tests__/react-page …` (the eleven files above plus the gate test) → `Test Files 11 passed (11)`, `Tests 73 passed (73)`. - Ablation: with the default put back to `true`, the default-off cases in the gate test and the capabilities test go red (2 failed, 6 passed); restored, `git diff HEAD` is empty. - `turbo run type-check --filter=@object-ui/core --filter=@object-ui/components` → 10/10 tasks. `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/plugin-list --filter=@object-ui/console` → 38/38 tasks. Head `f2792aa`. - `eslint --no-inline-config` on every changed `.ts`/`.tsx` → 0 errors. `check-changeset-presence`, `check-changeset-no-major`, `check:new-line-citations`, `check:control-bytes` → exit 0. `check-governed-queue-guard --test` on the file list → NOT GOVERNED. ## Changeset `.changeset/react-pages-default-off.md`: `minor` for `@object-ui/core`, `@object-ui/components`, `@object-ui/app-shell`, `@object-ui/console` (a published default changes; `minor` under this repository's version policy), with the FROM → TO for an embedding host. --- _Generated by [Claude Code](https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: os-zhuang <jack@objectstack.ai>
1 parent e6a06d2 commit 3b7b040

22 files changed

Lines changed: 333 additions & 55 deletions

‎.changeset/console-zod-jitless.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
The Console document sets zod's `jitless` before any module runs, so zod no longer probes for `eval` at boot (objectui#13511). Under a content-security-policy that refuses `eval` (the ObjectStack server's policy whenever `kind: 'react'` pages are off), that probe was refused and reported once per load; nothing broke, but each load filed a false violation report. zod now uses its interpreter on every deployment, whatever the policy.
6+
7+
Clause-②: no
8+
Metadata-protocol: none
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/components': minor
4+
'@object-ui/app-shell': minor
5+
'@object-ui/console': minor
6+
---
7+
8+
The `react-pages` capability (`kind: 'react'` pages, whose source runs in the application with no sandbox) now defaults to **off** in `@object-ui/core`. A host turns it on explicitly. This changes a published default; it is marked `minor` under this repository's version policy.
9+
10+
- **ObjectStack deployments.** The server decides: with `OS_PAGE_REACT_ENABLED=true` it writes the enable global (`window.__OBJECTUI_CAPABILITIES__`) into the Console document, and otherwise the disable global. A server older than that writes only the disable global, and only when the deployment set `OS_PAGE_REACT=off`, so on such a server react pages stay off until it is upgraded.
11+
- **A host that embeds the renderer.** FROM: react pages ran unless the host called `disableCapability('react-pages')`. TO: they run only after the host calls `enableCapability('react-pages')` (or sets the enable global before the bundle runs).
12+
- **While off**, a react page renders the "React pages are disabled on this deployment" notice, which now names `OS_PAGE_REACT_ENABLED=true` as the switch, and the Studio's new-page dialog offers only the `html` kind. `kind: 'html'` pages are unaffected.
13+
14+
Clause-②: no
15+
Metadata-protocol: none

‎apps/console/index.html‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,31 @@
1515
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
1616
<link rel="manifest" href="./manifest.json" />
1717
<title></title>
18+
<!--
19+
zod runs without its JIT (objectui#13511).
20+
21+
zod v4 decides whether it may compile parsers by calling
22+
`new Function('')` once, inside a try/catch, the first time an object
23+
schema is built. The document's content-security-policy refuses `eval`
24+
unless the deployment turns the `kind:'react'` page tier on, so that probe
25+
is refused and a browser that reports violations files one report per
26+
load, although nothing breaks. With `jitless` set, zod skips the probe and
27+
uses its interpreter.
28+
29+
⛔ It is set HERE, in a classic script ahead of the module entry, and not
30+
with `z.config()` in `src/main.tsx`: the bundle puts zod and the schemas
31+
built at load time into vendor chunks that the entry chunk imports, so
32+
they run before any line of the entry's own body (measured on the
33+
production build: the probe fired from `vendor-objectstack` with the call
34+
at the top of main.tsx). zod reads its config from this global, created on
35+
first load when absent (`zod/v4/core/core.js`), and every zod copy in the
36+
page shares it. `src/__tests__/zod-jitless-13511.test.ts` runs this script
37+
before the installed zod and holds both halves.
38+
-->
39+
<script>
40+
window.__zod_globalConfig = window.__zod_globalConfig || {};
41+
window.__zod_globalConfig.jitless = true;
42+
</script>
1843
<!--
1944
Pre-React branding, and the page's ONE `GET /api/v1/runtime/config`
2045
(objectui#5544).

‎apps/console/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@
116116
"tailwindcss": "^4.3.3",
117117
"typescript": "^6.0.3",
118118
"vite": "^8.2.1",
119-
"vitest": "^4.1.10"
119+
"vitest": "^4.1.10",
120+
"zod": "^4.6.5"
120121
},
121122
"dependencies": {
122123
"@object-ui/sdui-parser": "workspace:*"

‎apps/console/src/__tests__/sdui-workbench-preview-listview-binding-8510.test.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
*/
3939
import { describe, it, expect, vi, beforeEach } from 'vitest';
4040
import { render, waitFor } from '@testing-library/react';
41-
import { ComponentRegistry } from '@object-ui/core';
41+
import { CAP_REACT_PAGES, ComponentRegistry, enableCapability } from '@object-ui/core';
4242
import { SchemaRenderer, SchemaRendererProvider, AdapterCtx } from '@object-ui/react';
4343
// The page renderers (`type:'home'`), which dispatch `kind:'react'` to the
4444
// react page tier.
@@ -48,6 +48,10 @@ import '@object-ui/plugin-list';
4848
import { validateReactPageProps, REACT_PROP_RETIRED } from '@objectstack/lint';
4949
import { pagesOf, previewHarnessFiles, readHarness } from './helpers/preview-page-sources';
5050

51+
// `kind:'react'` pages run only where the host turns the react-pages capability on
52+
// (it defaults off); the pages this file renders need it on.
53+
enableCapability(CAP_REACT_PAGES);
54+
5155
const HARNESS = 'sdui-workbench-preview.tsx';
5256
const OBJECT = 'showcase_project';
5357

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* objectui#13511 — the Console document turns zod's JIT off before any module
3+
* runs.
4+
*
5+
* zod v4 probes for `eval` with `new Function('')` the first time an object
6+
* schema is built. Under the Console's content-security-policy that probe is
7+
* refused, and reported on every load, unless the react-pages tier is on. The
8+
* fix is one inline classic script in `apps/console/index.html`, so, following
9+
* `bootSplash.test.ts`, these tests read that file through `?raw` and run the
10+
* script itself rather than a copy of it.
11+
*
12+
* Two halves: the script is ahead of the module entry (a `z.config()` call in
13+
* the entry runs too late, because the bundle evaluates the vendor chunks that
14+
* build schemas before the entry's body), and running it before the installed
15+
* zod means zod builds and parses with no `Function` call, against a control
16+
* that the probe is seen without it. The second half is what notices a zod
17+
* upgrade that stops reading the global.
18+
*
19+
* The zod half runs in a child Node process: this worker's setup has already
20+
* loaded zod, which caches the probe's answer once per process.
21+
*/
22+
import indexHtml from '../../index.html?raw';
23+
import { describe, it, expect } from 'vitest';
24+
import { spawnSync } from 'node:child_process';
25+
import path from 'node:path';
26+
27+
const SCRIPT_MARKER = '__zod_globalConfig';
28+
const APP_ENTRY = '/src/main.tsx';
29+
30+
/** Every `<script>` open tag plus its inline body, in document order, comments removed. */
31+
function scriptsOf(html: string) {
32+
return [...html.replace(/<!--[\s\S]*?-->/g, '').matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/g)].map(
33+
(match) => ({ attrs: match[1] ?? '', body: match[2] ?? '' }),
34+
);
35+
}
36+
37+
/** The jitless script exactly as it ships; exactly one, or the test fails rather than test nothing. */
38+
function jitlessScript(): string {
39+
const found = scriptsOf(indexHtml).filter((script) => script.body.includes(SCRIPT_MARKER));
40+
if (found.length !== 1) throw new Error(`expected exactly 1 inline ${SCRIPT_MARKER} script in index.html, found ${found.length}`);
41+
return found[0].body;
42+
}
43+
44+
/** Counts `Function` calls while zod builds and parses one object schema, after `prelude`. */
45+
function probe(prelude: string): { calls: string[]; parsed: unknown } {
46+
const source = `
47+
const calls = [];
48+
const RealFunction = globalThis.Function;
49+
globalThis.Function = new Proxy(RealFunction, {
50+
construct(target, args) { calls.push(args.join(',')); return Reflect.construct(target, args); },
51+
apply(target, self, args) { calls.push(args.join(',')); return Reflect.apply(target, self, args); },
52+
});
53+
globalThis.window = globalThis;
54+
${prelude}
55+
const { z } = await import('zod');
56+
const schema = z.object({ name: z.string(), count: z.number().optional() });
57+
const parsed = schema.parse({ name: 'a', count: 1 });
58+
process.stdout.write(JSON.stringify({ calls, parsed }));
59+
`;
60+
const run = spawnSync(process.execPath, ['--input-type=module', '-e', source], {
61+
cwd: path.resolve(__dirname, '..'),
62+
encoding: 'utf8',
63+
});
64+
if (run.status !== 0) throw new Error(`probe exited ${run.status}\n${run.stderr}`);
65+
return JSON.parse(run.stdout);
66+
}
67+
68+
describe('apps/console/index.html — zod runs without its JIT (objectui#13511)', () => {
69+
it('sets it in a classic script ahead of the module entry', () => {
70+
const scripts = scriptsOf(indexHtml);
71+
const jitless = scripts.findIndex((script) => script.body.includes(SCRIPT_MARKER));
72+
const entry = scripts.findIndex((script) => script.attrs.includes(APP_ENTRY));
73+
expect(jitless).toBeGreaterThanOrEqual(0);
74+
expect(entry).toBeGreaterThan(jitless);
75+
expect(scripts[jitless].attrs).not.toMatch(/type\s*=\s*["']?module|\bsrc\s*=|\b(async|defer)\b/);
76+
});
77+
78+
it('control: without it, the installed zod calls Function when it builds an object schema', () => {
79+
const { calls, parsed } = probe('');
80+
expect(parsed).toEqual({ name: 'a', count: 1 });
81+
expect(calls.length).toBeGreaterThan(0);
82+
});
83+
84+
it('with it run first, the installed zod builds and parses an object schema without calling Function', () => {
85+
const { calls, parsed } = probe(jitlessScript());
86+
expect(parsed).toEqual({ name: 'a', count: 1 });
87+
expect(calls).toEqual([]);
88+
});
89+
});

‎apps/console/src/sdui-workbench-preview.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@ import React from 'react';
3535
import { createRoot } from 'react-dom/client';
3636
import { SchemaRenderer, SchemaRendererProvider, AdapterCtx } from '@object-ui/react';
3737
import { I18nProvider } from '@object-ui/i18n';
38-
// NOTE: we do NOT call enableCapability here — `react-pages` is ON by default.
39-
// `?disable=1` simulates a server that set OS_PAGE_REACT=off (the runtime would
40-
// inject this global); the page then renders the "disabled" notice.
41-
if (new URLSearchParams(location.search).has('disable')) {
42-
(window as unknown as { __OBJECTUI_CAPABILITIES_DISABLED__?: string[] }).__OBJECTUI_CAPABILITIES_DISABLED__ = ['react-pages'];
38+
// `react-pages` is OFF by default, so this preview enables it the way a server
39+
// with OS_PAGE_REACT_ENABLED=true does: it sets the enable global the runtime
40+
// injects. `?disable=1` leaves it unset (a server with the tier off); the page
41+
// then renders the "disabled" notice.
42+
if (!new URLSearchParams(location.search).has('disable')) {
43+
(window as unknown as { __OBJECTUI_CAPABILITIES__?: string[] }).__OBJECTUI_CAPABILITIES__ = ['react-pages'];
4344
}
4445

4546
// ---- in-memory adapter (just enough for ListView + ObjectForm) ----

‎content/docs/guide/react-pages.md‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,17 @@ function Page() {
6464
### The security gate
6565

6666
A react page's source is transpiled and evaluated directly in the application —
67-
no isolation, full access to the page's React tree. The platform assumes page
68-
authors are reviewed and draft-gated, so the host capability `react-pages`
69-
defaults **ON**.
70-
71-
A deployment that does not trust its authors turns it off server-side with
72-
`OS_PAGE_REACT=off` (or `disableCapability('react-pages')` in the host). Pages
73-
then render an explanatory notice instead of executing. Existing `kind:'html'`
74-
pages are unaffected.
67+
no isolation, full access to the page's React tree. So the host capability
68+
`react-pages` defaults **OFF**, and a deployment turns it on only when its page
69+
authors are trusted and reviewed.
70+
71+
On ObjectStack the operator turns it on server-side with
72+
`OS_PAGE_REACT_ENABLED=true`; the server then writes the enable global
73+
(`window.__OBJECTUI_CAPABILITIES__`) into the Console document, and with the
74+
tier off it writes the disable global instead. A host that embeds the renderer
75+
itself calls `enableCapability('react-pages')` before rendering. While the
76+
capability is off, a react page renders a notice that names the switch instead
77+
of executing. `kind:'html'` pages are unaffected either way.
7578

7679
### What is in scope
7780

‎packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreatePage-11823.test.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ import { registerMetadataPreview } from '../metadata-admin/preview-registry';
102102
import { PagePreview } from '../metadata-admin/previews/PagePreview';
103103
import { pageStarterSource } from './interfaceCreate';
104104
import { t } from '../metadata-admin/i18n';
105+
import { CAP_REACT_PAGES, enableCapability } from '@object-ui/core';
106+
107+
// `kind:'react'` pages run only where the host turns the react-pages capability on
108+
// (it defaults off); the pages this file renders need it on.
109+
enableCapability(CAP_REACT_PAGES);
105110

106111
const dataSource = createEmptyDataSource();
107112

‎packages/app-shell/src/views/studio-design/interfaceCreate.test.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ import type { NavNode } from './navSurface';
4949
import { t, type SupportedLocale } from '../metadata-admin/i18n';
5050
import { isStaticPageOption } from '../metadata-admin/inspectors/nav-target';
5151

52+
// `kind:'react'` pages run only where the host turns the react-pages capability on
53+
// (it defaults off); the pages this file renders need it on.
54+
enableCapability(CAP_REACT_PAGES);
55+
5256
const LOCALES: SupportedLocale[] = ['en-US', 'zh-CN'];
5357
const starterOf = (locale: SupportedLocale) => t('engine.studio.interfaces.create.pageStarter', locale);
5458

0 commit comments

Comments
 (0)