4646 * Escape hatch: `// client-boundary-allow: <reason>` on the line directly above
4747 * the import (reason required). Use only for a genuinely browser-only code path.
4848 *
49- * ## Deployment-shape flags in client surfaces
49+ * ## Deployment-shape flags in client code
5050 *
51- * A `'use client'` module under the workspace, organization, or standalone settings
52- * surfaces must not import `isHosted`, `isBillingEnabled`, `isChatEnabled`, or the
53- * enterprise feature flags from `env-flags`: those freeze at module init from the root
51+ * Client code — `stores/`, `hooks/`, `blocks/`, and any `'use client'` module or hook
52+ * under the workspace, organization, or standalone settings surfaces — must not read
53+ * `isHosted`, `isBillingEnabled`, `isChatEnabled`, or the enterprise feature flags from
54+ * `env-flags`, by named or namespace import: those freeze at module init from the root
5455 * layout's `NEXT_PUBLIC_*` transport, which a recovered 404 or `global-error` tab never
5556 * ran, so Sim Cloud renders as self-hosted. Read them through `useDeploymentShape()` /
56- * `getDeploymentShape()` from `@/lib/core/config/deployment-shape` (CLAUDE.md).
57+ * `getDeploymentShape()` from `@/lib/core/config/deployment-shape` (CLAUDE.md). The flag
58+ * list is read from that module's own `env-flags` import, so it cannot drift.
5759 *
5860 * Usage:
5961 * bun run scripts/check-client-boundary-imports.ts # report
@@ -76,32 +78,52 @@ function isServerSurface(rel: string): boolean {
7678 return false
7779}
7880
79- /** `env-flags` exports that `@/lib/core/config/deployment-shape` re-serves to the browser. */
80- const DEPLOYMENT_SHAPE_FLAGS = new Set ( [
81- 'isHosted' ,
82- 'isBillingEnabled' ,
83- 'isChatEnabled' ,
84- 'isAzureConfigured' ,
85- 'isCohereConfigured' ,
86- 'isAccessControlEnabled' ,
87- 'isAuditLogsEnabled' ,
88- 'isCustomBlocksEnabled' ,
89- 'isDataDrainsEnabled' ,
90- 'isDataRetentionEnabled' ,
91- 'isInboxEnabled' ,
92- 'isSandboxesEnabled' ,
93- 'isScimEnabled' ,
94- 'isSessionPoliciesEnabled' ,
95- 'isSsoEnabled' ,
96- 'isUsageMonitoringEnabled' ,
97- 'isWhitelabelingEnabled' ,
81+ const ENV_FLAGS = '@/lib/core/config/env-flags'
82+ const DEPLOYMENT_SHAPE_MODULE = path . join ( APP_DIR , 'lib/core/config/deployment-shape.ts' )
83+
84+ /**
85+ * Known deployment-shape violations awaiting a product decision (paths relative to apps/sim).
86+ * Each entry names why it cannot simply move to the reader.
87+ */
88+ const DEPLOYMENT_SHAPE_ALLOWLIST = new Set ( [
89+ // Picks the panel's default tab from `isChatEnabled` at module init, before any surface
90+ // seeds the shape; moving it to the reader changes the first-render tab, a product call.
91+ 'stores/panel/store.ts' ,
9892] )
9993
10094/** Surfaces whose shell seeds the server-resolved deployment shape (paths relative to apps/sim). */
10195function isDeploymentShapeSurface ( rel : string ) : boolean {
10296 return / ^ (?: a p p \/ (?: w o r k s p a c e | o | a c c o u n t | s e l f h o s t \/ s e t t i n g s ) | c o m p o n e n t s \/ s e t t i n g s | e e ) \/ / . test ( rel )
10397}
10498
99+ /**
100+ * Client code bound by the deployment-shape rule: client-only directories by path, and
101+ * `'use client'` modules or hooks (a `hooks/` folder or `use-*` file) inside a surface.
102+ */
103+ async function isDeploymentShapeClient ( rel : string , absFile : string ) : Promise < boolean > {
104+ if ( / \. (?: t e s t | s p e c | i n t e g r a t i o n ) \. t s x ? $ / . test ( rel ) ) return false
105+ if ( / ^ (?: s t o r e s | h o o k s | b l o c k s ) \/ / . test ( rel ) ) return true
106+ if ( ! isDeploymentShapeSurface ( rel ) ) return false
107+ return / (?: ^ | \/ ) (?: h o o k s \/ | u s e - [ ^ / ] + \. t s x ? $ ) / . test ( rel ) || isUseClientModule ( absFile )
108+ }
109+
110+ /**
111+ * Exports of `env-flags` an import clause reads: its named members, or, for a namespace
112+ * import (`* as flags`), every `flags.<name>` access in the file.
113+ */
114+ function envFlagReads ( clause : string , content : string ) : string [ ] {
115+ const namespace = / ^ \* \s + a s \s + ( \w + ) $ / . exec ( clause . trim ( ) ) ?. [ 1 ]
116+ if ( namespace ) {
117+ return [ ...content . matchAll ( new RegExp ( `\\b${ namespace } \\.(\\w+)` , 'g' ) ) ] . map ( ( m ) => m [ 1 ] )
118+ }
119+ if ( ! clause . includes ( '{' ) ) return [ ]
120+ return clause
121+ . slice ( clause . indexOf ( '{' ) + 1 , clause . lastIndexOf ( '}' ) )
122+ . split ( ',' )
123+ . map ( ( member ) => member . trim ( ) . split ( / \s + a s \s + / ) [ 0 ] )
124+ . filter ( Boolean )
125+ }
126+
105127const SOURCE_EXTENSIONS = [ '.ts' , '.tsx' ]
106128const ALLOW_DIRECTIVE = 'client-boundary-allow'
107129const sourceCache = new Map < string , string > ( )
@@ -231,9 +253,11 @@ function parseImports(content: string): ImportInfo[] {
231253 const imports : ImportInfo [ ] = [ ]
232254 const re = / ^ \s * i m p o r t \s + ( [ \s \S ] * ?) \s + f r o m \s + [ ' " ] ( [ ^ ' " ] + ) [ ' " ] /
233255 for ( let i = 0 ; i < lines . length ; i ++ ) {
234- if ( ! / ^ \s * i m p o r t \b / . test ( lines [ i ] ) || ! lines [ i ] . includes ( 'import' ) ) continue
235- // Join up to 12 following lines to capture multi-line import clauses.
236- const block = lines . slice ( i , i + 12 ) . join ( '\n' )
256+ if ( ! / ^ \s * i m p o r t \b / . test ( lines [ i ] ) || / ^ \s * i m p o r t \s * [ ' " ( ] / . test ( lines [ i ] ) ) continue
257+ // Join through the `from` line so a long multi-line clause is captured whole.
258+ let end = i
259+ while ( end < lines . length - 1 && ! / \b f r o m \s + [ ' " ] / . test ( lines [ end ] ) ) end ++
260+ const block = lines . slice ( i , end + 1 ) . join ( '\n' )
237261 const match = re . exec ( block )
238262 if ( ! match ) continue
239263 imports . push ( { line : i + 1 , clause : match [ 1 ] , specifier : match [ 2 ] } )
@@ -345,30 +369,40 @@ async function main() {
345369 }
346370 }
347371
372+ const shapeFlags = new Set (
373+ parseImports ( await readSource ( DEPLOYMENT_SHAPE_MODULE ) )
374+ . filter ( ( imp ) => imp . specifier === ENV_FLAGS )
375+ . flatMap ( ( imp ) => envFlagReads ( imp . clause , '' ) )
376+ )
377+ if ( shapeFlags . size === 0 ) {
378+ throw new Error (
379+ `${ DEPLOYMENT_SHAPE_MODULE } no longer imports from env-flags; update this check`
380+ )
381+ }
348382 const shapeViolations : Array < Violation & { flags : string [ ] } > = [ ]
349383 for ( const absFile of allFiles ) {
350384 if ( ! absFile . startsWith ( `${ APP_DIR } ${ path . sep } ` ) ) continue
351385 const rel = path . relative ( APP_DIR , absFile )
352- if ( ! isDeploymentShapeSurface ( rel ) || ! ( await isUseClientModule ( absFile ) ) ) continue
386+ if ( DEPLOYMENT_SHAPE_ALLOWLIST . has ( rel ) || ! ( await isDeploymentShapeClient ( rel , absFile ) ) ) {
387+ continue
388+ }
353389 const content = await readSource ( absFile )
354390 for ( const imp of parseImports ( content ) ) {
355- if ( imp . specifier !== '@/lib/core/config/env-flags' || ! importsAValue ( imp . clause ) ) continue
356- const braced = imp . clause . slice ( imp . clause . indexOf ( '{' ) + 1 , imp . clause . lastIndexOf ( '}' ) )
357- const flags = braced
358- . split ( ',' )
359- . map ( ( member ) => member . trim ( ) . split ( / \s + a s \s + / ) [ 0 ] )
360- . filter ( ( name ) => DEPLOYMENT_SHAPE_FLAGS . has ( name ) )
391+ if ( imp . specifier !== ENV_FLAGS || ! importsAValue ( imp . clause ) ) continue
392+ const flags = [ ...new Set ( envFlagReads ( imp . clause , content ) ) ] . filter ( ( name ) =>
393+ shapeFlags . has ( name )
394+ )
361395 if ( flags . length === 0 || hasAllowDirective ( content , imp . line ) ) continue
362396 shapeViolations . push ( { file : rel , line : imp . line , specifier : imp . specifier , flags } )
363397 }
364398 }
365399
366400 if ( shapeViolations . length === 0 ) {
367- console . log ( '✓ No client settings surface reads deployment-shape flags from env-flags.' )
401+ console . log ( '✓ No client code reads deployment-shape flags from env-flags.' )
368402 } else {
369403 failed = true
370404 console . error (
371- `\n✗ ${ shapeViolations . length } 'use client' module(s) in a workspace/organization/settings surface import deployment-shape flags from env-flags.\n` +
405+ `\n✗ ${ shapeViolations . length } client module(s) read deployment-shape flags from env-flags.\n` +
372406 ` Those constants freeze from the root layout's NEXT_PUBLIC_* transport, so a recovered 404/global-error tab renders Sim Cloud as self-hosted.\n` +
373407 ` Read them via useDeploymentShape() (components) or getDeploymentShape() (helpers) from @/lib/core/config/deployment-shape.\n`
374408 )
0 commit comments