Skip to content

Commit 89492e8

Browse files
os-zhuangclaude
andcommitted
feat(runtime): gate features.aiStudio on the environment's plan
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 <noreply@anthropic.com>
1 parent 55ac75d commit 89492e8

2 files changed

Lines changed: 122 additions & 6 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Tests for RuntimeConfigPlugin's per-request capability gating.
5+
*
6+
* The tenant runtime serves `GET /api/v1/runtime/config`. `features.aiStudio`
7+
* must follow the resolved environment's billing plan: free → off, paid → on,
8+
* and the static default must survive when the plan can't be resolved.
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { RuntimeConfigPlugin } from './runtime-config-plugin.js';
13+
14+
/** Drive the plugin's start() and capture the mounted `/runtime/config` handler. */
15+
async function mountAndGetHandler(opts: {
16+
pluginConfig?: ConstructorParameters<typeof RuntimeConfigPlugin>[0];
17+
resolveByHostname?: (host: string) => Promise<any>;
18+
}): Promise<(c: any) => Promise<any>> {
19+
let handler: ((c: any) => Promise<any>) | undefined;
20+
const rawApp = {
21+
get(path: string, h: (c: any) => Promise<any>) {
22+
if (path === '/api/v1/runtime/config') handler = h;
23+
},
24+
};
25+
const services: Record<string, any> = {
26+
'http-server': { getRawApp: () => rawApp },
27+
};
28+
if (opts.resolveByHostname) {
29+
services['env-registry'] = { resolveByHostname: opts.resolveByHostname };
30+
}
31+
const ctx: any = {
32+
logger: { info() {}, warn() {} },
33+
getService: (name: string) => {
34+
const s = services[name];
35+
if (!s) throw new Error(`no service ${name}`);
36+
return s;
37+
},
38+
hooks: [] as Array<() => Promise<void>>,
39+
hook(_event: string, cb: () => Promise<void>) { this.hooks.push(cb); },
40+
};
41+
const plugin = new RuntimeConfigPlugin(opts.pluginConfig ?? {});
42+
await plugin.start(ctx);
43+
for (const cb of ctx.hooks) await cb(); // fire kernel:ready
44+
if (!handler) throw new Error('handler not mounted');
45+
return handler;
46+
}
47+
48+
function fakeCtx(host: string) {
49+
let captured: any;
50+
return {
51+
c: { req: { header: (n: string) => (n.toLowerCase() === 'host' ? host : undefined) }, json: (b: any) => { captured = b; return b; } },
52+
get payload() { return captured; },
53+
};
54+
}
55+
56+
describe('RuntimeConfigPlugin — aiStudio plan gating', () => {
57+
it('disables aiStudio for a free-plan environment', async () => {
58+
const handler = await mountAndGetHandler({
59+
resolveByHostname: async () => ({ environmentId: 'env1', organizationId: 'org1', plan: 'free' }),
60+
});
61+
const { c } = fakeCtx('acme.objectos.ai');
62+
const body = await handler(c);
63+
expect(body.features.aiStudio).toBe(false);
64+
expect(body.defaultEnvironmentId).toBe('env1');
65+
});
66+
67+
it('enables aiStudio for a paid-plan environment', async () => {
68+
const handler = await mountAndGetHandler({
69+
resolveByHostname: async () => ({ environmentId: 'env2', plan: 'pro' }),
70+
});
71+
const { c } = fakeCtx('acme.objectos.ai');
72+
const body = await handler(c);
73+
expect(body.features.aiStudio).toBe(true);
74+
});
75+
76+
it('keeps the static default when the plan is absent', async () => {
77+
const handler = await mountAndGetHandler({
78+
resolveByHostname: async () => ({ environmentId: 'env3' }), // no plan
79+
});
80+
const { c } = fakeCtx('acme.objectos.ai');
81+
const body = await handler(c);
82+
expect(body.features.aiStudio).toBe(true); // default is true
83+
});
84+
85+
it('honours an explicit aiStudio=false default when plan is absent', async () => {
86+
const handler = await mountAndGetHandler({
87+
pluginConfig: { aiStudio: false },
88+
resolveByHostname: async () => ({ environmentId: 'env4' }),
89+
});
90+
const { c } = fakeCtx('acme.objectos.ai');
91+
const body = await handler(c);
92+
expect(body.features.aiStudio).toBe(false);
93+
});
94+
95+
it('a free plan overrides even an aiStudio=true default', async () => {
96+
const handler = await mountAndGetHandler({
97+
pluginConfig: { aiStudio: true },
98+
resolveByHostname: async () => ({ environmentId: 'env5', plan: 'FREE' }),
99+
});
100+
const { c } = fakeCtx('acme.objectos.ai');
101+
const body = await handler(c);
102+
expect(body.features.aiStudio).toBe(false);
103+
});
104+
});

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,6 @@ export class RuntimeConfigPlugin implements Plugin {
120120
// payload when the host doesn't map to any env (e.g. a
121121
// marketing root, a CLI-served single-env runtime, or
122122
// cloud.objectos.ai which mounts its own static handler).
123-
const features = {
124-
installLocal: this.installLocal,
125-
marketplace: true,
126-
aiStudio: this.aiStudio,
127-
};
128123
let envRegistry: any = null;
129124
try { envRegistry = ctx.getService('env-registry'); } catch { /* not mounted (file/CLI mode) */ }
130125

@@ -134,6 +129,13 @@ export class RuntimeConfigPlugin implements Plugin {
134129
let defaultEnvironmentId: string | undefined;
135130
let defaultOrgId: string | undefined;
136131
let resolvedSingleEnv = this.singleEnvironment;
132+
// Capability flag: paid environments unlock the Studio/AI
133+
// online-development surface; free environments hide it. Starts
134+
// from the static default and is overridden per-request once we
135+
// know the resolved environment's billing plan. When the plan
136+
// can't be resolved (file/CLI mode, legacy control plane) we
137+
// keep the static default rather than locking anyone out.
138+
let aiStudio = this.aiStudio;
137139
// EnvironmentDriverRegistry exposes `resolveByHostname()`;
138140
// older code paths used `resolveHostname()` on the client.
139141
// Accept either so production runtimes (which register the
@@ -157,6 +159,12 @@ export class RuntimeConfigPlugin implements Plugin {
157159
// operator's POV: surface as single-environment
158160
// so the SPA hides multi-env affordances.
159161
resolvedSingleEnv = true;
162+
// Gate the Studio/AI surface on the environment's
163+
// plan: free → off, any paid tier → on. Only an
164+
// explicit non-empty plan overrides the default, so
165+
// an absent/blank value leaves the static flag intact.
166+
const plan = typeof resolved.plan === 'string' ? resolved.plan.trim().toLowerCase() : '';
167+
if (plan) aiStudio = plan !== 'free';
160168
}
161169
} catch {
162170
// Resolver failures are non-fatal — fall through
@@ -169,7 +177,11 @@ export class RuntimeConfigPlugin implements Plugin {
169177
singleEnvironment: resolvedSingleEnv,
170178
defaultOrgId,
171179
defaultEnvironmentId,
172-
features,
180+
features: {
181+
installLocal: this.installLocal,
182+
marketplace: true,
183+
aiStudio,
184+
},
173185
branding: {
174186
productName: this.productName,
175187
productShortName: this.productShortName,

0 commit comments

Comments
 (0)