From da6acc015d312899a79cae8c39aeb442eb67c850 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:53:37 +0000 Subject: [PATCH 1/2] test(cli): pin that no one-shot command creates key material, before the fix Adds the one-shot settings composition (utils/one-shot-settings.ts) and the two pins that hold it: the enumeration of every CLI module that names the settings plugin or the local crypto provider, and, across every bootSchemaStack caller and mode, an empty key home in a development posture staying empty, with the default composition minting there as the control. The commands still compose the default here, so the pins are red on this commit by design; the next commit moves the commands onto the helper. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .../src/utils/one-shot-settings.pin.test.ts | 204 ++++++++++++++++++ packages/cli/src/utils/one-shot-settings.ts | 105 +++++++++ ...igrate.one-shot-family.integration.test.ts | 63 +++++- 3 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/utils/one-shot-settings.pin.test.ts create mode 100644 packages/cli/src/utils/one-shot-settings.ts diff --git a/packages/cli/src/utils/one-shot-settings.pin.test.ts b/packages/cli/src/utils/one-shot-settings.pin.test.ts new file mode 100644 index 00000000000..cf13aea53f7 --- /dev/null +++ b/packages/cli/src/utils/one-shot-settings.pin.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21471] The settings service is composed in ONE place in this package, and + * that place never mints a data key. + * + * ## The enumeration + * + * `SettingsServicePlugin` handed no `cryptoProvider` builds a + * `LocalCryptoProvider` of its own, and in a development posture with no key + * that provider writes a key file into the key home. `os secret orphans` (a + * report that "writes nothing") and the storage arm of the data-migration + * plugins (`os storage orphans`, `os migrate files-to-references`) composed it + * that way, one call site at a time. So the family here is not a list someone + * remembered: it is every non-test module under `src/` whose CODE names the + * plugin or the provider, by any spelling the code can use — the constructor, + * a destructured or renamed import, a property read, the capability table's + * string. A new composer fails the first case below, by file name. + * + * Allowed: `utils/one-shot-settings.ts`, which composes it for every one-shot + * command, and the hosts in {@link HOSTS}, each with the reason it may take + * the default. Comments are masked by the repo's one code/prose separator, so + * a docblock that mentions the plugin is not a composer. + * + * ⚠️ Out of reach, stated: a composition inside ANOTHER package that a command + * calls into (a library's own boot harness) names nothing here. That residue + * is the census's to list, not this pin's to see. + * + * ## The helper's contract + * + * Read directly, in a development posture with an empty key home, against the + * control that the default provider mints there. The command-level pin — every + * `bootSchemaStack` caller, every mode, booted for real — is + * `schema-migrate.one-shot-family.integration.test.ts`. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { LocalCryptoProvider } from '@objectstack/service-settings'; +// The one code/prose separator, typed by the hand-written `.d.mts` beside it. +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; +import { oneShotSettingsPlugin, refusingCryptoProvider, resolveExistingDataKey } from './one-shot-settings.js'; + +/** …/packages/cli/src/utils */ +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** …/packages/cli/src — the whole CLI source tree, this package's own. */ +const SRC = resolve(HERE, '..'); + +/** The one module that composes the settings service for a one-shot command. */ +const HELPER = 'utils/one-shot-settings.ts'; + +/** Modules that may take the default composition, and why each may. */ +const HOSTS: Record = { + 'commands/serve.ts': + 'the long-lived host (`os serve`, and `os dev` / `os start`, which spawn it): a key persisted in a ' + + 'development posture so that restarts reuse it is that host\'s documented behaviour, and a ' + + 'production posture refuses to boot without a stable key', +}; + +/** Every spelling under which code can reach the plugin or the provider. */ +const COMPOSER = /\b(?:SettingsServicePlugin|LocalCryptoProvider|InMemoryCryptoProvider)\b/; + +/** Every non-test source module under `src/`, as a path relative to it. */ +function sourceModules(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const abs = join(dir, entry.name); + if (entry.isDirectory()) out.push(...sourceModules(abs)); + else if ( + /\.[cm]?[jt]s$/.test(entry.name) + && !/\.(?:test|spec)\.[cm]?[jt]s$/.test(entry.name) + && !/\.d\.[cm]?ts$/.test(entry.name) + ) { + out.push(relative(SRC, abs).split('\\').join('/')); + } + } + return out; +} + +describe('[#21471] the settings service is composed in one place in this package', () => { + it('every module whose code names the settings plugin or the local provider is the helper or a declared host', () => { + const found = sourceModules(SRC) + .filter((rel) => COMPOSER.test(maskComments(readFileSync(join(SRC, rel), 'utf8')))) + .sort(); + // Non-vacuity: the detector sees the host's capability-table string. + expect(found).toContain('commands/serve.ts'); + expect(found, 'a module composes the settings service or a crypto provider outside the one-shot helper') + .toEqual([HELPER, ...Object.keys(HOSTS)].sort()); + }); + + it('the detector reads code, not prose', () => { + expect(COMPOSER.test(maskComments('// a docblock naming SettingsServicePlugin\nconst x = 1;\n'))).toBe(false); + expect(COMPOSER.test(maskComments("const { SettingsServicePlugin: S } = await import('x');\n"))).toBe(true); + expect(COMPOSER.test(maskComments("const spec = { export: 'SettingsServicePlugin' };\n"))).toBe(true); + expect(COMPOSER.test(maskComments('new mod.InMemoryCryptoProvider();\n'))).toBe(true); + }); +}); + +// ── The helper, in a development posture with an empty key home ──────────── + +const KEY_ENV = [ + 'NODE_ENV', 'OS_SECRET_KEY', 'OS_DEV_CRYPTO_KEY', 'OBJECTSTACK_DEV_CRYPTO_KEY', + 'OS_HOME', 'OBJECTSTACK_HOME', 'OS_CRYPTO_AUTOKEY', +] as const; +const KEY_HEX = '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f'; +const savedEnv: Record = {}; +let home: string; + +beforeEach(() => { + for (const k of KEY_ENV) savedEnv[k] = process.env[k]; + for (const k of KEY_ENV) delete process.env[k]; + process.env.NODE_ENV = 'development'; + home = mkdtempSync(join(tmpdir(), 'os-21471-key-home-')); + process.env.OS_HOME = home; +}); + +afterEach(() => { + for (const k of KEY_ENV) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + rmSync(home, { recursive: true, force: true }); +}); + +/** + * The provider the plugin was handed, read off its options: the field the + * plugin reads when it binds the engine (`this.opts.cryptoProvider ?? …`). + * Private to TypeScript, read on purpose — it IS the composition under test. + */ +function providerOf(plugin: unknown): { encrypt: (...a: unknown[]) => Promise } & Record { + return (plugin as { opts: { cryptoProvider: never } }).opts.cryptoProvider; +} + +describe('[#21471] the one-shot helper never mints a data key', () => { + it('the control: the default provider mints a key file in this posture and this home', () => { + const minted = new LocalCryptoProvider(); + expect(minted.keySource).toBe('generated-file'); + expect(readdirSync(home)).toEqual(['dev-crypto-key']); + }); + + it('no key: the helper resolves none, mints none, and hands the service a provider that refuses', async () => { + const key = await resolveExistingDataKey(); + expect(key.provider).toBeNull(); + expect(typeof key.unavailable).toBe('string'); + expect(key.unavailable).not.toBe(''); + + const plugin = await oneShotSettingsPlugin(); + const provider = providerOf(plugin); + expect(provider).toBeDefined(); + expect(provider).not.toBeInstanceOf(LocalCryptoProvider); + // The refusal carries why there is no key, so an operator reading it is told. + await expect(provider.encrypt('x', { scope: 'settings', namespace: 'n', key: 'k' })) + .rejects.toThrow(key.unavailable!); + expect(readdirSync(home)).toEqual([]); + + // Even with the auto-key opt-in present in the environment. + process.env.OS_CRYPTO_AUTOKEY = '1'; + expect((await resolveExistingDataKey()).provider).toBeNull(); + await oneShotSettingsPlugin(); + expect(readdirSync(home)).toEqual([]); + }); + + it('a key file that exists is read, never rewritten, and is the one the service is handed', async () => { + const file = join(home, 'dev-crypto-key'); + writeFileSync(file, Buffer.from(KEY_HEX, 'hex').toString('base64'), { mode: 0o600 }); + const before = readFileSync(file, 'utf8'); + + const key = await resolveExistingDataKey(); + expect(key.provider?.keySource).toBe('file'); + const plugin = await oneShotSettingsPlugin(key); + expect(providerOf(plugin)).toBe(key.provider); + expect(readdirSync(home)).toEqual(['dev-crypto-key']); + expect(readFileSync(file, 'utf8')).toBe(before); + }); + + it('an env key is used, and the key home is never touched', async () => { + process.env.OS_SECRET_KEY = KEY_HEX; + const key = await resolveExistingDataKey(); + expect(key.provider?.keySource).toBe('env:OS_SECRET_KEY'); + expect(existsSync(join(home, 'dev-crypto-key'))).toBe(false); + }); + + it('a key that is set but unusable is an answer, not a throw, and nothing is minted in its place', async () => { + process.env.OS_DEV_CRYPTO_KEY = 'not-a-key'; + const key = await resolveExistingDataKey(); + expect(key.provider).toBeNull(); + expect(key.unavailable).toContain('OS_DEV_CRYPTO_KEY'); + expect(readdirSync(home)).toEqual([]); + }); + + it('the refusing provider refuses every member of the contract, naming the reason', async () => { + const provider = refusingCryptoProvider('the reason'); + const ctx = { scope: 'settings', namespace: 'n', key: 'k' } as const; + const handle = { id: 'sec_x', kmsKeyId: 'local:v1', alg: 'aes-256-gcm', version: 1, ciphertext: 'c' }; + await expect(provider.encrypt('x', ctx)).rejects.toThrow(/the reason/); + await expect(provider.decrypt(handle, ctx)).rejects.toThrow(/the reason/); + await expect(provider.rotateKey(handle, ctx)).rejects.toThrow(/the reason/); + expect(() => provider.digest('x')).toThrow(/the reason/); + await expect(provider.keyedDigest('x')).rejects.toThrow(/the reason/); + }); +}); diff --git a/packages/cli/src/utils/one-shot-settings.ts b/packages/cli/src/utils/one-shot-settings.ts new file mode 100644 index 00000000000..92522a98145 --- /dev/null +++ b/packages/cli/src/utils/one-shot-settings.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { ICryptoProvider } from '@objectstack/spec/contracts'; + +/** + * [#21471] The settings service a ONE-SHOT command composes, and the one place + * in this package that composes it. + * + * ## Why a one-shot command never takes the settings service's default + * + * `SettingsServicePlugin` handed no `cryptoProvider` builds its own + * `LocalCryptoProvider` when it binds the engine, and that provider resolves + * its data key the way a SERVER does: in a development posture with no env key + * and no key file, it mints a key file in the key home so the next restart + * reuses it. That is right for `os serve`, the host it was written for. For a + * command that runs once and exits it is an undeclared side effect on key + * custody, and the worst kind: + * + * - a minted key can open nothing that is stored, so the run gains nothing; + * - the key file outlives the run, so the next process in a development + * posture on that host adopts it and seals under it; + * - commands whose contract is "writes nothing" (`os secret orphans`, + * `os storage orphans`, every dry run) leave key material behind. + * + * ## What it composes instead + * + * The provider over a data key that ALREADY exists — `OS_SECRET_KEY`, + * `OS_DEV_CRYPTO_KEY`, or the persisted key file, resolved the way every host + * resolves it — constructed in the strict posture with the auto-key opt-in + * withheld, whatever `NODE_ENV` says, so it never mints. With no key it hands + * the service a provider that refuses every call, naming why. A stored value + * then reads as the settings service reads any value it cannot open (`null`, + * with a warning), which is what a minted key would have produced too. + * + * `os secret rewrap` resolves the key FIRST and hands the same instance to the + * service and to its own re-wrap, so no provider in that run mints a key. + * Every other caller lets {@link oneShotSettingsPlugin} resolve it. + * + * ⛔ Nothing else in `src/` names `SettingsServicePlugin` or + * `LocalCryptoProvider`, except `commands/serve.ts`, the long-lived host: + * `one-shot-settings.pin.test.ts` reads every module and fails by file name. + * The behaviour is pinned across the whole `bootSchemaStack` family in + * `schema-migrate.one-shot-family.integration.test.ts`: a development posture + * with an empty key home leaves the key home empty, and the default + * composition minting there is the control. + */ + +/** A data key that existed before this run, or the reason there is none. */ +export interface ExistingDataKey { + /** The provider over that key, or `null` when no key exists. */ + provider: (ICryptoProvider & { keySource: string }) | null; + /** Why no key was resolved, or `null` when one was. */ + unavailable: string | null; +} + +/** + * Resolve the data key this host already has, never minting one. A key that + * is missing, or set but unusable, is an answer here, not a throw. + */ +export async function resolveExistingDataKey(): Promise { + // Loaded at the point of use, never at module load: oclif imports every + // command module on every invocation while building its table. + const { LocalCryptoProvider } = await import('@objectstack/service-settings'); + try { + const provider = new LocalCryptoProvider({ + mode: 'production', + env: { ...process.env, OS_CRYPTO_AUTOKEY: undefined }, + }); + return { provider, unavailable: null }; + } catch (error) { + return { provider: null, unavailable: error instanceof Error ? error.message : String(error) }; + } +} + +/** + * The provider handed to the settings service when no data key exists: every + * call refuses with the reason. Composed so the service never builds a default + * provider of its own. + */ +export function refusingCryptoProvider(reason: string): ICryptoProvider { + const refuse = (): never => { + throw new Error(`No data key is available to this run, so nothing may be sealed or opened: ${reason}`); + }; + return { + encrypt: async () => refuse(), + decrypt: async () => refuse(), + rotateKey: async () => refuse(), + digest: () => refuse(), + keyedDigest: async () => refuse(), + }; +} + +/** + * The settings service for a one-shot boot: no routes, and the provider over + * `key` (resolved here when the caller has none of its own), or one that + * refuses every call. ⛔ Never the service's default provider. + */ +export async function oneShotSettingsPlugin(key?: ExistingDataKey): Promise { + const resolved = key ?? await resolveExistingDataKey(); + const { SettingsServicePlugin } = await import('@objectstack/service-settings'); + return new SettingsServicePlugin({ + registerRoutes: false, + cryptoProvider: resolved.provider ?? refusingCryptoProvider(resolved.unavailable ?? 'no data key'), + }); +} diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index d3e9395eea0..2382e6a2051 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -12,6 +12,13 @@ * `--yes` write what the command was asked to write, and the artifact's * inline seed loader does not ride along. The operator never saw a seed * write in the preview. + * 3. [#21471] **No mode creates key material**: in a development posture + * with an empty key home, every mode of every command leaves the key home + * empty. The settings service's default provider mints a key file there + * (the control below boots exactly that composition and watches it + * appear), so a command that composes the settings service hands it the + * one-shot provider from `./one-shot-settings.ts` instead. A minted key + * opens nothing stored, and the next process on that host adopts it. * * ## Why one table, derived from source * @@ -69,8 +76,8 @@ import StorageOrphans from '../commands/storage/orphans.js'; // `run()`, which vitest clocks (`scripts/check-test-source-alias.mjs`). import '@objectstack/runtime'; import '@objectstack/objectql'; -import '@objectstack/platform-objects/plugin'; -import '@objectstack/service-settings'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { SettingsServicePlugin } from '@objectstack/service-settings'; import '@objectstack/service-storage'; import '@objectstack/plugin-audit'; @@ -541,3 +548,55 @@ describe('[#21391] a one-shot boot arms no lifecycle sweep', () => { } }, CASE_TIMEOUT_MS); }); + +describe('[#21471] no mode creates key material in an empty key home, in a development posture', () => { + /** Everything that decides the crypto posture and where the key home is. */ + const KEY_ENV = [ + 'NODE_ENV', 'OS_SECRET_KEY', 'OS_DEV_CRYPTO_KEY', 'OBJECTSTACK_DEV_CRYPTO_KEY', + 'OS_HOME', 'OBJECTSTACK_HOME', 'OS_CRYPTO_AUTOKEY', + ] as const; + const fileEnv: Record = {}; + let home: string; + + beforeEach(() => { + for (const k of KEY_ENV) fileEnv[k] = process.env[k]; + for (const k of KEY_ENV) delete process.env[k]; + // Neither `test` (no disk, ephemeral key) nor `production` (refuses + // without a key): the posture in which the default provider MINTS. + process.env.NODE_ENV = 'development'; + home = mkdtempSync(join(dir, 'key-home-')); + process.env.OS_HOME = home; + }); + + afterEach(() => { + for (const k of KEY_ENV) { + if (fileEnv[k] === undefined) delete process.env[k]; + else process.env[k] = fileEnv[k]; + } + }); + + it('the control: the settings service composed with its default provider mints a key file there', async () => { + const c = prepareCase([]); + const stack = await bootSchemaStack({ + jsonOutput: false, + databaseUrl: `file:${c.dbFile}`, + projectRoot: dir, + // The report's own boot, with the composition `os secret orphans` used to pass. + deferSchemaDdl: true, + readOnlyProbe: true, + extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })], + }); + await stack.shutdown(); + expect(readdirSync(home)).toEqual(['dev-crypto-key']); + }, CASE_TIMEOUT_MS); + + it.each([...NO_WRITE, ...WRITE])('$label', async ({ run, argv }) => { + const c = prepareCase(argv); + const result = await runJson(run, c.argv); + + // The run reached its report or its refusal, never a failed boot: a boot + // that never bound the settings service would leave the home empty too. + expect(result.payload?.error, JSON.stringify(result.payload).slice(0, 400)).not.toBe('boot_failed'); + expect(readdirSync(home), 'key material was created in the key home').toEqual([]); + }, CASE_TIMEOUT_MS); +}); From bda27b507349625ebc60538ceae017d8a1f7064e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:55:52 +0000 Subject: [PATCH 2/2] fix(cli): a one-shot command never mints a data key in the key home `os secret orphans` and the storage arm of the data-migration plugins (`os storage orphans`, `os migrate files-to-references`) composed the settings service with no crypto provider, so it built its default one, which in a development posture with no key writes a key file into the key home. Every one of them now composes the service through utils/one-shot-settings.ts: the provider over a key that already exists, in the strict posture with the auto-key opt-in withheld, or one that refuses every call. `os secret rewrap` moves onto the same helper, so there is one spelling. The two driver-contract tests boot the commands' own composition again. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz --- .changeset/21471-one-shot-never-mints-key.md | 13 ++++++ .../secret/orphans.driver-contract.test.ts | 6 ++- packages/cli/src/commands/secret/orphans.ts | 10 +++-- .../secret/rewrap.driver-contract.test.ts | 9 ++-- packages/cli/src/commands/secret/rewrap.ts | 44 ++++--------------- .../cli/src/utils/data-migration-plugins.ts | 9 ++-- 6 files changed, 44 insertions(+), 47 deletions(-) create mode 100644 .changeset/21471-one-shot-never-mints-key.md diff --git a/.changeset/21471-one-shot-never-mints-key.md b/.changeset/21471-one-shot-never-mints-key.md new file mode 100644 index 00000000000..270aa706298 --- /dev/null +++ b/.changeset/21471-one-shot-never-mints-key.md @@ -0,0 +1,13 @@ +--- +"@objectstack/cli": patch +--- + +`os secret orphans`, `os storage orphans` and `os migrate files-to-references` no longer create a data key file in the key home. A one-shot command never mints key material (#21471) + +Clause-②: no + +Each of these commands composes the settings service. Given no crypto provider, the service builds its own default one. In a development posture with no `OS_SECRET_KEY`, no `OS_DEV_CRYPTO_KEY` and no key file, that default writes a new key file into the key home. So a report that promises to write nothing left key material behind, and the next development-posture process on that host adopted the minted key. A minted key opens nothing that is stored, so the run gained nothing from it. + +- **What these commands hand the settings service now.** They pass the provider `os secret rewrap` already passed: the one over a data key that already exists, resolved the way every host resolves it, in the strict posture and with the auto-key opt-in withheld, so it never mints. With no key, the service gets a provider that refuses every call and says why. A stored setting that cannot be opened reads as it did with a freshly minted key: empty, with a warning. +- **One composition.** The settings service is composed in one place in `@objectstack/cli` (`utils/one-shot-settings.ts`), shared by `secret orphans`, `secret rewrap` and the storage arm of the data-migration plugins. `os serve` still takes the service's default: persisting a key in a development posture so restarts reuse it is that host's documented behaviour. +- **Visible difference.** On a host whose key lives only in the key file, these commands now print the strict posture's one-line note on stderr ("using the persisted key at …"), as `os secret rewrap` already did. stdout and `--json` output are unchanged. diff --git a/packages/cli/src/commands/secret/orphans.driver-contract.test.ts b/packages/cli/src/commands/secret/orphans.driver-contract.test.ts index 7cc7e040531..c49a23405fe 100644 --- a/packages/cli/src/commands/secret/orphans.driver-contract.test.ts +++ b/packages/cli/src/commands/secret/orphans.driver-contract.test.ts @@ -50,9 +50,11 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; -import { SettingsServicePlugin } from '@objectstack/service-settings'; +// Paid at module load, as every dist-resolved dependency the boot reaches is. +import '@objectstack/service-settings'; import { bootSchemaStack, type SchemaStack } from '../../utils/schema-migrate.js'; import type { SecretReferenceEngineLike } from '../../utils/secret-reference-union.js'; +import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js'; import SecretOrphans from './orphans.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -137,7 +139,7 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)', databaseUrl: `file:${dbFile}`, // Byte-identical to `orphans.ts`'s own list — the boot has to be the // command's, or the driver this file names is not the one it holds. - extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })], + extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()], }); const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined; diff --git a/packages/cli/src/commands/secret/orphans.ts b/packages/cli/src/commands/secret/orphans.ts index 40d081a473a..ec38310555c 100644 --- a/packages/cli/src/commands/secret/orphans.ts +++ b/packages/cli/src/commands/secret/orphans.ts @@ -18,6 +18,7 @@ import { isExitSignal, } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js'; import type { DatasourceArtefactLike, SecretReferenceEngineLike, @@ -196,8 +197,7 @@ export default class SecretOrphans extends Command { const { collectSecretReferenceUnion } = await import('../../utils/secret-reference-union.js'); const { buildPreDeleteExport, planSysSecretOrphanSweep, useHandlePredicate } = await import('../../utils/sys-secret-orphan-sweep.js'); - const { collectEncryptedSpecifierRefs, isSecretHandle, SettingsServicePlugin } = - await import('@objectstack/service-settings'); + const { collectEncryptedSpecifierRefs, isSecretHandle } = await import('@objectstack/service-settings'); const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); // The legacy-inline discriminator comes from the producer that mints the @@ -212,7 +212,11 @@ export default class SecretOrphans extends Command { // Settings is registered so its REGISTERED manifests are readable: the // attribution set is theirs, and without it nothing is attributable and // nothing is deletable (the safe direction, reported as a note). - extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })], + // [#21471] Composed through the one-shot helper, never with the + // service's default provider: in a development posture with no key, + // that default mints a key file in the key home, and this report + // promises to write nothing. + extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()], // [#21391] The report boots READ-ONLY, the boot `os migrate plan` // takes: `deferSchemaDdl` holds schema DDL back on every SQL // datasource, and `readOnlyProbe` keeps a missing sqlite file from diff --git a/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts b/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts index 2129e641c11..17b4a1817ea 100644 --- a/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts +++ b/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts @@ -31,10 +31,11 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; -import { ciphertextDerivationStatus, LocalCryptoProvider, SettingsServicePlugin } from '@objectstack/service-settings'; +import { ciphertextDerivationStatus, LocalCryptoProvider } from '@objectstack/service-settings'; import type { CryptoContext } from '@objectstack/spec/contracts'; import { bootSchemaStack, type SchemaStack } from '../../utils/schema-migrate.js'; import type { SecretReferenceEngineLike } from '../../utils/secret-reference-union.js'; +import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js'; import SecretRewrap from './rewrap.js'; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -102,8 +103,10 @@ describe('os secret rewrap — the concrete driver and the command, end to end ( stack = await bootSchemaStack({ jsonOutput: false, databaseUrl: `file:${dbFile}`, - // Byte-identical to `rewrap.ts`'s own list. - extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })], + // `rewrap.ts`'s own list: the one-shot settings composition, over the + // key this file declares in `OS_SECRET_KEY` (the command resolves the + // same key first and hands that instance in). + extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()], }); const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined; if (!engine) throw new Error('no objectql engine on the booted stack — nothing to measure'); diff --git a/packages/cli/src/commands/secret/rewrap.ts b/packages/cli/src/commands/secret/rewrap.ts index a6372dbc814..5644e3d4eda 100644 --- a/packages/cli/src/commands/secret/rewrap.ts +++ b/packages/cli/src/commands/secret/rewrap.ts @@ -16,11 +16,11 @@ import { isExitSignal, } from '../../utils/format.js'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; +import { oneShotSettingsPlugin, resolveExistingDataKey } from '../../utils/one-shot-settings.js'; import type { DatasourceArtefactLike, SecretReferenceEngineLike, } from '../../utils/secret-reference-union.js'; -import type { ICryptoProvider } from '@objectstack/spec/contracts'; import type { RewrapSecretRow, SysSecretRewrapReport, @@ -58,7 +58,9 @@ import { readDeclaredDatasources } from './orphans.js'; * is stored. It is resolved before the boot and handed to the settings * service the boot composes, so no provider in this run mints a key. No key is * a refusal, before any row is opened. The provider is `LocalCryptoProvider`, - * the one every in-tree host constructs. + * the one every in-tree host constructs, and both halves — the key and the + * settings service — come from `utils/one-shot-settings.ts`, the one + * composition every one-shot command shares. */ export default class SecretRewrap extends Command { static override description = @@ -140,24 +142,15 @@ export default class SecretRewrap extends Command { planSysSecretRewrap, rewrapUnfinished, } = await import('../../utils/sys-secret-rewrap.js'); - const { ciphertextDerivationStatus, LocalCryptoProvider, SettingsServicePlugin } = - await import('@objectstack/service-settings'); + const { ciphertextDerivationStatus } = await import('@objectstack/service-settings'); const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin'); // ── The provider, resolved BEFORE the boot, from a key that already exists ── // The strict posture never mints a key, and the auto-key opt-in is // withheld. A missing key is refused only once the plan has a row to open, // so a run with nothing to open still reports. - let provider: (ICryptoProvider & { keySource: string }) | null = null; - let keyUnavailable: string | null = null; - try { - provider = new LocalCryptoProvider({ - mode: 'production', - env: { ...process.env, OS_CRYPTO_AUTOKEY: undefined }, - }); - } catch (error) { - keyUnavailable = error instanceof Error ? error.message : String(error); - } + const dataKey = await resolveExistingDataKey(); + const { provider, unavailable: keyUnavailable } = dataKey; let stack; try { @@ -175,10 +168,7 @@ export default class SecretRewrap extends Command { // setting's value. extraPlugins: [ new PlatformObjectsPlugin(), - new SettingsServicePlugin({ - registerRoutes: false, - cryptoProvider: provider ?? refusingCryptoProvider(keyUnavailable ?? 'no data key'), - }), + await oneShotSettingsPlugin(dataKey), ], // The dry run boots READ-ONLY, the boot `os migrate plan` takes. // `--apply` keeps the plain boot: it writes rows. @@ -322,24 +312,6 @@ export default class SecretRewrap extends Command { } } -/** - * The provider this run hands the settings service when no data key exists: - * every call refuses with the reason. Composed so the service never builds a - * default provider of its own, which in a development posture mints a key. - */ -function refusingCryptoProvider(reason: string): ICryptoProvider { - const refuse = (): never => { - throw new Error(`No data key is available to this run, so nothing may be sealed or opened: ${reason}`); - }; - return { - encrypt: async () => refuse(), - decrypt: async () => refuse(), - rotateKey: async () => refuse(), - digest: () => refuse(), - keyedDigest: async () => refuse(), - }; -} - async function confirm(question: string): Promise { if (!process.stdin.isTTY) return false; // non-interactive → require --yes const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/packages/cli/src/utils/data-migration-plugins.ts b/packages/cli/src/utils/data-migration-plugins.ts index a465adcd509..58fb2b79ea9 100644 --- a/packages/cli/src/utils/data-migration-plugins.ts +++ b/packages/cli/src/utils/data-migration-plugins.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { resolveStorageCapabilityArg, resolveStorageLocalRootEnv } from '../commands/serve.js'; +import { oneShotSettingsPlugin } from './one-shot-settings.js'; /** * The plugins a gated data migration boots with. @@ -18,7 +19,10 @@ import { resolveStorageCapabilityArg, resolveStorageLocalRootEnv } from '../comm * - Settings first: the storage plugin re-resolves its adapter from * persisted settings when a settings service is present, which is how an * S3-configured deployment's backfill uploads land in S3 rather than on - * this machine. + * this machine. [#21471] It is the one-shot composition + * (`./one-shot-settings.ts`): the settings service opens a stored + * credential with the data key this host already has, and never mints + * one in the key home — `os storage orphans` is report-only. * - Storage config through the SAME resolver `os serve` uses * (`resolveStorageCapabilityArg`), fed by the SAME env channel * (`resolveStorageLocalRootEnv`, #4968), so the CLI materialises bytes @@ -61,8 +65,7 @@ export async function buildDataMigrationPlugins( } if (opts.storage === true) { try { - const { SettingsServicePlugin } = await import('@objectstack/service-settings'); - plugins.push(new SettingsServicePlugin({ registerRoutes: false })); + plugins.push(await oneShotSettingsPlugin()); } catch { // optional — without it, constructor/env-driven storage config still applies }