Skip to content

Commit 25797a1

Browse files
fix(cli): a one-shot command never mints a data key in the key home (#21471) (#21497)
Fixes #21471 Clause-②: no ## What changed `os secret orphans` is a report that promises to write nothing. It composed the settings service with no crypto provider, so the service built its default one. In a development posture with no env key and no key file, that default creates a key file in the key home. The database stayed untouched, but the key home did not, and the next development-posture process on that host adopted the minted key. The storage arm of the data-migration plugins (`os storage orphans`, also report-only, and `os migrate files-to-references`) composed the service the same way. - **One composition.** New `packages/cli/src/utils/one-shot-settings.ts` holds the idiom `os secret rewrap` already used, moved rather than copied: - `resolveExistingDataKey()` builds the provider over a key that already exists, in the strict posture with the auto-key opt-in withheld, so it never mints; - `refusingCryptoProvider()` refuses every call and names why; - `oneShotSettingsPlugin()` hands the settings service one of those two, never the default. - `secret/orphans.ts` and `utils/data-migration-plugins.ts` compose through it. `secret/rewrap.ts` moves onto it, so there is one spelling, not two. - The two driver-contract tests boot the commands' own composition again. Their "the command's own list" comments were stale for `rewrap` since its provider change. - Changeset: `@objectstack/cli` patch (`.changeset/21471-one-shot-never-mints-key.md`). ## Census: every CLI composition of the settings service, by any spelling How the census was built: - every non-test module under `packages/cli/src` whose comment-masked code names `SettingsServicePlugin`, `LocalCryptoProvider` or its deprecated alias (the enumeration pin's own detector; at `da6acc015d` it named exactly the four source composers below plus the new helper); - the commands that reach those modules through a call; - the one composition a command reaches inside another package. | Member | Reaches the settings service through | Disposition | Reading | |---|---|---|---| | `os secret orphans` (report and `--delete`) | `commands/secret/orphans.ts` | **closed here** | Composed the default. The key file appeared in both modes, red at `da6acc015d`, green at `bda27b5073` | | `os storage orphans` | storage arm of `utils/data-migration-plugins.ts` | **closed here** | Report-only. Red at `da6acc015d`, green at `bda27b5073` | | `os migrate files-to-references` (dry run and `--apply`) | the same storage arm | **closed here** | Red at `da6acc015d`, green at `bda27b5073`. It needs the real key when one exists, because the storage plugin reads its stored credentials through the settings service. That is why the composition reads an existing key rather than always refusing | | `os secret rewrap` | `commands/secret/rewrap.ts` | already correct; **moved onto the helper** | Green at both commits | | `os serve`, and `os dev` / `os start`, which spawn it | the capability table's `settings` row; two default providers for secret fields | not affected | This is the long-lived host. Persisting a key in a development posture so restarts reuse it is its documented behaviour, and a production posture refuses without a key. It is the one host the enumeration pin allows, with that reason | | `os migrate plan` / `os migrate apply` (`composeHostStack`) | only a host config's own plugins | not affected | Host plugins are composed for declarations only, with `start()` suppressed. The settings plugin builds its default from a hook registered in `start()`. Both are green in the family pin | | the other `bootSchemaStack` callers (`meta resync`, `migrate` `account-issuer` / `audit-metadata-bodies` / `duplicates` / `meta --stored` / `multi-value-columns` / `recorded-by` / `resume` / `summary-nulls` / `value-shapes`) | none | not affected | They compose no settings service. Every mode is green in the family pin | | `os verify` | `@objectstack/verify`'s boot harness, in another package | **affected, not closed here** | See Out of scope below | ## Pins - **`src/utils/one-shot-settings.pin.test.ts`** (unit tier) - The enumeration: every module whose code names the plugin or the provider is the helper or `commands/serve.ts`, failing by file name. A self-check confirms the detector ignores prose and sees a renamed destructure, the capability-table string and the alias. - The helper's contract in a development posture with an empty key home. Control: the default provider mints there. - Cases: no key means none is resolved, none is minted, and the service gets a refusing provider; an existing key file is read and never rewritten; an env key is used and the home is untouched; a set-but-unusable key is an answer, not a throw; the refusing provider refuses all five contract members. - **`src/utils/schema-migrate.one-shot-family.integration.test.ts`** (integration tier, by its existing `bootSchemaStack` import). It gains a third promise across the source-derived family: in a development posture with an empty key home, every mode of every `bootSchemaStack` caller leaves the home empty. - Positive control: the read-only boot with `new SettingsServicePlugin({ registerRoutes: false })`, the composition the report used to pass, leaves exactly one key file there. - A new caller is already forced into the table by the file's first case. - New cases ran at about 0.1 to 0.2 s each locally under the file's existing 120 s per-case timeout. ## Verification (head `bda27b5073`; red leg at `da6acc015d`) - **Red first.** The pins were committed before the fix (`da6acc015d`) and run against the unfixed commands: - the enumeration pin failed, naming `commands/secret/orphans.ts`, `commands/secret/rewrap.ts` and `utils/data-migration-plugins.ts` (7 of 8 tests passed, the control included); - the family key-home cases failed 5 and passed 23, the control included. The 5 failures were `migrate files-to-references`, `secret orphans`, `storage orphans`, `migrate files-to-references --apply` and `secret orphans --delete`, each as "key material was created in the key home", with the key file present. - **Green at `bda27b5073`.** - `vitest run src/utils/one-shot-settings.pin.test.ts`: 8 / 8 passed. - `vitest run --project integration` over the family file and both driver-contract files: 3 files, 85 / 85 passed. - The other tests that reach the changed modules: unit, 8 files, 61 passed; integration (`orphans.guards`, `rewrap.guards`, `summary-nulls`, `sys-secret-rewrap`), 4 files, 37 passed. - `pnpm --filter @objectstack/cli typecheck` (tsc plus `check:test-typecheck`): exit 0, with no new test-typecheck debt. - **Public door.** The built CLI's `os secret orphans --json`, in a development posture with an empty key home, left the home empty. - **`pnpm lint`** (`eslint . --no-inline-config`, the whole repo): exit 0 at `bda27b5073`, not narrowed. - **Gates.** `dispatch-gates.mjs --ran` with no paths: 64 derived, 64 run, every one exit 0, and 0 NOT MEASURED (a derived zero, from recorded exit codes). Four gates first refused on a missing build (exit 3, nothing measured). After the prerequisite builds they were re-run to exit 0: `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity`. - Not merged with `origin/main`. It is three commits ahead, and none of them touches these files. The derivation's stale-tree note names `scripts/engine-double-contract.pinned.json`, which this diff does not touch. ## Acceptance notes - **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, naming the persisted key's location, as `os secret rewrap` already did. stdout and `--json` are unchanged. - **Unreadable stored values.** With no key, a stored settings value that cannot be opened reads as `null` with a warning. A freshly minted key produced the same, because it can open nothing stored. - **The enumeration pin's reach** is `packages/cli/src`. A composition inside another package that a command calls into names nothing there; the pin header says so, and the census lists the one that exists. ## Out of scope (reported to the seat, not filed here) - **`os verify`** reaches `@objectstack/verify`'s boot harness. The harness composes the settings plugin with no provider and also sets a default local provider on the engine for secret fields. - Measured through the built CLI on `examples/app-todo`, in a development posture with an empty key home: after the run, the key home held a key file. - It is not closed here for two reasons. The composer is outside `packages/cli`. And it needs a different provider shape: the harness seals and opens secret fields against an in-memory database, so a read-or-refuse provider would break `os verify` on a keyless host where an ephemeral in-process key would not. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2ee8383 commit 25797a1

9 files changed

Lines changed: 414 additions & 49 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`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)
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
- **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.
12+
- **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.
13+
- **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.

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ import { tmpdir } from 'node:os';
5050
import { dirname, join, resolve } from 'node:path';
5151
import { fileURLToPath } from 'node:url';
5252
import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
53-
import { SettingsServicePlugin } from '@objectstack/service-settings';
53+
// Paid at module load, as every dist-resolved dependency the boot reaches is.
54+
import '@objectstack/service-settings';
5455
import { bootSchemaStack, type SchemaStack } from '../../utils/schema-migrate.js';
5556
import type { SecretReferenceEngineLike } from '../../utils/secret-reference-union.js';
57+
import { oneShotSettingsPlugin } from '../../utils/one-shot-settings.js';
5658
import SecretOrphans from './orphans.js';
5759

5860
const HERE = dirname(fileURLToPath(import.meta.url));
@@ -137,7 +139,7 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)',
137139
databaseUrl: `file:${dbFile}`,
138140
// Byte-identical to `orphans.ts`'s own list — the boot has to be the
139141
// command's, or the driver this file names is not the one it holds.
140-
extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })],
142+
extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()],
141143
});
142144

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

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +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';
2122
import type {
2223
DatasourceArtefactLike,
2324
SecretReferenceEngineLike,
@@ -196,8 +197,7 @@ export default class SecretOrphans extends Command {
196197
const { collectSecretReferenceUnion } = await import('../../utils/secret-reference-union.js');
197198
const { buildPreDeleteExport, planSysSecretOrphanSweep, useHandlePredicate } =
198199
await import('../../utils/sys-secret-orphan-sweep.js');
199-
const { collectEncryptedSpecifierRefs, isSecretHandle, SettingsServicePlugin } =
200-
await import('@objectstack/service-settings');
200+
const { collectEncryptedSpecifierRefs, isSecretHandle } = await import('@objectstack/service-settings');
201201
const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin');
202202

203203
// The legacy-inline discriminator comes from the producer that mints the
@@ -212,7 +212,11 @@ export default class SecretOrphans extends Command {
212212
// Settings is registered so its REGISTERED manifests are readable: the
213213
// attribution set is theirs, and without it nothing is attributable and
214214
// nothing is deletable (the safe direction, reported as a note).
215-
extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })],
215+
// [#21471] Composed through the one-shot helper, never with the
216+
// service's default provider: in a development posture with no key,
217+
// that default mints a key file in the key home, and this report
218+
// promises to write nothing.
219+
extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()],
216220
// [#21391] The report boots READ-ONLY, the boot `os migrate plan`
217221
// takes: `deferSchemaDdl` holds schema DDL back on every SQL
218222
// datasource, and `readOnlyProbe` keeps a missing sqlite file from

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ import { tmpdir } from 'node:os';
3131
import { dirname, join, resolve } from 'node:path';
3232
import { fileURLToPath } from 'node:url';
3333
import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin';
34-
import { ciphertextDerivationStatus, LocalCryptoProvider, SettingsServicePlugin } from '@objectstack/service-settings';
34+
import { ciphertextDerivationStatus, LocalCryptoProvider } from '@objectstack/service-settings';
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';
3839
import SecretRewrap from './rewrap.js';
3940

4041
const HERE = dirname(fileURLToPath(import.meta.url));
@@ -102,8 +103,10 @@ describe('os secret rewrap — the concrete driver and the command, end to end (
102103
stack = await bootSchemaStack({
103104
jsonOutput: false,
104105
databaseUrl: `file:${dbFile}`,
105-
// Byte-identical to `rewrap.ts`'s own list.
106-
extraPlugins: [new PlatformObjectsPlugin(), new SettingsServicePlugin({ registerRoutes: false })],
106+
// `rewrap.ts`'s own list: the one-shot settings composition, over the
107+
// key this file declares in `OS_SECRET_KEY` (the command resolves the
108+
// same key first and hands that instance in).
109+
extraPlugins: [new PlatformObjectsPlugin(), await oneShotSettingsPlugin()],
107110
});
108111
const engine = stack.kernel.getService('objectql') as SecretReferenceEngineLike | undefined;
109112
if (!engine) throw new Error('no objectql engine on the booted stack — nothing to measure');

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

Lines changed: 8 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ import {
1616
isExitSignal,
1717
} from '../../utils/format.js';
1818
import { bootSchemaStack } from '../../utils/schema-migrate.js';
19+
import { oneShotSettingsPlugin, resolveExistingDataKey } from '../../utils/one-shot-settings.js';
1920
import type {
2021
DatasourceArtefactLike,
2122
SecretReferenceEngineLike,
2223
} from '../../utils/secret-reference-union.js';
23-
import type { ICryptoProvider } from '@objectstack/spec/contracts';
2424
import type {
2525
RewrapSecretRow,
2626
SysSecretRewrapReport,
@@ -58,7 +58,9 @@ import { readDeclaredDatasources } from './orphans.js';
5858
* is stored. It is resolved before the boot and handed to the settings
5959
* service the boot composes, so no provider in this run mints a key. No key is
6060
* a refusal, before any row is opened. The provider is `LocalCryptoProvider`,
61-
* the one every in-tree host constructs.
61+
* the one every in-tree host constructs, and both halves — the key and the
62+
* settings service — come from `utils/one-shot-settings.ts`, the one
63+
* composition every one-shot command shares.
6264
*/
6365
export default class SecretRewrap extends Command {
6466
static override description =
@@ -140,24 +142,15 @@ export default class SecretRewrap extends Command {
140142
planSysSecretRewrap,
141143
rewrapUnfinished,
142144
} = await import('../../utils/sys-secret-rewrap.js');
143-
const { ciphertextDerivationStatus, LocalCryptoProvider, SettingsServicePlugin } =
144-
await import('@objectstack/service-settings');
145+
const { ciphertextDerivationStatus } = await import('@objectstack/service-settings');
145146
const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin');
146147

147148
// ── The provider, resolved BEFORE the boot, from a key that already exists ──
148149
// The strict posture never mints a key, and the auto-key opt-in is
149150
// withheld. A missing key is refused only once the plan has a row to open,
150151
// so a run with nothing to open still reports.
151-
let provider: (ICryptoProvider & { keySource: string }) | null = null;
152-
let keyUnavailable: string | null = null;
153-
try {
154-
provider = new LocalCryptoProvider({
155-
mode: 'production',
156-
env: { ...process.env, OS_CRYPTO_AUTOKEY: undefined },
157-
});
158-
} catch (error) {
159-
keyUnavailable = error instanceof Error ? error.message : String(error);
160-
}
152+
const dataKey = await resolveExistingDataKey();
153+
const { provider, unavailable: keyUnavailable } = dataKey;
161154

162155
let stack;
163156
try {
@@ -175,10 +168,7 @@ export default class SecretRewrap extends Command {
175168
// setting's value.
176169
extraPlugins: [
177170
new PlatformObjectsPlugin(),
178-
new SettingsServicePlugin({
179-
registerRoutes: false,
180-
cryptoProvider: provider ?? refusingCryptoProvider(keyUnavailable ?? 'no data key'),
181-
}),
171+
await oneShotSettingsPlugin(dataKey),
182172
],
183173
// The dry run boots READ-ONLY, the boot `os migrate plan` takes.
184174
// `--apply` keeps the plain boot: it writes rows.
@@ -322,24 +312,6 @@ export default class SecretRewrap extends Command {
322312
}
323313
}
324314

325-
/**
326-
* The provider this run hands the settings service when no data key exists:
327-
* every call refuses with the reason. Composed so the service never builds a
328-
* default provider of its own, which in a development posture mints a key.
329-
*/
330-
function refusingCryptoProvider(reason: string): ICryptoProvider {
331-
const refuse = (): never => {
332-
throw new Error(`No data key is available to this run, so nothing may be sealed or opened: ${reason}`);
333-
};
334-
return {
335-
encrypt: async () => refuse(),
336-
decrypt: async () => refuse(),
337-
rotateKey: async () => refuse(),
338-
digest: () => refuse(),
339-
keyedDigest: async () => refuse(),
340-
};
341-
}
342-
343315
async function confirm(question: string): Promise<boolean> {
344316
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
345317
const rl = createInterface({ input: process.stdin, output: process.stdout });

‎packages/cli/src/utils/data-migration-plugins.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { resolveStorageCapabilityArg, resolveStorageLocalRootEnv } from '../commands/serve.js';
4+
import { oneShotSettingsPlugin } from './one-shot-settings.js';
45

56
/**
67
* The plugins a gated data migration boots with.
@@ -18,7 +19,10 @@ import { resolveStorageCapabilityArg, resolveStorageLocalRootEnv } from '../comm
1819
* - Settings first: the storage plugin re-resolves its adapter from
1920
* persisted settings when a settings service is present, which is how an
2021
* S3-configured deployment's backfill uploads land in S3 rather than on
21-
* this machine.
22+
* this machine. [#21471] It is the one-shot composition
23+
* (`./one-shot-settings.ts`): the settings service opens a stored
24+
* credential with the data key this host already has, and never mints
25+
* one in the key home — `os storage orphans` is report-only.
2226
* - Storage config through the SAME resolver `os serve` uses
2327
* (`resolveStorageCapabilityArg`), fed by the SAME env channel
2428
* (`resolveStorageLocalRootEnv`, #4968), so the CLI materialises bytes
@@ -61,8 +65,7 @@ export async function buildDataMigrationPlugins(
6165
}
6266
if (opts.storage === true) {
6367
try {
64-
const { SettingsServicePlugin } = await import('@objectstack/service-settings');
65-
plugins.push(new SettingsServicePlugin({ registerRoutes: false }));
68+
plugins.push(await oneShotSettingsPlugin());
6669
} catch {
6770
// optional — without it, constructor/env-driven storage config still applies
6871
}

0 commit comments

Comments
 (0)