@@ -48,6 +48,7 @@ import fs from 'fs';
4848import { createHash , randomBytes } from 'crypto' ;
4949import { createRequire } from 'module' ;
5050import { pathToFileURL , fileURLToPath } from 'url' ;
51+ import { readEnvWithDeprecation } from '@objectstack/types' ;
5152
5253// ─── Constants ──────────────────────────────────────────────────────
5354
@@ -733,7 +734,7 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
733734 * Console keeps its session token where any script on the page can read it,
734735 * so only the build's own files and the document's own inline scripts (the
735736 * early-branding, process-polyfill, crypto-shim and boot-splash scripts, and
736- * the server's react-pages disable global) may run: a script element that
737+ * the server's react-pages capability global) may run: a script element that
737738 * markup injected later carries no nonce, and an inline event handler never
738739 * matches one. No public CDN is listed. A CDN host in `script-src` admits
739740 * every file that CDN serves, so the Studio's Monaco editor, which loads its
@@ -743,7 +744,8 @@ const DEFAULT_MAP_STYLE_ORIGIN = 'https://demotiles.maplibre.org';
743744 * whose grammar engine is WebAssembly compiled in the page. It permits
744745 * compiling WebAssembly and nothing else; JavaScript `eval` stays refused.
745746 * - `'unsafe-eval'` only while the `kind:'react'` page tier is on (ADR-0081;
746- * on unless `OS_PAGE_REACT=off`). That runtime evaluates the author's
747+ * off unless `OS_PAGE_REACT_ENABLED=true`, `readReactPagesTier`). That
748+ * runtime evaluates the author's
747749 * transpiled page source with `Function`, which nothing but `'unsafe-eval'`
748750 * permits. With the tier off, the policy refuses it.
749751 * - `style-src 'self' 'unsafe-inline'`: the document's own `<style>` (the boot
@@ -793,6 +795,43 @@ function consoleContentSecurityPolicy(input: {
793795 return directives . map ( ( directive ) => directive . join ( ' ' ) ) . join ( '; ' ) ;
794796}
795797
798+ /** The variable a deployment sets to turn the `kind:'react'` page tier on. */
799+ export const REACT_PAGES_ENV = 'OS_PAGE_REACT_ENABLED' ;
800+ /** Its earlier name, read when the new one is unset (`readEnvWithDeprecation`). */
801+ const REACT_PAGES_LEGACY_ENV = 'OS_PAGE_REACT' ;
802+ const REACT_PAGES_ON = new Set ( [ 'true' , '1' , 'on' , 'yes' , 'enabled' ] ) ;
803+ const REACT_PAGES_OFF = new Set ( [ '' , 'false' , '0' , 'off' , 'no' , 'disabled' ] ) ;
804+
805+ /**
806+ * Whether this deployment serves the `kind:'react'` page tier (ADR-0081, as
807+ * amended: off by default). That tier runs the page author's code in every
808+ * viewer's Console with no sandbox, so it is on only when the operator says so
809+ * with `OS_PAGE_REACT_ENABLED=true` (or the legacy `OS_PAGE_REACT=on`). A value
810+ * that is neither an on nor an off spelling leaves the tier off and is reported
811+ * through `onUnrecognized`, so a typo never reads as a silent refusal.
812+ */
813+ export function readReactPagesTier ( onUnrecognized ?: ( name : string , value : string ) => void ) : boolean {
814+ const raw = readEnvWithDeprecation ( REACT_PAGES_ENV , REACT_PAGES_LEGACY_ENV ) ;
815+ const value = String ( raw ?? '' ) . trim ( ) . toLowerCase ( ) ;
816+ if ( REACT_PAGES_ON . has ( value ) ) return true ;
817+ if ( ! REACT_PAGES_OFF . has ( value ) ) {
818+ const name = process . env [ REACT_PAGES_ENV ] !== undefined ? REACT_PAGES_ENV : REACT_PAGES_LEGACY_ENV ;
819+ onUnrecognized ?.( name , String ( raw ) ) ;
820+ }
821+ return false ;
822+ }
823+
824+ /**
825+ * The inline script that tells the Console's capability gate (objectui
826+ * `@object-ui/core`, `isCapabilityEnabled`) which way this deployment set the
827+ * react-pages tier. The server writes it in both directions, so its answer
828+ * holds whichever default the Console build carries.
829+ */
830+ function reactPagesCapabilityScript ( reactPages : boolean ) : string {
831+ const list = reactPages ? '__OBJECTUI_CAPABILITIES__' : '__OBJECTUI_CAPABILITIES_DISABLED__' ;
832+ return `<script>window.${ list } =(window.${ list } ||[]).concat('react-pages');</script>` ;
833+ }
834+
796835/**
797836 * Every `<script>` start tag in `html` with `nonce` added, unless it already
798837 * carries one. Only start tags match: `</script>` and escaped text do not.
@@ -863,14 +902,19 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
863902 }
864903
865904 // The `kind:'react'` page tier (executes author JS in the main React
866- // tree) is ON by default. A deployment that does not trust its page
867- // authors turns it off with `OS_PAGE_REACT=off`; we then inject the
868- // disable global the console's capability gate reads. Read per request
869- // (env can change without a rebuild — index.html is re-read on every
870- // fallback hit too).
871- const reactPagesDisabled = ( ) : boolean => {
872- const v = String ( process . env . OS_PAGE_REACT ?? '' ) . trim ( ) . toLowerCase ( ) ;
873- return v === 'off' || v === '0' || v === 'false' || v === 'no' || v === 'disabled' ;
905+ // tree) is OFF unless the operator turns it on (`readReactPagesTier`);
906+ // either way the document carries the global the console's capability
907+ // gate reads. Read per request (env can change without a rebuild —
908+ // index.html is re-read on every fallback hit too).
909+ let reportedUnrecognizedReactPages = false ;
910+ const reportUnrecognizedReactPages = ( name : string , value : string ) => {
911+ if ( reportedUnrecognizedReactPages ) return ;
912+ reportedUnrecognizedReactPages = true ;
913+ ctx . logger ?. warn ?.(
914+ `Console static: ${ name } =${ JSON . stringify ( value ) } is not an on or off value, so kind:'react' pages `
915+ + `stay off and render a notice instead of running. Set ${ REACT_PAGES_ENV } =true to turn them on, or `
916+ + 'unset it to keep them off. Reported once.' ,
917+ ) ;
874918 } ;
875919
876920 // 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?:
904948 const baseTag = `<base href="${ CONSOLE_PATH } /">` ;
905949 html = html . replace ( / < h e a d ( \s [ ^ > ] * ) ? > / i, ( m ) => `${ m } \n ${ baseTag } ` ) ;
906950 }
907- if ( ! reactPages ) {
908- const capTag =
909- `<script>window.__OBJECTUI_CAPABILITIES_DISABLED__=(window.__OBJECTUI_CAPABILITIES_DISABLED__||[]).concat('react-pages');</script>` ;
910- html = html . replace ( / < h e a d ( \s [ ^ > ] * ) ? > / i, ( m ) => `${ m } \n ${ capTag } ` ) ;
911- }
951+ const capTag = reactPagesCapabilityScript ( reactPages ) ;
952+ html = html . replace ( / < h e a d ( \s [ ^ > ] * ) ? > / i, ( m ) => `${ m } \n ${ capTag } ` ) ;
912953 // Stamped while the document holds only the build's markup and the
913954 // server's own tags: nothing read at request time is written yet, so
914955 // no runtime value can ever carry the nonce.
@@ -917,9 +958,9 @@ export function createConsoleStaticPlugin(distPath: string, options?: { isDev?:
917958
918959 const serveDocument = async ( c : any , reqPath : string ) : Promise < Response > => {
919960 const nonce = randomBytes ( 16 ) . toString ( 'base64' ) ;
920- // One read per document, so the injected disable global and the
961+ // One read per document, so the injected capability global and the
921962 // policy's `'unsafe-eval'` always agree.
922- const reactPages = ! reactPagesDisabled ( ) ;
963+ const reactPages = readReactPagesTier ( reportUnrecognizedReactPages ) ;
923964 const html = readIndexHtml ( reactPages , nonce ) ;
924965 const shell = await readShellConfig ( app , c , reportShellConfigReadFailure ) ;
925966 const policy = consoleContentSecurityPolicy ( {
0 commit comments