From d82fcf75b4d608a6f1392524701a8b6ed287fb5e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:57:29 +0000 Subject: [PATCH 1/2] refactor(objectql): move the secret and internal-field bodies out of engine.ts behind one host (#25522 cut 5) The nine ObjectQL bodies of the "Secret and internal-field handling" area (encryptSecretFields through resolveInternalField) move unedited, one level less indented, into engine-secret-internal-fields.ts. Each method keeps its name, signature, docblock and position and hands its body the members it reads through SecretInternalFieldsHost: _registry, cryptoProvider and txStore as getters, five methods as late-bound closures. stripSearchCompanionFromRead reads no member and is called directly. The three path readers move with them: the federated injected-column ledger key, the records-forms checklist source pointer, and row 27 of the system-context census page. Claude-Session: https://claude.ai/code/session_01FCw8ckDE3VXVavCoqsMQ3u Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 2 +- .../areas/records-forms.json | 2 +- .../src/engine-secret-internal-fields.ts | 360 ++++++++++++++++++ packages/objectql/src/engine.ts | 293 ++++---------- .../federated-injected-column-readers.test.ts | 2 +- 5 files changed, 439 insertions(+), 220 deletions(-) create mode 100644 packages/objectql/src/engine-secret-internal-fields.ts diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index f2cca8c09f8..e893a0499a8 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -136,7 +136,7 @@ that silently does not happen. | 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` | | 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` | | 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` | -| 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` | +| 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` | | 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` | | 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` | | 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` | diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 62271227127..caf06b945f6 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -4114,7 +4114,7 @@ }, "source": [ "packages/objectql/src/secret-fields.ts#SECRET_REF_PREFIX (SECRET_MASK, SECRET_REF_PREFIX, collectSecretFields/collectMaskedReadFields/collectCredentialFields)", - "packages/objectql/src/engine.ts#encryptSecretFields (encryptSecretFields — encrypt→sys_secret→ref, echoed-mask drop, fail-closed throw; decrypt path)", + "packages/objectql/src/engine-secret-internal-fields.ts#encryptSecretFields (encryptSecretFields — encrypt→sys_secret→ref, echoed-mask drop, fail-closed throw; decrypt path)", "packages/spec/src/data/field.zod.ts#FieldType (FieldType 'secret'/'password' ADR-0100 notes; ackPlaintextMasking)", "packages/cli/src/commands/serve.ts#host (LocalCryptoProvider host wiring via setCryptoProvider)", "packages/platform-objects/src/system/sys-secret.object.ts (ciphertext store)", diff --git a/packages/objectql/src/engine-secret-internal-fields.ts b/packages/objectql/src/engine-secret-internal-fields.ts new file mode 100644 index 00000000000..60feb52d476 --- /dev/null +++ b/packages/objectql/src/engine-secret-internal-fields.ts @@ -0,0 +1,360 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25522 cut 5] The secret and internal-field bodies of `ObjectQL`, moved out + * of `engine.ts` whole: the write-side credential pass (`encryptSecretFields`, + * `refuseEmptyPasswordFields`), the read-side strips (`maskSecretFields`, + * `omitInternalFields`, `stripSearchCompanionFromRead`), and the three + * privileged read verbs with the driver options they share (`resolveSecret`, + * `resolveSecretField`, `resolveInternalField`, `privilegedReadDriverOptions`). + * + * A MOVE, not a redesign. Each exported function below is the body of the + * `ObjectQL` method of the same name as it stood, re-indented one level and + * otherwise unedited: the same fail-closed throws, the same echoed-mask drop, + * the same mask-then-omit order, the same refusals before any driver read, and + * the same ambient-transaction rule for the privileged reads. Each method's + * docblock stays on the method in `engine.ts`, which is still the contract. + * + * ## How the moved bodies reach the class + * + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` + * header carries the full reasoning), applied per method as + * `engine-realtime-publish.ts` applies it. The class keeps all nine methods — + * the published `.d.ts` lists each one's name and position — and each body is + * one literal of exactly the {@link SecretInternalFieldsHost} members that + * method reads, plus a `.call`. The three fields the bodies read + * (`_registry`, `cryptoProvider`, `txStore`) go as GETTERS, so each read + * happens when the body makes it: `setCryptoProvider` replaces the provider at + * any time, and a body sees whatever is installed at the moment it reads, as + * it did. The five methods they call (`getDriver`, + * `transactionCoversDriverFor`, `omitInternalFields`, + * `privilegedReadDriverOptions`, `resolveSecret`) go as late-bound closures + * over the instance, so a double or spy set on the engine — tests replace + * `resolveInternalField` and `resolveSecretField` on the instance — is still + * the one a call reaches. `stripSearchCompanionFromRead` reads no member and + * is called directly. + * + * This module imports the engine only as a TYPE, so there is no module cycle + * at run time, and nothing re-exports it, so the published surface does not + * move. + */ + +import type { CryptoHandle } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { redactPropagatedDriverFault } from '@objectstack/types'; +import { + collectSecretFields, + collectMaskedReadFields, + collectMaskedPasswordFields, + collectInternalReadFields, + makeSecretRef, + parseSecretRef, + isSecretRef, + EmptyCredentialWriteError, + SECRET_MASK, +} from './secret-fields.js'; +import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js'; +import type { ObjectQL } from './engine.js'; + +/** + * The `ObjectQL` members the functions below read — every one of them, and no + * other; each function's `this` is the `Pick` of the members IT reads. Each is + * typed AS the class member (an indexed access on the class), so a change to a + * member's type on the class is a change here too, and naming a member the + * class does not have is a compile error. See the module header for which + * members are getters. + */ +export interface SecretInternalFieldsHost { + readonly _registry: ObjectQL['_registry']; + readonly cryptoProvider: ObjectQL['cryptoProvider']; + readonly txStore: ObjectQL['txStore']; + getDriver: ObjectQL['getDriver']; + transactionCoversDriverFor: ObjectQL['transactionCoversDriverFor']; + omitInternalFields: ObjectQL['omitInternalFields']; + privilegedReadDriverOptions: ObjectQL['privilegedReadDriverOptions']; + resolveSecret: ObjectQL['resolveSecret']; +} + +/** The body of `ObjectQL#encryptSecretFields`; the method's docblock there is its contract. */ +export async function encryptSecretFields( + this: Pick, + object: string, + row: Record, + context: ExecutionContext | undefined, + driverOptions: unknown, +): Promise { + if (!row || typeof row !== 'object') return; + const schema = this._registry.getObject(object); + + // Echoed-mask drop for every field the read path masks (secret + generic + // password). The read path returns SECRET_MASK; a client that PATCHes it + // back means "unchanged", so drop the key rather than persist the literal + // mask. Doing this up front keeps the better-auth exemption in one place + // (collectMaskedReadFields) and covers objects that have a password field + // but no secret field. (#2036, ADR-0100) + for (const field of collectMaskedReadFields(schema)) { + if (field in row && row[field] === SECRET_MASK) delete row[field]; + } + + const secretFields = collectSecretFields(schema); + if (secretFields.length === 0) return; + + for (const field of secretFields) { + if (!(field in row)) continue; + const value = row[field]; + + if (value === null || typeof value === 'undefined') continue; // clear + // [#8559] The empty string is neither a clear (`null` is) nor a storable + // credential — refuse it BEFORE the crypto gates below: this is a verdict + // on the payload, so it must not depend on whether a CryptoProvider or + // sys_secret store happens to be wired. + if (value === '') throw new EmptyCredentialWriteError(object, field, 'secret'); + if (isSecretRef(value)) continue; // already encrypted ref + + if (!this.cryptoProvider) { + throw new Error( + `Cannot persist secret field "${object}.${field}": no CryptoProvider is registered. ` + + 'Wire one via engine.setCryptoProvider(...) (e.g. LocalCryptoProvider in dev, ' + + 'a KMS/Vault provider in production). Refusing to store cleartext (fail-closed).', + ); + } + + const plain = typeof value === 'string' ? value : JSON.stringify(value); + // ADR-0128 D1: this producer's own scope, so the AAD names the + // object-secret-field vocabulary and no other producer's context opens it. + const handle: CryptoHandle = await this.cryptoProvider.encrypt(plain, { + scope: 'object_secret_field', + namespace: object, + key: field, + tenantId: context?.tenantId, + }); + + let secretDriver; + try { + secretDriver = this.getDriver('sys_secret'); + } catch { + throw new Error( + `Cannot persist secret field "${object}.${field}": the sys_secret store is not available. ` + + 'Ensure the platform-objects (sys_secret) are registered before writing secret fields (fail-closed).', + ); + } + + await secretDriver.create( + 'sys_secret', + { + id: handle.id, + namespace: object, + key: field, + kms_key_id: handle.kmsKeyId, + alg: handle.alg, + version: handle.version, + ciphertext: handle.ciphertext, + created_at: new Date().toISOString(), + }, + driverOptions as any, + ); + + row[field] = makeSecretRef(handle.id); + } +} + +/** The body of `ObjectQL#refuseEmptyPasswordFields`; the method's docblock there is its contract. */ +export function refuseEmptyPasswordFields( + this: Pick, + object: string, + row: Record, +): void { + if (!row || typeof row !== 'object') return; + const schema = this._registry.getObject(object); + for (const field of collectMaskedPasswordFields(schema)) { + if (field in row && row[field] === '') { + throw new EmptyCredentialWriteError(object, field, 'password'); + } + } +} + +/** The body of `ObjectQL#maskSecretFields`; the method's docblock there is its contract. */ +export function maskSecretFields( + this: Pick, + object: string, + rows: any, +): void { + if (!rows) return; + const schema = this._registry.getObject(object); + const maskedFields = collectMaskedReadFields(schema); + if (maskedFields.length > 0) { + const list = Array.isArray(rows) ? rows : [rows]; + for (const row of list) { + if (!row || typeof row !== 'object') continue; + for (const field of maskedFields) { + if (!(field in row)) continue; + row[field] = row[field] == null ? null : SECRET_MASK; + } + } + } + // Runs AFTER the mask, so a field that is somehow both `secret`-typed and + // `internal` ends up omitted rather than masked — the stricter disposition + // wins, which is the only safe way for the two to compose. + this.omitInternalFields(object, rows); +} + +/** The body of `ObjectQL#omitInternalFields`; the method's docblock there is its contract. */ +export function omitInternalFields( + this: Pick, + object: string, + rows: any, +): void { + if (!rows) return; + const schema = this._registry.getObject(object); + const internalFields = collectInternalReadFields(schema); + if (internalFields.length === 0) return; + const list = Array.isArray(rows) ? rows : [rows]; + for (const row of list) { + if (!row || typeof row !== 'object') continue; + for (const field of internalFields) delete row[field]; + } +} + +/** The body of `ObjectQL#stripSearchCompanionFromRead`; the method's docblock there is its contract. */ +export function stripSearchCompanionFromRead( + rows: unknown, + requestedFields: readonly string[] | undefined, + context: ExecutionContext | undefined, +): void { + if (context?.isSystem && isSearchCompanionRequested(requestedFields)) return; + stripSearchCompanion(rows); +} + +/** The body of `ObjectQL#privilegedReadDriverOptions`; the method's docblock there is its contract. */ +export function privilegedReadDriverOptions( + this: Pick, + object: string, +): { transaction: unknown } | undefined { + const tx = this.txStore.getStore()?.transaction; + if (tx === undefined) return undefined; + if (!this.transactionCoversDriverFor(object, tx)) return undefined; + return { transaction: tx }; +} + +/** The body of `ObjectQL#resolveSecret`; the method's docblock there is its contract. */ +export async function resolveSecret( + this: Pick, + ref: unknown, + opts?: { tenantId?: string }, +): Promise { + const id = parseSecretRef(ref); + if (!id) return null; + if (!this.cryptoProvider) { + throw new Error('Cannot resolve secret: no CryptoProvider is registered (fail-closed).'); + } + const secretDriver = this.getDriver('sys_secret'); + const found = await secretDriver.find( + 'sys_secret', + { where: { id } }, + this.privilegedReadDriverOptions('sys_secret'), + ); + const secret: any = Array.isArray(found) ? found[0] : found; + if (!secret) { + throw new Error(`Cannot resolve secret: sys_secret row "${id}" not found (fail-closed).`); + } + const handle: CryptoHandle = { + id: secret.id, + kmsKeyId: secret.kms_key_id, + alg: secret.alg, + version: secret.version, + ciphertext: secret.ciphertext, + }; + // ADR-0128 D1: a `secret:` ref is the object-secret-field producer's + // holder, so the scope is that producer's. A row another producer sealed + // under a scoped derivation therefore does not open here. + return this.cryptoProvider.decrypt(handle, { + scope: 'object_secret_field', + namespace: secret.namespace, + key: secret.key, + tenantId: opts?.tenantId, + }); +} + +/** The body of `ObjectQL#resolveSecretField`; the method's docblock there is its contract. */ +export async function resolveSecretField( + this: Pick, + object: string, + recordId: string, + field: string, + opts?: { tenantId?: string }, +): Promise { + const schema = this._registry.getObject(object); + if (!collectSecretFields(schema).includes(field)) { + throw new Error( + `Cannot resolve secret field "${object}.${field}": it is not declared as type 'secret'. ` + + 'Only the encrypted secret channel is dereferenceable — a `password` field is stored as ' + + 'plaintext at rest and is masked deliberately (ADR-0100), so dereferencing one here ' + + 'would be a mask bypass, not a decrypt.', + ); + } + const driver = this.getDriver(object); + let found: unknown; + try { + found = await driver.find( + object, + { where: { id: recordId } }, + this.privilegedReadDriverOptions(object), + ); + } catch (e) { + // [#21274] A boundary outside the middleware seam: the record id is bound. + throw redactPropagatedDriverFault(e); + } + const row: any = Array.isArray(found) ? found[0] : found; + if (!row) return null; + return this.resolveSecret(row[field], opts); +} + +/** The body of `ObjectQL#resolveInternalField`; the method's docblock there is its contract. */ +export async function resolveInternalField( + this: Pick, + object: string, + recordIds: readonly string[], + field: string, +): Promise> { + const schema = this._registry.getObject(object); + if (!collectInternalReadFields(schema).includes(field)) { + const err: Error & { code?: string; status?: number; httpStatus?: number; object?: string; field?: string } = + new Error( + `Cannot resolve internal field "${object}.${field}": it is not declared \`internal: true\`. ` + + 'Only fields the engine omits from the generic read path are dereferenceable here — ' + + 'anything else either comes back on find/findOne already, or is protected by its own ' + + 'channel (`secret` refs via resolveSecretField; `password` is masked deliberately, ' + + 'ADR-0100, so dereferencing one here would be a mask bypass).', + ); + err.code = 'INVALID_FIELD'; + err.status = 400; + err.httpStatus = 400; + err.object = object; + err.field = field; + throw err; + } + const out = new Map(); + if (recordIds.length === 0) return out; + const driver = this.getDriver(object); + let found: unknown; + try { + found = await driver.find( + object, + { + where: { id: { $in: [...recordIds] } }, + fields: ['id', field], + }, + this.privilegedReadDriverOptions(object), + ); + } catch (e) { + // [#21274] A boundary outside the middleware seam: the record ids are bound. + throw redactPropagatedDriverFault(e); + } + for (const row of Array.isArray(found) ? found : [found]) { + if (!row || typeof row !== 'object') continue; + const id = (row as Record).id; + if (typeof id !== 'string' && typeof id !== 'number') continue; + out.set(String(id), (row as Record)[field] ?? null); + } + return out; +} diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index d25eef0449d..257dbfc44ee 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -112,6 +112,19 @@ import { checkDriversHealth, resolvePrimaryDatasource } from './engine-lifecycle // call; the methods of the same names below hand each body the members it // reads (`RealtimePublishHost`). import { publishBulkDataEvent, publishDataEvent } from './engine-realtime-publish.js'; +// [#25522 cut 5] The secret and internal-field bodies; the methods of the same +// names below hand each body the members it reads (`SecretInternalFieldsHost`). +import { + encryptSecretFields, + maskSecretFields, + omitInternalFields, + privilegedReadDriverOptions, + refuseEmptyPasswordFields, + resolveInternalField, + resolveSecret, + resolveSecretField, + stripSearchCompanionFromRead, +} from './engine-secret-internal-fields.js'; import { ExecutionContext, ExecutionContextSchema, type InstalledPackageSource } from '@objectstack/spec/kernel'; import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; @@ -255,18 +268,10 @@ export type InsertManyRowOutcome = | { ok: false; error: unknown }; import { CoreServiceName, StorageNameMapping } from '@objectstack/spec/system'; import { IRealtimeService } from '@objectstack/spec/contracts'; -import type { ICryptoProvider, CryptoHandle } from '@objectstack/spec/contracts'; +import type { ICryptoProvider } from '@objectstack/spec/contracts'; import { - collectSecretFields, - collectMaskedReadFields, - collectMaskedPasswordFields, collectInternalReadFields, collectCredentialFields, - makeSecretRef, - parseSecretRef, - isSecretRef, - EmptyCredentialWriteError, - SECRET_MASK, } from './secret-fields.js'; import { assertGroupByNamesNoJsonStoredField } from './group-by-structured-json-door.js'; import { assertAggregationFieldTypesAccepted } from './aggregate-field-type-door.js'; @@ -274,7 +279,7 @@ import { assertAggregatedColumnsAreDistinct, assertDateBucketTargetsAreTemporal import { applyProtection, pluralToSingular, ExternalWriteForbiddenError } from '@objectstack/spec/shared'; import { SchemaRegistry, computeFQN, type ArtifactInstallScope } from './registry.js'; import { expandSearchToFilter, normalizeSearch, SEARCHABLE_ENUM_TYPES, SEARCHABLE_TEXTUAL_TYPES } from './search-filter.js'; -import { isSearchCompanionRequested, stripSearchCompanion } from './search-companion.js'; +import { stripSearchCompanion } from './search-companion.js'; import { ExpressionEngine, analyzeRelationshipTraversals, toEvalPermissions, type EvalError, type EvalPermissions } from '@objectstack/formula'; import type { Expression } from '@objectstack/spec'; import { @@ -11339,79 +11344,15 @@ export class ObjectQL implements IObjectQLEngine { context: ExecutionContext | undefined, driverOptions: unknown, ): Promise { - if (!row || typeof row !== 'object') return; - const schema = this._registry.getObject(object); - - // Echoed-mask drop for every field the read path masks (secret + generic - // password). The read path returns SECRET_MASK; a client that PATCHes it - // back means "unchanged", so drop the key rather than persist the literal - // mask. Doing this up front keeps the better-auth exemption in one place - // (collectMaskedReadFields) and covers objects that have a password field - // but no secret field. (#2036, ADR-0100) - for (const field of collectMaskedReadFields(schema)) { - if (field in row && row[field] === SECRET_MASK) delete row[field]; - } - - const secretFields = collectSecretFields(schema); - if (secretFields.length === 0) return; - - for (const field of secretFields) { - if (!(field in row)) continue; - const value = row[field]; - - if (value === null || typeof value === 'undefined') continue; // clear - // [#8559] The empty string is neither a clear (`null` is) nor a storable - // credential — refuse it BEFORE the crypto gates below: this is a verdict - // on the payload, so it must not depend on whether a CryptoProvider or - // sys_secret store happens to be wired. - if (value === '') throw new EmptyCredentialWriteError(object, field, 'secret'); - if (isSecretRef(value)) continue; // already encrypted ref - - if (!this.cryptoProvider) { - throw new Error( - `Cannot persist secret field "${object}.${field}": no CryptoProvider is registered. ` - + 'Wire one via engine.setCryptoProvider(...) (e.g. LocalCryptoProvider in dev, ' - + 'a KMS/Vault provider in production). Refusing to store cleartext (fail-closed).', - ); - } - - const plain = typeof value === 'string' ? value : JSON.stringify(value); - // ADR-0128 D1: this producer's own scope, so the AAD names the - // object-secret-field vocabulary and no other producer's context opens it. - const handle: CryptoHandle = await this.cryptoProvider.encrypt(plain, { - scope: 'object_secret_field', - namespace: object, - key: field, - tenantId: context?.tenantId, - }); - - let secretDriver; - try { - secretDriver = this.getDriver('sys_secret'); - } catch { - throw new Error( - `Cannot persist secret field "${object}.${field}": the sys_secret store is not available. ` - + 'Ensure the platform-objects (sys_secret) are registered before writing secret fields (fail-closed).', - ); - } - - await secretDriver.create( - 'sys_secret', - { - id: handle.id, - namespace: object, - key: field, - kms_key_id: handle.kmsKeyId, - alg: handle.alg, - version: handle.version, - ciphertext: handle.ciphertext, - created_at: new Date().toISOString(), - }, - driverOptions as any, - ); - - row[field] = makeSecretRef(handle.id); - } + // [#25522 cut 5] The body moved to `engine-secret-internal-fields.ts` (see `SecretInternalFieldsHost`). + const readRegistry = () => this._registry; + const readCryptoProvider = () => this.cryptoProvider; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + get cryptoProvider() { return readCryptoProvider(); }, + getDriver: (...a) => this.getDriver(...a), + }; + return encryptSecretFields.call(host, object, row, context, driverOptions); } /** @@ -11438,13 +11379,12 @@ export class ObjectQL implements IObjectQLEngine { * guard the same doors. */ private refuseEmptyPasswordFields(object: string, row: Record): void { - if (!row || typeof row !== 'object') return; - const schema = this._registry.getObject(object); - for (const field of collectMaskedPasswordFields(schema)) { - if (field in row && row[field] === '') { - throw new EmptyCredentialWriteError(object, field, 'password'); - } - } + // [#25522 cut 5] The body moved to `engine-secret-internal-fields.ts` (see `SecretInternalFieldsHost`). + const readRegistry = () => this._registry; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + }; + return refuseEmptyPasswordFields.call(host, object, row); } /** @@ -11461,23 +11401,13 @@ export class ObjectQL implements IObjectQLEngine { * {@link omitInternalFields} for why the two dispositions differ. */ private maskSecretFields(object: string, rows: any): void { - if (!rows) return; - const schema = this._registry.getObject(object); - const maskedFields = collectMaskedReadFields(schema); - if (maskedFields.length > 0) { - const list = Array.isArray(rows) ? rows : [rows]; - for (const row of list) { - if (!row || typeof row !== 'object') continue; - for (const field of maskedFields) { - if (!(field in row)) continue; - row[field] = row[field] == null ? null : SECRET_MASK; - } - } - } - // Runs AFTER the mask, so a field that is somehow both `secret`-typed and - // `internal` ends up omitted rather than masked — the stricter disposition - // wins, which is the only safe way for the two to compose. - this.omitInternalFields(object, rows); + // [#25522 cut 5] The body moved to `engine-secret-internal-fields.ts` (see `SecretInternalFieldsHost`). + const readRegistry = () => this._registry; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + omitInternalFields: (...a) => this.omitInternalFields(...a), + }; + return maskSecretFields.call(host, object, rows); } /** @@ -11536,15 +11466,12 @@ export class ObjectQL implements IObjectQLEngine { * {@link resolveInternalField} — never a carve-out here. */ private omitInternalFields(object: string, rows: any): void { - if (!rows) return; - const schema = this._registry.getObject(object); - const internalFields = collectInternalReadFields(schema); - if (internalFields.length === 0) return; - const list = Array.isArray(rows) ? rows : [rows]; - for (const row of list) { - if (!row || typeof row !== 'object') continue; - for (const field of internalFields) delete row[field]; - } + // [#25522 cut 5] The body moved to `engine-secret-internal-fields.ts` (see `SecretInternalFieldsHost`). + const readRegistry = () => this._registry; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + }; + return omitInternalFields.call(host, object, rows); } /** @@ -11616,8 +11543,8 @@ export class ObjectQL implements IObjectQLEngine { requestedFields: readonly string[] | undefined, context: ExecutionContext | undefined, ): void { - if (context?.isSystem && isSearchCompanionRequested(requestedFields)) return; - stripSearchCompanion(rows); + // [#25522 cut 5] The body moved to `engine-secret-internal-fields.ts`; it reads no member. + stripSearchCompanionFromRead(rows, requestedFields, context); } /** @@ -11654,10 +11581,13 @@ export class ObjectQL implements IObjectQLEngine { * when there is no ambient transaction, which is the pre-existing shape. */ private privilegedReadDriverOptions(object: string): { transaction: unknown } | undefined { - const tx = this.txStore.getStore()?.transaction; - if (tx === undefined) return undefined; - if (!this.transactionCoversDriverFor(object, tx)) return undefined; - return { transaction: tx }; + // [#25522 cut 5] The body moved to `engine-secret-internal-fields.ts` (see `SecretInternalFieldsHost`). + const readTxStore = () => this.txStore; + const host: ThisParameterType = { + get txStore() { return readTxStore(); }, + transactionCoversDriverFor: (...a) => this.transactionCoversDriverFor(...a), + }; + return privilegedReadDriverOptions.call(host, object); } /** @@ -11670,37 +11600,14 @@ export class ObjectQL implements IObjectQLEngine { * `sys_secret` row is missing. Returns `null` when `ref` is not a secret ref. */ async resolveSecret(ref: unknown, opts?: { tenantId?: string }): Promise { - const id = parseSecretRef(ref); - if (!id) return null; - if (!this.cryptoProvider) { - throw new Error('Cannot resolve secret: no CryptoProvider is registered (fail-closed).'); - } - const secretDriver = this.getDriver('sys_secret'); - const found = await secretDriver.find( - 'sys_secret', - { where: { id } }, - this.privilegedReadDriverOptions('sys_secret'), - ); - const secret: any = Array.isArray(found) ? found[0] : found; - if (!secret) { - throw new Error(`Cannot resolve secret: sys_secret row "${id}" not found (fail-closed).`); - } - const handle: CryptoHandle = { - id: secret.id, - kmsKeyId: secret.kms_key_id, - alg: secret.alg, - version: secret.version, - ciphertext: secret.ciphertext, + // [#25522 cut 5] The body moved to `engine-secret-internal-fields.ts` (see `SecretInternalFieldsHost`). + const readCryptoProvider = () => this.cryptoProvider; + const host: ThisParameterType = { + get cryptoProvider() { return readCryptoProvider(); }, + getDriver: (...a) => this.getDriver(...a), + privilegedReadDriverOptions: (...a) => this.privilegedReadDriverOptions(...a), }; - // ADR-0128 D1: a `secret:` ref is the object-secret-field producer's - // holder, so the scope is that producer's. A row another producer sealed - // under a scoped derivation therefore does not open here. - return this.cryptoProvider.decrypt(handle, { - scope: 'object_secret_field', - namespace: secret.namespace, - key: secret.key, - tenantId: opts?.tenantId, - }); + return resolveSecret.call(host, ref, opts); } /** @@ -11739,30 +11646,15 @@ export class ObjectQL implements IObjectQLEngine { field: string, opts?: { tenantId?: string }, ): Promise { - const schema = this._registry.getObject(object); - if (!collectSecretFields(schema).includes(field)) { - throw new Error( - `Cannot resolve secret field "${object}.${field}": it is not declared as type 'secret'. ` - + 'Only the encrypted secret channel is dereferenceable — a `password` field is stored as ' - + 'plaintext at rest and is masked deliberately (ADR-0100), so dereferencing one here ' - + 'would be a mask bypass, not a decrypt.', - ); - } - const driver = this.getDriver(object); - let found: unknown; - try { - found = await driver.find( - object, - { where: { id: recordId } }, - this.privilegedReadDriverOptions(object), - ); - } catch (e) { - // [#21274] A boundary outside the middleware seam: the record id is bound. - throw redactPropagatedDriverFault(e); - } - const row: any = Array.isArray(found) ? found[0] : found; - if (!row) return null; - return this.resolveSecret(row[field], opts); + // [#25522 cut 5] The body moved to `engine-secret-internal-fields.ts` (see `SecretInternalFieldsHost`). + const readRegistry = () => this._registry; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + getDriver: (...a) => this.getDriver(...a), + privilegedReadDriverOptions: (...a) => this.privilegedReadDriverOptions(...a), + resolveSecret: (...a) => this.resolveSecret(...a), + }; + return resolveSecretField.call(host, object, recordId, field, opts); } /** @@ -11822,47 +11714,14 @@ export class ObjectQL implements IObjectQLEngine { recordIds: readonly string[], field: string, ): Promise> { - const schema = this._registry.getObject(object); - if (!collectInternalReadFields(schema).includes(field)) { - const err: Error & { code?: string; status?: number; httpStatus?: number; object?: string; field?: string } = - new Error( - `Cannot resolve internal field "${object}.${field}": it is not declared \`internal: true\`. ` - + 'Only fields the engine omits from the generic read path are dereferenceable here — ' - + 'anything else either comes back on find/findOne already, or is protected by its own ' - + 'channel (`secret` refs via resolveSecretField; `password` is masked deliberately, ' - + 'ADR-0100, so dereferencing one here would be a mask bypass).', - ); - err.code = 'INVALID_FIELD'; - err.status = 400; - err.httpStatus = 400; - err.object = object; - err.field = field; - throw err; - } - const out = new Map(); - if (recordIds.length === 0) return out; - const driver = this.getDriver(object); - let found: unknown; - try { - found = await driver.find( - object, - { - where: { id: { $in: [...recordIds] } }, - fields: ['id', field], - }, - this.privilegedReadDriverOptions(object), - ); - } catch (e) { - // [#21274] A boundary outside the middleware seam: the record ids are bound. - throw redactPropagatedDriverFault(e); - } - for (const row of Array.isArray(found) ? found : [found]) { - if (!row || typeof row !== 'object') continue; - const id = (row as Record).id; - if (typeof id !== 'string' && typeof id !== 'number') continue; - out.set(String(id), (row as Record)[field] ?? null); - } - return out; + // [#25522 cut 5] The body moved to `engine-secret-internal-fields.ts` (see `SecretInternalFieldsHost`). + const readRegistry = () => this._registry; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + getDriver: (...a) => this.getDriver(...a), + privilegedReadDriverOptions: (...a) => this.privilegedReadDriverOptions(...a), + }; + return resolveInternalField.call(host, object, recordIds, field); } /** diff --git a/packages/objectql/src/federated-injected-column-readers.test.ts b/packages/objectql/src/federated-injected-column-readers.test.ts index 17bd6adaf9c..7bf9ab42181 100644 --- a/packages/objectql/src/federated-injected-column-readers.test.ts +++ b/packages/objectql/src/federated-injected-column-readers.test.ts @@ -317,7 +317,7 @@ const READERS: Record = { disposition: 'writer', why: 'stamps the acting user onto a record the caller writes; it reads nothing', }, - 'engine.ts#encryptSecretFields :: created_at': { disposition: 'not-a-read', why: PLATFORM_ROW }, + 'engine-secret-internal-fields.ts#encryptSecretFields :: created_at': { disposition: 'not-a-read', why: PLATFORM_ROW }, 'engine.ts#recordObservedDeviation :: updated_at': { disposition: 'not-a-read', why: PLATFORM_ROW }, 'engine.ts#retractCreationAttestation :: updated_at': { disposition: 'not-a-read', why: PLATFORM_ROW }, 'engine.ts#syncObjectSchema :: isFederatedObject()': { disposition: 'not-a-read', why: SYNC_ROUTE }, From bd40de27336b8976d75e1e9a1995d8c2fe993e89 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 08:59:31 +0000 Subject: [PATCH 2/2] chore(adr-anchors): anchor engine-secret-internal-fields.ts to ADR-0100 and ADR-0128 The two ADR-0128 D1 citations in the moved encrypt and decrypt bodies left engine.ts's anchored surface with them (decision-letter citations 1,844 at base, 1,842 after the move). This entry anchors the new module, so the letters are verified where the code now lives (1,845 at head). Claude-Session: https://claude.ai/code/session_01FCw8ckDE3VXVavCoqsMQ3u Co-authored-by: Claude --- ...__objectql__src__engine-secret-internal-fields.ts.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 scripts/adr-anchors/packages__objectql__src__engine-secret-internal-fields.ts.json diff --git a/scripts/adr-anchors/packages__objectql__src__engine-secret-internal-fields.ts.json b/scripts/adr-anchors/packages__objectql__src__engine-secret-internal-fields.ts.json new file mode 100644 index 00000000000..7eb2a2d1ffd --- /dev/null +++ b/scripts/adr-anchors/packages__objectql__src__engine-secret-internal-fields.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/objectql/src/engine-secret-internal-fields.ts", + "adrs": [ + "ADR-0100", + "ADR-0128" + ], + "invariant": "ADR-0100's credential channels at the engine: `encryptSecretFields` encrypts a `secret` value through the ICryptoProvider into a `sys_secret` row and stores only the opaque ref, and fails CLOSED (throws) with no provider or no `sys_secret` store, never persisting cleartext; a `password` value is stored as-is; an echoed mask is dropped, `\"\"` is refused, `null` clears. The read path masks both (`maskSecretFields`) and then OMITS `internal: true` fields (`omitInternalFields`), the stricter disposition last; the only way back to a value is the three named privileged verbs (`resolveSecret`, `resolveSecretField`, `resolveInternalField`), each refusing a field outside its own channel. ADR-0128 D1: both the encrypt and the decrypt here name the object-secret-field producer's own scope (`object_secret_field`), so no other producer's ciphertext opens through this path; never borrow another producer's scope." +}