From ff5062ee66d69c0704bc739618a9376f3252b893 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 10:07:36 +0000 Subject: [PATCH 1/4] feat(core): the react-pages capability defaults off; a host turns it on Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- .changeset/react-pages-default-off.md | 15 +++++ apps/console/src/sdui-workbench-preview.tsx | 11 ++-- content/docs/guide/react-pages.md | 19 +++--- .../views/studio-design/interfaceCreate.tsx | 4 +- .../src/renderers/layout/react-page.tsx | 17 +++-- .../runtime/__tests__/capabilities.test.ts | 64 ++++++++++++++----- packages/core/src/runtime/capabilities.ts | 17 ++--- 7 files changed, 98 insertions(+), 49 deletions(-) create mode 100644 .changeset/react-pages-default-off.md diff --git a/.changeset/react-pages-default-off.md b/.changeset/react-pages-default-off.md new file mode 100644 index 0000000000..020304e86a --- /dev/null +++ b/.changeset/react-pages-default-off.md @@ -0,0 +1,15 @@ +--- +'@object-ui/core': minor +'@object-ui/components': minor +'@object-ui/app-shell': minor +'@object-ui/console': minor +--- + +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. + +- **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. +- **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). +- **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. + +Clause-②: no +Metadata-protocol: none diff --git a/apps/console/src/sdui-workbench-preview.tsx b/apps/console/src/sdui-workbench-preview.tsx index 126d39255d..f02cd7e779 100644 --- a/apps/console/src/sdui-workbench-preview.tsx +++ b/apps/console/src/sdui-workbench-preview.tsx @@ -35,11 +35,12 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { SchemaRenderer, SchemaRendererProvider, AdapterCtx } from '@object-ui/react'; import { I18nProvider } from '@object-ui/i18n'; -// NOTE: we do NOT call enableCapability here — `react-pages` is ON by default. -// `?disable=1` simulates a server that set OS_PAGE_REACT=off (the runtime would -// inject this global); the page then renders the "disabled" notice. -if (new URLSearchParams(location.search).has('disable')) { - (window as unknown as { __OBJECTUI_CAPABILITIES_DISABLED__?: string[] }).__OBJECTUI_CAPABILITIES_DISABLED__ = ['react-pages']; +// `react-pages` is OFF by default, so this preview enables it the way a server +// with OS_PAGE_REACT_ENABLED=true does: it sets the enable global the runtime +// injects. `?disable=1` leaves it unset (a server with the tier off); the page +// then renders the "disabled" notice. +if (!new URLSearchParams(location.search).has('disable')) { + (window as unknown as { __OBJECTUI_CAPABILITIES__?: string[] }).__OBJECTUI_CAPABILITIES__ = ['react-pages']; } // ---- in-memory adapter (just enough for ListView + ObjectForm) ---- diff --git a/content/docs/guide/react-pages.md b/content/docs/guide/react-pages.md index 05c4fe5dc9..b22d437653 100644 --- a/content/docs/guide/react-pages.md +++ b/content/docs/guide/react-pages.md @@ -64,14 +64,17 @@ function Page() { ### The security gate A react page's source is transpiled and evaluated directly in the application — -no isolation, full access to the page's React tree. The platform assumes page -authors are reviewed and draft-gated, so the host capability `react-pages` -defaults **ON**. - -A deployment that does not trust its authors turns it off server-side with -`OS_PAGE_REACT=off` (or `disableCapability('react-pages')` in the host). Pages -then render an explanatory notice instead of executing. Existing `kind:'html'` -pages are unaffected. +no isolation, full access to the page's React tree. So the host capability +`react-pages` defaults **OFF**, and a deployment turns it on only when its page +authors are trusted and reviewed. + +On ObjectStack the operator turns it on server-side with +`OS_PAGE_REACT_ENABLED=true`; the server then writes the enable global +(`window.__OBJECTUI_CAPABILITIES__`) into the Console document, and with the +tier off it writes the disable global instead. A host that embeds the renderer +itself calls `enableCapability('react-pages')` before rendering. While the +capability is off, a react page renders a notice that names the switch instead +of executing. `kind:'html'` pages are unaffected either way. ### What is in scope diff --git a/packages/app-shell/src/views/studio-design/interfaceCreate.tsx b/packages/app-shell/src/views/studio-design/interfaceCreate.tsx index 9d64227b21..4f1fcb4862 100644 --- a/packages/app-shell/src/views/studio-design/interfaceCreate.tsx +++ b/packages/app-shell/src/views/studio-design/interfaceCreate.tsx @@ -101,8 +101,8 @@ export const DEFAULT_PAGE_SOURCE_KIND: PageSourceKind = 'html'; /** * The source kinds this deployment can render, in the order the dialog offers - * them. `react` is left out where the host has turned the react-pages - * capability off (`OS_PAGE_REACT=off`): the save door would accept such a page, + * them. `react` is left out unless the host has turned the react-pages + * capability on (`OS_PAGE_REACT_ENABLED=true`): the save door would accept such a page, * but the preview beside its editor, and the running app, would only say that * react pages are disabled here. */ diff --git a/packages/components/src/renderers/layout/react-page.tsx b/packages/components/src/renderers/layout/react-page.tsx index 149963d292..f82e28f743 100644 --- a/packages/components/src/renderers/layout/react-page.tsx +++ b/packages/components/src/renderers/layout/react-page.tsx @@ -10,11 +10,10 @@ * Unlike `kind:'html'` (constrained JSX parsed, never executed), a react page's * `source` is real JavaScript/JSX: hooks, event handlers, `.map`, arbitrary * expressions. It is transpiled (Sucrase) and evaluated directly in the main - * React tree by `@object-ui/react-runtime` — NO sandbox. The platform trusts - * its (reviewed, draft-gated) page authors, so the host capability - * `CAP_REACT_PAGES` defaults ON; a deployment that does not trust its authors - * turns it OFF server-side (the runtime injects the disable global when - * `OS_PAGE_REACT=off`). The transpiler is lazy-loaded — fetched in a + * React tree by `@object-ui/react-runtime` — NO sandbox. So the host capability + * `CAP_REACT_PAGES` defaults OFF; a deployment whose page authors are trusted + * turns it ON server-side (the runtime injects the enable global when + * `OS_PAGE_REACT_ENABLED=true`). The transpiler is lazy-loaded — fetched in a * separate chunk only when a react page actually renders with the capability on. * * Scope injected into the source: @@ -205,9 +204,9 @@ function CapabilityDisabledNotice(): React.ReactElement {
React pages are disabled on this deployment

kind:'react' pages execute author JavaScript directly in the - application. This deployment has turned the capability off - (OS_PAGE_REACT=off / disableCapability('react-pages')). - It is ON by default; re-enable it if your page authors are trusted. + application, so they are off unless the deployment turns them on. An operator whose + page authors are trusted sets OS_PAGE_REACT_ENABLED=true on the server + (a host embedding the renderer calls enableCapability('react-pages')).

); @@ -218,7 +217,7 @@ export const ReactKindPage: React.FC<{ schema: any }> = ({ schema }) => { // The live data source for the injected data blocks (and the page's own // `useAdapter()` calls). Same object the rest of the app renders against. const adapter = useAdapter(); - // Gate: default-closed. Off in OSS / untrusted builds. Read here so the hooks + // Gate: default-closed, on only where the host enabled it. Read here so the hooks // below stay unconditional; the disabled notice is returned after them, and // the effect never loads the gated runtime when disabled. const capabilityEnabled = isCapabilityEnabled(CAP_REACT_PAGES); diff --git a/packages/core/src/runtime/__tests__/capabilities.test.ts b/packages/core/src/runtime/__tests__/capabilities.test.ts index 6442a47f21..a29a24677b 100644 --- a/packages/core/src/runtime/__tests__/capabilities.test.ts +++ b/packages/core/src/runtime/__tests__/capabilities.test.ts @@ -1,34 +1,64 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { - enableCapability, - disableCapability, - isCapabilityEnabled, - CAP_REACT_PAGES, -} from '../capabilities.js'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Each case loads a fresh copy of the module: the overrides and the one-time + * read of the page globals are module state, and the default is only + * observable before anything has overridden it. + */ +async function load() { + vi.resetModules(); + return import('../capabilities.js'); +} + +type CapabilityGlobals = { + __OBJECTUI_CAPABILITIES__?: unknown; + __OBJECTUI_CAPABILITIES_DISABLED__?: unknown; +}; +const g = globalThis as unknown as CapabilityGlobals; describe('runtime capabilities', () => { beforeEach(() => { - // reset react-pages to its default by clearing the explicit override - enableCapability(CAP_REACT_PAGES); + delete g.__OBJECTUI_CAPABILITIES__; + delete g.__OBJECTUI_CAPABILITIES_DISABLED__; + }); + afterEach(() => { + delete g.__OBJECTUI_CAPABILITIES__; + delete g.__OBJECTUI_CAPABILITIES_DISABLED__; }); - it('react-pages defaults ON', () => { - // default state (no host override) is enabled - expect(isCapabilityEnabled(CAP_REACT_PAGES)).toBe(true); + it('react-pages defaults OFF when no host turns it on', async () => { + const { isCapabilityEnabled, CAP_REACT_PAGES } = await load(); + expect(isCapabilityEnabled(CAP_REACT_PAGES)).toBe(false); }); - it('unknown capabilities default OFF', () => { - expect(isCapabilityEnabled('something-unknown')).toBe(false); + it('the enable global a server writes turns react-pages on', async () => { + g.__OBJECTUI_CAPABILITIES__ = ['react-pages']; + const { isCapabilityEnabled, CAP_REACT_PAGES } = await load(); + expect(isCapabilityEnabled(CAP_REACT_PAGES)).toBe(true); }); - it('a host can disable react-pages (server opt-out)', () => { - disableCapability(CAP_REACT_PAGES); + it('the disable global wins over a stale enable list in the same page', async () => { + g.__OBJECTUI_CAPABILITIES__ = ['react-pages']; + g.__OBJECTUI_CAPABILITIES_DISABLED__ = ['react-pages']; + const { isCapabilityEnabled, CAP_REACT_PAGES } = await load(); expect(isCapabilityEnabled(CAP_REACT_PAGES)).toBe(false); + }); + + it('a host can enable and disable react-pages', async () => { + const { enableCapability, disableCapability, isCapabilityEnabled, CAP_REACT_PAGES } = await load(); enableCapability(CAP_REACT_PAGES); expect(isCapabilityEnabled(CAP_REACT_PAGES)).toBe(true); + disableCapability(CAP_REACT_PAGES); + expect(isCapabilityEnabled(CAP_REACT_PAGES)).toBe(false); + }); + + it('unknown capabilities default OFF', async () => { + const { isCapabilityEnabled } = await load(); + expect(isCapabilityEnabled('something-unknown')).toBe(false); }); - it('exposes the react-pages capability constant', () => { + it('exposes the react-pages capability constant', async () => { + const { CAP_REACT_PAGES } = await load(); expect(CAP_REACT_PAGES).toBe('react-pages'); }); }); diff --git a/packages/core/src/runtime/capabilities.ts b/packages/core/src/runtime/capabilities.ts index 77a1f4099b..69cab9f6ee 100644 --- a/packages/core/src/runtime/capabilities.ts +++ b/packages/core/src/runtime/capabilities.ts @@ -11,11 +11,12 @@ * bundle code runs. * * {@link CAP_REACT_PAGES} (`kind:'react'` — execute author JavaScript in the - * main React tree) defaults **ON**: ObjectStack pages are authored by trusted - * authors and pass human review (draft-gating, ADR-0033), so the platform - * trusts them by default. A deployment that does NOT trust its page authors - * turns it off server-side — the ObjectStack runtime injects the disable global - * when `OS_PAGE_REACT=off` (see framework cli/utils/console.ts). + * main React tree, no sandbox) defaults **OFF**: whether a deployment's page + * authors are trusted is the operator's call, so the host turns it on + * explicitly. The ObjectStack runtime injects the enable global when the + * deployment sets `OS_PAGE_REACT_ENABLED=true`, and the disable global + * otherwise (objectstack `packages/cli/src/utils/console.ts`, + * `readReactPagesTier`; ADR-0081 as amended). * * Globals a host/server can set in the page before the bundle runs: * - `globalThis.__OBJECTUI_CAPABILITIES__` — string[] to force ON @@ -24,7 +25,7 @@ /** Default state per capability when neither the host nor a global overrides it. */ const DEFAULTS: Record = { - 'react-pages': true, + 'react-pages': false, }; // Explicit host overrides (enable/disable) — win over defaults and globals. @@ -70,7 +71,7 @@ export function isCapabilityEnabled(name: string): boolean { /** * `kind:'react'` — execute trusted author JavaScript (full React: hooks, event - * handlers, arbitrary JS) directly in the main React tree. Default ON; a - * deployment disables it server-side with `OS_PAGE_REACT=off`. + * handlers, arbitrary JS) directly in the main React tree. Default OFF; a + * deployment turns it on server-side with `OS_PAGE_REACT_ENABLED=true`. */ export const CAP_REACT_PAGES = 'react-pages'; From 6b87dda20513a49d8ec4a0657fb278ade5b6dc84 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 10:49:21 +0000 Subject: [PATCH 2/4] test: react-page tests opt in to the tier; pin the default-off gate Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- ...nch-preview-listview-binding-8510.test.tsx | 6 +- ...urface.interfacesCreatePage-11823.test.tsx | 5 ++ .../studio-design/interfaceCreate.test.tsx | 4 ++ .../src/__tests__/react-page-adapter.test.tsx | 6 +- .../react-page-capability-gate.test.tsx | 62 +++++++++++++++++++ .../react-page-content-identity.test.tsx | 5 ++ .../react-page-invalidation.test.tsx | 5 ++ .../src/__tests__/react-page-scope.test.tsx | 6 +- .../src/__tests__/react-page-state.test.tsx | 6 +- ...stView.objectProviderBinding-7477.test.tsx | 6 +- 10 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 packages/components/src/__tests__/react-page-capability-gate.test.tsx diff --git a/apps/console/src/__tests__/sdui-workbench-preview-listview-binding-8510.test.tsx b/apps/console/src/__tests__/sdui-workbench-preview-listview-binding-8510.test.tsx index 38064ef8f5..50c43cdcf8 100644 --- a/apps/console/src/__tests__/sdui-workbench-preview-listview-binding-8510.test.tsx +++ b/apps/console/src/__tests__/sdui-workbench-preview-listview-binding-8510.test.tsx @@ -38,7 +38,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, waitFor } from '@testing-library/react'; -import { ComponentRegistry } from '@object-ui/core'; +import { CAP_REACT_PAGES, ComponentRegistry, enableCapability } from '@object-ui/core'; import { SchemaRenderer, SchemaRendererProvider, AdapterCtx } from '@object-ui/react'; // The page renderers (`type:'home'`), which dispatch `kind:'react'` to the // react page tier. @@ -48,6 +48,10 @@ import '@object-ui/plugin-list'; import { validateReactPageProps, REACT_PROP_RETIRED } from '@objectstack/lint'; import { pagesOf, previewHarnessFiles, readHarness } from './helpers/preview-page-sources'; +// `kind:'react'` pages run only where the host turns the react-pages capability on +// (it defaults off); the pages this file renders need it on. +enableCapability(CAP_REACT_PAGES); + const HARNESS = 'sdui-workbench-preview.tsx'; const OBJECT = 'showcase_project'; diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreatePage-11823.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreatePage-11823.test.tsx index 1d289aa033..3c5fd2280c 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreatePage-11823.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.interfacesCreatePage-11823.test.tsx @@ -102,6 +102,11 @@ import { registerMetadataPreview } from '../metadata-admin/preview-registry'; import { PagePreview } from '../metadata-admin/previews/PagePreview'; import { pageStarterSource } from './interfaceCreate'; import { t } from '../metadata-admin/i18n'; +import { CAP_REACT_PAGES, enableCapability } from '@object-ui/core'; + +// `kind:'react'` pages run only where the host turns the react-pages capability on +// (it defaults off); the pages this file renders need it on. +enableCapability(CAP_REACT_PAGES); const dataSource = createEmptyDataSource(); diff --git a/packages/app-shell/src/views/studio-design/interfaceCreate.test.tsx b/packages/app-shell/src/views/studio-design/interfaceCreate.test.tsx index f89beeb48b..2e41ef6ff6 100644 --- a/packages/app-shell/src/views/studio-design/interfaceCreate.test.tsx +++ b/packages/app-shell/src/views/studio-design/interfaceCreate.test.tsx @@ -49,6 +49,10 @@ import type { NavNode } from './navSurface'; import { t, type SupportedLocale } from '../metadata-admin/i18n'; import { isStaticPageOption } from '../metadata-admin/inspectors/nav-target'; +// `kind:'react'` pages run only where the host turns the react-pages capability on +// (it defaults off); the pages this file renders need it on. +enableCapability(CAP_REACT_PAGES); + const LOCALES: SupportedLocale[] = ['en-US', 'zh-CN']; const starterOf = (locale: SupportedLocale) => t('engine.studio.interfaces.create.pageStarter', locale); diff --git a/packages/components/src/__tests__/react-page-adapter.test.tsx b/packages/components/src/__tests__/react-page-adapter.test.tsx index d24d8bf9b1..432521032a 100644 --- a/packages/components/src/__tests__/react-page-adapter.test.tsx +++ b/packages/components/src/__tests__/react-page-adapter.test.tsx @@ -29,11 +29,15 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { CAP_REACT_PAGES, ComponentRegistry, enableCapability } from '@object-ui/core'; import { SchemaRenderer, AdapterCtx, SchemaRendererContext } from '@object-ui/react'; import type { PageDocumentNode } from '@object-ui/types'; import '../renderers'; +// `kind:'react'` pages run only where the host turns the react-pages capability on +// (it defaults off); the pages this file renders need it on. +enableCapability(CAP_REACT_PAGES); + /** The adapter each render of the stand-in block resolved from its context. */ const seen: unknown[] = []; /** Whether each render's node carried a `dataSource` key. */ diff --git a/packages/components/src/__tests__/react-page-capability-gate.test.tsx b/packages/components/src/__tests__/react-page-capability-gate.test.tsx new file mode 100644 index 0000000000..24cd386261 --- /dev/null +++ b/packages/components/src/__tests__/react-page-capability-gate.test.tsx @@ -0,0 +1,62 @@ +/** + * The `kind:'react'` page tier is off unless the host turns it on. + * + * A react page's source runs in the application with no sandbox, so + * `CAP_REACT_PAGES` defaults off in `@object-ui/core`. These cases render a + * real react page through the real `type:'home'` PageRenderer, with nothing + * but the capability changed: by default it draws the notice that names the + * server switch and never runs the source; once the host enables the + * capability (the global the ObjectStack server writes, or + * `enableCapability`), the same source runs. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import React from 'react'; +import { CAP_REACT_PAGES, disableCapability, enableCapability } from '@object-ui/core'; +import { SchemaRenderer, AdapterCtx } from '@object-ui/react'; +// Registers PageRenderer for `type:'home'`, which dispatches kind:'react'. +import '../renderers'; +// The runtime chunk the page loads once enabled, imported here so its cold +// transform is not billed to a `findBy` window. +import '@object-ui/react-runtime'; + +const adapter = { find: async () => [] } as any; + +const SCHEMA = { + type: 'home', + kind: 'react', + name: 'gate_page', + source: `function Page() { return

ran

; }`, +}; + +function Host() { + return ( + + + + ); +} + +afterEach(() => cleanup()); + +describe('the react-pages capability gate', () => { + it('is closed by default: the page draws the notice naming the switch and never runs', async () => { + const { findByText, queryByTestId, container } = render(); + expect(await findByText('React pages are disabled on this deployment')).toBeTruthy(); + expect(container.textContent).toContain('OS_PAGE_REACT_ENABLED=true'); + expect(queryByTestId('ran')).toBeNull(); + }); + + it('runs the same page once the host enables the capability, and closes again when it is disabled', async () => { + enableCapability(CAP_REACT_PAGES); + const first = render(); + expect((await first.findByTestId('ran')).textContent).toBe('ran'); + first.unmount(); + + disableCapability(CAP_REACT_PAGES); + const second = render(); + expect(await second.findByText('React pages are disabled on this deployment')).toBeTruthy(); + expect(second.queryByTestId('ran')).toBeNull(); + }); +}); diff --git a/packages/components/src/__tests__/react-page-content-identity.test.tsx b/packages/components/src/__tests__/react-page-content-identity.test.tsx index 9ed5c8bb46..d2b5e9d79c 100644 --- a/packages/components/src/__tests__/react-page-content-identity.test.tsx +++ b/packages/components/src/__tests__/react-page-content-identity.test.tsx @@ -29,6 +29,11 @@ import { SchemaRenderer, AdapterCtx } from '@object-ui/react'; // Registers PageRenderer for `type:'home'`, which dispatches kind:'react'. At // module scope, not in a hook (object-ui/no-dynamic-import-in-test-hook). import '../renderers'; +import { CAP_REACT_PAGES, enableCapability } from '@object-ui/core'; + +// `kind:'react'` pages run only where the host turns the react-pages capability on +// (it defaults off); the pages this file renders need it on. +enableCapability(CAP_REACT_PAGES); const adapter = { find: async () => [], getObjectSchema: async () => ({ name: 'x', fields: {} }) } as any; diff --git a/packages/components/src/__tests__/react-page-invalidation.test.tsx b/packages/components/src/__tests__/react-page-invalidation.test.tsx index d9503df295..9166d6ffa8 100644 --- a/packages/components/src/__tests__/react-page-invalidation.test.tsx +++ b/packages/components/src/__tests__/react-page-invalidation.test.tsx @@ -44,6 +44,11 @@ import type { PageDocumentNode } from '@object-ui/types'; import '@object-ui/react-runtime'; // Registers PageRenderer for `type:'home'`, which dispatches kind:'react'. import '../renderers'; +import { CAP_REACT_PAGES, enableCapability } from '@object-ui/core'; + +// `kind:'react'` pages run only where the host turns the react-pages capability on +// (it defaults off); the pages this file renders need it on. +enableCapability(CAP_REACT_PAGES); /** The Live data pattern the react-pages guide teaches, nonce included. */ const SOURCE = ` diff --git a/packages/components/src/__tests__/react-page-scope.test.tsx b/packages/components/src/__tests__/react-page-scope.test.tsx index fe469be955..a7be93bac9 100644 --- a/packages/components/src/__tests__/react-page-scope.test.tsx +++ b/packages/components/src/__tests__/react-page-scope.test.tsx @@ -35,11 +35,15 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { render, waitFor } from '@testing-library/react'; import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { CAP_REACT_PAGES, ComponentRegistry, enableCapability } from '@object-ui/core'; import { SchemaRenderer, AdapterCtx, SchemaRendererContext } from '@object-ui/react'; // Registers PageRenderer for `type:'home'`, which dispatches kind:'react'. import '../renderers'; +// `kind:'react'` pages run only where the host turns the react-pages capability on +// (it defaults off); the pages this file renders need it on. +enableCapability(CAP_REACT_PAGES); + /** Props the stand-in blocks last received, for flat-prop assertions. */ const captured: { listView?: any; listViewContextAdapter?: unknown; objectForm?: any; kanban?: any } = {}; diff --git a/packages/components/src/__tests__/react-page-state.test.tsx b/packages/components/src/__tests__/react-page-state.test.tsx index adf561a775..66e4be0461 100644 --- a/packages/components/src/__tests__/react-page-state.test.tsx +++ b/packages/components/src/__tests__/react-page-state.test.tsx @@ -30,7 +30,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { render, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; -import { ComponentRegistry } from '@object-ui/core'; +import { CAP_REACT_PAGES, ComponentRegistry, enableCapability } from '@object-ui/core'; import { SchemaRenderer, AdapterCtx } from '@object-ui/react'; const adapter = { find: async () => [], getObjectSchema: async () => ({ name: 'showcase_project', fields: {} }) } as any; @@ -56,6 +56,10 @@ const PLAIN_SCHEMA = { type: 'home', kind: 'react', name: 'test_page', source: c // object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021). import '../renderers'; +// `kind:'react'` pages run only where the host turns the react-pages capability on +// (it defaults off); the pages this file renders need it on. +enableCapability(CAP_REACT_PAGES); + afterEach(() => { ComponentRegistry.unregister('object-kanban', 'plugin-kanban'); ComponentRegistry.unregister('object-kanban'); diff --git a/packages/plugin-list/src/__tests__/ListView.objectProviderBinding-7477.test.tsx b/packages/plugin-list/src/__tests__/ListView.objectProviderBinding-7477.test.tsx index ca9e0e64df..911056dd8c 100644 --- a/packages/plugin-list/src/__tests__/ListView.objectProviderBinding-7477.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.objectProviderBinding-7477.test.tsx @@ -86,7 +86,7 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, waitFor, cleanup, act } from '@testing-library/react'; -import { ComponentRegistry } from '@object-ui/core'; +import { CAP_REACT_PAGES, ComponentRegistry, enableCapability } from '@object-ui/core'; import { SchemaRenderer, SchemaRendererProvider, AdapterCtx } from '@object-ui/react'; // Registers the page renderers (`type:'page'` / `'home'`), which dispatch // `kind:'react'` to `ReactKindPage`. @@ -95,6 +95,10 @@ import '@object-ui/components'; import '../index'; import { ListView } from '../ListView'; +// `kind:'react'` pages run only where the host turns the react-pages capability on +// (it defaults off); the pages this file renders need it on. +enableCapability(CAP_REACT_PAGES); + const OBJECT = 'crm_task'; const rows = [ From f2792aa671792ac3ffface8a414f30b6a818bfc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 11:09:21 +0000 Subject: [PATCH 3/4] test(components): type the gate test's page node Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- .../src/__tests__/react-page-capability-gate.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/components/src/__tests__/react-page-capability-gate.test.tsx b/packages/components/src/__tests__/react-page-capability-gate.test.tsx index 24cd386261..967725b64f 100644 --- a/packages/components/src/__tests__/react-page-capability-gate.test.tsx +++ b/packages/components/src/__tests__/react-page-capability-gate.test.tsx @@ -15,6 +15,7 @@ import { render, cleanup } from '@testing-library/react'; import React from 'react'; import { CAP_REACT_PAGES, disableCapability, enableCapability } from '@object-ui/core'; import { SchemaRenderer, AdapterCtx } from '@object-ui/react'; +import type { PageDocumentNode } from '@object-ui/types'; // Registers PageRenderer for `type:'home'`, which dispatches kind:'react'. import '../renderers'; // The runtime chunk the page loads once enabled, imported here so its cold @@ -23,10 +24,11 @@ import '@object-ui/react-runtime'; const adapter = { find: async () => [] } as any; -const SCHEMA = { +const SCHEMA: PageDocumentNode = { type: 'home', kind: 'react', name: 'gate_page', + label: 'Gate page', source: `function Page() { return

ran

; }`, }; From e50715596e4050dc5e4eba5f671dabb40fe499bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 13:41:21 +0000 Subject: [PATCH 4/4] fix(console): set zod jitless in the document head, so the boot never probes eval zod v4 calls `new Function('')` once, the first time an object schema is built, to decide whether it may compile parsers. Under a content-security-policy that refuses `eval` the probe is refused and reported on every load. An inline classic script ahead of the module entry sets `jitless` on zod's global config; a `z.config()` call in `main.tsx` runs too late, because the production bundle evaluates the vendor chunks that build schemas before the entry's body. Fixes #13511 Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01X4d6XVfcfxL17bMpptytcc --- .changeset/console-zod-jitless.md | 8 ++ apps/console/index.html | 25 ++++++ apps/console/package.json | 3 +- .../src/__tests__/zod-jitless-13511.test.ts | 89 +++++++++++++++++++ pnpm-lock.yaml | 3 + 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 .changeset/console-zod-jitless.md create mode 100644 apps/console/src/__tests__/zod-jitless-13511.test.ts diff --git a/.changeset/console-zod-jitless.md b/.changeset/console-zod-jitless.md new file mode 100644 index 0000000000..ef500b6e6e --- /dev/null +++ b/.changeset/console-zod-jitless.md @@ -0,0 +1,8 @@ +--- +'@object-ui/console': patch +--- + +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. + +Clause-②: no +Metadata-protocol: none diff --git a/apps/console/index.html b/apps/console/index.html index e4cebb7d9b..f0d70beb44 100644 --- a/apps/console/index.html +++ b/apps/console/index.html @@ -15,6 +15,31 @@ + + /g, '').matchAll(/]*)>([\s\S]*?)<\/script>/g)].map( + (match) => ({ attrs: match[1] ?? '', body: match[2] ?? '' }), + ); +} + +/** The jitless script exactly as it ships; exactly one, or the test fails rather than test nothing. */ +function jitlessScript(): string { + const found = scriptsOf(indexHtml).filter((script) => script.body.includes(SCRIPT_MARKER)); + if (found.length !== 1) throw new Error(`expected exactly 1 inline ${SCRIPT_MARKER} script in index.html, found ${found.length}`); + return found[0].body; +} + +/** Counts `Function` calls while zod builds and parses one object schema, after `prelude`. */ +function probe(prelude: string): { calls: string[]; parsed: unknown } { + const source = ` +const calls = []; +const RealFunction = globalThis.Function; +globalThis.Function = new Proxy(RealFunction, { + construct(target, args) { calls.push(args.join(',')); return Reflect.construct(target, args); }, + apply(target, self, args) { calls.push(args.join(',')); return Reflect.apply(target, self, args); }, +}); +globalThis.window = globalThis; +${prelude} +const { z } = await import('zod'); +const schema = z.object({ name: z.string(), count: z.number().optional() }); +const parsed = schema.parse({ name: 'a', count: 1 }); +process.stdout.write(JSON.stringify({ calls, parsed })); +`; + const run = spawnSync(process.execPath, ['--input-type=module', '-e', source], { + cwd: path.resolve(__dirname, '..'), + encoding: 'utf8', + }); + if (run.status !== 0) throw new Error(`probe exited ${run.status}\n${run.stderr}`); + return JSON.parse(run.stdout); +} + +describe('apps/console/index.html — zod runs without its JIT (objectui#13511)', () => { + it('sets it in a classic script ahead of the module entry', () => { + const scripts = scriptsOf(indexHtml); + const jitless = scripts.findIndex((script) => script.body.includes(SCRIPT_MARKER)); + const entry = scripts.findIndex((script) => script.attrs.includes(APP_ENTRY)); + expect(jitless).toBeGreaterThanOrEqual(0); + expect(entry).toBeGreaterThan(jitless); + expect(scripts[jitless].attrs).not.toMatch(/type\s*=\s*["']?module|\bsrc\s*=|\b(async|defer)\b/); + }); + + it('control: without it, the installed zod calls Function when it builds an object schema', () => { + const { calls, parsed } = probe(''); + expect(parsed).toEqual({ name: 'a', count: 1 }); + expect(calls.length).toBeGreaterThan(0); + }); + + it('with it run first, the installed zod builds and parses an object schema without calling Function', () => { + const { calls, parsed } = probe(jitlessScript()); + expect(parsed).toEqual({ name: 'a', count: 1 }); + expect(calls).toEqual([]); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5898326264..3496c6b076 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(@vitest/coverage-v8@4.1.10)(@vitest/ui@4.1.10)(happy-dom@20.11.2)(jsdom@29.1.1(@noble/hashes@2.3.0))(msw@2.15.0(@types/node@26.2.0)(typescript@6.0.3))(vite@8.3.5(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + zod: + specifier: ^4.6.5 + version: 4.6.7 apps/site: dependencies: