1414 * cloudUrl: string, // base URL of the upstream cloud ('' = same origin)
1515 * singleEnvironment: boolean,
1616 * defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname
17- * features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds },
17+ * features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... },
1818 * branding: { productName, productShortName }
1919 * }
2020 *
21- * ## Policy seam (ADR-0008 / open-mechanism-closed-intelligence )
21+ * ## Feature seam (open-core boundary — cloud ADR-0012 )
2222 *
23- * Which features a *plan* unlocks is distribution policy, not mechanism — it
24- * intentionally does NOT live in this open package. Hosts inject it via
25- * {@link RuntimeConfigPluginConfig.resolvePlanFeatures}: the cloud
26- * distribution passes its plan-entitlement rules there; a self-hosted or
27- * vanilla deployment omits it and gets static config-driven flags.
23+ * This open package owns the **mechanism**: serve a per-request `features`
24+ * map to the SPA. It does NOT own the **catalog or policy** — which feature
25+ * keys exist and which billing plan unlocks them is a distribution concern
26+ * and must never be enumerated here (that would bleed commercial/pricing
27+ * policy into the open framework).
28+ *
29+ * Hosts inject policy via {@link RuntimeConfigPluginConfig.resolveFeatures}: it
30+ * receives an opaque environment token (the cloud distribution passes the plan
31+ * string) and returns an **open-ended** map of feature flags that is merged
32+ * verbatim into `features`. The framework neither names nor knows those keys —
33+ * e.g. the cloud distribution returns `customDomain` / `sso` from its plan
34+ * entitlements without any framework change. A self-hosted / vanilla
35+ * deployment omits the hook and gets static, config-driven flags.
36+ *
37+ * `aiStudio` / `autoPublishAiBuilds` are the framework's own non-commercial
38+ * mechanism defaults (ADR-0005: AI authoring is an all-plan capability gated
39+ * by cost, not a paid tier), so they keep first-class config knobs here.
2840 */
2941
3042import type { Plugin , PluginContext } from '@objectstack/core' ;
3143import { resolveCloudUrl } from './cloud-url.js' ;
3244
33- /** Capability flags a host's plan policy can derive per request. */
34- export interface RuntimeConfigPlanFeatures {
45+ /**
46+ * Feature-flag overrides a host's distribution policy can derive per request.
47+ *
48+ * Open-ended on purpose: the framework's own flags (`aiStudio`,
49+ * `autoPublishAiBuilds`) are named, but a distribution may return **any**
50+ * additional boolean keys (commercial tiering, white-label toggles, …) and
51+ * they pass through to the SPA untouched. The framework does not enumerate
52+ * the distribution's feature catalog.
53+ */
54+ export interface RuntimeFeatureOverrides {
3555 /** Whether the SPA should surface AI-driven metadata authoring. */
3656 aiStudio ?: boolean ;
3757 /** Whether AI-built apps auto-publish in the author's own environment. */
3858 autoPublishAiBuilds ?: boolean ;
59+ /** Distribution-specific flags pass through opaquely (e.g. customDomain, sso). */
60+ [ feature : string ] : boolean | undefined ;
3961}
4062
63+ /** @deprecated billing-vocab name; use {@link RuntimeFeatureOverrides}. */
64+ export type RuntimeConfigPlanFeatures = RuntimeFeatureOverrides ;
65+
4166export interface RuntimeConfigPluginConfig {
4267 /**
4368 * Upstream cloud base URL. Falls back to `resolveCloudUrl()` (reads
@@ -71,13 +96,21 @@ export interface RuntimeConfigPluginConfig {
7196 /** Short product name (PWA shortName, compact spots). Defaults to productName. */
7297 productShortName ?: string ;
7398 /**
74- * Plan → feature policy hook. Called with `undefined` for the static
75- * default (no environment resolved / no plan known) and with the
76- * environment's plan string once hostname resolution provides one.
77- * Returned flags override the static config defaults; omitted keys keep
78- * them. When the hook itself is omitted, flags are purely config-driven.
99+ * Distribution feature-policy hook (open-core seam — cloud ADR-0012).
100+ * Called with `undefined` for the static default (no environment resolved
101+ * / no token known) and with an opaque environment token (the cloud
102+ * distribution passes the plan string) once hostname resolution provides
103+ * one. Returned flags are merged verbatim into `features` — arbitrary keys
104+ * pass through. Omitted keys keep the static config defaults; when the hook
105+ * itself is omitted, flags are purely config-driven. The framework does NOT
106+ * know the distribution's feature catalog or pricing.
107+ */
108+ resolveFeatures ?: ( token : string | undefined ) => RuntimeFeatureOverrides ;
109+ /**
110+ * @deprecated billing-vocab name; use {@link resolveFeatures}. Still
111+ * honoured when `resolveFeatures` is absent so existing hosts keep working.
79112 */
80- resolvePlanFeatures ?: ( plan : string | undefined ) => RuntimeConfigPlanFeatures ;
113+ resolvePlanFeatures ?: ( plan : string | undefined ) => RuntimeFeatureOverrides ;
81114}
82115
83116export class RuntimeConfigPlugin implements Plugin {
@@ -90,7 +123,7 @@ export class RuntimeConfigPlugin implements Plugin {
90123 private readonly singleEnvironment : boolean ;
91124 private readonly productName : string ;
92125 private readonly productShortName : string ;
93- private readonly resolvePlanFeatures ?: ( plan : string | undefined ) => RuntimeConfigPlanFeatures ;
126+ private readonly resolveFeatures ?: ( token : string | undefined ) => RuntimeFeatureOverrides ;
94127
95128 constructor ( config : RuntimeConfigPluginConfig = { } ) {
96129 // An explicit empty string means "stay on this origin" — bypass the
@@ -101,7 +134,8 @@ export class RuntimeConfigPlugin implements Plugin {
101134 this . installLocal = ! ! config . installLocal ;
102135 this . aiStudio = config . aiStudio !== false ; // default true (override-to-hide)
103136 this . singleEnvironment = ! ! config . singleEnvironment ;
104- this . resolvePlanFeatures = config . resolvePlanFeatures ;
137+ // Prefer the plan-agnostic seam; fall back to the deprecated alias.
138+ this . resolveFeatures = config . resolveFeatures ?? config . resolvePlanFeatures ;
105139 const envName = ( typeof process !== 'undefined' ? process . env ?. OS_PRODUCT_NAME : undefined ) ?. trim ( ) ;
106140 const envShort = ( typeof process !== 'undefined' ? process . env ?. OS_PRODUCT_SHORT_NAME : undefined ) ?. trim ( ) ;
107141 this . productName = ( config . productName ?? envName ?? 'ObjectOS' ) . trim ( ) || 'ObjectOS' ;
@@ -138,12 +172,20 @@ export class RuntimeConfigPlugin implements Plugin {
138172 let envRegistry : any = null ;
139173 try { envRegistry = ctx . getService ( 'env-registry' ) ; } catch { /* not mounted (file/CLI mode) */ }
140174
141- const featuresFor = ( plan : string | undefined , base : { aiStudio : boolean ; autoPublishAiBuilds : boolean } ) => {
142- const derived = this . resolvePlanFeatures ?.( plan ) ;
143- return {
144- aiStudio : derived ?. aiStudio ?? base . aiStudio ,
145- autoPublishAiBuilds : derived ?. autoPublishAiBuilds ?? base . autoPublishAiBuilds ,
146- } ;
175+ // Merge the distribution's feature overrides over the static base.
176+ // Arbitrary keys returned by the host pass through verbatim — the
177+ // framework does not enumerate the distribution's feature catalog.
178+ const featuresFor = (
179+ token : string | undefined ,
180+ base : Record < string , boolean > ,
181+ ) : Record < string , boolean > => {
182+ const derived = this . resolveFeatures ?.( token ) ;
183+ if ( ! derived ) return { ...base } ;
184+ const out : Record < string , boolean > = { ...base } ;
185+ for ( const [ k , v ] of Object . entries ( derived ) ) {
186+ if ( typeof v === 'boolean' ) out [ k ] = v ;
187+ }
188+ return out ;
147189 } ;
148190
149191 const handler = async ( c : any ) => {
@@ -153,7 +195,7 @@ export class RuntimeConfigPlugin implements Plugin {
153195 let defaultOrgId : string | undefined ;
154196 let resolvedSingleEnv = this . singleEnvironment ;
155197 // Static defaults: config-driven, optionally shaped by the
156- // host's policy hook for the "no plan known" case.
198+ // host's policy hook for the "no token known" case.
157199 let features = featuresFor ( undefined , { aiStudio : this . aiStudio , autoPublishAiBuilds : false } ) ;
158200 // EnvironmentDriverRegistry exposes `resolveByHostname()`;
159201 // older code paths used `resolveHostname()` on the client.
@@ -176,8 +218,8 @@ export class RuntimeConfigPlugin implements Plugin {
176218 // operator's POV: surface as single-environment
177219 // so the SPA hides multi-env affordances.
178220 resolvedSingleEnv = true ;
179- // Plan -derived features — only an explicit
180- // non-empty plan re-runs the policy hook.
221+ // Distribution -derived features — only an explicit
222+ // non-empty token re-runs the policy hook.
181223 if ( typeof resolved . plan === 'string' && resolved . plan . trim ( ) !== '' ) {
182224 features = featuresFor ( resolved . plan , features ) ;
183225 }
@@ -196,8 +238,8 @@ export class RuntimeConfigPlugin implements Plugin {
196238 features : {
197239 installLocal : this . installLocal ,
198240 marketplace : true ,
199- aiStudio : features . aiStudio ,
200- autoPublishAiBuilds : features . autoPublishAiBuilds ,
241+ // aiStudio + autoPublishAiBuilds + any distribution keys.
242+ ... features ,
201243 } ,
202244 branding : {
203245 productName : this . productName ,
@@ -208,13 +250,6 @@ export class RuntimeConfigPlugin implements Plugin {
208250 rawApp . get ( '/api/v1/runtime/config' , handler ) ;
209251 // Legacy alias for older Studio bundles.
210252 rawApp . get ( '/api/v1/studio/runtime-config' , handler ) ;
211- ctx . logger ?. info ?.( '[RuntimeConfigPlugin] mounted /api/v1/runtime/config' , {
212- cloudUrl : this . cloudUrl || '(empty)' ,
213- installLocal : this . installLocal ,
214- perHostEnvResolution : ! ! envRegistry ,
215- } ) ;
216253 } ) ;
217254 } ;
218-
219- destroy = async ( ) : Promise < void > => { } ;
220255}
0 commit comments