Skip to content

Commit 4bea755

Browse files
refactor(objectql): move the secret and internal-field bodies out of engine.ts into engine-secret-internal-fields.ts (#25522 cut 5) (#26028)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XHUVA2v8YMSniMMGK58oCeh)_ Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 Cut 5 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6412019236`, which amends `6411795983` after the dev report `6411990837`. The nine bodies of the "Secret and internal-field handling" area move unedited into a new module, `packages/objectql/src/engine-secret-internal-fields.ts`: `encryptSecretFields`, `refuseEmptyPasswordFields`, `maskSecretFields`, `omitInternalFields`, `stripSearchCompanionFromRead`, `privilegedReadDriverOptions`, `resolveSecret`, `resolveSecretField` and `resolveInternalField`. No secret-handling behaviour changes. #25522 stays open for the later cuts. Branch `claude/project-thread-npj50o` (the card thread's harness-pinned branch), cut from `origin/main` at `c8cb510a5`. `main` has since moved to `c16c00f93`; nothing in that range touches `packages/objectql/**`, the two docs files or the anchor directory entries this PR edits, so the branch is not merged forward and CI builds the merge ref. #25980 (the last commit before the base) changed `engine.ts` only in its preamble and trailer; the nine bodies are byte-identical from `a4eae0649` to `c8cb510a5`. ## Why this area (the fresh seam table) Measured on `origin/main` `a4eae06499`: `engine.ts` is 23,458 lines, 7,214 of them (30.8%) from the last 30 days (`git blame -s --since=2026-09-26T08:45Z`), 121 commits in 30 days and 31 in 7. The full 41-section table in #23050's shape, with a per-method screen of every section, is in the dev report `6411990837` and its project file. The three areas left by cut 4 do not pay a move-only cut without a `packages/spec` edit: | area | 30-day lines | movable inside the claim | why not | |---|---|---|---| | Roll-up summaries | 0 | 46 body lines, about -15 net | `buildSummaryIndex` is cited from `packages/spec` (`functional-completeness.ts`, ADR-anchored; `liveness/field.json` `summaryOperations`, and `liveness/` ships in the spec tarball); `recomputeSummaries` hands `this` to `aggregateSummaryValue` | | Datasource registry | 1 | 27 body lines, about -5 net | `unregisterDriver` and `clearDatasourceUnavailable` are `this.FIELD.delete(` sites (U = 3) | | Related-record expansion | 10 | 0 | `expandRelatedRecords` reads the private static `MAX_EXPAND_DEPTH` and is cited from `liveness/query.json` and `liveness/field.json` | | **Secret and internal-field handling** (this PR) | 32 of 566 | **all 9 bodies, 208 body lines** | none: no inferred-return public method, no `this.FIELD.delete/insert/update(` site, no private-static read (screened by AST), no `packages/spec` or `docs/adr` pointer | ## The shape (#23050 shape B, as cuts 1 to 4 apply it) - The class keeps all nine methods with name, modifiers, signature, docblock and position, because tsup emits members with their JSDoc into the `.d.ts`. Each body is now one host literal plus `fn.call(host, …)`, marked `[#25522 cut 5]`. - `SecretInternalFieldsHost` is new. Each member is typed as the class member (`ObjectQL['cryptoProvider']`, `ObjectQL['getDriver']`), and each moved function types its `this` as the `Pick` of exactly the members its body reads; each stub annotates its literal as `ThisParameterType` of that function. No cast, and no member changes visibility. - The three fields (`_registry`, `cryptoProvider`, `txStore`) go as GETTERS, so `setCryptoProvider` between calls is seen exactly as before. The five methods the bodies call (`getDriver`, `transactionCoversDriverFor`, `omitInternalFields`, `privilegedReadDriverOptions`, `resolveSecret`) go as late-bound closures over the instance, so a double set on the engine still intercepts. Tests do replace `resolveInternalField` (4 sites) and `resolveSecretField` (2) on the instance; those methods stay on the class, so the doubles still take effect. - `stripSearchCompanionFromRead` reads no member and is called directly, as cut 1 calls `objectDisplayLabel`. - **Imports.** The module imports `ObjectQL` as a TYPE only (no runtime cycle) and nothing re-exports it. `engine.ts` drops the names whose last code reader moved: `CryptoHandle`, `collectSecretFields`, `collectMaskedReadFields`, `collectMaskedPasswordFields`, `makeSecretRef`, `parseSecretRef`, `isSecretRef`, `EmptyCredentialWriteError`, `SECRET_MASK` and `isSearchCompanionRequested`. `collectInternalReadFields`, `collectCredentialFields`, `stripSearchCompanion`, `redactPropagatedDriverFault` and `ICryptoProvider` still have readers there and stay. ## Net lines (the seat's rule) `engine.ts` 23,492 → 23,351, **net -141**. The module is 360 lines: the header and host interface (about 75), nine one-line docblocks, and the 208 moved body lines with their signatures. 667 changed lines over 6 files (+447 / -220), well under the 3,000 human-merge line. ## The published `.d.ts` does not change (ruling `6348891160`) Base: `@objectstack/objectql` built with `engine.ts` restored from `c8cb510a5` and the new module removed (tree only, restored with `git checkout HEAD --`, `git status` clean afterwards). Head: built at `bd40de2733`. The head `dist/index.mjs` carries the stub's `readCryptoProvider`, which the base build lacks, so each leg built the source it names. All six declaration files are **raw byte-identical**, so the ruled normaliser has nothing to do: | file | sha256, base = head | |---|---| | `index.d.ts` | `3d0e556fe6cf12c69d3b9516d9f414d235bd7279b3f9cb4bbd10f8fb3f098726` | | `index.d.mts` | `9d741b723c1c6697d9b2ccd98cac69075dc5f87328d50936c5d73496c45d4626` | | `core.d.ts` | `a024599701ba68ebfa6d0c3781d3c8d755ab1c7c1fc2db5ab89cb30020091a7e` | | `core.d.mts` | `9acb9b18b3220f17a45abc36ef86fb54f53244e634b71400ba45c12895dc5b2b` | | `util-CR5tVhqM.d.ts` and `.d.mts` | `2696708b9ee625ddcdf62709ea19e6d92b39d2d82cdd803652be1b17f625b059` | **Positive control:** making line 8014 of the head `util-CR5tVhqM.d.ts` (`private maskSecretFields;`) public gives `7f91b0b1062f7356115bd25365df7bc3baaae7678e3d06dc92552ffdb87df35f`, so the comparison can fail. Export lists, ESM = CJS at both ends: `.` 182 names (sorted sha256 `881616cc…0adaa`), `./core` 54 (`393e8069…90f49`), identical at base and head; no moved name is exported. ## Proof of move-only A TypeScript-AST leaf-token compare (recursive `getChildren`, JSDoc skipped), base `engine.ts` against the head module: - Each of the nine: parameters (less `this`) + return type + body tokens **SAME**, `async` the same, and every body line equals the base line less two leading spaces. `encryptSecretFields` 354 tokens / 73 lines, `resolveInternalField` 324 / 41, `resolveSecret` 197 / 31, `resolveSecretField` 169 / 24, `maskSecretFields` 128 / 17, `omitInternalFields` 96 / 9, `refuseEmptyPasswordFields` 76 / 7, `privilegedReadDriverOptions` 57 / 4, `stripSearchCompanionFromRead` 37 / 2. - **Negative controls:** one `return` rewritten in the moved `resolveInternalField` or `maskSecretFields` reports DIFFER. - Class: 318 members at both ends, same order; 309 byte-identical (full text, docblocks included); the 9 stubs keep docblock, modifiers and signature byte-identical up to the body. - Top level: 224 of 228 base statements byte-identical in head. The others are the class, the three narrowed imports above, and the new module import with its comment. ## Readers of the moved lines, migrated in this PR - `docs/qa/platform-checklist/areas/records-forms.json`: the secret-field item's source pointer, `engine.ts#encryptSecretFields` → `engine-secret-internal-fields.ts#encryptSecretFields`. `check:platform-checklist` green. - `content/docs/permissions/system-context.mdx` row 27: `engine.ts#stripSearchCompanionFromRead` → `engine-secret-internal-fields.ts#stripSearchCompanionFromRead`, the `isSystem` read's new home. `check-system-context-census` green: 151 read sites in 22 packages across 79 files, the page cites 144 symbols against 144 required. - `packages/objectql/src/federated-injected-column-readers.test.ts`: the ledger key `engine.ts#encryptSecretFields :: created_at` → `engine-secret-internal-fields.ts#encryptSecretFields :: created_at` (the scan keys by file). - **ADR anchors.** The engine anchor entry's one paragraph is the transaction seam (`buildDriverOptions`, `enforceTransactionOrigin`) and describes no moved body. But the moved encrypt and decrypt bodies carry two `ADR-0128 D1` citations, and moving them dropped the anchored surface's decision-letter count from 1,844 to 1,842. Cut 4 resolved the same drop with an entry for its new module, so this PR adds `scripts/adr-anchors/packages__objectql__src__engine-secret-internal-fields.ts.json` (ADR-0100, ADR-0128). `check:adr-anchors`: base 86 anchored files, 1,844 letter citations; head 87, **1,845**. - **Census and receivers.** `check-tenant-audit-census` green (243 write call sites, every `UNTYPED_RECEIVERS` row matched; the moved `secretDriver.create(` is a local receiver, not a `this.FIELD` one). `check:logger-receiver-detach` green (the moved bodies read no logger). `check:durability-log-level` green over the objectql read seams. ## Acceptance notes - Prose that names `engine.ts` beside a moved method still resolves by method name, because every method stays on the class. Examples: `service-settings`'s `settings-crypto-fail-closed.test.ts` comment and `plugin-security`'s `insert-check-post-image.test.ts` comments. They are outside this claim's file surface and left as they are. - Serial check, re-read before `pr_create`: 29 PRs are open. One touches `engine.ts`: #26026 (seat 1's #24768, branch `claude/project-thread-otcr21`). Its hunks are in `createWithAutonumberResync`, `insert` and `update`, and `git merge-tree` of the two heads is clean (exit 0). Whichever lands second merges `main`. ## Verification At head `bd40de2733`: - `pnpm --filter @objectstack/objectql typecheck` (`tsc --noEmit` + `check:test-typecheck`, 39 files / 233 errors / 64 pinned signatures held): exit 0. `eslint --no-inline-config packages/objectql`: exit 0. - `pnpm --filter @objectstack/objectql exec vitest run --project local`: 470 files, 8,742 tests, 8,742 passed, 0 failed, 0 skipped. This includes `read-internal-column.test.ts`, the secret and password suites, and `federated-injected-column-readers.test.ts`. - Dependants: every changed member keeps its declaration and all six published `.d.ts` files are byte-identical, so no dependant can see a difference; CI's `TypeScript Type Check` covers them. - `dispatch-gates --commands` derived 103 families on the real diff. All were run at head, each exit code recorded before any pipe, and reconciled with `--ran` (103 accounted for: 102 run, 1 NOT MEASURED). `check:skill-examples` exited 3 until `client-react`'s closure was built, then 0. **NOT MEASURED** (CI runs it): `check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET (it needs every package's `dist/`; the export lists above answer it for objectql). `check-engine-split-ratio --days 90`: 98.1%, exit 0. - Hand gates, all exit 0: `check-adr-anchors` (base and head, above), `check-adr-symbol-anchors` (2,658 anchors resolve), `check-tenant-audit-census`, `check-nul-bytes`, `check-platform-checklist --changed-from origin/main` (2 anchored files, 14 anchors, 0 lost), `check-commit-card-trailers --range origin/main..HEAD`. `skip-changeset`: move-only. The published `.d.ts` files and export lists are byte-identical, `liveness/` and every other published path are untouched, and the module's runtime is the same code under a different file name. --- _Generated by [Claude Code](https://claude.ai/code/session_01FCw8ckDE3VXVavCoqsMQ3u)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 10ba46b commit 4bea755

6 files changed

Lines changed: 447 additions & 220 deletions

File tree

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ that silently does not happen.
136136
| 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `packages/plugins/plugin-security/src/system-write-guard.ts#isUserContextWrite`, `#assertEngineOwnedWriteAllowed` |
137137
| 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `packages/plugins/plugin-auth/src/identity-write-guard.ts#isUserContextWrite` |
138138
| 26b | API key restore guard does not refuse | plugin-auth | Get: a write setting `revoked: false` on a `sys_api_key` row an administrator's forced password reset revoked is admitted without the platform-administrator check a user-context restore must pass; the reason is still cleared. Lose: nothing a caller reaches: no system writer restores a key, and the mint, the last-used stamp and the forced reset itself never set `revoked: false` | `packages/plugins/plugin-auth/src/api-key-restore-guard.ts#isUserContextWrite` |
139-
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `packages/objectql/src/engine.ts#stripSearchCompanionFromRead` |
139+
| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `packages/objectql/src/engine-secret-internal-fields.ts#stripSearchCompanionFromRead` |
140140
| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `packages/objectql/src/engine.ts#dependentCountIsDisclosable` |
141141
| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `packages/objectql/src/engine.ts#recordReferenceCheckElevation` |
142142
| 29b | A traversing validation rule's related read skips the **caller's bounds** | objectql | Get: no row bound from the caller's own read on a related object no organization wall scopes, no read door on a header the rule reads through a `master_detail` field, and a related read even with no organization under a walled posture (a `tenantId` on the context still scopes it). Every caller that is not system is held to all three: a header its read door does not return is unavailable to the rule exactly as a header that exists nowhere is, whatever it holds, and a header it can read is judged on its stored values. A plain `lookup` to an object an organization wall scopes asks no read door, system caller or not | `packages/objectql/src/engine.ts#resolveOneHopRelated` |

‎docs/qa/platform-checklist/areas/records-forms.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4114,7 +4114,7 @@
41144114
},
41154115
"source": [
41164116
"packages/objectql/src/secret-fields.ts#SECRET_REF_PREFIX (SECRET_MASK, SECRET_REF_PREFIX, collectSecretFields/collectMaskedReadFields/collectCredentialFields)",
4117-
"packages/objectql/src/engine.ts#encryptSecretFields (encryptSecretFields — encrypt→sys_secret→ref, echoed-mask drop, fail-closed throw; decrypt path)",
4117+
"packages/objectql/src/engine-secret-internal-fields.ts#encryptSecretFields (encryptSecretFields — encrypt→sys_secret→ref, echoed-mask drop, fail-closed throw; decrypt path)",
41184118
"packages/spec/src/data/field.zod.ts#FieldType (FieldType 'secret'/'password' ADR-0100 notes; ackPlaintextMasking)",
41194119
"packages/cli/src/commands/serve.ts#host (LocalCryptoProvider host wiring via setCryptoProvider)",
41204120
"packages/platform-objects/src/system/sys-secret.object.ts (ciphertext store)",
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 5] The secret and internal-field bodies of `ObjectQL`, moved out
5+
* of `engine.ts` whole: the write-side credential pass (`encryptSecretFields`,
6+
* `refuseEmptyPasswordFields`), the read-side strips (`maskSecretFields`,
7+
* `omitInternalFields`, `stripSearchCompanionFromRead`), and the three
8+
* privileged read verbs with the driver options they share (`resolveSecret`,
9+
* `resolveSecretField`, `resolveInternalField`, `privilegedReadDriverOptions`).
10+
*
11+
* A MOVE, not a redesign. Each exported function below is the body of the
12+
* `ObjectQL` method of the same name as it stood, re-indented one level and
13+
* otherwise unedited: the same fail-closed throws, the same echoed-mask drop,
14+
* the same mask-then-omit order, the same refusals before any driver read, and
15+
* the same ambient-transaction rule for the privileged reads. Each method's
16+
* docblock stays on the method in `engine.ts`, which is still the contract.
17+
*
18+
* ## How the moved bodies reach the class
19+
*
20+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
21+
* header carries the full reasoning), applied per method as
22+
* `engine-realtime-publish.ts` applies it. The class keeps all nine methods —
23+
* the published `.d.ts` lists each one's name and position — and each body is
24+
* one literal of exactly the {@link SecretInternalFieldsHost} members that
25+
* method reads, plus a `.call`. The three fields the bodies read
26+
* (`_registry`, `cryptoProvider`, `txStore`) go as GETTERS, so each read
27+
* happens when the body makes it: `setCryptoProvider` replaces the provider at
28+
* any time, and a body sees whatever is installed at the moment it reads, as
29+
* it did. The five methods they call (`getDriver`,
30+
* `transactionCoversDriverFor`, `omitInternalFields`,
31+
* `privilegedReadDriverOptions`, `resolveSecret`) go as late-bound closures
32+
* over the instance, so a double or spy set on the engine — tests replace
33+
* `resolveInternalField` and `resolveSecretField` on the instance — is still
34+
* the one a call reaches. `stripSearchCompanionFromRead` reads no member and
35+
* is called directly.
36+
*
37+
* This module imports the engine only as a TYPE, so there is no module cycle
38+
* at run time, and nothing re-exports it, so the published surface does not
39+
* move.
40+
*/
41+
42+
import type { CryptoHandle } from '@objectstack/spec/contracts';
43+
import type { ExecutionContext } from '@objectstack/spec/kernel';
44+
import { redactPropagatedDriverFault } from '@objectstack/types';
45+
import {
46+
collectSecretFields,
47+
collectMaskedReadFields,
48+
collectMaskedPasswordFields,
49+
collectInternalReadFields,
50+
makeSecretRef,
51+
parseSecretRef,
52+
isSecretRef,
53+
EmptyCredentialWriteError,
54+
SECRET_MASK,
55+
} from './secret-fields.js';
56+
import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js';
57+
import type { ObjectQL } from './engine.js';
58+
59+
/**
60+
* The `ObjectQL` members the functions below read — every one of them, and no
61+
* other; each function's `this` is the `Pick` of the members IT reads. Each is
62+
* typed AS the class member (an indexed access on the class), so a change to a
63+
* member's type on the class is a change here too, and naming a member the
64+
* class does not have is a compile error. See the module header for which
65+
* members are getters.
66+
*/
67+
export interface SecretInternalFieldsHost {
68+
readonly _registry: ObjectQL['_registry'];
69+
readonly cryptoProvider: ObjectQL['cryptoProvider'];
70+
readonly txStore: ObjectQL['txStore'];
71+
getDriver: ObjectQL['getDriver'];
72+
transactionCoversDriverFor: ObjectQL['transactionCoversDriverFor'];
73+
omitInternalFields: ObjectQL['omitInternalFields'];
74+
privilegedReadDriverOptions: ObjectQL['privilegedReadDriverOptions'];
75+
resolveSecret: ObjectQL['resolveSecret'];
76+
}
77+
78+
/** The body of `ObjectQL#encryptSecretFields`; the method's docblock there is its contract. */
79+
export async function encryptSecretFields(
80+
this: Pick<SecretInternalFieldsHost, '_registry' | 'cryptoProvider' | 'getDriver'>,
81+
object: string,
82+
row: Record<string, unknown>,
83+
context: ExecutionContext | undefined,
84+
driverOptions: unknown,
85+
): Promise<void> {
86+
if (!row || typeof row !== 'object') return;
87+
const schema = this._registry.getObject(object);
88+
89+
// Echoed-mask drop for every field the read path masks (secret + generic
90+
// password). The read path returns SECRET_MASK; a client that PATCHes it
91+
// back means "unchanged", so drop the key rather than persist the literal
92+
// mask. Doing this up front keeps the better-auth exemption in one place
93+
// (collectMaskedReadFields) and covers objects that have a password field
94+
// but no secret field. (#2036, ADR-0100)
95+
for (const field of collectMaskedReadFields(schema)) {
96+
if (field in row && row[field] === SECRET_MASK) delete row[field];
97+
}
98+
99+
const secretFields = collectSecretFields(schema);
100+
if (secretFields.length === 0) return;
101+
102+
for (const field of secretFields) {
103+
if (!(field in row)) continue;
104+
const value = row[field];
105+
106+
if (value === null || typeof value === 'undefined') continue; // clear
107+
// [#8559] The empty string is neither a clear (`null` is) nor a storable
108+
// credential — refuse it BEFORE the crypto gates below: this is a verdict
109+
// on the payload, so it must not depend on whether a CryptoProvider or
110+
// sys_secret store happens to be wired.
111+
if (value === '') throw new EmptyCredentialWriteError(object, field, 'secret');
112+
if (isSecretRef(value)) continue; // already encrypted ref
113+
114+
if (!this.cryptoProvider) {
115+
throw new Error(
116+
`Cannot persist secret field "${object}.${field}": no CryptoProvider is registered. `
117+
+ 'Wire one via engine.setCryptoProvider(...) (e.g. LocalCryptoProvider in dev, '
118+
+ 'a KMS/Vault provider in production). Refusing to store cleartext (fail-closed).',
119+
);
120+
}
121+
122+
const plain = typeof value === 'string' ? value : JSON.stringify(value);
123+
// ADR-0128 D1: this producer's own scope, so the AAD names the
124+
// object-secret-field vocabulary and no other producer's context opens it.
125+
const handle: CryptoHandle = await this.cryptoProvider.encrypt(plain, {
126+
scope: 'object_secret_field',
127+
namespace: object,
128+
key: field,
129+
tenantId: context?.tenantId,
130+
});
131+
132+
let secretDriver;
133+
try {
134+
secretDriver = this.getDriver('sys_secret');
135+
} catch {
136+
throw new Error(
137+
`Cannot persist secret field "${object}.${field}": the sys_secret store is not available. `
138+
+ 'Ensure the platform-objects (sys_secret) are registered before writing secret fields (fail-closed).',
139+
);
140+
}
141+
142+
await secretDriver.create(
143+
'sys_secret',
144+
{
145+
id: handle.id,
146+
namespace: object,
147+
key: field,
148+
kms_key_id: handle.kmsKeyId,
149+
alg: handle.alg,
150+
version: handle.version,
151+
ciphertext: handle.ciphertext,
152+
created_at: new Date().toISOString(),
153+
},
154+
driverOptions as any,
155+
);
156+
157+
row[field] = makeSecretRef(handle.id);
158+
}
159+
}
160+
161+
/** The body of `ObjectQL#refuseEmptyPasswordFields`; the method's docblock there is its contract. */
162+
export function refuseEmptyPasswordFields(
163+
this: Pick<SecretInternalFieldsHost, '_registry'>,
164+
object: string,
165+
row: Record<string, unknown>,
166+
): void {
167+
if (!row || typeof row !== 'object') return;
168+
const schema = this._registry.getObject(object);
169+
for (const field of collectMaskedPasswordFields(schema)) {
170+
if (field in row && row[field] === '') {
171+
throw new EmptyCredentialWriteError(object, field, 'password');
172+
}
173+
}
174+
}
175+
176+
/** The body of `ObjectQL#maskSecretFields`; the method's docblock there is its contract. */
177+
export function maskSecretFields(
178+
this: Pick<SecretInternalFieldsHost, '_registry' | 'omitInternalFields'>,
179+
object: string,
180+
rows: any,
181+
): void {
182+
if (!rows) return;
183+
const schema = this._registry.getObject(object);
184+
const maskedFields = collectMaskedReadFields(schema);
185+
if (maskedFields.length > 0) {
186+
const list = Array.isArray(rows) ? rows : [rows];
187+
for (const row of list) {
188+
if (!row || typeof row !== 'object') continue;
189+
for (const field of maskedFields) {
190+
if (!(field in row)) continue;
191+
row[field] = row[field] == null ? null : SECRET_MASK;
192+
}
193+
}
194+
}
195+
// Runs AFTER the mask, so a field that is somehow both `secret`-typed and
196+
// `internal` ends up omitted rather than masked — the stricter disposition
197+
// wins, which is the only safe way for the two to compose.
198+
this.omitInternalFields(object, rows);
199+
}
200+
201+
/** The body of `ObjectQL#omitInternalFields`; the method's docblock there is its contract. */
202+
export function omitInternalFields(
203+
this: Pick<SecretInternalFieldsHost, '_registry'>,
204+
object: string,
205+
rows: any,
206+
): void {
207+
if (!rows) return;
208+
const schema = this._registry.getObject(object);
209+
const internalFields = collectInternalReadFields(schema);
210+
if (internalFields.length === 0) return;
211+
const list = Array.isArray(rows) ? rows : [rows];
212+
for (const row of list) {
213+
if (!row || typeof row !== 'object') continue;
214+
for (const field of internalFields) delete row[field];
215+
}
216+
}
217+
218+
/** The body of `ObjectQL#stripSearchCompanionFromRead`; the method's docblock there is its contract. */
219+
export function stripSearchCompanionFromRead(
220+
rows: unknown,
221+
requestedFields: readonly string[] | undefined,
222+
context: ExecutionContext | undefined,
223+
): void {
224+
if (context?.isSystem && isSearchCompanionRequested(requestedFields)) return;
225+
stripSearchCompanion(rows);
226+
}
227+
228+
/** The body of `ObjectQL#privilegedReadDriverOptions`; the method's docblock there is its contract. */
229+
export function privilegedReadDriverOptions(
230+
this: Pick<SecretInternalFieldsHost, 'txStore' | 'transactionCoversDriverFor'>,
231+
object: string,
232+
): { transaction: unknown } | undefined {
233+
const tx = this.txStore.getStore()?.transaction;
234+
if (tx === undefined) return undefined;
235+
if (!this.transactionCoversDriverFor(object, tx)) return undefined;
236+
return { transaction: tx };
237+
}
238+
239+
/** The body of `ObjectQL#resolveSecret`; the method's docblock there is its contract. */
240+
export async function resolveSecret(
241+
this: Pick<SecretInternalFieldsHost, 'cryptoProvider' | 'getDriver' | 'privilegedReadDriverOptions'>,
242+
ref: unknown,
243+
opts?: { tenantId?: string },
244+
): Promise<string | null> {
245+
const id = parseSecretRef(ref);
246+
if (!id) return null;
247+
if (!this.cryptoProvider) {
248+
throw new Error('Cannot resolve secret: no CryptoProvider is registered (fail-closed).');
249+
}
250+
const secretDriver = this.getDriver('sys_secret');
251+
const found = await secretDriver.find(
252+
'sys_secret',
253+
{ where: { id } },
254+
this.privilegedReadDriverOptions('sys_secret'),
255+
);
256+
const secret: any = Array.isArray(found) ? found[0] : found;
257+
if (!secret) {
258+
throw new Error(`Cannot resolve secret: sys_secret row "${id}" not found (fail-closed).`);
259+
}
260+
const handle: CryptoHandle = {
261+
id: secret.id,
262+
kmsKeyId: secret.kms_key_id,
263+
alg: secret.alg,
264+
version: secret.version,
265+
ciphertext: secret.ciphertext,
266+
};
267+
// ADR-0128 D1: a `secret:` ref is the object-secret-field producer's
268+
// holder, so the scope is that producer's. A row another producer sealed
269+
// under a scoped derivation therefore does not open here.
270+
return this.cryptoProvider.decrypt(handle, {
271+
scope: 'object_secret_field',
272+
namespace: secret.namespace,
273+
key: secret.key,
274+
tenantId: opts?.tenantId,
275+
});
276+
}
277+
278+
/** The body of `ObjectQL#resolveSecretField`; the method's docblock there is its contract. */
279+
export async function resolveSecretField(
280+
this: Pick<SecretInternalFieldsHost, '_registry' | 'getDriver' | 'privilegedReadDriverOptions' | 'resolveSecret'>,
281+
object: string,
282+
recordId: string,
283+
field: string,
284+
opts?: { tenantId?: string },
285+
): Promise<string | null> {
286+
const schema = this._registry.getObject(object);
287+
if (!collectSecretFields(schema).includes(field)) {
288+
throw new Error(
289+
`Cannot resolve secret field "${object}.${field}": it is not declared as type 'secret'. `
290+
+ 'Only the encrypted secret channel is dereferenceable — a `password` field is stored as '
291+
+ 'plaintext at rest and is masked deliberately (ADR-0100), so dereferencing one here '
292+
+ 'would be a mask bypass, not a decrypt.',
293+
);
294+
}
295+
const driver = this.getDriver(object);
296+
let found: unknown;
297+
try {
298+
found = await driver.find(
299+
object,
300+
{ where: { id: recordId } },
301+
this.privilegedReadDriverOptions(object),
302+
);
303+
} catch (e) {
304+
// [#21274] A boundary outside the middleware seam: the record id is bound.
305+
throw redactPropagatedDriverFault(e);
306+
}
307+
const row: any = Array.isArray(found) ? found[0] : found;
308+
if (!row) return null;
309+
return this.resolveSecret(row[field], opts);
310+
}
311+
312+
/** The body of `ObjectQL#resolveInternalField`; the method's docblock there is its contract. */
313+
export async function resolveInternalField(
314+
this: Pick<SecretInternalFieldsHost, '_registry' | 'getDriver' | 'privilegedReadDriverOptions'>,
315+
object: string,
316+
recordIds: readonly string[],
317+
field: string,
318+
): Promise<Map<string, unknown>> {
319+
const schema = this._registry.getObject(object);
320+
if (!collectInternalReadFields(schema).includes(field)) {
321+
const err: Error & { code?: string; status?: number; httpStatus?: number; object?: string; field?: string } =
322+
new Error(
323+
`Cannot resolve internal field "${object}.${field}": it is not declared \`internal: true\`. `
324+
+ 'Only fields the engine omits from the generic read path are dereferenceable here — '
325+
+ 'anything else either comes back on find/findOne already, or is protected by its own '
326+
+ 'channel (`secret` refs via resolveSecretField; `password` is masked deliberately, '
327+
+ 'ADR-0100, so dereferencing one here would be a mask bypass).',
328+
);
329+
err.code = 'INVALID_FIELD';
330+
err.status = 400;
331+
err.httpStatus = 400;
332+
err.object = object;
333+
err.field = field;
334+
throw err;
335+
}
336+
const out = new Map<string, unknown>();
337+
if (recordIds.length === 0) return out;
338+
const driver = this.getDriver(object);
339+
let found: unknown;
340+
try {
341+
found = await driver.find(
342+
object,
343+
{
344+
where: { id: { $in: [...recordIds] } },
345+
fields: ['id', field],
346+
},
347+
this.privilegedReadDriverOptions(object),
348+
);
349+
} catch (e) {
350+
// [#21274] A boundary outside the middleware seam: the record ids are bound.
351+
throw redactPropagatedDriverFault(e);
352+
}
353+
for (const row of Array.isArray(found) ? found : [found]) {
354+
if (!row || typeof row !== 'object') continue;
355+
const id = (row as Record<string, unknown>).id;
356+
if (typeof id !== 'string' && typeof id !== 'number') continue;
357+
out.set(String(id), (row as Record<string, unknown>)[field] ?? null);
358+
}
359+
return out;
360+
}

0 commit comments

Comments
 (0)