Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/runtime-features-open-ended.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@objectstack/cloud-connection": minor
---

RuntimeConfigPlugin: make the per-request `features` seam open-ended and plan-agnostic (open-core boundary, cloud ADR-0012).

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).
2 changes: 1 addition & 1 deletion packages/cloud-connection/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export type { InstalledManifestEntry } from './local-manifest-source.js';
export { CloudConnectionPlugin, createCloudConnectionPlugin } from './cloud-connection-plugin.js';
export type { CloudConnectionPluginConfig } from './cloud-connection-plugin.js';
export { RuntimeConfigPlugin } from './runtime-config-plugin.js';
export type { RuntimeConfigPluginConfig, RuntimeConfigPlanFeatures } from './runtime-config-plugin.js';
export type { RuntimeConfigPluginConfig, RuntimeFeatureOverrides, RuntimeConfigPlanFeatures } from './runtime-config-plugin.js';
// ADR-0008 consumption side — the self-hosted credential ledger (bind
// persists the oscc_ bearer here; forwards present it to the control plane).
export { ConnectionCredentialStore, DEFAULT_CONNECTION_CREDENTIAL_PATH } from './connection-credential-store.js';
Expand Down
82 changes: 82 additions & 0 deletions packages/cloud-connection/src/runtime-config-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* RuntimeConfigPlugin feature seam (open-core boundary, cloud ADR-0012).
*
* The framework owns the mechanism (serve a per-request `features` map) but not
* the catalog: a host's `resolveFeatures` hook may return ANY boolean keys and
* they pass through verbatim. The deprecated `resolvePlanFeatures` alias still
* works.
*/

import { describe, it, expect } from 'vitest';
import { RuntimeConfigPlugin, type RuntimeConfigPluginConfig } from './runtime-config-plugin.js';

async function getConfig(opts: {
pluginConfig?: RuntimeConfigPluginConfig;
resolveByHostname?: (host: string) => Promise<any>;
host?: string;
}): Promise<any> {
let handler: ((c: any) => Promise<any>) | undefined;
const rawApp = { get(path: string, h: (c: any) => Promise<any>) { if (path === '/api/v1/runtime/config') handler = h; } };
const services: Record<string, any> = { 'http-server': { getRawApp: () => rawApp } };
if (opts.resolveByHostname) services['env-registry'] = { resolveByHostname: opts.resolveByHostname };
const ctx: any = {
logger: { info() {}, warn() {} },
getService: (n: string) => { const s = services[n]; if (!s) throw new Error(`no ${n}`); return s; },
hooks: [] as Array<() => Promise<void>>,
hook(_e: string, cb: () => Promise<void>) { this.hooks.push(cb); },
};
const plugin = new RuntimeConfigPlugin(opts.pluginConfig ?? {});
await plugin.start(ctx);
for (const cb of ctx.hooks) await cb();
if (!handler) throw new Error('handler not mounted');
return handler({ req: { header: (n: string) => (n.toLowerCase() === 'host' ? (opts.host ?? '') : undefined) }, json: (b: any) => b });
}

describe('RuntimeConfigPlugin feature seam', () => {
it('always ships the base mechanism flags', async () => {
const body = await getConfig({});
expect(body.features.installLocal).toBe(false);
expect(body.features.marketplace).toBe(true);
expect(body.features.aiStudio).toBe(true);
expect(body.features.autoPublishAiBuilds).toBe(false);
});

it('passes ARBITRARY distribution keys through verbatim (open-ended)', async () => {
const body = await getConfig({
pluginConfig: {
controlPlaneUrl: '',
resolveFeatures: (token) => token === 'team'
? { customDomain: true, sso: false, aiStudio: true }
: { customDomain: false, sso: false },
},
resolveByHostname: async () => ({ environmentId: 'e1', organizationId: 'o1', plan: 'team' }),
host: 'tenant.example.com',
});
// Framework never names customDomain/sso — they still reach the SPA.
expect(body.features.customDomain).toBe(true);
expect(body.features.sso).toBe(false);
expect(body.features.aiStudio).toBe(true);
// base flags survive the merge
expect(body.features.marketplace).toBe(true);
});

it('honours the deprecated resolvePlanFeatures alias', async () => {
const body = await getConfig({
pluginConfig: {
controlPlaneUrl: '',
resolvePlanFeatures: () => ({ aiStudio: false, legacyFlag: true }),
},
resolveByHostname: async () => ({ environmentId: 'e1', plan: 'free' }),
host: 'tenant.example.com',
});
expect(body.features.aiStudio).toBe(false);
expect(body.features.legacyFlag).toBe(true);
});

it('static default (no env resolved) is config-driven', async () => {
const body = await getConfig({ pluginConfig: { controlPlaneUrl: '', aiStudio: false } });
expect(body.features.aiStudio).toBe(false);
});
});
105 changes: 70 additions & 35 deletions packages/cloud-connection/src/runtime-config-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,55 @@
* cloudUrl: string, // base URL of the upstream cloud ('' = same origin)
* singleEnvironment: boolean,
* defaultOrgId?, defaultEnvironmentId?, // multi-tenant, per-hostname
* features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds },
* features: { installLocal, marketplace, aiStudio, autoPublishAiBuilds, ... },
* branding: { productName, productShortName }
* }
*
* ## Policy seam (ADR-0008 / open-mechanism-closed-intelligence)
* ## Feature seam (open-core boundary — cloud ADR-0012)
*
* Which features a *plan* unlocks is distribution policy, not mechanism — it
* intentionally does NOT live in this open package. Hosts inject it via
* {@link RuntimeConfigPluginConfig.resolvePlanFeatures}: the cloud
* distribution passes its plan-entitlement rules there; a self-hosted or
* vanilla deployment omits it and gets static config-driven flags.
* This open package owns the **mechanism**: serve a per-request `features`
* map to the SPA. It does NOT own the **catalog or policy** — which feature
* keys exist and which billing plan unlocks them is a distribution concern
* and must never be enumerated here (that would bleed commercial/pricing
* policy into the open framework).
*
* Hosts inject policy via {@link RuntimeConfigPluginConfig.resolveFeatures}: it
* receives an opaque environment token (the cloud distribution passes the plan
* string) and returns an **open-ended** map of feature flags that is merged
* verbatim into `features`. The framework neither names nor knows those keys —
* e.g. the cloud distribution returns `customDomain` / `sso` from its plan
* entitlements without any framework change. A self-hosted / vanilla
* deployment omits the hook and gets static, config-driven flags.
*
* `aiStudio` / `autoPublishAiBuilds` are the framework's own non-commercial
* mechanism defaults (ADR-0005: AI authoring is an all-plan capability gated
* by cost, not a paid tier), so they keep first-class config knobs here.
*/

import type { Plugin, PluginContext } from '@objectstack/core';
import { resolveCloudUrl } from './cloud-url.js';

/** Capability flags a host's plan policy can derive per request. */
export interface RuntimeConfigPlanFeatures {
/**
* Feature-flag overrides a host's distribution policy can derive per request.
*
* Open-ended on purpose: the framework's own flags (`aiStudio`,
* `autoPublishAiBuilds`) are named, but a distribution may return **any**
* additional boolean keys (commercial tiering, white-label toggles, …) and
* they pass through to the SPA untouched. The framework does not enumerate
* the distribution's feature catalog.
*/
export interface RuntimeFeatureOverrides {
/** Whether the SPA should surface AI-driven metadata authoring. */
aiStudio?: boolean;
/** Whether AI-built apps auto-publish in the author's own environment. */
autoPublishAiBuilds?: boolean;
/** Distribution-specific flags pass through opaquely (e.g. customDomain, sso). */
[feature: string]: boolean | undefined;
}

/** @deprecated billing-vocab name; use {@link RuntimeFeatureOverrides}. */
export type RuntimeConfigPlanFeatures = RuntimeFeatureOverrides;

export interface RuntimeConfigPluginConfig {
/**
* Upstream cloud base URL. Falls back to `resolveCloudUrl()` (reads
Expand Down Expand Up @@ -71,13 +96,21 @@ export interface RuntimeConfigPluginConfig {
/** Short product name (PWA shortName, compact spots). Defaults to productName. */
productShortName?: string;
/**
* Plan → feature policy hook. Called with `undefined` for the static
* default (no environment resolved / no plan known) and with the
* environment's plan string once hostname resolution provides one.
* Returned flags override the static config defaults; omitted keys keep
* them. When the hook itself is omitted, flags are purely config-driven.
* Distribution feature-policy hook (open-core seam — cloud ADR-0012).
* Called with `undefined` for the static default (no environment resolved
* / no token known) and with an opaque environment token (the cloud
* distribution passes the plan string) once hostname resolution provides
* one. Returned flags are merged verbatim into `features` — arbitrary keys
* pass through. Omitted keys keep the static config defaults; when the hook
* itself is omitted, flags are purely config-driven. The framework does NOT
* know the distribution's feature catalog or pricing.
*/
resolveFeatures?: (token: string | undefined) => RuntimeFeatureOverrides;
/**
* @deprecated billing-vocab name; use {@link resolveFeatures}. Still
* honoured when `resolveFeatures` is absent so existing hosts keep working.
*/
resolvePlanFeatures?: (plan: string | undefined) => RuntimeConfigPlanFeatures;
resolvePlanFeatures?: (plan: string | undefined) => RuntimeFeatureOverrides;
}

export class RuntimeConfigPlugin implements Plugin {
Expand All @@ -90,7 +123,7 @@ export class RuntimeConfigPlugin implements Plugin {
private readonly singleEnvironment: boolean;
private readonly productName: string;
private readonly productShortName: string;
private readonly resolvePlanFeatures?: (plan: string | undefined) => RuntimeConfigPlanFeatures;
private readonly resolveFeatures?: (token: string | undefined) => RuntimeFeatureOverrides;

constructor(config: RuntimeConfigPluginConfig = {}) {
// An explicit empty string means "stay on this origin" — bypass the
Expand All @@ -101,7 +134,8 @@ export class RuntimeConfigPlugin implements Plugin {
this.installLocal = !!config.installLocal;
this.aiStudio = config.aiStudio !== false; // default true (override-to-hide)
this.singleEnvironment = !!config.singleEnvironment;
this.resolvePlanFeatures = config.resolvePlanFeatures;
// Prefer the plan-agnostic seam; fall back to the deprecated alias.
this.resolveFeatures = config.resolveFeatures ?? config.resolvePlanFeatures;
const envName = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_NAME : undefined)?.trim();
const envShort = (typeof process !== 'undefined' ? process.env?.OS_PRODUCT_SHORT_NAME : undefined)?.trim();
this.productName = (config.productName ?? envName ?? 'ObjectOS').trim() || 'ObjectOS';
Expand Down Expand Up @@ -138,12 +172,20 @@ export class RuntimeConfigPlugin implements Plugin {
let envRegistry: any = null;
try { envRegistry = ctx.getService('env-registry'); } catch { /* not mounted (file/CLI mode) */ }

const featuresFor = (plan: string | undefined, base: { aiStudio: boolean; autoPublishAiBuilds: boolean }) => {
const derived = this.resolvePlanFeatures?.(plan);
return {
aiStudio: derived?.aiStudio ?? base.aiStudio,
autoPublishAiBuilds: derived?.autoPublishAiBuilds ?? base.autoPublishAiBuilds,
};
// Merge the distribution's feature overrides over the static base.
// Arbitrary keys returned by the host pass through verbatim — the
// framework does not enumerate the distribution's feature catalog.
const featuresFor = (
token: string | undefined,
base: Record<string, boolean>,
): Record<string, boolean> => {
const derived = this.resolveFeatures?.(token);
if (!derived) return { ...base };
const out: Record<string, boolean> = { ...base };
for (const [k, v] of Object.entries(derived)) {
if (typeof v === 'boolean') out[k] = v;
}
return out;
};

const handler = async (c: any) => {
Expand All @@ -153,7 +195,7 @@ export class RuntimeConfigPlugin implements Plugin {
let defaultOrgId: string | undefined;
let resolvedSingleEnv = this.singleEnvironment;
// Static defaults: config-driven, optionally shaped by the
// host's policy hook for the "no plan known" case.
// host's policy hook for the "no token known" case.
let features = featuresFor(undefined, { aiStudio: this.aiStudio, autoPublishAiBuilds: false });
// EnvironmentDriverRegistry exposes `resolveByHostname()`;
// older code paths used `resolveHostname()` on the client.
Expand All @@ -176,8 +218,8 @@ export class RuntimeConfigPlugin implements Plugin {
// operator's POV: surface as single-environment
// so the SPA hides multi-env affordances.
resolvedSingleEnv = true;
// Plan-derived features — only an explicit
// non-empty plan re-runs the policy hook.
// Distribution-derived features — only an explicit
// non-empty token re-runs the policy hook.
if (typeof resolved.plan === 'string' && resolved.plan.trim() !== '') {
features = featuresFor(resolved.plan, features);
}
Expand All @@ -196,8 +238,8 @@ export class RuntimeConfigPlugin implements Plugin {
features: {
installLocal: this.installLocal,
marketplace: true,
aiStudio: features.aiStudio,
autoPublishAiBuilds: features.autoPublishAiBuilds,
// aiStudio + autoPublishAiBuilds + any distribution keys.
...features,
},
branding: {
productName: this.productName,
Expand All @@ -208,13 +250,6 @@ export class RuntimeConfigPlugin implements Plugin {
rawApp.get('/api/v1/runtime/config', handler);
// Legacy alias for older Studio bundles.
rawApp.get('/api/v1/studio/runtime-config', handler);
ctx.logger?.info?.('[RuntimeConfigPlugin] mounted /api/v1/runtime/config', {
cloudUrl: this.cloudUrl || '(empty)',
installLocal: this.installLocal,
perHostEnvResolution: !!envRegistry,
});
});
};

destroy = async (): Promise<void> => {};
}