diff --git a/packages/runtime/src/cloud/runtime-config-plugin.test.ts b/packages/runtime/src/cloud/runtime-config-plugin.test.ts deleted file mode 100644 index d477966bf4..0000000000 --- a/packages/runtime/src/cloud/runtime-config-plugin.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// 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 9fb1de5ccd..c2e4707dd2 100644 --- a/packages/runtime/src/cloud/runtime-config-plugin.ts +++ b/packages/runtime/src/cloud/runtime-config-plugin.ts @@ -120,6 +120,11 @@ 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) */ } @@ -129,13 +134,6 @@ 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 @@ -159,12 +157,6 @@ 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 @@ -177,11 +169,7 @@ export class RuntimeConfigPlugin implements Plugin { singleEnvironment: resolvedSingleEnv, defaultOrgId, defaultEnvironmentId, - features: { - installLocal: this.installLocal, - marketplace: true, - aiStudio, - }, + features, branding: { productName: this.productName, productShortName: this.productShortName,