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
16 changes: 16 additions & 0 deletions .changeset/serve-refuses-unloaded-crypto-provider-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/cli': patch
---

fix(cli): `os serve` refuses to boot when a `plugins` entry that may provide the crypto provider does not load

Clause-②: no
Metadata-protocol: none

`os serve` now refuses to boot when an entry of the stack's `plugins` does not load and nothing rules out that it provides the crypto provider (`crypto-provider`), which seals every secret field, datasource credential and encrypted setting. The refusal names each such entry and the reason it did not load.

- An entry that never materialized, such as a package specifier that does not resolve or import, says nothing about what it provides, so the boot is refused.
- An entry that materialized and declares `providesServices: ['crypto-provider']` is refused.
- An entry whose `providesServices` does not name the crypto provider is ruled out. The boot prints `✗ Failed to load plugin` for it and goes on without it, as before.

This is the rule the one-shot secret commands keep for the same host config.
61 changes: 61 additions & 0 deletions packages/cli/src/commands/serve-host-crypto-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
createHostCryptoProvider,
HostCryptoProviderRefusal,
refuseTwoCryptoProviderPlugins,
refuseUnloadedCryptoProviderPlugins,
} from './serve.js';

// [#10126] Pay the first load of what `serve.ts` reaches through dynamic
Expand Down Expand Up @@ -445,6 +446,57 @@ describe('two registrations refuse at boot', () => {
});
});

describe('a plugins entry that did not load refuses the boot unless it is ruled out as the crypto provider', () => {
const refusalOf = (unloaded: Parameters<typeof refuseUnloadedCryptoProviderPlugins>[0]): Error | undefined => {
try { refuseUnloadedCryptoProviderPlugins(unloaded); }
catch (err) { return err as Error; }
return undefined;
};

it('an entry that never materialized says nothing it provides, and is refused, named by its specifier', () => {
const refusal = refusalOf([{ index: 2, entry: '@acme/kms-crypto', error: new Error("Failed to import plugin '@acme/kms-crypto': not found") }]);
expect(refusal).toBeInstanceOf(HostCryptoProviderRefusal);
expect(refusal!.message).toContain("plugins[2] ('@acme/kms-crypto') did not load");
expect(refusal!.message).toContain(`'${CRYPTO_PROVIDER_SERVICE}'`);
});

it('an entry that materialized and declares the provider is refused, named by its plugin name', () => {
const plugin = providerPlugin('com.example.aws-kms-crypto', kmsProvider().provider);
const refusal = refusalOf([
{ index: 0, entry: '@acme/aws-kms-crypto', materialized: plugin, error: new Error('Invalid semantic version') },
]);
expect(refusal).toBeInstanceOf(HostCryptoProviderRefusal);
expect(refusal!.message).toContain("plugins[0] ('@acme/aws-kms-crypto')");
const inline = refusalOf([{ index: 1, entry: plugin, materialized: plugin, error: new Error('boom') }]);
expect(inline!.message).toContain("plugins[1] ('com.example.aws-kms-crypto') did not load (boom)");
expectNoKeyMaterial(inline!.message);
});

it('an object entry that failed before it materialized is judged by what the entry itself declares', () => {
const plugin = providerPlugin('com.example.vault-crypto', kmsProvider().provider);
expect(refusalOf([{ index: 0, entry: plugin, error: new Error('boom') }])).toBeInstanceOf(HostCryptoProviderRefusal);
});

it('CONTROL: an entry that declares other services, or none, is ruled out; nothing unloaded is not refused', () => {
expect(refusalOf([
{ index: 0, entry: { name: 'com.example.widgets', providesServices: ['widgets'], init: async () => {} }, error: new Error('x') },
{ index: 1, entry: '@acme/widgets', materialized: { name: 'com.example.plain', init: async () => {} }, error: new Error('y') },
])).toBeUndefined();
expect(refusalOf([])).toBeUndefined();
});

it('every refused entry is named, and the ruled-out ones are not', () => {
const refusal = refusalOf([
{ index: 0, entry: '@acme/one', error: new Error('a') },
{ index: 1, entry: { name: 'com.example.widgets', providesServices: ['widgets'], init: async () => {} }, error: new Error('b') },
{ index: 2, entry: '@acme/two', error: new Error('c') },
]);
expect(refusal!.message).toContain("plugins[0] ('@acme/one')");
expect(refusal!.message).toContain("plugins[2] ('@acme/two')");
expect(refusal!.message).not.toContain('com.example.widgets');
});
});

describe('the deferred path: nothing is sealed by a provider the boot did not choose', () => {
it('a credential bound before the post-start wiring is sealed by the registered provider', async () => {
const kms = kmsProvider();
Expand Down Expand Up @@ -564,6 +616,15 @@ describe('the wiring inside run()', () => {
expect(SERVE.match(/setCryptoProvider\(/g)).toHaveLength(1);
});

it('refuses an unloaded entry that may provide the crypto provider right after the plugins loop, before the host provider is built', () => {
const record = SERVE.indexOf('unloadedStackPlugins.push({ index, entry: plugin, materialized, error: e });');
const check = SERVE.indexOf('refuseUnloadedCryptoProviderPlugins(unloadedStackPlugins);');
const host = SERVE.indexOf('hostCrypto = createHostCryptoProvider(() => localCryptoProvider.resolve());');
expect(record).toBeGreaterThan(0);
expect(check).toBeGreaterThan(record);
expect(host).toBeGreaterThan(check);
});

it('checks the declared providers before the boot', () => {
const check = SERVE.indexOf('refuseTwoCryptoProviderPlugins([...stackMountedPlugins, ...resolverMounted]);');
const start = SERVE.indexOf('await runtime.start();');
Expand Down
64 changes: 63 additions & 1 deletion packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4712,15 +4712,21 @@ export default class Serve extends Command {
// `materializeStackPlugin` — so the crypto-provider composition check
// before `runtime.start()` reads what each one declares.
const stackMountedPlugins: unknown[] = [];
// The entries that did not load, with what each materialized to (when it
// got that far), for `refuseUnloadedCryptoProviderPlugins` below.
const unloadedStackPlugins: UnloadedStackPlugin[] = [];
if (plugins.length > 0) {
let index = -1;
for (const plugin of plugins) {
index += 1;
let materialized: unknown;
try {
// [#22301] What an entry becomes — a string is a package specifier,
// a plain bundle (no `init`) is wrapped into `AppPlugin`, an instance
// is itself — is ONE rule, `materializeStackPlugin` (`@objectstack/
// core`), which `@objectstack/verify`'s `bootStack` mounts the same
// array by. Only the loading is this boot's own:
const pluginToLoad: any = await materializeStackPlugin(plugin, {
const pluginToLoad: any = materialized = await materializeStackPlugin(plugin, {
// Host-anchored, NOT a bare `import()`: this specifier comes from
// the served app's own config, so what the app DECLARES about it is
// the contract (commit 9cc6777d3). The helper carries the failure wrapper too.
Expand Down Expand Up @@ -4752,9 +4758,13 @@ export default class Serve extends Command {
trackPlugin(pluginName);
} catch (e: any) {
console.error(chalk.red(` ✗ Failed to load plugin: ${e.message}`));
unloadedStackPlugins.push({ index, entry: plugin, materialized, error: e });
}
}
}
// An entry that did not load and may provide the crypto provider refuses
// the boot, before any secret is sealed: see the function.
refuseUnloadedCryptoProviderPlugins(unloadedStackPlugins);

// [ADR-0025 §3.5 M1] The plugins `os plugin install` installed for this
// project and environment, verified again before any of their code is
Expand Down Expand Up @@ -6402,6 +6412,58 @@ export function refuseTwoCryptoProviderPlugins(composed: Iterable<unknown>): voi
}
}

/** A `plugins` entry `os serve` could not load: {@link refuseUnloadedCryptoProviderPlugins}. */
export interface UnloadedStackPlugin {
/** Its position in the stack's `plugins`. */
index: number;
/** The entry as the config wrote it. */
entry: unknown;
/** What it materialized to before it failed; `undefined` when it never did. */
materialized?: unknown;
error: unknown;
}

/**
* Refuse a boot in which a `plugins` entry did not load and nothing rules out
* that it provides the crypto provider ({@link CRYPTO_PROVIDER_SERVICE}).
*
* The provider seals every secret field, datasource credential and encrypted
* setting, and the host chooses it once at `kernel:ready`
* ({@link createHostCryptoProvider}). A boot that goes on without the plugin
* that provides it would choose another one, so it does not go on. An entry is
* ruled out only by what it says it provides: the plugin it materialized to
* (or the entry itself, when it is an object) has a `providesServices`
* (ADR-0116) that does not name the provider. An entry that never
* materialized (a package specifier that does not resolve or import) says
* nothing, and is refused. Every other unloaded entry keeps the boot's
* existing handling: it is printed and the boot goes on without it.
*
* The one-shot secret commands keep the same rule over the same host config.
*/
export function refuseUnloadedCryptoProviderPlugins(unloaded: readonly UnloadedStackPlugin[]): void {
const refused = unloaded.filter(({ entry, materialized }) => {
const said = materialized && typeof materialized === 'object'
? materialized
: entry && typeof entry === 'object' ? entry : undefined;
if (!said) return true;
const provides = (said as { providesServices?: unknown }).providesServices;
return Array.isArray(provides) && provides.includes(CRYPTO_PROVIDER_SERVICE);
});
if (refused.length === 0) return;
const label = ({ index, entry, materialized }: UnloadedStackPlugin): string => {
if (typeof entry === 'string') return `plugins[${index}] ('${entry}')`;
const name = (materialized as { name?: unknown } | null | undefined)?.name ?? (entry as { name?: unknown } | null)?.name;
return typeof name === 'string' ? `plugins[${index}] ('${name}')` : `plugins[${index}]`;
};
const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error));
throw new HostCryptoProviderRefusal(
`Refusing to boot: ${refused.map((u) => `${label(u)} did not load (${reason(u.error)})`).join('; ')}, `
+ `and nothing rules out that ${refused.length === 1 ? 'it provides' : 'one of them provides'} the crypto provider `
+ `('${CRYPTO_PROVIDER_SERVICE}'). It seals every secret field, datasource credential and encrypted setting, `
+ 'so this boot does not choose another one. Make the entry load, or remove it from the stack\'s plugins.',
);
}

/**
* What {@link createUnknownHostnameGuardPlugin} is constructed with.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
// test-budget: 40 — spawns `os serve` (src, via runServe) once per single-cause fixture: four boots, two refused and two that reach the ready banner; budget to be re-measured

/**
* `os serve` refuses to boot when a `plugins` entry fails to load and it cannot
* rule out that the entry provides the crypto provider (`crypto-provider`).
*
* The crypto provider seals every secret the deployment holds. A boot that
* goes on without the plugin that provides it chooses another one at
* `kernel:ready`, so this boot refuses instead, naming the entry. That is the
* rule the one-shot secret commands keep for the same host config.
*
* - an entry that does not materialize (a specifier that does not resolve):
* nothing says what it provides, so the boot is refused;
* - an entry that materializes, declares the crypto provider in
* `providesServices`, and is refused by the kernel: refused;
* - CONTROL: an entry that materializes, declares nothing of the kind, and is
* refused by the kernel: the boot goes on, as before, and says so;
* - CONTROL: a config whose every entry loads boots.
*
* A real `os serve` process per case (`runServe`): the refusal is a property of
* the boot loop inside `run()`, which no in-process call reaches.
*/

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runServe } from './helpers/serve-process.js';

/** Either terminal outcome: the complete ready banner, or the refusal line. */
const SETTLED = /Press Ctrl\+C to stop|Refusing to boot/;
const REFUSED = /Refusing to boot/;

const OBJECT = `objects: [{ name: 'cpl_probe', label: 'Probe', fields: { title: { type: 'text', label: 'Title' } } }],`;

const CONFIGS = {
// A plugin instance beside the specifier makes this a host config, whose
// `plugins` the boot loop mounts (a config of specifiers alone boots the
// library stack instead).
unresolvable: `export default {
plugins: [{ name: 'com.acme.host', version: '1.0.0', init: async () => {} }, '@acme/not-installed-crypto-provider'],
${OBJECT}
};
`,
declaringRefused: `export default {
plugins: [{
name: 'com.acme.kms-crypto-provider',
version: 'not-a-version',
providesServices: ['crypto-provider'],
init: async () => {},
}],
${OBJECT}
};
`,
nonDeclaringRefused: `export default {
plugins: [{
name: 'com.acme.unrelated',
version: 'not-a-version',
providesServices: ['acme-widgets'],
init: async () => {},
}],
${OBJECT}
};
`,
allLoad: `export default {
plugins: [{ name: 'com.acme.fine', version: '1.0.0', init: async () => {} }],
${OBJECT}
};
`,
} as const;

const dirs: Partial<Record<keyof typeof CONFIGS, string>> = {};

beforeAll(() => {
for (const [key, config] of Object.entries(CONFIGS) as Array<[keyof typeof CONFIGS, string]>) {
const dir = mkdtempSync(join(tmpdir(), `os-crypto-plugin-load-${key}-`));
writeFileSync(join(dir, 'objectstack.config.ts'), config, 'utf8');
dirs[key] = dir;
}
});

afterAll(() => {
for (const dir of Object.values(dirs)) if (dir) rmSync(dir, { recursive: true, force: true });
});

const boot = async (key: keyof typeof CONFIGS) => {
const run = await runServe(dirs[key]!, [], { waitFor: SETTLED, timeoutMs: 150_000 });
return { ...run, output: run.stdout + run.stderr };
};

describe('os serve — a plugins entry that fails to load and may provide the crypto provider', () => {
it('an entry that does not materialize: the boot is refused, naming the entry', async () => {
const { output, port } = await boot('unresolvable');
expect(output).toContain('Failed to load plugin');
expect(output, output).toMatch(REFUSED);
expect(output).toContain('@acme/not-installed-crypto-provider');
expect(output).toContain("'crypto-provider'");
expect(port, 'a refused boot binds nothing').toBeUndefined();
}, 180_000);

it('an entry that declares the crypto provider and is refused by the kernel: the boot is refused, naming it', async () => {
const { output, port } = await boot('declaringRefused');
expect(output).toContain('Failed to load plugin');
expect(output, output).toMatch(REFUSED);
expect(output).toContain('com.acme.kms-crypto-provider');
expect(port).toBeUndefined();
}, 180_000);

it('CONTROL: an entry that declares no crypto provider and is refused by the kernel: the boot goes on', async () => {
const { output, port } = await boot('nonDeclaringRefused');
expect(output).toContain('Failed to load plugin');
expect(output, output).not.toMatch(REFUSED);
expect(output).toMatch(/Press Ctrl\+C to stop/);
expect(port).toBeGreaterThan(0);
}, 180_000);

it('CONTROL: a config whose every entry loads boots', async () => {
const { output, port } = await boot('allLoad');
expect(output).not.toContain('Failed to load plugin');
expect(output, output).not.toMatch(REFUSED);
expect(port).toBeGreaterThan(0);
}, 180_000);
});
Loading