diff --git a/.changeset/ce-ai-opt-in.md b/.changeset/ce-ai-opt-in.md new file mode 100644 index 0000000000..d48a0f14d9 --- /dev/null +++ b/.changeset/ce-ai-opt-in.md @@ -0,0 +1,27 @@ +--- +"@objectstack/cli": minor +"@objectstack/spec": minor +--- + +feat(cli): make the AI service opt-in via a declared dependency; honor `config.tiers` + +**AI edition boundary (cli).** The CLI auto-registered the headless `AIServicePlugin` +whenever the `ai` tier was enabled (default) and `@objectstack/service-ai` was +merely *resolvable*. In a workspace/monorepo the package is hoist-resolvable even +when an app does not declare it, so every app got the AI service — discovery +reported `services.ai: available` and the agent runtime served any +metadata-defined agents — including Community-Edition apps that ship no AI. + +Now the *declared* dependency is the boundary: AIService auto-registers only when +the host app declares `@objectstack/service-ai` **or** `@objectstack/service-ai-studio` +(Studio attaches its personas via the base service's `ai:ready` hook, so declaring +Studio implies the base). A CE app that declares neither gets no AI service, no +agents, and `services.ai: { enabled: false, status: 'unavailable' }` in discovery +(so the console hides its AI surface). MCP and every other capability are +unaffected. The `app-showcase`/`app-crm` examples now declare `@objectstack/service-ai`. + +**`config.tiers` now honored (spec).** `ObjectStackDefinitionSchema` gains a `tiers` +field, so `defineStack` no longer strips it. `config.tiers` (e.g. a list WITHOUT +`ai`) now actually overrides the `--preset` default — previously it was silently +dropped by schema validation, making the `--preset` help text inaccurate. This is +a second, in-place way to disable AI for a deployment without touching dependencies. diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 8075b0af34..cb6a618633 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@objectstack/runtime": "workspace:*", + "@objectstack/service-ai": "workspace:*", "@objectstack/spec": "workspace:*" }, "devDependencies": { diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 1a9196bc21..89d0679044 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -27,6 +27,7 @@ "@objectstack/connector-slack": "workspace:*", "@objectstack/driver-sql": "workspace:*", "@objectstack/runtime": "workspace:*", + "@objectstack/service-ai": "workspace:*", "@objectstack/spec": "workspace:*" }, "devDependencies": { diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 12811184e2..a341dc8a00 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1416,7 +1416,36 @@ export default class Serve extends Command { return import(/* webpackIgnore: true */ pkg); } }; - if (!hasAIPlugin && tierEnabled('ai')) { + // [CE AI opt-in] Auto-register the headless AI service ONLY when the host + // app DECLARES the AI service (or the cloud AI Studio that builds on it). + // Declaration is the edition boundary: a Community-Edition app that omits + // both gets no AI service, no + // agents, and no `services.ai` in discovery (so the console hides its AI + // surface), while MCP and every other capability are unaffected. Gating on + // a *declared* dep — not mere resolvability — makes this reliable in a + // workspace/monorepo, where the package stays hoist-resolvable when undeclared. + const _fs = await import('node:fs'); + const hostDeclaresDependency = (pkg: string): boolean => { + try { + const hostPkg = JSON.parse( + _fs.readFileSync(_hostRequire.resolve('./package.json'), 'utf8'), + ) as Record | undefined>; + return Boolean( + hostPkg.dependencies?.[pkg] ?? hostPkg.devDependencies?.[pkg] + ?? hostPkg.optionalDependencies?.[pkg] ?? hostPkg.peerDependencies?.[pkg], + ); + } catch { + return false; + } + }; + // AI Studio (`@objectstack/service-ai-studio`) attaches its personas via the + // `ai:ready` hook the base service fires, so declaring Studio implies the base + // service — load it even when only Studio is in the deps (the base is a + // transitive dep of Studio, so it stays resolvable). + const wantsAiService = + hostDeclaresDependency('@objectstack/service-ai') + || hostDeclaresDependency('@objectstack/service-ai-studio'); + if (!hasAIPlugin && tierEnabled('ai') && wantsAiService) { try { const aiPkg = '@objectstack/service-ai'; const { AIServicePlugin } = await importFromHost(aiPkg); diff --git a/packages/runtime/src/security/api-key.test.ts b/packages/runtime/src/security/api-key.test.ts index 3553b951f3..a506151be4 100644 --- a/packages/runtime/src/security/api-key.test.ts +++ b/packages/runtime/src/security/api-key.test.ts @@ -84,8 +84,13 @@ describe('extractApiKey', () => { expect(extractApiKey({ authorization: 'apikey osk_123' })).toBe('osk_123'); }); - it('does NOT treat Bearer tokens as API keys', () => { - expect(extractApiKey({ authorization: 'Bearer osk_123' })).toBeUndefined(); + it('treats Bearer tokens as API keys only when osk_-prefixed', () => { + // A bare Bearer (e.g. a session token) is NOT an api-key. + expect(extractApiKey({ authorization: 'Bearer plain' })).toBeUndefined(); + // A Bearer carrying the api-key prefix IS accepted (remote MCP clients) — + // mirrors @objectstack/core's api-key test; this stale duplicate predated + // that behavior. extractApiKey is re-exported from core, so they must agree. + expect(extractApiKey({ authorization: 'Bearer osk_123' })).toBe('osk_123'); }); it('prefers x-api-key over Authorization', () => { diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index cdfc8f2e4e..4915e366cf 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -358,6 +358,13 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ */ requires: z.array(z.string()).optional().describe('Capability names this stack requires from the platform'), + /** + * Plugin tier presets to auto-register (e.g. `core`, `ai`, `ui`, `auth`). + * Overrides the `--preset` flag; omit to use the preset default. Set a list + * WITHOUT `ai` to run without the AI service (Community-Edition deployments). + */ + tiers: z.array(z.string()).optional().describe('Plugin tier presets to enable; overrides --preset'), + /** * DevPlugins: Development Capabilities * List of plugins to load ONLY in development environment. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da53cfac76..3eb6f7c1c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: '@objectstack/runtime': specifier: workspace:* version: link:../../packages/runtime + '@objectstack/service-ai': + specifier: workspace:* + version: link:../../packages/services/service-ai '@objectstack/spec': specifier: workspace:* version: link:../../packages/spec @@ -140,6 +143,9 @@ importers: '@objectstack/runtime': specifier: workspace:* version: link:../../packages/runtime + '@objectstack/service-ai': + specifier: workspace:* + version: link:../../packages/services/service-ai '@objectstack/spec': specifier: workspace:* version: link:../../packages/spec