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
413 changes: 229 additions & 184 deletions packages/cli/src/commands/serve.ts

Large diffs are not rendered by default.

44 changes: 44 additions & 0 deletions packages/cli/test/serve-capability-vocabulary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
PLATFORM_CAPABILITY_TOKENS,
DEPRECATED_PLATFORM_CAPABILITY_ALIASES,
} from '@objectstack/spec/kernel';
import Serve from '../src/commands/serve.js';

// framework#3265 — drift guard: the serve path's provider registries must stay
// inside the spec-owned platform capability vocabulary, so the standalone
// runtime and cloud's objectos-runtime keep resolving the SAME token set.

describe('serve capability registries vs spec vocabulary (#3265)', () => {
it('every CAPABILITY_PROVIDERS token is in PLATFORM_CAPABILITY_TOKENS', () => {
for (const token of Object.keys(Serve.CAPABILITY_PROVIDERS)) {
expect(PLATFORM_CAPABILITY_TOKENS, `provider token '${token}' missing from spec vocabulary`).toContain(token);
}
});

it('every CAPABILITY_TO_TIER token is in PLATFORM_CAPABILITY_TOKENS', () => {
for (const token of Object.keys(Serve.CAPABILITY_TO_TIER)) {
expect(PLATFORM_CAPABILITY_TOKENS, `tier token '${token}' missing from spec vocabulary`).toContain(token);
}
});

it('registries use only canonical spellings — never deprecated aliases', () => {
const legacy = Object.keys(DEPRECATED_PLATFORM_CAPABILITY_ALIASES);
for (const token of [...Object.keys(Serve.CAPABILITY_PROVIDERS), ...Object.keys(Serve.CAPABILITY_TO_TIER)]) {
expect(legacy).not.toContain(token);
}
});

it('tier-gated and provider-backed tokens do not overlap (each token has ONE resolution path)', () => {
const providerTokens = new Set(Object.keys(Serve.CAPABILITY_PROVIDERS));
for (const tierToken of Object.keys(Serve.CAPABILITY_TO_TIER)) {
expect(providerTokens.has(tierToken)).toBe(false);
}
});

it('ALWAYS_ON_CAPABILITIES stays inside the vocabulary too', () => {
for (const token of Serve.ALWAYS_ON_CAPABILITIES) {
expect(PLATFORM_CAPABILITY_TOKENS).toContain(token);
}
});
});
8 changes: 8 additions & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"ConversionFixture (interface)",
"ConversionNotice (interface)",
"CronExpressionInputSchema (const)",
"DEPRECATED_PLATFORM_CAPABILITY_ALIASES (const)",
"DatasourceMappingRule (type)",
"DatasourceMappingRuleSchema (const)",
"DefineStackOptions (interface)",
Expand Down Expand Up @@ -76,6 +77,7 @@
"ObjectUICapabilities (type)",
"ObjectUICapabilitiesSchema (const)",
"P (const)",
"PLATFORM_CAPABILITY_TOKENS (const)",
"PluginContext (type)",
"Predicate (type)",
"PredicateInput (type)",
Expand All @@ -100,6 +102,7 @@
"applyConversions (function)",
"applyConversionsToFlow (function)",
"applyMetaMigrations (function)",
"canonicalizePlatformCapability (function)",
"cel (function)",
"collectConversionNotices (function)",
"composeMigrationChain (function)",
Expand Down Expand Up @@ -142,6 +145,7 @@
"formatZodError (function)",
"formatZodIssue (function)",
"isAggregatedViewContainer (function)",
"isKnownPlatformCapability (function)",
"mapMembershipRole (function)",
"normalizeMetadataCollection (function)",
"normalizePluginMetadata (function)",
Expand Down Expand Up @@ -1290,6 +1294,7 @@
"CustomizationPolicy (type)",
"CustomizationPolicySchema (const)",
"DEFAULT_METADATA_TYPE_REGISTRY (const)",
"DEPRECATED_PLATFORM_CAPABILITY_ALIASES (const)",
"DeadLetterQueueEntry (type)",
"DeadLetterQueueEntrySchema (const)",
"DependencyConflict (type)",
Expand Down Expand Up @@ -1511,6 +1516,7 @@
"OpsFilePathSchema (const)",
"OpsPluginStructure (type)",
"OpsPluginStructureSchema (const)",
"PLATFORM_CAPABILITY_TOKENS (const)",
"PROTOCOL_MAJOR (const)",
"PROTOCOL_VERSION (const)",
"PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS (const)",
Expand Down Expand Up @@ -1744,6 +1750,7 @@
"VersionConstraint (type)",
"VersionConstraintSchema (const)",
"VulnerabilitySeverity (type)",
"canonicalizePlatformCapability (function)",
"deriveNamespaceFromPackageId (function)",
"evaluateLockForDelete (function)",
"evaluateLockForWrite (function)",
Expand All @@ -1753,6 +1760,7 @@
"getMetadataTypeActions (function)",
"getMetadataTypeSchema (function)",
"isConsumerInstallable (function)",
"isKnownPlatformCapability (function)",
"listMetadataCreateSeedTypes (function)",
"listMetadataTypeSchemaTypes (function)",
"lowerRequiresFeature (function)",
Expand Down
8 changes: 8 additions & 0 deletions packages/spec/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ export * from './migrations/index.js';

export { type PluginContext } from './kernel/plugin.zod';

// Platform SERVICE capability vocabulary for `requires: [...]` (framework#3265).
export {
PLATFORM_CAPABILITY_TOKENS,
DEPRECATED_PLATFORM_CAPABILITY_ALIASES,
canonicalizePlatformCapability,
isKnownPlatformCapability,
} from './kernel/platform-capabilities';

// Expression Protocol (M9 — canonical wire format for formulas / predicates / conditions)
export {
ExpressionDialect,
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/kernel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './feature.zod';
export * from './manifest.zod';
export * from './metadata-customization.zod';
export * from './namespace-prefix';
export * from './platform-capabilities';
export * from './metadata-loader.zod';
export * from './metadata-plugin.zod';
export * from './metadata-protection.zod';
Expand Down
63 changes: 63 additions & 0 deletions packages/spec/src/kernel/platform-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import {
PLATFORM_CAPABILITY_TOKENS,
DEPRECATED_PLATFORM_CAPABILITY_ALIASES,
canonicalizePlatformCapability,
isKnownPlatformCapability,
} from './platform-capabilities';

// framework#3265 — one capability vocabulary across the standalone serve path
// and cloud's objectos-runtime loader, canonical spelling kebab-case.

describe('PLATFORM_CAPABILITY_TOKENS', () => {
it('is frozen and duplicate-free', () => {
expect(Object.isFrozen(PLATFORM_CAPABILITY_TOKENS)).toBe(true);
expect(new Set(PLATFORM_CAPABILITY_TOKENS).size).toBe(PLATFORM_CAPABILITY_TOKENS.length);
});

it('every token is canonical lower-case kebab-case', () => {
for (const t of PLATFORM_CAPABILITY_TOKENS) {
expect(t).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
}
});

it('contains the tier-gated and headline service tokens', () => {
for (const t of ['ai', 'ai-studio', 'automation', 'analytics', 'pinyin-search', 'hierarchy-security']) {
expect(PLATFORM_CAPABILITY_TOKENS).toContain(t);
}
});

it('contains no deprecated spellings', () => {
for (const legacy of Object.keys(DEPRECATED_PLATFORM_CAPABILITY_ALIASES)) {
expect(PLATFORM_CAPABILITY_TOKENS).not.toContain(legacy);
}
});
});

describe('DEPRECATED_PLATFORM_CAPABILITY_ALIASES / canonicalizePlatformCapability', () => {
it('maps every alias onto a token that exists in the vocabulary', () => {
for (const canonical of Object.values(DEPRECATED_PLATFORM_CAPABILITY_ALIASES)) {
expect(PLATFORM_CAPABILITY_TOKENS).toContain(canonical);
}
});

it('canonicalizes the legacy cloud spellings', () => {
expect(canonicalizePlatformCapability('aiStudio')).toBe('ai-studio');
expect(canonicalizePlatformCapability('aiSeat')).toBe('ai-seat');
});

it('is identity for canonical and unknown tokens', () => {
expect(canonicalizePlatformCapability('ai-studio')).toBe('ai-studio');
expect(canonicalizePlatformCapability('automation')).toBe('automation');
expect(canonicalizePlatformCapability('not-a-capability')).toBe('not-a-capability');
});
});

describe('isKnownPlatformCapability', () => {
it('accepts canonical tokens and deprecated aliases, rejects unknowns', () => {
expect(isKnownPlatformCapability('ai-studio')).toBe(true);
expect(isKnownPlatformCapability('aiStudio')).toBe(true);
expect(isKnownPlatformCapability('governance')).toBe(true);
expect(isKnownPlatformCapability('automations')).toBe(false);
});
});
92 changes: 92 additions & 0 deletions packages/spec/src/kernel/platform-capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Platform SERVICE capability vocabulary — the canonical tokens accepted in a
* stack's `requires: [...]` declaration (framework#3265).
*
* ONE vocabulary across every runtime that resolves `requires`: the standalone
* `os serve` / `os start` path (`@objectstack/cli`) and cloud's multi-tenant
* `objectos-runtime` capability loader. Both loaders key their provider
* registries by these tokens, so a stack declaration means the same thing
* wherever it boots. (These are platform SERVICE capabilities — NOT the
* ADR-0066 authorization capabilities declared in `capabilities: [...]`.)
*
* Canonical spelling is lower-case kebab-case (`ai-studio`, `pinyin-search`,
* `hierarchy-security`). Legacy camelCase spellings that shipped in cloud
* configs are mapped through {@link DEPRECATED_PLATFORM_CAPABILITY_ALIASES}
* for one deprecation cycle — `defineStack` normalizes them with a warning at
* authoring time, and runtimes canonicalize raw artifact input the same way.
*
* Growing the platform: when a new `requires`-resolvable service ships, add
* its token HERE as well as to the runtime's provider registry — the CLI's
* vocabulary-drift test fails if the registries and this list fall out of
* sync. Unknown tokens are warn-only for now (`defineStack` and the serve
* resolver both warn) so third-party experimentation isn't bricked; once the
* vocabulary has proven complete the warn is intended to become a reject
* (Prime Directive #12: surface producer mistakes at authoring, loudly).
*/
export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([
// Tier-gated capabilities (framework `serve.ts` CAPABILITY_TO_TIER)
'ai',
'ai-studio',
'i18n',
'ui',
'auth',
// Service capabilities (framework `serve.ts` CAPABILITY_PROVIDERS)
'automation',
'analytics',
'audit',
'cache',
'storage',
'queue',
'job',
'messaging',
'triggers',
'realtime',
'mcp',
'marketplace',
'email',
'sms',
'sharing',
'pinyin-search',
'reports',
'approvals',
'settings',
'webhooks',
// Enterprise / cloud-runtime capabilities (no open-edition provider:
// `hierarchy-security` ships in @objectstack/security-enterprise via
// `plugins[]`; `ai-seat` / `governance` are resolved by cloud's
// objectos-runtime loader only)
'hierarchy-security',
'ai-seat',
'governance',
]);

/**
* Deprecated `requires` spellings → canonical token. One deprecation cycle:
* authoring (`defineStack`) rewrites these with a warning; runtimes accept
* them via {@link canonicalizePlatformCapability} so artifacts built by an
* older spec keep booting. Do not add new aliases — new tokens ship in
* canonical kebab-case only.
*/
export const DEPRECATED_PLATFORM_CAPABILITY_ALIASES: Readonly<Record<string, string>> =
Object.freeze({
aiStudio: 'ai-studio',
aiSeat: 'ai-seat',
});

/**
* Map a `requires` token to its canonical spelling (identity for tokens that
* are already canonical or unknown).
*/
export function canonicalizePlatformCapability(token: string): string {
return DEPRECATED_PLATFORM_CAPABILITY_ALIASES[token] ?? token;
}

/**
* True when the token (after alias canonicalization) is part of the platform
* capability vocabulary.
*/
export function isKnownPlatformCapability(token: string): boolean {
return PLATFORM_CAPABILITY_TOKENS.includes(canonicalizePlatformCapability(token));
}
49 changes: 49 additions & 0 deletions packages/spec/src/stack-requires.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { defineStack } from './stack.zod';

// framework#3265 — defineStack canonicalizes deprecated `requires` spellings at
// the PRODUCER (authoring time) and warn-validates tokens against the platform
// capability vocabulary. Warn-first: unknown tokens must not throw (yet).

afterEach(() => {
vi.restoreAllMocks();
});

describe('defineStack requires canonicalization (#3265)', () => {
it('rewrites deprecated aliases to canonical kebab tokens with a warning', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const stack = defineStack({ requires: ['ai', 'aiStudio', 'automation'] });
expect(stack.requires).toEqual(['ai', 'ai-studio', 'automation']);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("'aiStudio' is a deprecated spelling");
expect(warn.mock.calls[0][0]).toContain("'ai-studio'");
});

it('canonical declarations pass through untouched and warning-free', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const stack = defineStack({ requires: ['ai', 'ai-studio', 'hierarchy-security', 'governance'] });
expect(stack.requires).toEqual(['ai', 'ai-studio', 'hierarchy-security', 'governance']);
expect(warn).not.toHaveBeenCalled();
});

it('warns on unknown tokens but does NOT throw (warn-first)', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const stack = defineStack({ requires: ['automations'] });
expect(stack.requires).toEqual(['automations']);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0][0]).toContain("'automations' is not a known platform capability");
});

it('warns once per distinct token, not once per occurrence', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
defineStack({ requires: ['aiStudio', 'aiStudio'] });
expect(warn).toHaveBeenCalledTimes(1);
});

it('non-strict mode skips canonicalization and warnings by contract', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const stack = defineStack({ requires: ['aiStudio'] }, { strict: false });
expect(stack.requires).toEqual(['aiStudio']);
expect(warn).not.toHaveBeenCalled();
});
});
Loading