Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/21471-one-shot-never-mints-key.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 7 additions & 3 deletions packages/cli/src/commands/secret/orphans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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');
Expand Down
44 changes: 8 additions & 36 deletions packages/cli/src/commands/secret/rewrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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<boolean> {
if (!process.stdin.isTTY) return false; // non-interactive → require --yes
const rl = createInterface({ input: process.stdin, output: process.stdout });
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/utils/data-migration-plugins.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading