Skip to content

Commit d3f3686

Browse files
fix(cli): one-shot secret commands seal with the crypto provider os serve uses (#31228)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQSKftazBL1M6YfL5N8dHSC)_ Fixes #31007 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:一次性 CLI 密钥命令(`os secret rewrap`、`os secret orphans`、带设置服务的数据迁移)不再自建本地密钥提供方,而是用 `os serve` 同一个选择函数 `createHostCryptoProvider`:宿主配置里有插件声明 `crypto-provider` 就启动它并用它加解密;两个声明拒绝并点名两者;没有声明照旧用已存在的本地密钥。 - 为什么改:#30993 之后 `serve` 用插件注册的 KMS/Vault 提供方封存密钥,这些一次性命令仍用本地密钥,打不开 serve 封存的值;`rewrap` 甚至可能把行改封到本地密钥下(降级)。 - 风险与回滚:失败即关闭:声明的提供方启动失败、启动后没注册、或宿主配置/插件条目读不了,命令都拒绝、点名、不写任何行,⛔ 不回退本地密钥。新增一个读配置的步骤,配置坏了的项目上这些命令会从「照跑」变成「拒绝」。回滚即 revert。 - 先看:`packages/cli/src/utils/one-shot-settings.ts`(`oneShotCrypto` / `readDeclaredCryptoProvider`,拒绝分支);`packages/cli/src/commands/secret/rewrap.ts`(启动失败与 `confirm()` 的拒绝点);`packages/cli/src/commands/serve.ts` 抽出的 `cryptoProviderPlugins`。 - 两分钟验证:`pnpm --filter @objectstack/cli exec vitest run src/utils/one-shot-crypto-provider.test.ts src/commands/secret/rewrap.driver-contract.test.ts`,预期 `Tests 15 passed (15)`。 - 需要你做的:无,席位落地。 ## What changes - **`packages/cli/src/utils/one-shot-settings.ts`**: new `oneShotCrypto({ cwd, env })` composes a one-shot boot's crypto through `os serve`'s own function, `createHostCryptoProvider`. It reads the host config's `plugins` (`stackBootPlugins(config, false)`), materializes each entry the way `serve` does (`materializeStackPlugin`, `Serve.importConfigPlugin` anchored at the config's directory), and keeps the plugins that declare `providesServices: ['crypto-provider']` (`cryptoProviderPlugins`). - **One declaring plugin**: it is composed into the boot and the run seals and opens with the provider it registers. Its `init()` is wrapped so a throw refuses naming it. The host seam's local fallback for that deployment is a refusal naming it, so a plugin that boots and registers nothing stops the boot at `kernel:ready`. The local key is never resolved. - **Two**: `refuseTwoCryptoProviderPlugins` (serve's) refuses before either initializes, both named. - **None, or no host config**: as before — the local provider over the key that already exists, strict posture, never minting. With no key, a provider that refuses every call. - **Config that does not load, or an entry that does not materialize**: refused, naming the file or `plugins[i]`. Whether a provider is declared cannot be read, and "none" would be the fallback the security read forbids. - `oneShotSettingsPlugin()` becomes `oneShotSettingsPlugins()`: it returns the composition's plugin list (declared provider plugin, host crypto seam, settings service over the seam's provider), spread into the boot. - **`packages/cli/src/commands/serve.ts`**: `cryptoProviderPlugins(composed)` is extracted from `refuseTwoCryptoProviderPlugins`, so serve's two-provider refusal and the one-shot reader share one reading of "who declares the provider". No behaviour change in `serve`. - **`packages/cli/src/commands/secret/rewrap.ts`**: composes `oneShotCrypto()` before the boot (refusals there answer `crypto_provider_refused`), spreads its plugins into the boot, and after the boot confirms the chosen provider with `serve`'s `confirm()`. A boot failure with a declared provider names it. A row to open needs either a confirmed provider or, on the local path, an existing key (`crypto_key_unavailable`, unchanged). The report's key source names the provider: `crypto provider 'PLUGIN'`. - **`packages/cli/src/utils/data-migration-plugins.ts`**, **`packages/cli/src/commands/secret/orphans.ts`**: spread the plural composition. The storage arm's `try/catch` around the settings service used to swallow every failure as "optional". It now rethrows a `HostCryptoProviderRefusal`, so a declared provider the run cannot read stops the run. - **`.changeset/31007-one-shot-crypto-provider.md`**: `@objectstack/cli` patch, both declaration lines. ## Tests - New **`packages/cli/src/utils/one-shot-crypto-provider.test.ts`** (8 cases). They mirror `serve-host-crypto-provider.test.ts`: each writes a host config into a fresh directory, composes `oneShotCrypto`, and boots the composition in a **real `ObjectKernel`**. - One declared and registered provider: `source` is `registered`, `confirm()` returns the plugin's provider, the seal carries its `kmsKeyId` and does not open under the local key, the settings service holds the host provider, and `key` is `null` (local never resolved). - None declared (no config, and a config with an unrelated plugin): `source` is `local`. What it seals opens under a `LocalCryptoProvider` built the original way, and the other way round. With no key in production, every call refuses with the reason. - Two declared: `HostCryptoProviderRefusal` naming `'com.example.kms-a' and 'com.example.kms-b'`, and neither `init()` ran. - Fail closed, each with a usable local key present: `init()` throws (refusal names the plugin and its cause); boots but registers none (refused at `kernel:ready`, naming it; later calls refuse too); config throws at load (refusal names the file); a `plugins` entry that does not resolve (refusal names `plugins[0] ('…')`). Every refusal is checked for key bytes. - **`packages/cli/src/commands/secret/rewrap.driver-contract.test.ts`** (+2, real SQLite boot through the command): - A declared provider that registers a provider over the sealing key, with `OS_SECRET_KEY` removed: the dry run re-wraps 2 rows with 0 unreadable, and the report's key source is `crypto provider 'com.example.kms'`. - **The security read's refusal case**: a declared provider whose `init()` throws, with the deployment's old local key still in `OS_SECRET_KEY` and a held version-1 row still to re-wrap. `--apply --yes` answers `boot_failed` naming `'com.example.kms-offline'`, exit 1, and `sys_secret` reads back equal to before. - **Ablation** of that refusal (`scripts/ablation-replace.mjs`: anchor ` if (declared) {` hit once, blob `17673a5a` → `f0975a50`, i.e. the declared provider ignored and the local key used): the case reds with `expected undefined to be 'boot_failed'`, and the run's report reads `keySource: env:OS_SECRET_KEY`, `rewrap: 3`. The rows were re-sealed under the local key, the downgrade the security read forbids. Restore: blob equals HEAD (`17673a5a`), `git diff HEAD` empty. The test imports the module as relative source, so no build sits on the path. - Updated: `one-shot-settings.pin.test.ts` (the helper's no-key and key-file cases read the provider the host seam chooses), `rewrap.guards.test.ts` (the mocked boot drives the host seam's `init()` and `kernel:ready` as a kernel would), the two driver-contract suites (plural composition). ## Acceptance notes - **Measured arm of the card: "use it" when it boots, "refuse naming it" when it cannot.** The one-shot boots compose no host plugin today. The declaring plugin is composed in full (its `start()` runs too), as `serve` composes it. The alternative, `composeProviderForDeclarations` (start suppressed), would leave a provider that connects in `start()` unusable in every one-shot. A plugin whose dependencies the one-shot boot lacks fails the boot, and the refusal names it. - **Only `plugins`, not `devPlugins`.** That is the list `os serve` boots without `--dev`, and `os start`. A provider declared only in `devPlugins` is not read here. - **Undeclared registrations.** A plugin that registers `crypto-provider` without declaring it is not booted by a one-shot run, so the run takes the local path. Reading the provider off stored rows would close that, but nothing asks for it. It cannot downgrade a row such a provider sealed: the local key does not open it, so rewrap counts it `refused_unreadable` and writes nothing. - **New refusal surface.** Every one-shot command now loads the host config when one is present. A config `serve` itself could not load now refuses these commands instead of being ignored. - Out of scope per the card: installed plugins (`os plugin install`) sit outside #30993's declaration check. - `serve`'s own loop logs and continues past a stack plugin that fails to load (`✗ Failed to load plugin`), so a provider plugin that fails to import there leaves `serve` on the local provider. Not touched here (serve path, not this card); noted for the seat. ## Verification All readings at head `e82bb064` (after merging `origin/main` at `4c318214`). - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` → 63 families, every one run with its exit code captured before any pipe. `--ran` reconciliation: `✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED`. All 63 exit 0. - `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit && pnpm check:test-typecheck`) → exit 0; `check:test-typecheck: OK`. - `eslint --no-inline-config` over every touched cli file → exit 0. - cli `unit` tier, `vitest run --project unit` → `Test Files 399 passed (399)`, `Tests 5630 passed (5630)`. - Touched suites (unit + integration): `one-shot-crypto-provider`, `one-shot-settings.pin`, `rewrap.guards`, `rewrap.driver-contract`, `orphans.guards`, `orphans.driver-contract`, `serve-host-crypto-provider` → `Test Files 7 passed (7)`, `Tests 79 passed (79)`. - The composition reaches every `bootSchemaStack` caller, so `schema-migrate.one-shot-family.integration.test.ts` was run locally: `Tests 164 passed (164)`. The rest of the `integration` tier is declared to CI. <details><summary>Ablation output</summary> ```text ablation-replace: anchor " if (declared) {" x1 (before) ablation-replace: ok mutation landed: anchor 1 -> 0, blob 17673a5 -> f0975a50d53f × [#31007] a declared provider that cannot boot: --apply refuses naming it, and every stored value is byte-equal AssertionError: {"mode":"apply","report":{"mode":"apply","keySource":"env:OS_SECRET_KEY", … "counts":{"total":5,"rewrap":3, … : expected undefined to be 'boot_failed' ablation-replace: ok restored: blob == HEAD (17673a5) and `git diff HEAD` is empty ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5kDtnUcfoZfMJqLNWd)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5b22ee7 commit d3f3686

11 files changed

Lines changed: 800 additions & 76 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): one-shot secret commands seal and open with the crypto provider `os serve` uses
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`os secret rewrap`, `os secret orphans` and the data migrations that compose the settings service (`os migrate files-to-references`, `os storage orphans`, `os migrate recompute-home-amounts`) used to build their own `LocalCryptoProvider`. On a deployment whose plugin registers a KMS- or Vault-backed provider as the `crypto-provider` service, they could open nothing `os serve` sealed, and a re-wrap could have re-sealed under the local key.
11+
12+
They now choose the provider with `os serve`'s own function. They read the host config's `plugins`, as `os serve` does without `--dev`:
13+
14+
- One plugin declares `providesServices: ['crypto-provider']`: the run boots it and seals and opens with the provider it registers.
15+
- Two plugins declare it: the run refuses before either initializes, and names both.
16+
- No plugin declares it: nothing changes. The run uses the local provider over the key that already exists, and never mints one.
17+
18+
The run fails closed and never falls back to the local key for a deployment that declares a provider. It refuses, names the provider, and writes nothing when:
19+
20+
- the declared plugin cannot boot in the run;
21+
- the declared plugin boots but registers no provider;
22+
- the host config, or one of its `plugins` entries, cannot be loaded.
23+
24+
`os secret rewrap` names the provider as the report's key source.

‎packages/cli/src/commands/secret/orphans.driver-contract.test.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,19 @@ import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
6060
import '@objectstack/service-settings';
6161
import { bootSchemaStack, type SchemaStack } from '../../utils/schema-migrate.js';
6262
import type { SecretReferenceEngineLike } from '../../utils/secret-reference-union.js';
63-
import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js';
63+
import { oneShotSettingsPlugins } from '../../utils/one-shot-settings.js';
6464
import SecretOrphans from './orphans.js';
6565

66+
// [#31007] Pay the first load of what the one-shot crypto composition reaches through
67+
// dynamic `import()`s (the host config reader and `serve.ts`) at MODULE LOAD, outside
68+
// any clocked case.
69+
import '@objectstack/driver-sql';
70+
import '@objectstack/driver-turso';
71+
import '@objectstack/plugin-security';
72+
import '@objectstack/rest';
73+
import '@objectstack/runtime';
74+
import '@objectstack/service-datasource';
75+
6676
const HERE = dirname(fileURLToPath(import.meta.url));
6777
const CLI_ROOT = resolve(HERE, '..', '..', '..');
6878

@@ -182,7 +192,7 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)',
182192
databaseUrl: `file:${dbFile}`,
183193
// Byte-identical to `orphans.ts`'s own list — the boot has to be the
184194
// command's, or the driver this file names is not the one it holds.
185-
extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()],
195+
extraPlugins: [new PlatformObjectsPlugin(), ...await oneShotSettingsPlugins()],
186196
});
187197

188198
const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined;

‎packages/cli/src/commands/secret/orphans.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
isExitSignal,
1919
} from '../../utils/format.js';
2020
import { bootSchemaStack } from '../../utils/schema-migrate.js';
21-
import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js';
21+
import { oneShotSettingsPlugins } from '../../utils/one-shot-settings.js';
2222
import { absentTableReads, secretUnionReadView } from '../../utils/absent-table-reads.js';
2323
import { describeDriverSqliteFile } from '../../utils/connection-display.js';
2424
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
@@ -230,7 +230,7 @@ export default class SecretOrphans extends Command {
230230
// service's default provider: in a development posture with no key,
231231
// that default mints a key file in the key home, and this report
232232
// promises to write no row or table, and writes no key file either.
233-
extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()],
233+
extraPlugins: [new PlatformObjectsPlugin(), ...await oneShotSettingsPlugins()],
234234
// [#21391] The report boots READ-ONLY, the boot `os migrate plan`
235235
// takes: `deferSchemaDdl` holds schema DDL back on every SQL
236236
// datasource, and `readOnlyProbe` keeps a missing sqlite file from

‎packages/cli/src/commands/secret/rewrap.driver-contract.test.ts‎

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,19 @@ import { ciphertextDerivationStatus, LocalCryptoProvider } from '@objectstack/se
3535
import type { CryptoContext } from '@objectstack/spec/contracts';
3636
import { bootSchemaStack, type SchemaStack } from '../../utils/schema-migrate.js';
3737
import type { SecretReferenceEngineLike } from '../../utils/secret-reference-union.js';
38-
import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js';
38+
import { oneShotSettingsPlugins } from '../../utils/one-shot-settings.js';
3939
import SecretRewrap from './rewrap.js';
4040

41+
// [#31007] Pay the first load of what the one-shot crypto composition reaches through
42+
// dynamic `import()`s (the host config reader and `serve.ts`) at MODULE LOAD, outside
43+
// any clocked case.
44+
import '@objectstack/driver-sql';
45+
import '@objectstack/driver-turso';
46+
import '@objectstack/plugin-security';
47+
import '@objectstack/rest';
48+
import '@objectstack/runtime';
49+
import '@objectstack/service-datasource';
50+
4151
const HERE = dirname(fileURLToPath(import.meta.url));
4252
const CLI_ROOT = resolve(HERE, '..', '..', '..');
4353

@@ -106,7 +116,7 @@ describe('os secret rewrap — the concrete driver and the command, end to end (
106116
// `rewrap.ts`'s own list: the one-shot settings composition, over the
107117
// key this file declares in `OS_SECRET_KEY` (the command resolves the
108118
// same key first and hands that instance in).
109-
extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()],
119+
extraPlugins: [new PlatformObjectsPlugin(), ...await oneShotSettingsPlugins()],
110120
});
111121
const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined;
112122
if (!engine) throw new Error('no objectql engine on the booted stack — nothing to measure');
@@ -205,6 +215,58 @@ describe('os secret rewrap — the concrete driver and the command, end to end (
205215
expect(await secretDriver.find('sys_secret', {})).toEqual(before);
206216
}, 180_000);
207217

218+
/**
219+
* [#31007] A host config in the run's directory whose one plugin declares
220+
* the crypto provider — `os serve`'s provider for this deployment. Removed
221+
* again by the returned function.
222+
*/
223+
const declareProvider = (plugin: Record<string, unknown>): (() => void) => {
224+
(globalThis as Record<string, unknown>).__os_rewrap_provider_plugin = plugin;
225+
const file = join(dir, 'objectstack.config.mjs');
226+
writeFileSync(file, "export default { name: 'rewrap_dc', plugins: [globalThis.__os_rewrap_provider_plugin] };\n");
227+
return () => {
228+
rmSync(file, { force: true });
229+
delete (globalThis as Record<string, unknown>).__os_rewrap_provider_plugin;
230+
};
231+
};
232+
233+
it('[#31007] a declared provider that boots re-wraps the rows, and the local key is never read', async () => {
234+
const opened: string[] = [];
235+
const inner = new LocalCryptoProvider({ key: KEY });
236+
const kms = {
237+
encrypt: (plain: string, ctx: CryptoContext) => inner.encrypt(plain, ctx),
238+
decrypt: (handle: Parameters<typeof inner.decrypt>[0], ctx: CryptoContext) => { opened.push(ctx.scope); return inner.decrypt(handle, ctx); },
239+
rotateKey: (handle: Parameters<typeof inner.rotateKey>[0], ctx: CryptoContext) => inner.rotateKey(handle, ctx),
240+
digest: (plain: string) => inner.digest(plain),
241+
keyedDigest: (plain: string) => inner.keyedDigest(plain),
242+
};
243+
const removeConfig = declareProvider({
244+
name: 'com.example.kms',
245+
version: '1.0.0',
246+
providesServices: ['crypto-provider'],
247+
init: async (ctx: { registerService(name: string, service: unknown): void }) => {
248+
ctx.registerService('crypto-provider', kms);
249+
},
250+
});
251+
const savedKey = process.env.OS_SECRET_KEY;
252+
// No local key at all: only the registered provider can open these rows.
253+
delete process.env.OS_SECRET_KEY;
254+
try {
255+
const before = await secretDriver.find('sys_secret', {});
256+
const { payload, exitCode } = await runJson(['--declared-datasources', declaredFile]);
257+
258+
expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBeUndefined();
259+
expect(payload.report.byClass).toMatchObject({ rewrap: 2, refused_unreadable: 0 });
260+
expect(payload.report.keySource).toBe("crypto provider 'com.example.kms'");
261+
expect(exitCode).toBe(0);
262+
expect(opened.length).toBeGreaterThan(0);
263+
expect(await secretDriver.find('sys_secret', {})).toEqual(before);
264+
} finally {
265+
process.env.OS_SECRET_KEY = savedKey;
266+
removeConfig();
267+
}
268+
}, 180_000);
269+
208270
it('with no data key, the run refuses before opening a row, and NO provider in its boot mints one', async () => {
209271
// A development posture with no key anywhere: the posture in which a
210272
// default provider mints a key file. The settings service the boot
@@ -277,4 +339,37 @@ describe('os secret rewrap — the concrete driver and the command, end to end (
277339
expect(again.exitCode).toBe(0);
278340
expect(await secretDriver.find('sys_secret', {})).toEqual(tableBefore);
279341
}, 180_000);
342+
// LAST on purpose: a boot that fails keeps this process's stdout reserved
343+
// for the JSON payload (`bootSchemaStack`'s `jsonOutput`), as the one-shot
344+
// process exits right after it, so no run may follow it in this file.
345+
it('[#31007] a declared provider that cannot boot: --apply refuses naming it, and every stored value is byte-equal', async () => {
346+
// A held version-1 row the earlier --apply has not re-wrapped, so a run
347+
// that fell back to the local key — still present in `OS_SECRET_KEY` —
348+
// would re-wrap it.
349+
const engine = stack!.kernel.getService('objectql') as SecretReferenceEngineLike;
350+
const settingDriver = engine.getDriverForObject('sys_setting') as unknown as DriverProbe;
351+
const held = v1Row('sec_rewrap_dc_31007', 'smtp', 'username', 'dc-31007-plain');
352+
await secretDriver.create('sys_secret', { ...held });
353+
await settingDriver.create('sys_setting', { namespace: 'smtp', key: 'username', value_enc: held.id, organization_id: 'org_fixture' });
354+
355+
const removeConfig = declareProvider({
356+
name: 'com.example.kms-offline',
357+
version: '1.0.0',
358+
providesServices: ['crypto-provider'],
359+
init: async () => { throw new Error('KMS endpoint unreachable'); },
360+
});
361+
try {
362+
const before = await secretDriver.find('sys_secret', {});
363+
const { payload, exitCode } = await runJson(['--apply', '--yes', '--declared-datasources', declaredFile]);
364+
365+
expect(payload.error, JSON.stringify(payload).slice(0, 400)).toBe('boot_failed');
366+
expect(payload.message).toContain("'com.example.kms-offline'");
367+
expect(payload.message).toContain('Nothing was opened or written');
368+
expect(payload.message).not.toContain(KEY.toString('hex'));
369+
expect(exitCode).toBe(1);
370+
expect(await secretDriver.find('sys_secret', {})).toEqual(before);
371+
} finally {
372+
removeConfig();
373+
}
374+
}, 180_000);
280375
});

‎packages/cli/src/commands/secret/rewrap.guards.test.ts‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ import SecretRewrap from './rewrap.js';
4040
import { bootSchemaStack } from '../../utils/schema-migrate.js';
4141
import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js';
4242

43+
// [#31007] Pay the first load of what the one-shot crypto composition reaches through
44+
// dynamic `import()`s (the host config reader and `serve.ts`) at MODULE LOAD, outside
45+
// any clocked case.
46+
import '@objectstack/driver-sql';
47+
import '@objectstack/driver-turso';
48+
import '@objectstack/plugin-security';
49+
import '@objectstack/rest';
50+
import '@objectstack/runtime';
51+
import '@objectstack/service-datasource';
52+
4353
vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: vi.fn() }));
4454
// Constructed and handed to the (mocked) boot, never used.
4555
vi.mock('@objectstack/platform-objects/plugin', () => ({ PlatformObjectsPlugin: class {} }));
@@ -137,16 +147,35 @@ function wireBoot(
137147
},
138148
};
139149
const absent = new Set(opts.absent ?? []);
140-
vi.mocked(bootSchemaStack).mockResolvedValue({
141-
// The primary datasource's driver, as `SchemaStack.driver` carries it.
142-
...(opts.driverConfig ? { driver: { config: opts.driverConfig } } : {}),
143-
kernel: { getService: (name: string) => (name === 'objectql' ? engine : undefined) },
144-
tableAbsent: (objectName: string) => absent.has(objectName),
145-
shutdown: async () => {},
146-
} as never);
150+
vi.mocked(bootSchemaStack).mockImplementation((async (bootOpts: { extraPlugins?: unknown[] }) => {
151+
await driveHostCrypto(bootOpts.extraPlugins ?? []);
152+
return {
153+
// The primary datasource's driver, as `SchemaStack.driver` carries it.
154+
...(opts.driverConfig ? { driver: { config: opts.driverConfig } } : {}),
155+
kernel: { getService: (name: string) => (name === 'objectql' ? engine : undefined) },
156+
tableAbsent: (objectName: string) => absent.has(objectName),
157+
shutdown: async () => {},
158+
};
159+
}) as never);
147160
return harness;
148161
}
149162

163+
/**
164+
* [#31007] What the replaced boot still does for the crypto: the host's
165+
* crypto-provider seam (`createHostCryptoProvider`, `os serve`'s) is
166+
* initialized and its `kernel:ready` hook fired, as a kernel would, with no
167+
* plugin registering a provider — so the run's provider is the local one,
168+
* chosen by the function `serve` chooses with.
169+
*/
170+
async function driveHostCrypto(plugins: readonly unknown[]): Promise<void> {
171+
const host = plugins.find((p) => (p as { name?: unknown }).name === 'com.objectstack.cli.host-crypto-provider') as
172+
{ init(ctx: unknown): Promise<void> } | undefined;
173+
if (!host) throw new Error('the run composed no host crypto-provider seam');
174+
const ready: Array<() => unknown> = [];
175+
await host.init({ getServices: () => new Map(), hook: (_name: string, handler: () => unknown) => ready.push(handler) });
176+
for (const handler of ready) await handler();
177+
}
178+
150179
async function run(argv: string[]): Promise<{ payload: Record<string, any>; exitCode: number }> {
151180
const chunks: string[] = [];
152181
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(

0 commit comments

Comments
 (0)