From 89492e87d8d70c5f68d6557b6fa7a60015f56fcc Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:14:59 +0800 Subject: [PATCH] feat(runtime): gate features.aiStudio on the environment's plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tenant runtime serves GET /api/v1/runtime/config per request, resolving the environment by hostname. It now reads the resolved environment's billing `plan` and sets features.aiStudio accordingly: free → off, any paid tier → on. The SPA already hides the Studio/AI online-development surface when features.aiStudio is false (objectui ConsoleLayout), so this is a pure UI distinction — the AIStudioPlugin stays mounted on every shared-container tenant environment. Plan is supplied by the control plane's /api/v1/cloud/resolve-hostname (objectstack-ai/cloud). When the plan can't be resolved (file/CLI mode, legacy control plane), the static default flag is preserved so nobody is locked out. Co-Authored-By: Claude Opus 4.8 --- .../src/cloud/runtime-config-plugin.test.ts | 104 ++++++++++++++++++ .../src/cloud/runtime-config-plugin.ts | 24 +++- 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 packages/runtime/src/cloud/runtime-config-plugin.test.ts 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,