Skip to content

Commit bda27b5

Browse files
committed
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
1 parent da6acc0 commit bda27b5

6 files changed

Lines changed: 44 additions & 47 deletions

File tree

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)