Skip to content

Commit cb074ec

Browse files
feat(cli): os secret orphans also reports sys_secret handles more than one holder names, or a column other than their own holds (#27792)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DECWEobVDFstMHroz5Kem8z)_ Fixes #27721 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:`os secret orphans` 的报告模式在原有孤儿报告之外,新增一节「被命名的 handle」:列出被多于一个持有记录命名的 `sys_secret` handle(shared),以及持有它的列不是其 `(namespace, key)` 所指列的 handle(misplaced),带计数与每个持有者的对象/字段/记录;谓词更新造成的同列共享单独列出、不作判定。 - **为什么改**:非系统写入现在只能保留本记录自己的引用,但此前已存的值没有被重新检查;原报告只回答「谁都不引用的行」,回答不了「一个 handle 被几处引用、是否在自己的列里」。 - **风险与回滚**:只读、只报告;不进入 `--delete` 的删除计划,不修复任何东西,不打印任何密文或明文。`--json` 多出一个顶层键 `holders`(增量)。回滚即整 PR revert。 - **先看**:`packages/cli/src/utils/sys-secret-holder-report.ts`(分类规则与谓词更新的区分)、`packages/cli/src/utils/secret-reference-union.ts`(每个引用新增的结构化持有坐标)、`packages/cli/src/commands/secret/orphans.ts`(只在报告模式输出)。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run --project integration src/utils/sys-secret-holder-report.test.ts`,预期 14 个用例全绿,其中谓词更新那条由真实引擎写出共享 handle。 - **需要你做的**:无,席位落地。 ## What changes - **`secret-reference-union.ts`** — every `SecretReference` now carries `column`: the holding object, field and record, and the `(namespace, key)` its producer records on a handle it mints for that column (`null` for the datasource binder, which records the pair its caller passes). Collected at the same read; the settings read now also selects `id` to name the record. The printable `holder` string is unchanged, and the module still contains no classification. - **`sys-secret-holder-report.ts`** (new, pure) — a report-only pass over the `sys_secret` snapshots and the union: - `shared`: a handle more than one holder record names; - `misplaced`: a handle at least one holder of which is not the column its `(namespace, key)` names; - `sharedInOwnColumn`, listed apart: every holder is an `object-field` row of the handle's own minting column. That is the shape a predicate update writes; stored rows cannot tell it from a reference copied between rows of the same column, so it is listed, not judged. A share across settings rows never gets this exemption: settings seals one handle per row write. - Datasource holders are not judged for placement (counted, with a note). An incomplete union is reported on what was enumerated and names the missing family. - **`os secret orphans`** — report mode prints the section and `--json` carries it as a new top-level `holders` key beside `plan`. `--delete` is untouched; the section never feeds the deletion plan. No new flag. - **Docs** — the `os secret orphans` section of `content/docs/deployment/cli.mdx` describes the section. Changeset: `@objectstack/cli` patch. On the additive `--json` key: no shipped consumer parses the `os secret orphans --json` document. In this repository it is read only by tests (`orphans.driver-contract.test.ts`, `data-commands.absent-database.integration.test.ts`, both via `toMatchObject` on `mode` and `plan`), and the docs call it "the same report, machine-readable" without stating a closed shape. ## Why The orphan report answers which `sys_secret` rows nothing names. It did not answer whether a handle something does name is named by more than one holder, or held by a column other than the one its pair names, which is the question the engine's release asks before it removes a handle. Values stored before writes were limited to their own record's reference were never re-examined. Whether a repair path follows is a later decision; this PR builds none. ## Tests - `sys-secret-holder-report.test.ts` (new, 14 cases): fixtures minted by the real producers (real `ObjectQL` write path including a real predicate update, real `LocalCryptoProvider`, real datasource binder), read back by the real union collector. Pins: a handle two settings rows name is `shared` with both holders' coordinates; a handle copied onto another column is `shared` and `misplaced`, naming which holder is out of place; a handle whose only holder is out of place is `misplaced`; the share the engine's predicate update wrote is in `sharedInOwnColumn` and in neither other class; a settings share is not exempted; datasource placement is not judged; counts per class; no plaintext and no cipher material anywhere in the serialized report (with an anti-vacuity check), and the store is unchanged; an incomplete union names its missing family. - `secret-reference-union.test.ts`: each family's reference carries its `column` coordinates. - `orphans.driver-contract.test.ts` (real SQLite, the command's own boot): the `--json` report lists a handle two seeded settings rows name under `holders.shared`, and the command's whole stdout carries none of the seeded cipher material. The command is run once and memoized across the two end-to-end cases. - Ablations (each restored, blob equal to HEAD and `git diff HEAD` empty, via `scripts/ablation-replace.mjs`): - own-column predicate forced `false` → 3 red (the predicate-update pin, the settings-not-exempt pin, counts); - placement forced `in_place` → 7 red (both misplaced pins, the cross-column share, the control, counts, the incomplete-union pin, the settings-not-exempt pin); - single-holder skip forced for every handle → 6 red (both shared pins, the predicate-update pin, counts, the incomplete-union pin, the settings-not-exempt pin); - `holders` dropped from the command's `--json` → the command-level pin red. ## Acceptance notes - `sys-secret-orphan-sweep.test.ts` changes one fixture literal only (the new required `column` member); no assertion moved. - A reference that names a handle with no `sys_secret` row is not listed: the card asks about `sys_secret` handles, and a dangling reference is a different question. ## Verification All readings on head `2569bd1ae2` (this branch with `main` at `6d8372a8cd` merged in), after `pnpm install --frozen-lockfile && pnpm build`. - `node scripts/pm/dispatch-gates.mjs --ran` — `93 derived, 93 run, 0 NOT-MEASURED, 0 UNRUN` (exit 0). Every derived family exited 0. - `pnpm --filter @objectstack/cli typecheck` — exit 0 (`tsc --noEmit` and `check:test-typecheck: OK`, debt unchanged at 3 files / 28 errors / 6 pinned signatures). - Touched suites, `vitest run --project integration`: `sys-secret-holder-report`, `secret-reference-union`, `sys-secret-orphan-sweep`, `orphans.guards`, `orphans.driver-contract`, `orphans.journal-mode`, `data-commands.absent-database` — Test Files 7 passed, Tests 136 passed. - The whole `unit` project of `@objectstack/cli` ran once before the merge (head `48a9b65dd2`): 349 of 351 files passed, 5055 tests passed. The two files that failed (`published-subpath-console.pin`, `published-subpath-hook-body.pin`) refused because `packages/cli` itself was not built yet; after the full build they pass on the final head (2 files, 29 tests). - ESLint with the repo config and `--no-inline-config` over the seven touched TypeScript files — exit 0. The repo-wide `pnpm lint` is CI's `Lint & Repo Gates`. - Dogfood battery: n/a — no `!` in the title and no `(narrowing)` arm. <details> <summary>Derived gate families and exit codes (93)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-examples-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-coverage :: exit 0 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (ESLint over the touched files locally; the repo-wide run is CI's `Lint & Repo Gates`.) - [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 with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4cw9cC8mhpZX1HgeLg5 --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cw9cC8mhpZX1HgeLg5)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent a3014cb commit cb074ec

9 files changed

Lines changed: 851 additions & 18 deletions
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os secret orphans` also reports the `sys_secret` handles more than one holder names, and the ones a column other than their own holds
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The orphan report answered which `sys_secret` rows nothing names. Its report mode now also lists, from the same cross-producer reference union, the rows something does name in a way no producer writes:
11+
12+
- **`shared`** — a handle more than one holder record names. A producer seals one handle per value it writes.
13+
- **`misplaced`** — a handle held by a column other than the one its `(namespace, key)` names: the settings row's own pair, or the object and field name for a `secret` field. A datasource holder is not judged for placement, because the datasource binder records the pair its caller passes.
14+
- **`sharedInOwnColumn`**, listed apart — a handle every holder of which is a row of its own minting column. That is what a predicate update writes (one sealed value onto every row it matched), and stored rows cannot tell it from a reference copied between rows of the same column, so it is listed and not judged.
15+
16+
Each entry carries the handle id, its `(namespace, key)`, the holder count and every holder's object, field and record. Never a secret and never cipher material. Under `--json` the section is a new top-level `holders` key beside `plan`. Report mode only: it changes nothing `--delete` removes, and it repairs nothing.

‎content/docs/deployment/cli.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,19 @@ how you state there are none. Rows nothing attributes are never deleted, nor are
664664
whose setting still resolves through a legacy inline value, nor rows that were merely
665665
re-wrapped in place (a re-wrap keeps the handle and is not a retirement).
666666
667+
**The report also lists the rows something names in a way no producer writes.** A
668+
handle more than one holder record names is listed as *shared*, since a producer seals
669+
one handle per value. A handle a column holds whose `(namespace, key)` names another
670+
column is listed as *misplaced*: settings records the setting's own pair, and an
671+
object's `secret` field records the object and field name. A datasource holder is not
672+
judged for placement, because the datasource binder records the pair its caller passes.
673+
A predicate update writes one handle onto every row it matched, all in the column the
674+
handle was minted for, so a share of exactly that shape is listed apart and not judged:
675+
stored rows cannot tell it from a reference copied between rows of the same column.
676+
Each entry names the handle, its holder count and every holder's object, field and
677+
record, never a secret. Under `--json` this is the `holders` key. It is report-only: it
678+
changes nothing `--delete` removes and repairs nothing.
679+
667680
<Callout type="warn">
668681
**This does not retire an exposed credential, and the export is not optional.** On the
669682
pre-fix rotation path the handle was never repointed, so the value **still in force** is

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

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@
4141
* A run that reported `total: 0` with an empty `legacyInlineRows` would be
4242
* indistinguishable from a broken read, which is why both are asserted with
4343
* seeded values rather than merely for absence of an error.
44+
*
45+
* ## [#27721] The holder section, through the same run
46+
*
47+
* One more seeded handle, named by two settings rows, has to arrive under the
48+
* report's `holders.shared` with both holders' coordinates; and nothing the
49+
* command prints may carry any seeded cipher material.
4450
*/
4551

4652
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
@@ -96,6 +102,43 @@ const SEEDED_SETTING = {
96102
value_enc: 'ENC(v1:inline-legacy-ciphertext)',
97103
};
98104

105+
/**
106+
* [#27721] A handle two `sys_setting` rows name: the shape the holder report
107+
* lists as shared. Settings mints one handle per row write, so this is never a
108+
* share a producer made.
109+
*/
110+
const SEEDED_SHARED_SECRET = {
111+
id: 'sec_27721_shared_probe',
112+
namespace: 'mail',
113+
key: 'smtp_password',
114+
alg: 'aes-256-gcm',
115+
version: 1,
116+
kms_key_id: 'kms_local',
117+
ciphertext: 'ENC(v1:shared-probe-cipher-material)',
118+
};
119+
const SEEDED_SHARING_SETTINGS = [
120+
{ id: 'set_27721_tenant', namespace: 'mail', key: 'smtp_password', scope: 'tenant', value_enc: SEEDED_SHARED_SECRET.id },
121+
{ id: 'set_27721_user', namespace: 'mail', key: 'smtp_password', scope: 'user', user_id: 'usr_27721', value_enc: SEEDED_SHARED_SECRET.id },
122+
];
123+
124+
/** The `--json` report document, as far as these assertions read it. */
125+
interface ReportPayload {
126+
mode?: string;
127+
error?: string;
128+
plan?: {
129+
counts: { total: number };
130+
rows: Array<{ id: string }>;
131+
legacyInlineRows: Array<{ namespace: string; key: string }>;
132+
};
133+
holders?: {
134+
shared: Array<{
135+
id: string;
136+
holderCount: number;
137+
holders: Array<{ object: string; field: string; record: string | null; placement: string }>;
138+
}>;
139+
};
140+
}
141+
99142
interface DriverProbe {
100143
find(object: string, query: Record<string, unknown>): Promise<unknown>;
101144
create(object: string, data: Record<string, unknown>): Promise<unknown>;
@@ -153,6 +196,10 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)',
153196
await secretDriver.create('sys_secret', { ...SEEDED_SECRET });
154197
// The row names an organization: a fresh tenant column is NOT NULL (ADR-0131 D1).
155198
await settingDriver.create('sys_setting', { ...SEEDED_SETTING, organization_id: 'org_fixture' });
199+
await secretDriver.create('sys_secret', { ...SEEDED_SHARED_SECRET });
200+
for (const row of SEEDED_SHARING_SETTINGS) {
201+
await settingDriver.create('sys_setting', { ...row, organization_id: 'org_fixture' });
202+
}
156203
}, 180_000);
157204

158205
afterAll(async () => {
@@ -230,7 +277,12 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)',
230277
expect((await secretDriver.find('sys_secret', {})) as unknown[]).not.toEqual([]);
231278
}, 60_000);
232279

233-
it('the command runs against this database and BOTH former call sites carry their rows', async () => {
280+
/**
281+
* Run the command once, `--json`, against this database. Memoized: every
282+
* assertion below reads the one run, so the boot is paid once.
283+
*/
284+
let report: Promise<{ stdout: string; payload: ReportPayload }> | undefined;
285+
const runReport = () => (report ??= (async () => {
234286
const chunks: string[] = [];
235287
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(
236288
((chunk: unknown, ...rest: unknown[]) => {
@@ -252,15 +304,13 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)',
252304
}
253305

254306
const lines = chunks.join('').split('\n').filter((l) => l.trim() !== '');
255-
const payload = JSON.parse(lines[lines.length - 1]) as {
256-
mode?: string;
257-
error?: string;
258-
plan?: {
259-
counts: { total: number };
260-
rows: Array<{ id: string }>;
261-
legacyInlineRows: Array<{ namespace: string; key: string }>;
262-
};
263-
};
307+
const payload = JSON.parse(lines[lines.length - 1]) as ReportPayload;
308+
309+
return { stdout: chunks.join(''), payload };
310+
})());
311+
312+
it('the command runs against this database and BOTH former call sites carry their rows', async () => {
313+
const { payload } = await runReport();
264314

265315
// A boot or read failure lands as an `error` envelope; naming it here beats
266316
// a downstream `undefined` that reads like a shape change.
@@ -278,4 +328,24 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)',
278328
expect.objectContaining({ namespace: SEEDED_SETTING.namespace, key: SEEDED_SETTING.key }),
279329
);
280330
}, 180_000);
331+
332+
it('[#27721] lists the handle two settings rows name, and prints no cipher material', async () => {
333+
const { stdout, payload } = await runReport();
334+
expect(payload.error).toBeUndefined();
335+
336+
const shared = payload.holders?.shared.find((f) => f.id === SEEDED_SHARED_SECRET.id);
337+
expect(shared?.holderCount).toBe(2);
338+
expect(shared?.holders.map((h) => [h.object, h.field, h.record, h.placement]).sort()).toEqual([
339+
['sys_setting', 'value_enc', 'set_27721_tenant', 'in_place'],
340+
['sys_setting', 'value_enc', 'set_27721_user', 'in_place'],
341+
]);
342+
343+
// Anti-vacuity: the run did read the rows whose cipher material is checked.
344+
expect(payload.plan!.rows.map((r) => r.id)).toEqual(
345+
expect.arrayContaining([SEEDED_SECRET.id, SEEDED_SHARED_SECRET.id]),
346+
);
347+
for (const cipher of [SEEDED_SECRET.ciphertext, SEEDED_SHARED_SECRET.ciphertext, SEEDED_SETTING.value_enc]) {
348+
expect(stdout).not.toContain(cipher);
349+
}
350+
}, 180_000);
281351
});

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

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {
3232
SettingRowSnapshot,
3333
SysSecretSweepPlan,
3434
} from '../../utils/sys-secret-orphan-sweep.js';
35+
import type { SysSecretHolderReport } from '../../utils/sys-secret-holder-report.js';
3536

3637
/**
3738
* `os secret orphans` — the operator-run half of #8103.
@@ -93,11 +94,20 @@ import type {
9394
* make the mistake describable and still permanent. `--delete` without
9495
* `--export` is refused, the file is written with owner-only permissions, and
9596
* it is READ BACK and checked against the plan before a single row is removed.
97+
*
98+
* ## The rows something DOES name (#27721)
99+
*
100+
* The report also lists, from the same union, each handle more than one holder
101+
* names and each handle a column other than its own holds
102+
* (`sys-secret-holder-report.ts`), with the predicate-update share listed
103+
* apart. Report mode only, and ⛔ nothing acts on it: it is not an input to the
104+
* deletion plan, and no repair follows from it here.
96105
*/
97106
export default class SecretOrphans extends Command {
98107
static override description =
99108
'Report `sys_secret` rows no producer references any more, and (only with --delete) remove the '
100-
+ 'ones attributable to settings. Report-only by default: it writes no row or table and deletes nothing.';
109+
+ 'ones attributable to settings. Report-only by default: it writes no row or table and deletes nothing. '
110+
+ 'The report also lists handles more than one holder names, or a column other than their own holds.';
101111

102112
static override examples = [
103113
'<%= config.bin %> secret orphans',
@@ -200,6 +210,7 @@ export default class SecretOrphans extends Command {
200210
const { collectSecretReferenceUnion } = await import('../../utils/secret-reference-union.js');
201211
const { buildPreDeleteExport, planSysSecretOrphanSweep, useHandlePredicate } =
202212
await import('../../utils/sys-secret-orphan-sweep.js');
213+
const { reportSysSecretHolders } = await import('../../utils/sys-secret-holder-report.js');
203214
const { collectEncryptedSpecifierRefs, isSecretHandle } = await import('@objectstack/service-settings');
204215
const { PlatformObjectsPlugin } = await import('@objectstack/platform-objects/plugin');
205216

@@ -340,8 +351,10 @@ export default class SecretOrphans extends Command {
340351
reads.notice(json);
341352

342353
if (!flags.delete) {
343-
if (json) { await emitJson({ mode: 'report', plan }, 0, { compact: true }); return; }
354+
const holders = reportSysSecretHolders({ secrets, union });
355+
if (json) { await emitJson({ mode: 'report', plan, holders }, 0, { compact: true }); return; }
344356
renderPlan(plan);
357+
renderHolders(holders);
345358
printInfo(`Report only — no row or table was written or deleted.${journalNote} (${timer.display()})`);
346359
printInfo('Re-run with --delete --export <path> to remove the deletable rows.');
347360
return;
@@ -583,3 +596,25 @@ function renderPlan(plan: SysSecretSweepPlan): void {
583596
console.log(chalk.bold('\n Read before acting'));
584597
for (const note of plan.notes) printWarning(note);
585598
}
599+
600+
/** Render the holder report for a human. Handle ids and holder coordinates only. */
601+
function renderHolders(report: SysSecretHolderReport): void {
602+
console.log(chalk.bold('\n Named handles — shared, or held out of place'));
603+
printInfo(
604+
`named ${report.counts.named} · shared ${report.counts.shared} · misplaced ${report.counts.misplaced} · `
605+
+ `shared in own column ${report.counts.sharedInOwnColumn}`,
606+
);
607+
const sections: Array<[string, SysSecretHolderReport['shared']]> = [
608+
['SHARED ', report.shared],
609+
['MISPLACED', report.misplaced],
610+
['OWN COL ', report.sharedInOwnColumn],
611+
];
612+
for (const [tag, findings] of sections) {
613+
for (const finding of findings) {
614+
console.log(` ${chalk.yellow(tag)} ${finding.id} (${finding.namespace}.${finding.key}) · ${finding.holderCount} holder(s)`);
615+
for (const holder of finding.holders) console.log(chalk.dim(` ${holder.placement}: ${holder.family}: ${holder.holder}`));
616+
console.log(chalk.dim(` ${finding.reason}`));
617+
}
618+
}
619+
for (const note of report.notes) printWarning(note);
620+
}

‎packages/cli/src/utils/secret-reference-union.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,10 @@ describe('family 1 — settings (`sys_setting.value_enc` and `sys_platform_setti
348348
const ref = union.references.find((r) => r.handleId === rt.settingsHandleId);
349349
expect(ref?.family).toBe('settings');
350350
expect(ref?.holder).toContain('sys_setting(namespace=smtp,key=password');
351+
// [#27721] The same place as data: the record, and the pair settings mints for it.
352+
expect(ref?.column).toEqual({
353+
object: 'sys_setting', field: 'value_enc', record: 'set_1', mintedPair: { namespace: 'smtp', key: 'password' },
354+
});
351355
// No other family holds it — so removing family 1's enumeration reds this.
352356
expect(union.references.filter((r) => r.handleId === rt.settingsHandleId)).toHaveLength(1);
353357
});
@@ -394,6 +398,9 @@ describe('family 1 — settings (`sys_setting.value_enc` and `sys_platform_setti
394398
expect(refs).toHaveLength(1);
395399
expect(refs[0].family).toBe('settings');
396400
expect(refs[0].holder).toBe('sys_platform_setting(namespace=smtp,key=password)');
401+
expect(refs[0].column).toEqual({
402+
object: 'sys_platform_setting', field: 'value_enc', record: 'ps_1', mintedPair: { namespace: 'smtp', key: 'password' },
403+
});
397404
});
398405

399406
it('an unreadable sys_platform_setting gaps the WHOLE settings family', async () => {
@@ -419,6 +426,10 @@ describe('family 2 — the engine secret-field channel (`secret:<id>` on a busin
419426
const ref = union.references.find((r) => r.handleId === rt.objectFieldHandleId);
420427
expect(ref?.family).toBe('object-field');
421428
expect(ref?.holder).toBe('smtp.password#rec_1');
429+
// [#27721] The engine mints a secret field's handle under the object and field name.
430+
expect(ref?.column).toEqual({
431+
object: 'smtp', field: 'password', record: 'rec_1', mintedPair: { namespace: 'smtp', key: 'password' },
432+
});
422433
expect(union.references.filter((r) => r.handleId === rt.objectFieldHandleId)).toHaveLength(1);
423434
});
424435

@@ -492,6 +503,10 @@ describe('family 3 — datasource artefacts (`external.credentialsRef`)', () =>
492503
const ref = union.references.find((r) => r.handleId === rt.datasourceHandleId);
493504
expect(ref?.family).toBe('datasource');
494505
expect(ref?.holder).toBe('datasource(main).external.credentialsRef');
506+
// [#27721] No minted pair: the binder records whatever pair its caller passes.
507+
expect(ref?.column).toEqual({
508+
object: 'datasource', field: 'external.credentialsRef', record: 'main', mintedPair: null,
509+
});
495510
expect(union.references.filter((r) => r.handleId === rt.datasourceHandleId)).toHaveLength(1);
496511
});
497512

@@ -500,7 +515,12 @@ describe('family 3 — datasource artefacts (`external.credentialsRef`)', () =>
500515
{ name: 'analytics', external: { credentialsRef: 'sys_secret:sec_declared_1' } },
501516
]);
502517
expect(result.references).toEqual([
503-
{ handleId: 'sec_declared_1', family: 'datasource', holder: 'datasource(analytics).external.credentialsRef' },
518+
{
519+
handleId: 'sec_declared_1',
520+
family: 'datasource',
521+
holder: 'datasource(analytics).external.credentialsRef',
522+
column: { object: 'datasource', field: 'external.credentialsRef', record: 'analytics', mintedPair: null },
523+
},
504524
]);
505525
});
506526

‎packages/cli/src/utils/secret-reference-union.ts‎

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,35 @@ export interface SecretReference {
133133
* `datasource(main).external.credentialsRef`.
134134
*/
135135
holder: string;
136+
/** The same place as data, for a reader that compares coordinates rather than printing them. */
137+
column: SecretHolderColumn;
138+
}
139+
140+
/**
141+
* [#27721] The coordinates of the column that holds a reference.
142+
*
143+
* {@link SecretReference.holder} is for printing; this is for comparing, so a
144+
* reader asks "which column holds it" without parsing a printed string back.
145+
* Collected at the same read as the reference, and nothing more is read for it.
146+
*/
147+
export interface SecretHolderColumn {
148+
/** The holding object (`sys_setting`, a business object), or `datasource` for an artefact. */
149+
object: string;
150+
/** The holding column (`value_enc`, the `secret` field, `external.credentialsRef`). */
151+
field: string;
152+
/** The holding record: the row id, or the datasource name. `null` when the read returned none. */
153+
record: string | null;
154+
/**
155+
* The `(namespace, key)` its producer records on a `sys_secret` row it mints
156+
* FOR this column: the settings row's own pair, or the object and field name
157+
* (`encryptSecretFields`).
158+
*
159+
* `null` when the producer lets its caller choose the pair, so the pair names
160+
* no column: the datasource binder records whatever its caller passes
161+
* (`SecretInput.namespace` / `key`, and the binder's own `namespace` option),
162+
* defaulting only when none is passed.
163+
*/
164+
mintedPair: { namespace: string; key: string } | null;
136165
}
137166

138167
/** A family whose references were fully enumerated. */
@@ -380,10 +409,10 @@ export async function collectSettingsSecretReferences(
380409
}
381410

382411
// `sys_platform_setting` has no `scope` and no `user_id`: one row per
383-
// `(namespace, key)` for the deployment.
412+
// `(namespace, key)` for the deployment. `id` names the holding record.
384413
const fields = holder === 'sys_setting'
385-
? ['namespace', 'key', 'scope', 'user_id', 'value_enc']
386-
: ['namespace', 'key', 'value_enc'];
414+
? ['id', 'namespace', 'key', 'scope', 'user_id', 'value_enc']
415+
: ['id', 'namespace', 'key', 'value_enc'];
387416
let result: unknown;
388417
try {
389418
result = await driver.find(holder, { fields });
@@ -405,6 +434,12 @@ export async function collectSettingsSecretReferences(
405434
holder: `${holder}(namespace=${String(row.namespace)},key=${String(row.key)}`
406435
+ `${row.scope == null ? '' : `,scope=${String(row.scope)}`}`
407436
+ `${row.user_id == null ? '' : `,user_id=${String(row.user_id)}`})`,
437+
column: {
438+
object: holder,
439+
field: 'value_enc',
440+
record: row.id == null ? null : String(row.id),
441+
mintedPair: { namespace: String(row.namespace), key: String(row.key) },
442+
},
408443
});
409444
}
410445
}
@@ -481,6 +516,12 @@ export async function collectObjectFieldSecretReferences(
481516
handleId,
482517
family,
483518
holder: `${objectName}.${field}#${String(row.id)}`,
519+
column: {
520+
object: objectName,
521+
field,
522+
record: row.id == null ? null : String(row.id),
523+
mintedPair: { namespace: objectName, key: field },
524+
},
484525
});
485526
}
486527
}
@@ -535,7 +576,17 @@ export function collectDatasourceSecretReferences(
535576
const key = JSON.stringify([handleId, holder]);
536577
if (seen.has(key)) continue; // the same reference, reached through two sources
537578
seen.add(key);
538-
references.push({ handleId, family, holder });
579+
references.push({
580+
handleId,
581+
family,
582+
holder,
583+
column: {
584+
object: 'datasource',
585+
field: 'external.credentialsRef',
586+
record: artefact.name == null ? null : String(artefact.name),
587+
mintedPair: null,
588+
},
589+
});
539590
}
540591

541592
return { family, status: 'enumerated', references };

0 commit comments

Comments
 (0)