diff --git a/packages/runtime/src/cloud/runtime-config-plugin.test.ts b/packages/runtime/src/cloud/runtime-config-plugin.test.ts new file mode 100644 index 000000000..d477966bf --- /dev/null +++ b/packages/runtime/src/cloud/runtime-config-plugin.test.ts @@ -0,0 +1,104 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Tests for RuntimeConfigPlugin's per-request capability gating. + * + * The tenant runtime serves `GET /api/v1/runtime/config`. `features.aiStudio` + * must follow the resolved environment's billing plan: free → off, paid → on, + * and the static default must survive when the plan can't be resolved. + */ + +import { describe, it, expect } from 'vitest'; +import { RuntimeConfigPlugin } from './runtime-config-plugin.js'; + +/** Drive the plugin's start() and capture the mounted `/runtime/config` handler. */ +async function mountAndGetHandler(opts: { + pluginConfig?: ConstructorParameters[0]; + resolveByHostname?: (host: string) => Promise; +}): Promise<(c: any) => Promise> { + let handler: ((c: any) => Promise) | undefined; + const rawApp = { + get(path: string, h: (c: any) => Promise) { + if (path === '/api/v1/runtime/config') handler = h; + }, + }; + const services: Record = { + 'http-server': { getRawApp: () => rawApp }, + }; + if (opts.resolveByHostname) { + services['env-registry'] = { resolveByHostname: opts.resolveByHostname }; + } + const ctx: any = { + logger: { info() {}, warn() {} }, + getService: (name: string) => { + const s = services[name]; + if (!s) throw new Error(`no service ${name}`); + return s; + }, + hooks: [] as Array<() => Promise>, + hook(_event: string, cb: () => Promise) { this.hooks.push(cb); }, + }; + const plugin = new RuntimeConfigPlugin(opts.pluginConfig ?? {}); + await plugin.start(ctx); + for (const cb of ctx.hooks) await cb(); // fire kernel:ready + if (!handler) throw new Error('handler not mounted'); + return handler; +} + +function fakeCtx(host: string) { + let captured: any; + return { + c: { req: { header: (n: string) => (n.toLowerCase() === 'host' ? host : undefined) }, json: (b: any) => { captured = b; return b; } }, + get payload() { return captured; }, + }; +} + +describe('RuntimeConfigPlugin — aiStudio plan gating', () => { + it('disables aiStudio for a free-plan environment', async () => { + const handler = await mountAndGetHandler({ + resolveByHostname: async () => ({ environmentId: 'env1', organizationId: 'org1', plan: 'free' }), + }); + const { c } = fakeCtx('acme.objectos.ai'); + const body = await handler(c); + expect(body.features.aiStudio).toBe(false); + expect(body.defaultEnvironmentId).toBe('env1'); + }); + + it('enables aiStudio for a paid-plan environment', async () => { + const handler = await mountAndGetHandler({ + resolveByHostname: async () => ({ environmentId: 'env2', plan: 'pro' }), + }); + const { c } = fakeCtx('acme.objectos.ai'); + const body = await handler(c); + expect(body.features.aiStudio).toBe(true); + }); + + it('keeps the static default when the plan is absent', async () => { + const handler = await mountAndGetHandler({ + resolveByHostname: async () => ({ environmentId: 'env3' }), // no plan + }); + const { c } = fakeCtx('acme.objectos.ai'); + const body = await handler(c); + expect(body.features.aiStudio).toBe(true); // default is true + }); + + it('honours an explicit aiStudio=false default when plan is absent', async () => { + const handler = await mountAndGetHandler({ + pluginConfig: { aiStudio: false }, + resolveByHostname: async () => ({ environmentId: 'env4' }), + }); + const { c } = fakeCtx('acme.objectos.ai'); + const body = await handler(c); + expect(body.features.aiStudio).toBe(false); + }); + + it('a free plan overrides even an aiStudio=true default', async () => { + const handler = await mountAndGetHandler({ + pluginConfig: { aiStudio: true }, + resolveByHostname: async () => ({ environmentId: 'env5', plan: 'FREE' }), + }); + const { c } = fakeCtx('acme.objectos.ai'); + const body = await handler(c); + expect(body.features.aiStudio).toBe(false); + }); +}); diff --git a/packages/runtime/src/cloud/runtime-config-plugin.ts b/packages/runtime/src/cloud/runtime-config-plugin.ts index c2e4707dd..9fb1de5cc 100644 --- a/packages/runtime/src/cloud/runtime-config-plugin.ts +++ b/packages/runtime/src/cloud/runtime-config-plugin.ts @@ -120,11 +120,6 @@ export class RuntimeConfigPlugin implements Plugin { // payload when the host doesn't map to any env (e.g. a // marketing root, a CLI-served single-env runtime, or // cloud.objectos.ai which mounts its own static handler). - const features = { - installLocal: this.installLocal, - marketplace: true, - aiStudio: this.aiStudio, - }; let envRegistry: any = null; try { envRegistry = ctx.getService('env-registry'); } catch { /* not mounted (file/CLI mode) */ } @@ -134,6 +129,13 @@ export class RuntimeConfigPlugin implements Plugin { let defaultEnvironmentId: string | undefined; let defaultOrgId: string | undefined; let resolvedSingleEnv = this.singleEnvironment; + // Capability flag: paid environments unlock the Studio/AI + // online-development surface; free environments hide it. Starts + // from the static default and is overridden per-request once we + // know the resolved environment's billing plan. When the plan + // can't be resolved (file/CLI mode, legacy control plane) we + // keep the static default rather than locking anyone out. + let aiStudio = this.aiStudio; // EnvironmentDriverRegistry exposes `resolveByHostname()`; // older code paths used `resolveHostname()` on the client. // Accept either so production runtimes (which register the @@ -157,6 +159,12 @@ export class RuntimeConfigPlugin implements Plugin { // operator's POV: surface as single-environment // so the SPA hides multi-env affordances. resolvedSingleEnv = true; + // Gate the Studio/AI surface on the environment's + // plan: free → off, any paid tier → on. Only an + // explicit non-empty plan overrides the default, so + // an absent/blank value leaves the static flag intact. + const plan = typeof resolved.plan === 'string' ? resolved.plan.trim().toLowerCase() : ''; + if (plan) aiStudio = plan !== 'free'; } } catch { // Resolver failures are non-fatal — fall through @@ -169,7 +177,11 @@ export class RuntimeConfigPlugin implements Plugin { singleEnvironment: resolvedSingleEnv, defaultOrgId, defaultEnvironmentId, - features, + features: { + installLocal: this.installLocal, + marketplace: true, + aiStudio, + }, branding: { productName: this.productName, productShortName: this.productShortName,