Skip to content

Commit 08a11f7

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(cloud-connection): open-ended, plan-agnostic runtime feature seam (cloud ADR-0012) (#1847)
The RuntimeConfigPlugin owns the mechanism (serve a per-request `features` map to the SPA) but must not own the catalog or pricing policy. Previously the plugin enumerated a closed feature set (`aiStudio`/`autoPublishAiBuilds`) and used billing vocabulary (`resolvePlanFeatures` / `RuntimeConfigPlanFeatures`), so a distribution couldn't add a commercial feature (custom domain, SSO, …) without editing the open framework. Now the feature map is open-ended: the policy hook's returned flags are merged verbatim into `features`, so a host (e.g. the cloud control plane) injects arbitrary keys without any framework change. Renames the seam plan-agnostic: `resolveFeatures` / `RuntimeFeatureOverrides`, with `resolvePlanFeatures` / `RuntimeConfigPlanFeatures` kept as deprecated, still-honoured aliases. Adds runtime-config-plugin.test.ts covering base flags, arbitrary-key passthrough, the deprecated alias, and the static default. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5be7102 commit 08a11f7

4 files changed

Lines changed: 160 additions & 36 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@objectstack/cloud-connection": minor
3+
---
4+
5+
RuntimeConfigPlugin: make the per-request `features` seam open-ended and plan-agnostic (open-core boundary, cloud ADR-0012).
6+
7+
The framework now transports an opaque feature map: a host's policy hook may return ANY boolean feature keys and they pass through to the SPA verbatim — the framework no longer enumerates a distribution's commercial feature catalog. Adds `resolveFeatures` (plan-agnostic) and `RuntimeFeatureOverrides`; deprecates `resolvePlanFeatures` / `RuntimeConfigPlanFeatures` (still honoured for backward compatibility).

packages/cloud-connection/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export type { InstalledManifestEntry } from './local-manifest-source.js';
4343
export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js';
4444
export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js';
4545
export { RuntimeConfigPlugin } from './runtime-config-plugin.js';
46-
export type { RuntimeConfigPluginConfig, RuntimeConfigPlanFeatures } from './runtime-config-plugin.js';
46+
export type { RuntimeConfigPluginConfig, RuntimeFeatureOverrides, RuntimeConfigPlanFeatures } from './runtime-config-plugin.js';
4747
// ADR-0008 consumption side — the self-hosted credential ledger (bind
4848
// persists the oscc_ bearer here; forwards present it to the control plane).
4949
export { ConnectionCredentialStore, DEFAULT_CONNECTION_CREDENTIAL_PATH } from './connection-credential-store.js';
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* RuntimeConfigPlugin feature seam (open-core boundary, cloud ADR-0012).
5+
*
6+
* The framework owns the mechanism (serve a per-request `features` map) but not
7+
* the catalog: a host's `resolveFeatures` hook may return ANY boolean keys and
8+
* they pass through verbatim. The deprecated `resolvePlanFeatures` alias still
9+
* works.
10+
*/
11+
12+
import { describe, it, expect } from 'vitest';
13+
import { RuntimeConfigPlugin, type RuntimeConfigPluginConfig } from './runtime-config-plugin.js';
14+
15+
async function getConfig(opts: {
16+
pluginConfig?: RuntimeConfigPluginConfig;
17+
resolveByHostname?: (host: string) => Promise<any>;
18+
host?: string;
19+
}): Promise<any> {
20+
let handler: ((c: any) => Promise<any>) | undefined;
21+
const rawApp = { get(path: string, h: (c: any) => Promise<any>) { if (path === '/api/v1/runtime/config') handler = h; } };
22+
const services: Record<string, any> = { 'http-server': { getRawApp: () => rawApp } };
23+
if (opts.resolveByHostname) services['env-registry'] = { resolveByHostname: opts.resolveByHostname };
24+
const ctx: any = {
25+
logger: { info() {}, warn() {} },
26+
getService: (n: string) => { const s = services[n]; if (!s) throw new Error(`no ${n}`); return s; },
27+
hooks: [] as Array<() => Promise<void>>,
28+
hook(_e: string, cb: () => Promise<void>) { this.hooks.push(cb); },
29+
};
30+
const plugin = new RuntimeConfigPlugin(opts.pluginConfig ?? {});
31+
await plugin.start(ctx);
32+
for (const cb of ctx.hooks) await cb();
33+
if (!handler) throw new Error('handler not mounted');
34+
return handler({ req: { header: (n: string) => (n.toLowerCase() === 'host' ? (opts.host ?? '') : undefined) }, json: (b: any) => b });
35+
}
36+
37+
describe('RuntimeConfigPlugin feature seam', () => {
38+
it('always ships the base mechanism flags', async () => {
39+
const body = await getConfig({});
40+
expect(body.features.installLocal).toBe(false);
41+
expect(body.features.marketplace).toBe(true);
42+
expect(body.features.aiStudio).toBe(true);
43+
expect(body.features.autoPublishAiBuilds).toBe(false);
44+
});
45+
46+
it('passes ARBITRARY distribution keys through verbatim (open-ended)', async () => {
47+
const body = await getConfig({
48+
pluginConfig: {
49+
controlPlaneUrl: '',
50+
resolveFeatures: (token) => token === 'team'
51+
? { customDomain: true, sso: false, aiStudio: true }
52+
: { customDomain: false, sso: false },
53+
},
54+
resolveByHostname: async () => ({ environmentId: 'e1', organizationId: 'o1', plan: 'team' }),
55+
host: 'tenant.example.com',
56+
});
57+
// Framework never names customDomain/sso — they still reach the SPA.
58+
expect(body.features.customDomain).toBe(true);
59+
expect(body.features.sso).toBe(false);
60+
expect(body.features.aiStudio).toBe(true);
61+
// base flags survive the merge
62+
expect(body.features.marketplace).toBe(true);
63+
});
64+
65+
it('honours the deprecated resolvePlanFeatures alias', async () => {
66+
const body = await getConfig({
67+
pluginConfig: {
68+
controlPlaneUrl: '',
69+
resolvePlanFeatures: () => ({ aiStudio: false, legacyFlag: true }),
70+
},
71+
resolveByHostname: async () => ({ environmentId: 'e1', plan: 'free' }),
72+
host: 'tenant.example.com',
73+
});
74+
expect(body.features.aiStudio).toBe(false);
75+
expect(body.features.legacyFlag).toBe(true);
76+
});
77+
78+
it('static default (no env resolved) is config-driven', async () => {
79+
const body = await getConfig({ pluginConfig: { controlPlaneUrl: '', aiStudio: false } });
80+
expect(body.features.aiStudio).toBe(false);
81+
});
82+
});

packages/cloud-connection/src/runtime-config-plugin.ts

Lines changed: 70 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,30 +14,55 @@
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

3042
import type { Plugin, PluginContext } from '@objectstack/core';
3143
import { 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+
4166
export 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

83116
export 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

Comments
 (0)