Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/ce-ai-opt-in.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions examples/app-crm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"dependencies": {
"@objectstack/runtime": "workspace:*",
"@objectstack/service-ai": "workspace:*",
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
Expand Down
1 change: 1 addition & 0 deletions examples/app-showcase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@objectstack/connector-slack": "workspace:*",
"@objectstack/driver-sql": "workspace:*",
"@objectstack/runtime": "workspace:*",
"@objectstack/service-ai": "workspace:*",
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
Expand Down
31 changes: 30 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, string> | 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);
Expand Down
9 changes: 7 additions & 2 deletions packages/runtime/src/security/api-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.