Skip to content

Commit 5fcebf6

Browse files
os-zhuangclaude
andcommitted
feat: kind:'react' defaults ON, disable server-side via OS_DISABLE_REACT_PAGES
Per platform-author decision the react tier is no longer enterprise-gated/ default-off. It runs author JS, so it stays behind a host capability — but that capability defaults ON (trusted, reviewed, draft-gated authors). A deployment that does not trust its authors disables it with one env var: - cli: console serving injects window.__OBJECTUI_CAPABILITIES_DISABLED__ = ['react-pages'] into the served HTML when OS_DISABLE_REACT_PAGES is set (read per request, no rebuild). - spec: kind describe reframed (default-on, OS_DISABLE_REACT_PAGES opt-out). - ADR-0081 + showcase comment reframed away from enterprise-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b2e6925 commit 5fcebf6

5 files changed

Lines changed: 46 additions & 20 deletions

File tree

.changeset/sdui-react-page-tier.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
---
22
"@objectstack/spec": minor
33
"@objectstack/lint": minor
4+
"@objectstack/cli": minor
45
---
56

67
ADR-0081: split the AI page-authoring surface into honest tiers.
78

89
- `PageSchema.kind` gains `'html'` and `'react'`. `'html'` is the constrained
910
parse-never-execute tier (the renamed `'jsx'`, kept as a deprecated alias);
10-
`'react'` is the trusted real-React tier (executed at render by
11-
`@object-ui/react-runtime`, gated behind a host capability, enterprise/private
12-
only). The completeness gate now requires `source` for all three.
11+
`'react'` is the real-React tier (executed at render by
12+
`@object-ui/react-runtime`). It runs author JS, so it is gated by a host
13+
capability that **defaults ON** (the platform trusts reviewed, draft-gated
14+
authors) and is disabled **server-side** via the `OS_DISABLE_REACT_PAGES`
15+
env var. The completeness gate now requires `source` for all three kinds.
16+
- `@objectstack/cli` console serving injects the disable global into the served
17+
HTML when `OS_DISABLE_REACT_PAGES` is set (read per request, no rebuild).
1318
- `validate-jsx-pages` lints `html`/`jsx` (constrained parse) and intentionally
1419
skips `react` (real JS, not constrained JSX).

docs/adr/0081-trusted-react-page-tier.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
**Premise**: ADR-0080 shipped `kind:'jsx'` — a *constrained* JSX dialect parsed (never executed) into the SDUI tree. In practice that name oversold it: authors expected "JSX" to mean *any* HTML and *any* JavaScript (`useState`, `.map`, `onClick`), and the constrained, no-execution model categorically cannot do that. Two distinct things were conflated under one name. This ADR splits them: it makes the safe constrained tier **live up to a narrower, honest name (`html`)**, and adds a separate, explicitly-trusted **`react`** tier for the full-power case — available only where the operator accepts running author code.
99

10-
> **Trigger**: testing the merged `kind:'jsx'` feature, the author found it "名不副实" (misnamed) — basic HTML tags didn't render and scripts were impossible — and recognized that the real-React capability belongs behind a switch, enabled only in a trusted private/enterprise deployment where "running unsandboxed author code in the main React app" is an acceptable, deliberate trust decision.
10+
> **Trigger**: testing the merged `kind:'jsx'` feature, the author found it "名不副实" (misnamed) — basic HTML tags didn't render and scripts were impossible — and recognized that the real-React capability belongs behind a switch. The platform trusts its (reviewed, draft-gated) page authors, so that switch defaults ON; a deployment that does not trust its authors turns it off server-side. "Running author code in the main React app" is an accepted, default trust decision, reversible per deployment — not an enterprise-only feature.
1111
1212
---
1313

@@ -16,7 +16,7 @@
1616
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'`.
1717
2. **[capability] The `html` tier now resolves the full safe native HTML tag set.** Previously only `div/span/table/code/label` + semantic sectioning tags were registered, so `<h1>/<p>/<a>/<ul>/<li>/<img>/<blockquote>…` failed as `unknown-component`. They are now registered as passthrough renderers — the tier is finally honest about "HTML".
1818
3. **[new tier] `kind:'react'` executes real React.** Its `source` is real JS/JSX (hooks, `.map`, event handlers, expressions), transpiled (Sucrase) and evaluated **in the main React tree — no sandbox** — by the new `@object-ui/react-runtime`. This is `parse = execute`: the categorical opposite of the `html` tier.
19-
4. **[security = trust, not sandbox] `react` is gated by a host capability, default OFF.** `CAP_REACT_PAGES` is flipped only by the *host process* (`enableCapability('react-pages')`), never by authored metadata. Open-source / multi-tenant builds ship it **off**; enterprise / private deployments — where authors are trusted and changes pass human review (draft-gating, ADR-0033) — opt in. A sandbox (iframe/worker) is the tool for *untrusted* execution; we deliberately choose **trust + review** instead, which keeps the author's code a first-class citizen of the app (shared React tree, real components, real data) at the cost of requiring the operator's explicit consent.
19+
4. **[security = trust, not sandbox] `react` is gated by a host capability that defaults ON.** `CAP_REACT_PAGES` is controlled only by the *host*, never by authored metadata. It defaults ON because ObjectStack pages are authored by trusted authors and pass human review (draft-gating, ADR-0033). A deployment that does NOT trust its authors disables it **server-side** — the ObjectStack runtime injects the disable global when `OS_DISABLE_REACT_PAGES` is set (one env var, no rebuild). A sandbox (iframe/worker) is the tool for *untrusted* execution; we deliberately choose **trust + review** instead, which keeps the author's code a first-class citizen of the app (shared React tree, real components, real data). This is NOT an enterprise-only feature.
2020
5. **[runtime] Vendored, not depended.** `@object-ui/react-runtime` inlines the ~150-LOC react-runner core (MIT) so we fully own the injected **scope/imports surface** (the actual capability ceiling) and can **lazy-load** it — the transpiler ships in a separate chunk fetched only when a `react` page renders *with the capability on*.
2121
6. **[scope] Inject data blocks + React; leave layout to HTML.** The runtime scope exposes `React`, the curated **public data blocks** (`<ObjectTable>`, `<ObjectForm>`, charts, metrics — each a prop-driven wrapper over `SchemaRenderer`), a `<Block type=…/>` escape hatch, and the page `data`. Layout/structure is plain HTML + Tailwind (React's strength) — we do **not** bridge React children into schema-children renderers.
2222
7. **[lint] `react` is not linted by the constrained parser.** `validate-jsx-pages` (ADR-0080's build gate) validates `html`/`jsx` only; running the constrained JSX parser over real React source would false-error on hooks and expressions. The `react` tier's safety boundary is the **capability + human review**, not a static gate.
@@ -31,7 +31,7 @@
3131
| Processing | **parsed**, never executed → SDUI tree | transpiled + **executed** in the main React tree |
3232
| JavaScript | none (static literals) | full |
3333
| Safety model | safe by construction (no execution) | **trust** (capability + review); no sandbox |
34-
| Where it runs | OSS default ON | enterprise/private only, capability default **OFF** |
34+
| Where it runs | OSS default ON | default ON; disable server-side via `OS_DISABLE_REACT_PAGES` |
3535
| Author-time gate | `os build` constrained-parse lint | none (real JS); error boundary at render |
3636
| Contract | registry `inputs` (ADR-0080) | injected scope = the ceiling |
3737

@@ -47,9 +47,10 @@ The dividing line is exactly ADR-0080 §2's "parse ≠ execute". `html` keeps th
4747
### 2. Make `html` honest
4848
Register the safe native HTML flow/inline set (`h1–h6, p, a, ul/ol/li, dl/dt/dd, blockquote, pre, strong/em/b/i/u/small/mark/sub/sup, figure/figcaption, img, hr, br, time, address, …`) as passthrough renderers that forward standard attributes and recurse children. Excluded: anything already registered, and anything that can execute or escape (`script/style/iframe/object/embed/link/meta/form/input`, plus `button` which is the component). Defense-in-depth even though this tier never executes JS: strip `on*` handlers and `dangerouslySetInnerHTML`, neutralize `javascript:`/`data:` URLs on `<a href>`. Unknown attributes are warnings (the page still renders), per the ADR-0080 validator.
4949

50-
### 3. Add `react` as a capability, not a default
51-
- **Gate**: `@object-ui/core` gains `enableCapability`/`disableCapability`/`isCapabilityEnabled` + `CAP_REACT_PAGES`. Default-closed. Host-only (a global pre-seed `globalThis.__OBJECTUI_CAPABILITIES__` is also honored for HTML-bootstrap hosts). It is structurally impossible to enable from authored metadata.
52-
- **Renderer**: PageRenderer routes `kind:'react'` to a renderer that — only when the capability is on — lazy-imports `@object-ui/react-runtime`, builds the scope, and renders via its error-boundaried `ReactRunner`. Capability off → a clear "this page requires the React-pages capability" notice, not a crash.
50+
### 3. Add `react` behind a host capability that defaults ON
51+
- **Gate**: `@object-ui/core` gains `enableCapability`/`disableCapability`/`isCapabilityEnabled` + `CAP_REACT_PAGES`. `react-pages` defaults ON. Host-only — it reads `globalThis.__OBJECTUI_CAPABILITIES_DISABLED__` (force off) and `__OBJECTUI_CAPABILITIES__` (force on); it is structurally impossible to flip from authored metadata.
52+
- **Server opt-out**: the ObjectStack console serving (framework `cli/utils/console.ts`) injects the disable global into the served HTML when `OS_DISABLE_REACT_PAGES` is set — one env var, read per request, no rebuild. This is the single server-side switch a deployment uses to turn the tier off.
53+
- **Renderer**: PageRenderer routes `kind:'react'` to a renderer that — when the capability is on — lazy-imports `@object-ui/react-runtime`, builds the scope, and renders via its error-boundaried `ReactRunner`. Capability off → a clear "React pages are disabled on this deployment" notice, not a crash.
5354

5455
### 4. Vendor the runtime; own the scope
5556
`@object-ui/react-runtime` inlines react-runner: `transform` (Sucrase `jsx`+`typescript`+`imports`, production), `generateElement` (eval with injected scope; `createRequire` resolves `require()` only against an explicit `imports` map — a control point), and `ReactRunner` (a component with `getDerivedStateFromError`). The capability ceiling is precisely the injected scope/imports — owning that file is why we vendor rather than depend.
@@ -62,4 +63,4 @@ Register the safe native HTML flow/inline set (`h1–h6, p, a, ul/ol/li, dl/dt/d
6263
- **Two safety stories, each clean.** `html` is safe by construction and stays the OSS/multi-tenant default. `react` is safe by *trust*, opt-in, and obviously so (you typed `enableCapability`).
6364
- **No build/deploy for either.** `html` compiles at save; `react` transpiles at render (lazy chunk). Neither needs a bundler in the author loop.
6465
- **AI fit.** AI writes both fluently. For `react`, correctness is bounded by the injected scope + human review (ADR-0033), not by hoping the model never writes a bad effect.
65-
- **Cost.** `react` runs author code in the main tree: a thrown render is caught by the error boundary, but an author can still write a slow/incorrect page. That is the accepted price of the trusted tier and the reason it is off by default. Revisit if a future need arises for *untrusted* rich pages — that would be a *different* ADR (sandboxed execution), not this one.
66+
- **Cost.** `react` runs author code in the main tree: a thrown render is caught by the error boundary, but an author can still write a slow/incorrect page. That is the accepted price of the trusted tier; a deployment that does not trust its authors flips `OS_DISABLE_REACT_PAGES`. Revisit if a future need arises for *untrusted* rich pages — that would be a *different* ADR (sandboxed execution), not this one.

examples/app-showcase/src/pages/crm-workbench.page.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ import { definePage } from '@objectstack/spec/ui';
2121
* blocks as PascalCase components (`ListView`, `ObjectForm`, `ObjectMetric`, …),
2222
* each a real registered renderer. Layout is plain HTML + Tailwind.
2323
*
24-
* NOTE: `kind:'react'` executes author code, so it is gated behind the host
25-
* capability `CAP_REACT_PAGES` (default OFF). A trusted/enterprise deployment
26-
* enables it from its host bootstrap — e.g. `enableCapability('react-pages')`
27-
* or `globalThis.__OBJECTUI_CAPABILITIES__ = ['react-pages']`. Until then this
28-
* page renders an "enterprise capability required" notice rather than executing.
24+
* NOTE: `kind:'react'` executes author code, so it is gated by the host
25+
* capability `CAP_REACT_PAGES`, which defaults ON (the platform trusts its
26+
* reviewed, draft-gated authors). A deployment that does not trust its authors
27+
* turns it off server-side by setting `OS_DISABLE_REACT_PAGES`, in which case
28+
* this page renders a "disabled on this deployment" notice instead of executing.
2929
*/
3030
export const CrmWorkbenchPage = definePage({
3131
name: 'showcase_crm_workbench',

packages/cli/src/utils/console.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,17 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
292292
return;
293293
}
294294

295+
// A deployment that does not trust its page authors disables the
296+
// `kind:'react'` tier (which executes author JS in the main React tree)
297+
// by setting `OS_DISABLE_REACT_PAGES`. We inject the disable global the
298+
// console's capability gate reads; the flag is ON by default otherwise.
299+
// Read per request (env can change without a rebuild — index.html is
300+
// re-read on every fallback hit too).
301+
const reactPagesDisabled = (): boolean => {
302+
const v = String(process.env.OS_DISABLE_REACT_PAGES ?? '').trim().toLowerCase();
303+
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
304+
};
305+
295306
const readIndexHtml = () => {
296307
const raw = fs.readFileSync(indexPath, 'utf-8');
297308
// Inject <base href="${CONSOLE_PATH}/"> so:
@@ -302,9 +313,17 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
302313
// build from being pinned to a specific mount.
303314
//
304315
// Idempotent — bails if the build already shipped a <base>.
305-
if (/<base\s/i.test(raw)) return raw;
306-
const baseTag = `<base href="${CONSOLE_PATH}/">`;
307-
return raw.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n ${baseTag}`);
316+
let html = raw;
317+
if (!/<base\s/i.test(html)) {
318+
const baseTag = `<base href="${CONSOLE_PATH}/">`;
319+
html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n ${baseTag}`);
320+
}
321+
if (reactPagesDisabled()) {
322+
const capTag =
323+
`<script>window.__OBJECTUI_CAPABILITIES_DISABLED__=(window.__OBJECTUI_CAPABILITIES_DISABLED__||[]).concat('react-pages');</script>`;
324+
html = html.replace(/<head(\s[^>]*)?>/i, (m) => `${m}\n ${capTag}`);
325+
}
326+
return html;
308327
};
309328

310329
// The Console is the default end-user surface — root `/` redirects

packages/spec/src/ui/page.zod.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,8 +363,9 @@ export const PageSchema = lazySchema(() => z.object({
363363
"Page override mode. full | slotted = structured authoring; " +
364364
"html = author-written constrained JSX/HTML+Tailwind compiled (parsed, never " +
365365
"executed) to the tree (ADR-0080; the legacy value 'jsx' is a deprecated alias); " +
366-
"react = TRUSTED real-React source executed at render by the runtime (ADR-0081), " +
367-
"gated to enterprise/private deployments.",
366+
"react = real-React source executed at render by the runtime (ADR-0081); it " +
367+
"runs author JS, so it is gated by a host capability that defaults ON and is " +
368+
"disabled server-side via OS_DISABLE_REACT_PAGES.",
368369
),
369370

370371
/**

0 commit comments

Comments
 (0)