diff --git a/src/features/housekeeping/foundation/commands/dispatcher.test.ts b/src/features/housekeeping/foundation/commands/dispatcher.test.ts index a484a377bb..eeb5130b99 100644 --- a/src/features/housekeeping/foundation/commands/dispatcher.test.ts +++ b/src/features/housekeeping/foundation/commands/dispatcher.test.ts @@ -5,6 +5,7 @@ import type { HousekeepingCapabilityContext } from "../contracts"; import { anyCapability, fail, ok } from "../contracts"; import { dispatchHousekeepingCommand } from "./dispatcher"; import { + getHousekeepingCommand, type HousekeepingCommand, registerHousekeepingCommand, } from "./registry"; @@ -150,6 +151,129 @@ describe("dispatchHousekeepingCommand", () => { expect(executed).toBe(false); }); + it("uses unchanged private validation after public descriptor mutation attempts", async () => { + const commandId = "system.dispatch.reflective-schema-mutation"; + let executions = 0; + register( + baseCommand(commandId, { + input: z.object({ + objectDescriptor: z.string().min(3), + objectDescriptors: z.string().min(3), + reflectDescriptor: z.string().min(3), + }), + execute: async (context) => { + executions += 1; + return ok(null, context.correlationId); + }, + }), + ); + const registered = getHousekeepingCommand(commandId); + if (!registered) throw new Error("registered command missing"); + + const readers = [ + ["objectDescriptor", Object.getOwnPropertyDescriptor], + [ + "objectDescriptors", + (target: object, property: PropertyKey) => + Reflect.get(Object.getOwnPropertyDescriptors(target), property) as + | PropertyDescriptor + | undefined, + ], + ["reflectDescriptor", Reflect.getOwnPropertyDescriptor], + ] as const; + const descriptorValue = ( + target: object, + property: PropertyKey, + readDescriptor: ( + target: object, + property: PropertyKey, + ) => PropertyDescriptor | undefined, + ): unknown => { + const descriptor = readDescriptor(target, property); + if (!descriptor) { + throw new Error(`missing descriptor: ${String(property)}`); + } + if ("value" in descriptor) return descriptor.value; + if (descriptor.get) return Reflect.apply(descriptor.get, target, []); + return undefined; + }; + + for (const [field, readDescriptor] of readers) { + const definition = descriptorValue( + registered.input, + "def", + readDescriptor, + ) as object; + const shape = descriptorValue( + definition, + "shape", + readDescriptor, + ) as Record; + const child = shape[field]; + if (!child) throw new Error(`public child missing: ${field}`); + const childDefinition = descriptorValue( + child, + "def", + readDescriptor, + ) as object; + const checks = descriptorValue( + childDefinition, + "checks", + readDescriptor, + ) as readonly object[]; + const internal = descriptorValue( + checks[0] as object, + "_zod", + readDescriptor, + ) as object; + const checkDefinition = descriptorValue( + internal, + "def", + readDescriptor, + ) as { minimum: number }; + try { + shape[field] = z.number(); + } catch { + // A readonly facade may reject the assignment. + } + try { + checkDefinition.minimum = 0; + } catch { + // A readonly facade may reject the assignment. + } + } + + const forged = await dispatchHousekeepingCommand( + { + commandId, + input: { + objectDescriptor: 7, + objectDescriptors: 7, + reflectDescriptor: 7, + }, + }, + dependencies(), + ); + expect(forged).toMatchObject({ + ok: false, + error: { code: "VALIDATION" }, + }); + expect(executions).toBe(0); + + const valid = await dispatchHousekeepingCommand( + { + commandId, + input: { + objectDescriptor: "valid", + objectDescriptors: "valid", + reflectDescriptor: "valid", + }, + }, + dependencies(), + ); + expect(valid).toMatchObject({ ok: true }); + expect(executions).toBe(1); + }); it("rejects a missing required reason before the command can execute", async () => { let executed = false; const audit = auditRecorder(); diff --git a/src/features/housekeeping/foundation/commands/registry.test.ts b/src/features/housekeeping/foundation/commands/registry.test.ts index f38d721d59..39498895b2 100644 --- a/src/features/housekeeping/foundation/commands/registry.test.ts +++ b/src/features/housekeeping/foundation/commands/registry.test.ts @@ -25,6 +25,52 @@ function command( }; } +type DescriptorReader = ( + target: object, + property: PropertyKey, +) => PropertyDescriptor | undefined; + +const descriptorReaders = [ + [ + "Object.getOwnPropertyDescriptor", + "object-descriptor", + Object.getOwnPropertyDescriptor, + ], + [ + "Object.getOwnPropertyDescriptors", + "object-descriptors", + (target: object, property: PropertyKey) => + Reflect.get(Object.getOwnPropertyDescriptors(target), property) as + | PropertyDescriptor + | undefined, + ], + [ + "Reflect.getOwnPropertyDescriptor", + "reflect-descriptor", + Reflect.getOwnPropertyDescriptor, + ], +] as const satisfies readonly (readonly [string, string, DescriptorReader])[]; + +function readDescriptorValue( + target: object, + property: PropertyKey, + readDescriptor: DescriptorReader, +): unknown { + const descriptor = readDescriptor(target, property); + if (!descriptor) throw new Error(`missing descriptor: ${String(property)}`); + if ("value" in descriptor) return descriptor.value; + if (descriptor.get) return Reflect.apply(descriptor.get, target, []); + return undefined; +} + +function attemptMutation(mutate: () => void): void { + try { + mutate(); + } catch { + // Readonly public views may reject the mutation directly. + } +} + describe("housekeeping command registry", () => { it("returns the validated snapshot registered under its global ID", () => { const registered = command("people.registry.lookup"); @@ -296,6 +342,170 @@ describe("housekeeping command registry", () => { }); expect(String(thrown)).not.toContain("caller lazy secret"); }); + it.each(descriptorReaders)( + "keeps private validation unchanged after nested mutation through %s", + (_api, suffix, readDescriptor) => { + const input = z.object({ value: z.string().min(3) }); + const id = `people.registry.${suffix}`; + registerHousekeepingCommand({ + ...command(id), + input, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand, { id: number }>); + const registered = getHousekeepingCommand(id); + if (!registered) throw new Error("registered command missing"); + + const definition = readDescriptorValue( + registered.input, + "def", + readDescriptor, + ) as object; + const shape = readDescriptorValue( + definition, + "shape", + readDescriptor, + ) as { value: z.ZodType }; + const child = shape.value; + const childDefinition = readDescriptorValue( + child, + "def", + readDescriptor, + ) as object; + const checks = readDescriptorValue( + childDefinition, + "checks", + readDescriptor, + ) as readonly object[]; + const internal = readDescriptorValue( + checks[0] as object, + "_zod", + readDescriptor, + ) as object; + const checkDefinition = readDescriptorValue( + internal, + "def", + readDescriptor, + ) as { minimum: number }; + + attemptMutation(() => { + shape.value = z.number(); + }); + attemptMutation(() => { + checkDefinition.minimum = 0; + }); + + expect(registered.input.safeParse({ value: "ab" }).success).toBe(false); + expect(registered.input.safeParse({ value: "abcd" }).success).toBe(true); + expect(registered.input.safeParse({ value: 7 }).success).toBe(false); + }, + ); + + it("keeps own-key and symbol iteration detached from private checks", () => { + const input = z.object({ value: z.string().min(3) }); + const id = "people.registry.symbol-iteration"; + registerHousekeepingCommand({ + ...command(id), + input, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand, { id: number }>); + const registered = getHousekeepingCommand(id); + if (!registered) throw new Error("registered command missing"); + + const publicInput = registered.input as z.ZodObject<{ + value: z.ZodString; + }>; + const shape = publicInput.shape; + expect(Reflect.ownKeys(publicInput.def)).toContain("shape"); + expect(Object.keys(shape)).toEqual(["value"]); + const child = Object.entries(shape)[0]?.[1]; + if (!child) throw new Error("public child schema missing"); + const checks = child.def.checks ?? []; + const spreadChecks = [...checks]; + const iterator = checks[Symbol.iterator](); + const iteratedCheck = iterator.next().value; + if (!iteratedCheck) throw new Error("public check missing"); + expect(spreadChecks[0]).toBe(iteratedCheck); + + attemptMutation(() => { + ( + iteratedCheck as unknown as { + _zod: { def: { minimum: number } }; + } + )._zod.def.minimum = 0; + }); + + expect(registered.input.safeParse({ value: "ab" }).success).toBe(false); + expect(registered.input.safeParse({ value: "abcd" }).success).toBe(true); + }); + + it("keeps prototype methods operational without passing the private schema to callbacks", async () => { + const input = z.object({ value: z.string().min(3) }); + const id = "people.registry.prototype-methods"; + registerHousekeepingCommand({ + ...command(id), + input, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand, { id: number }>); + const registered = getHousekeepingCommand(id); + if (!registered) throw new Error("registered command missing"); + const publicInput = registered.input as z.ZodObject<{ + value: z.ZodString; + }>; + + const publicPrototype = Object.getPrototypeOf(publicInput); + expect(publicPrototype).toBe(Reflect.getPrototypeOf(publicInput)); + const prototypeMutation = Symbol("prototype-mutation"); + Object.defineProperty(publicPrototype, prototypeMutation, { + configurable: true, + value(this: z.ZodObject<{ value: z.ZodString }>): unknown { + attemptMutation(() => { + this.def.shape.value = z.number() as never; + }); + return this.def; + }, + }); + try { + ( + publicInput as typeof publicInput & { + [prototypeMutation](): unknown; + } + )[prototypeMutation](); + } finally { + Reflect.deleteProperty(publicPrototype, prototypeMutation); + } + expect(publicInput.safeParse({ value: "abcd" }).success).toBe(true); + expect(publicInput.safeParse({ value: 7 }).success).toBe(false); + + let appliedSchema: z.ZodType | undefined; + const applied = publicInput.apply((schema) => { + appliedSchema = schema; + return schema; + }); + expect(appliedSchema).toBe(publicInput); + expect(applied).toBe(publicInput); + + let externallyRegistered: z.ZodType | undefined; + const externalRegistry = { + add(schema: z.ZodType): void { + externallyRegistered = schema; + }, + }; + expect( + publicInput.register(externalRegistry as never, undefined as never), + ).toBe(publicInput); + expect(externallyRegistered).toBe(publicInput); + + const partial = publicInput.partial(); + const picked = publicInput.pick({ value: true }); + expect(partial.safeParse({}).success).toBe(true); + expect(picked.safeParse({ value: "abcd" }).success).toBe(true); + expect((await publicInput.safeParseAsync({ value: "ab" })).success).toBe( + false, + ); + expect((await publicInput.safeParseAsync({ value: "abcd" })).success).toBe( + true, + ); + }); it("seals the global registry after deterministic bootstrap", () => { sealHousekeepingCommandRegistry(); diff --git a/src/features/housekeeping/foundation/commands/registry.ts b/src/features/housekeeping/foundation/commands/registry.ts index 288a68e770..9b38d65440 100644 --- a/src/features/housekeeping/foundation/commands/registry.ts +++ b/src/features/housekeeping/foundation/commands/registry.ts @@ -357,6 +357,22 @@ function isZodInternalNode(value: object): value is ZodInternalNode { function createReadonlyValidationFacade(schema: T): T { const views = new WeakMap(); + const parseResultMethods = new Set([ + "parse", + "safeParse", + "parseAsync", + "safeParseAsync", + "spa", + "encode", + "decode", + "encodeAsync", + "decodeAsync", + "safeEncode", + "safeDecode", + "safeEncodeAsync", + "safeDecodeAsync", + "toJSONSchema", + ]); function readonlyView(value: unknown): unknown { if ( @@ -369,56 +385,208 @@ function createReadonlyValidationFacade(schema: T): T { if (cached !== undefined) return cached; if (value instanceof z.ZodType) return schemaFacade(value); if (typeof value === "function") { - const wrapped = (...args: unknown[]) => - readonlyView(Reflect.apply(value, undefined, args)); - views.set(value, wrapped); - return Object.freeze(wrapped); + return functionFacade(value as (...args: never[]) => unknown); } - - const view = new Proxy(value, { - defineProperty: () => false, - deleteProperty: () => false, - get: (target, property, receiver) => { - const raw = Reflect.get(target, property, receiver); - if (typeof raw !== "function") return readonlyView(raw); - return (...args: unknown[]) => - readonlyView(Reflect.apply(raw, receiver, args)); - }, - set: () => false, - setPrototypeOf: () => false, - }); - views.set(value, view); - return view; + if (Array.isArray(value)) return arrayFacade(value); + if (value instanceof Map) return mapFacade(value); + if (value instanceof Set) return setFacade(value); + if (value instanceof RegExp) { + const detached = new RegExp(value.source, value.flags); + views.set(value, detached); + return Object.freeze(detached); + } + if (value instanceof Date) { + const detached = new Date(value.getTime()); + views.set(value, detached); + return Object.freeze(detached); + } + return objectFacade(value); } + function functionFacade(value: (...args: never[]) => unknown): unknown { + const wrapped = function (this: unknown, ...args: unknown[]) { + const result = new.target + ? Reflect.construct(value, args) + : Reflect.apply(value, this, args); + return readonlyView(result); + }; + views.set(value, wrapped); + return Object.freeze(wrapped); + } + + function arrayFacade(value: readonly unknown[]): readonly unknown[] { + const detached: unknown[] = []; + views.set(value, detached); + for (const item of value) detached.push(readonlyView(item)); + return Object.freeze(detached); + } + + function mapFacade( + value: ReadonlyMap, + ): ReadonlyMap { + const detached = new Map(); + views.set(value, detached); + for (const [key, item] of value) { + detached.set(readonlyView(key), readonlyView(item)); + } + return Object.freeze(detached); + } + + function setFacade(value: ReadonlySet): ReadonlySet { + const detached = new Set(); + views.set(value, detached); + for (const item of value) detached.add(readonlyView(item)); + return Object.freeze(detached); + } + + function objectFacade(value: object): object { + const detached = Object.create(Object.getPrototypeOf(value)) as Record< + PropertyKey, + unknown + >; + views.set(value, detached); + copyReadonlyProperties(value, detached, (method) => + boundFunctionFacade(method, value), + ); + return Object.freeze(detached); + } + + function boundFunctionFacade( + method: (...args: never[]) => unknown, + receiver: object, + ): (...args: unknown[]) => unknown { + return Object.freeze((...args: unknown[]) => + readonlyView(Reflect.apply(method, receiver, args)), + ); + } + + function copyReadonlyProperties( + source: object, + destination: Record, + wrapMethod: ( + method: (...args: never[]) => unknown, + property: PropertyKey, + ) => unknown, + ): void { + for (const key of Reflect.ownKeys(source)) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (!descriptor) continue; + if ("value" in descriptor) { + Object.defineProperty(destination, key, { + configurable: false, + enumerable: descriptor.enumerable, + value: + typeof descriptor.value === "function" + ? wrapMethod(descriptor.value, key) + : readonlyView(descriptor.value), + writable: false, + }); + continue; + } + + const getter = descriptor.get; + const safeGetter = getter + ? () => readonlyView(Reflect.apply(getter, source, [])) + : undefined; + Object.defineProperty(destination, key, { + configurable: false, + enumerable: descriptor.enumerable, + get: safeGetter, + }); + } + } + + function materializeSchemaPrototypeMethods(target: z.ZodType): void { + let prototype = Object.getPrototypeOf(target); + while (prototype && prototype !== Object.prototype) { + for (const property of Reflect.ownKeys(prototype)) { + if (Object.hasOwn(target, property)) continue; + const descriptor = Object.getOwnPropertyDescriptor(prototype, property); + if ( + !descriptor?.get || + !descriptor.set || + !descriptor.configurable || + descriptor.enumerable + ) { + continue; + } + Reflect.get(target, property, target); + } + prototype = Object.getPrototypeOf(prototype); + } + } function schemaFacade(target: S): S { const cached = views.get(target); if (cached !== undefined) return cached as S; - const facade = Object.create(Object.getPrototypeOf(target)) as Record< + materializeSchemaPrototypeMethods(target); + const shell = Object.create(Object.getPrototypeOf(target)) as Record< PropertyKey, unknown >; + let facade: S; + facade = new Proxy(shell, { + defineProperty: () => false, + deleteProperty: () => false, + get: (publicShell, property, receiver) => { + if (Object.hasOwn(publicShell, property)) { + return Reflect.get(publicShell, property, receiver); + } + const inherited = Reflect.get(publicShell, property, receiver); + return readonlyView(inherited); + }, + set: () => false, + setPrototypeOf: () => false, + }) as unknown as S; views.set(target, facade); - for (const key of Reflect.ownKeys(target)) { - const raw = Reflect.get(target, key); - const exposed = - typeof raw === "function" - ? (...args: unknown[]) => { - const result = Reflect.apply(raw, target, args); - return result instanceof z.ZodType - ? isolateValidationGraph(result, "derived-schema") - : result; - } - : readonlyView(raw); - Object.defineProperty(facade, key, { - configurable: false, - enumerable: Object.prototype.propertyIsEnumerable.call(target, key), - value: exposed, - writable: false, + copyReadonlyProperties(target, shell, (method, property) => + schemaMethodFacade(method, property, target, facade), + ); + Object.freeze(shell); + return facade; + } + + function schemaMethodFacade( + method: (...args: never[]) => unknown, + property: PropertyKey, + target: S, + facade: S, + ): (...args: unknown[]) => unknown { + if (property === "apply") { + return Object.freeze((...args: unknown[]) => { + const callback = args[0]; + if (typeof callback !== "function") { + throw new TypeError("schema apply callback missing"); + } + return Reflect.apply(callback, undefined, [facade]); }); } - return Object.freeze(facade) as S; + if (property === "register") { + return Object.freeze((...args: unknown[]) => { + const registry = args[0]; + if ( + typeof registry !== "object" || + registry === null || + typeof (registry as { add?: unknown }).add !== "function" + ) { + throw new TypeError("schema registry missing"); + } + ( + registry as { + add(schema: S, metadata?: unknown): unknown; + } + ).add(facade, args[1]); + return facade; + }); + } + + return Object.freeze((...args: unknown[]) => { + const result = Reflect.apply(method, target, args); + if (result instanceof z.ZodType) { + return isolateValidationGraph(result, "derived-schema"); + } + return parseResultMethods.has(property) ? result : readonlyView(result); + }); } return schemaFacade(schema);