diff --git a/packages/runtime/src/cloud/objectos-stack.test.ts b/packages/runtime/src/cloud/objectos-stack.test.ts new file mode 100644 index 000000000..78c10756c --- /dev/null +++ b/packages/runtime/src/cloud/objectos-stack.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Tests for the `extraPlugins` host seam on createObjectOSStack (ADR §5.2). + * A host (e.g. ObjectStack Cloud) supplies product/policy plugins via the + * official seam instead of mutating the returned plugins array by hand. + */ + +import { describe, it, expect } from 'vitest'; +import { createObjectOSStack } from './objectos-stack.js'; +import type { Plugin } from '@objectstack/core'; + +function fakePlugin(name: string): Plugin { + return { + name, + version: '0.0.0', + async init() { /* no-op */ }, + async start() { /* no-op */ }, + } as Plugin; +} + +describe('createObjectOSStack — extraPlugins seam', () => { + it('appends host extraPlugins after the framework defaults', async () => { + const a = fakePlugin('com.host.alpha'); + const b = fakePlugin('com.host.beta'); + const stack = await createObjectOSStack({ + controlPlaneUrl: 'http://localhost:0', + extraPlugins: [a, b], + }); + const names = stack.plugins.map((p: any) => p.name); + // Both host plugins are present... + expect(names).toContain('com.host.alpha'); + expect(names).toContain('com.host.beta'); + // ...and appended LAST, after the framework marketplace proxy. + const proxyIdx = names.indexOf('com.objectstack.runtime.marketplace-proxy'); + expect(proxyIdx).toBeGreaterThanOrEqual(0); + expect(names.indexOf('com.host.alpha')).toBeGreaterThan(proxyIdx); + // ...preserving the given order. + expect(names.indexOf('com.host.beta')).toBeGreaterThan(names.indexOf('com.host.alpha')); + }); + + it('is a no-op when extraPlugins is omitted (default stack unchanged)', async () => { + const withSeam = await createObjectOSStack({ controlPlaneUrl: 'http://localhost:0', extraPlugins: [] }); + const without = await createObjectOSStack({ controlPlaneUrl: 'http://localhost:0' }); + expect(withSeam.plugins.length).toBe(without.plugins.length); + }); +}); diff --git a/packages/runtime/src/cloud/objectos-stack.ts b/packages/runtime/src/cloud/objectos-stack.ts index c8511adc8..32f9d31e5 100644 --- a/packages/runtime/src/cloud/objectos-stack.ts +++ b/packages/runtime/src/cloud/objectos-stack.ts @@ -64,6 +64,21 @@ export interface ObjectOSStackConfig { artifactCacheTtlMs?: number; /** API prefix (carried for parity with cloud-stack). Default: /api/v1. */ apiPrefix?: string; + /** + * Host-supplied runtime plugins appended to the stack's default plugin + * list. This is the official seam for a host (e.g. the ObjectStack Cloud + * repo) to add **product/policy** plugins — marketplace install, cloud- + * account binding, set-initial-password — to the otherwise-neutral + * framework runtime, WITHOUT a framework release and without reaching into + * the returned array by hand. + * + * They are appended last, so they mount their routes after the framework + * plugins and can override/augment behaviour (e.g. supply a credentialled + * install path that the browse-only MarketplaceProxyPlugin deliberately + * does not). See docs/design/cloud-account-binding-marketplace-install.md + * (ADR §5.2 — "framework exposes seams; cloud supplies metadata + policy"). + */ + extraPlugins?: Plugin[]; } export interface ObjectOSStackResult { @@ -252,7 +267,17 @@ export async function createObjectOSStack(config: ObjectOSStackConfig): Promise< const enginePlugins = await createHostEnginePlugins(); return { - plugins: [...enginePlugins, new ObjectOSEnvironmentPlugin(merged), new AuthProxyPlugin(), new MarketplaceProxyPlugin({ controlPlaneUrl: merged.controlPlaneUrl === 'file' ? undefined : merged.controlPlaneUrl }), new RuntimeConfigPlugin({ controlPlaneUrl: merged.controlPlaneUrl === 'file' ? undefined : merged.controlPlaneUrl, installLocal: false })], + plugins: [ + ...enginePlugins, + new ObjectOSEnvironmentPlugin(merged), + new AuthProxyPlugin(), + new MarketplaceProxyPlugin({ controlPlaneUrl: merged.controlPlaneUrl === 'file' ? undefined : merged.controlPlaneUrl }), + new RuntimeConfigPlugin({ controlPlaneUrl: merged.controlPlaneUrl === 'file' ? undefined : merged.controlPlaneUrl, installLocal: false }), + // Host-supplied product/policy plugins (the official seam — see + // ObjectOSStackConfig.extraPlugins). Appended last so they mount + // after the framework defaults. + ...(config.extraPlugins ?? []), + ], api: { enableProjectScoping: true, projectResolution: 'auto', diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index 76ea46760..a0408713f 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ '@objectstack/spec/kernel': path.resolve(__dirname, '../spec/src/kernel/index.ts'), '@objectstack/spec/shared': path.resolve(__dirname, '../spec/src/shared/index.ts'), '@objectstack/spec/system': path.resolve(__dirname, '../spec/src/system/index.ts'), + '@objectstack/spec/ui': path.resolve(__dirname, '../spec/src/ui/index.ts'), '@objectstack/spec': path.resolve(__dirname, '../spec/src/index.ts'), '@objectstack/types': path.resolve(__dirname, '../types/src/index.ts'), },