feat(namespace): egress guard + validation speak the law — whereMatcher's resolver reads system.* from the record and bare names from the metadata bag only (the bare-system switch is dead); validateFindParams refuses cursor/includeRelations/writeOnly typed (accepted-and-ignored dies as a class), validates order, and parses every orderBy address
Some checks are pending
CI / Node 22 (push) Waiting to run
CI / Node 24 (push) Waiting to run
CI / Bun (latest) (push) Waiting to run

This commit is contained in:
David Snelling 2026-08-03 15:51:14 -07:00
parent 4679c89458
commit c2fb28a2f7
3 changed files with 101 additions and 36 deletions

View file

@ -244,3 +244,42 @@ export class InvalidFieldAddressError extends Error {
this.kind = kind this.kind = kind
} }
} }
/**
* @description Refusal for a syntactically valid address that resolves to
* NOTHING a bare name no user field carries. Carries the did-you-mean
* (both candidate spellings when the name collides with a system scalar) so
* the fix ships inside the error. Thrown by the query layer with index
* knowledge, never by the pure parser.
*/
export class UnresolvableFieldError extends Error {
public readonly raw: string
public readonly kind: FieldAddressKind
constructor(raw: string, kind: FieldAddressKind) {
super(buildUnresolvableMessage(raw, kind))
this.name = 'UnresolvableFieldError'
this.raw = raw
this.kind = kind
}
}
/**
* @description Refusal for a find() option that is accepted by the type
* surface but NOT implemented an accepted option must work or refuse;
* accepted-and-ignored died as a class (sealed 2026-08-03). Names the
* option and the honest state so nobody discovers a no-op by measurement.
*/
export class UnsupportedFindOptionError extends Error {
public readonly option: string
constructor(option: string) {
super(
`find() option '${option}' is not implemented — it used to be silently ` +
`ignored, which read as working. Remove it from the call (or track the ` +
`feature request); it will be honored or refused, never swallowed.`
)
this.name = 'UnsupportedFindOptionError'
this.option = option
}
}

View file

@ -61,41 +61,44 @@ export class UnsupportedWhereOperatorError extends Error {
* @returns The field's value, or `undefined` when absent. * @returns The field's value, or `undefined` when absent.
*/ */
export function resolveEntityField(entity: Entity, field: string): unknown { export function resolveEntityField(entity: Entity, field: string): unknown {
switch (field) { // THE ONE ADDRESSING LAW (sealed 2026-08-03): `system.<field>` reads the
case 'noun': // entity scalar; bare and `metadata.`-prefixed names read the user's
case 'type': // metadata bag (dotted paths traverse INSIDE the bag). The old bare-name
return entity.type // switch over system fields is dead — bare `createdAt` is the user's own
case 'subtype': // field now; the engine scalar is `system.createdAt`. Plumbing (vector,
return entity.subtype // connections, level, data, _rev) is invisible: no spelling reaches it.
case 'id': if (field.startsWith('system.')) {
return entity.id switch (field.slice('system.'.length)) {
case 'createdAt': case 'type':
return entity.createdAt return entity.type
case 'updatedAt': case 'subtype':
return entity.updatedAt return entity.subtype
case 'service': case 'id':
return entity.service return entity.id
case 'createdBy': case 'createdAt':
return entity.createdBy return entity.createdAt
case 'confidence': case 'updatedAt':
return entity.confidence return entity.updatedAt
case 'weight': case 'service':
return entity.weight return entity.service
case '_rev': case 'createdBy':
return entity._rev return entity.createdBy
case 'data': case 'confidence':
return entity.data return entity.confidence
case 'weight':
return entity.weight
case 'visibility':
return (entity as unknown as Record<string, unknown>).visibility
}
// Out-of-map system spelling: parse refuses these upstream with a typed
// error; reaching here (internal callers only) reads as absent.
return undefined
} }
if (field.includes('.')) { const path = field.startsWith('metadata.') ? field.slice('metadata.'.length) : field
// Dotted path: resolve against the whole entity first (`metadata.x`), const bag = (entity.metadata ?? {}) as Record<string, unknown>
// then against the metadata bag (`address.city` on nested metadata). if (!path.includes('.')) return bag[path]
const fromEntity = resolvePath(entity as unknown as Record<string, unknown>, field) return resolvePath(bag, path)
if (fromEntity !== undefined) return fromEntity
return resolvePath((entity.metadata ?? {}) as Record<string, unknown>, field)
}
return ((entity.metadata ?? {}) as Record<string, unknown>)[field]
} }
/** Walk a dotted path through nested plain objects. */ /** Walk a dotted path through nested plain objects. */

View file

@ -17,6 +17,7 @@ import { findCallerLocation } from './callerLocation.js'
// fallback branches that no supported runtime can reach. // fallback branches that no supported runtime can reach.
import * as os from 'node:os' import * as os from 'node:os'
import * as fs from 'node:fs' import * as fs from 'node:fs'
import { parseFieldAddress, UnsupportedFindOptionError } from '../db/fieldAddressing.js'
const getSystemMemory = (): number => { const getSystemMemory = (): number => {
if (os) { if (os) {
@ -466,9 +467,31 @@ export function validateFindParams(params: FindParams): void {
throw new Error('cannot specify both query and vector - they are mutually exclusive') throw new Error('cannot specify both query and vector - they are mutually exclusive')
} }
// Universal truth: can't use both cursor and offset pagination // ACCEPTED-AND-IGNORED DIED AS A CLASS (sealed 2026-08-03): options the
if (params.cursor !== undefined && params.offset !== undefined) { // engine does not implement REFUSE with a typed error instead of silently
throw new Error('cannot use both cursor and offset pagination simultaneously') // doing nothing — a production consumer discovered a no-op by measurement
// once; never again.
if (params.cursor !== undefined) {
throw new UnsupportedFindOptionError('cursor')
}
if ((params as Record<string, unknown>).includeRelations !== undefined) {
throw new UnsupportedFindOptionError('includeRelations')
}
if ((params as Record<string, unknown>).writeOnly !== undefined) {
throw new UnsupportedFindOptionError('writeOnly')
}
// THE ONE ADDRESSING LAW: the orderBy address must PARSE (bare/metadata. =
// user field, system.<field> = the ruled map, anything else refuses typed
// with the valid map in the message) and order must be a real direction.
if (params.orderBy !== undefined) {
if (typeof params.orderBy !== 'string') {
throw new Error('orderBy must be a string field address')
}
parseFieldAddress(params.orderBy, 'entity') // throws InvalidFieldAddressError on a bad address
}
if (params.order !== undefined && params.order !== 'asc' && params.order !== 'desc') {
throw new Error(`order must be 'asc' or 'desc', got '${String(params.order)}'`)
} }
// Auto-limit query length based on memory // Auto-limit query length based on memory