diff --git a/src/features/housekeeping/foundation/commands/bootstrap.test.ts b/src/features/housekeeping/foundation/commands/bootstrap.test.ts index 56901042c8..748c92cea9 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.test.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.test.ts @@ -1,10 +1,58 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { anyCapability, ok } from "../contracts"; -import { housekeepingCommandRegistryReady } from "./bootstrap"; -import { registerHousekeepingCommand } from "./registry"; +import { + defineHousekeepingCommands, + housekeepingCommandRegistryReady, + registerHousekeepingCommands, +} from "./bootstrap"; +import { + type HousekeepingCommand, + registerHousekeepingCommand, +} from "./registry"; describe("housekeeping command bootstrap", () => { + it("preserves heterogeneous concrete input and output types", () => { + const stringCommand: HousekeepingCommand< + { value: string }, + { length: number } + > = { + id: "system.bootstrap.string-shape", + owner: "system", + risk: "safe", + capability: anyCapability("admin.settings.view"), + input: z.object({ value: z.string() }), + requiresReason: false, + rateLimit: { attempts: 1, windowMs: 1_000 }, + execute: async (context, input) => + ok({ length: input.value.length }, context.correlationId), + }; + const numberCommand: HousekeepingCommand< + { count: number }, + { doubled: number } + > = { + id: "system.bootstrap.number-shape", + owner: "system", + risk: "safe", + capability: anyCapability("admin.settings.view"), + input: z.object({ count: z.number().int() }), + requiresReason: false, + rateLimit: { attempts: 2, windowMs: 2_000 }, + execute: async (context, input) => + ok({ doubled: input.count * 2 }, context.correlationId), + }; + + const commands = defineHousekeepingCommands(stringCommand, numberCommand); + const stringInput: z.input<(typeof commands)[0]["input"]> = { + value: "typed", + }; + const numberInput: z.input<(typeof commands)[1]["input"]> = { count: 4 }; + expect(commands[0].input.parse(stringInput)).toEqual({ value: "typed" }); + expect(commands[1].input.parse(numberInput)).toEqual({ count: 4 }); + expect(() => registerHousekeepingCommands(commands)).toThrow( + "command registry is sealed", + ); + }); it("registers the complete current list and seals during module initialization", () => { expect(housekeepingCommandRegistryReady).toBe(true); expect(() => diff --git a/src/features/housekeeping/foundation/commands/bootstrap.ts b/src/features/housekeeping/foundation/commands/bootstrap.ts index 38b0e61172..19a4ac02fe 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.ts @@ -6,12 +6,37 @@ import { sealHousekeepingCommandRegistry, } from "./registry"; -const currentHousekeepingCommands = - [] as const satisfies readonly HousekeepingCommand[]; +type PreserveHousekeepingCommand = + Definition extends HousekeepingCommand + ? Definition + : never; -for (const command of currentHousekeepingCommands) { - registerHousekeepingCommand(command); +type HousekeepingCommandTuple = + Commands & { + readonly [Index in keyof Commands]: PreserveHousekeepingCommand< + Commands[Index] + >; + }; + +export function defineHousekeepingCommands< + const Commands extends readonly unknown[], +>(...commands: HousekeepingCommandTuple): Commands { + return commands; } + +export function registerHousekeepingCommands< + const Commands extends readonly unknown[], +>(commands: HousekeepingCommandTuple): void { + for (const command of commands) { + registerHousekeepingCommand( + command as unknown as HousekeepingCommand, + ); + } +} + +const currentHousekeepingCommands = defineHousekeepingCommands(); + +registerHousekeepingCommands(currentHousekeepingCommands); sealHousekeepingCommandRegistry(); export const housekeepingCommandRegistryReady = true; diff --git a/src/features/housekeeping/foundation/commands/dispatcher.test.ts b/src/features/housekeeping/foundation/commands/dispatcher.test.ts index b5e4bbfacf..a484a377bb 100644 --- a/src/features/housekeeping/foundation/commands/dispatcher.test.ts +++ b/src/features/housekeeping/foundation/commands/dispatcher.test.ts @@ -385,7 +385,7 @@ describe("dispatchHousekeepingCommand", () => { ]); expect(audit.entries[1]?.correlationId).toBe(result.correlationId); }); - it("runs sensitive validation and rate preflight before intent and execution", async () => { + it("runs sensitive capability and rate preflight before intent and execution", async () => { const events: string[] = []; const audit = auditRecorder(events); const tracedContext = { @@ -399,9 +399,7 @@ describe("dispatchHousekeepingCommand", () => { baseCommand("system.dispatch.preflight-order", { risk: "sensitive", requiresReason: true, - input: z.object({ enabled: z.boolean() }).superRefine(() => { - events.push("input"); - }), + input: z.object({ enabled: z.boolean() }), execute: async (context) => { events.push("execute"); return ok(null, context.correlationId); @@ -427,7 +425,6 @@ describe("dispatchHousekeepingCommand", () => { expect(events).toEqual([ "capability", - "input", "rate", "audit:intent", "execute", @@ -566,15 +563,19 @@ describe("dispatchHousekeepingCommand", () => { const audit = auditRecorder(); register( baseCommand("system.dispatch.schema-exception", { - input: z.preprocess(() => { - throw new Error("schema secret exposed"); - }, z.object({})), + input: z.object({ value: z.string() }), execute: async (context) => ok(null, context.correlationId), }), ); + const throwingInput = Object.defineProperty({}, "value", { + enumerable: true, + get: () => { + throw new Error("schema secret exposed"); + }, + }); const result = await dispatchHousekeepingCommand( - { commandId: "system.dispatch.schema-exception", input: {} }, + { commandId: "system.dispatch.schema-exception", input: throwingInput }, dependencies({ audit: audit.writer }), ); diff --git a/src/features/housekeeping/foundation/commands/registry.test.ts b/src/features/housekeeping/foundation/commands/registry.test.ts index b64e22d969..f38d721d59 100644 --- a/src/features/housekeeping/foundation/commands/registry.test.ts +++ b/src/features/housekeeping/foundation/commands/registry.test.ts @@ -6,6 +6,7 @@ import { type HousekeepingCommand, registerHousekeepingCommand, sealHousekeepingCommandRegistry, + UnsupportedHousekeepingCommandSchemaError, } from "./registry"; function command( @@ -154,6 +155,147 @@ describe("housekeeping command registry", () => { registered.input.safeParse({ value: 4, guard: "abcd" }).success, ).toBe(false); }); + it("snapshots caller-owned lazy targets across supported container schemas", () => { + let lazyChild: z.ZodType = z.string().min(2); + let defaultValue = "registered-default"; + const input = z.object({ + choice: z.union([z.lazy(() => lazyChild), z.number().int()]), + optional: z.lazy(() => lazyChild).optional(), + defaulted: z.string().default(() => defaultValue), + list: z.array(z.lazy(() => lazyChild)), + lookup: z.record( + z.string(), + z.lazy(() => lazyChild), + ), + }); + registerHousekeepingCommand({ + ...command("people.registry.lazy-containers"), + input, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand, { id: number }>); + const registered = getHousekeepingCommand( + "people.registry.lazy-containers", + ); + if (!registered) throw new Error("registered command missing"); + + lazyChild = z.boolean(); + defaultValue = "caller-mutated-default"; + + const parsed = registered.input.safeParse({ + choice: "ok", + list: ["one"], + lookup: { key: "two" }, + }); + expect(parsed).toMatchObject({ + success: true, + data: { defaulted: "registered-default" }, + }); + expect( + registered.input.safeParse({ + choice: true, + optional: true, + list: [true], + lookup: { key: true }, + }).success, + ).toBe(false); + }); + + it("snapshots recursive lazy back-edges with cycle safety", () => { + type TreeNode = { name: string; children: TreeNode[] }; + let nameSchema: z.ZodType = z.string().min(2); + let recursiveSchema: z.ZodType; + recursiveSchema = z.object({ + name: z.lazy(() => nameSchema), + children: z.array(z.lazy(() => recursiveSchema)), + }) as z.ZodType; + registerHousekeepingCommand({ + ...command("people.registry.recursive-lazy"), + input: recursiveSchema, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand); + const registered = getHousekeepingCommand("people.registry.recursive-lazy"); + if (!registered) throw new Error("registered command missing"); + + nameSchema = z.number(); + recursiveSchema = z.object({ + name: z.number(), + children: z.array(z.unknown()), + }) as unknown as z.ZodType; + + expect( + registered.input.safeParse({ + name: "root", + children: [{ name: "leaf", children: [] }], + }).success, + ).toBe(true); + expect( + registered.input.safeParse({ + name: "root", + children: [{ name: 7, children: [] }], + }).success, + ).toBe(false); + }); + + it.each([ + ["refine", "custom", z.string().refine((value) => value === "allowed")], + ["super-refine", "custom", z.string().superRefine(() => undefined)], + [ + "preprocess", + "transform", + z.preprocess((value) => String(value), z.string()), + ], + ["transform", "transform", z.string().transform((value) => value.length)], + ["async-refine", "custom", z.string().refine(async () => true)], + ] as const)( + "rejects unsupported executable validation schema %s", + (suffix, schemaKind, input) => { + const id = `people.registry.unsupported-${suffix}`; + let thrown: unknown; + try { + registerHousekeepingCommand({ + ...command(id), + input, + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(UnsupportedHousekeepingCommandSchemaError); + expect(thrown).toMatchObject({ + name: "UnsupportedHousekeepingCommandSchemaError", + code: "UNSUPPORTED_COMMAND_INPUT_SCHEMA", + commandId: id, + schemaKind, + }); + expect(getHousekeepingCommand(id)).toBeUndefined(); + }, + ); + + it("rejects a lazy edge that cannot be resolved at registration", () => { + const id = "people.registry.unresolvable-lazy"; + let thrown: unknown; + try { + registerHousekeepingCommand({ + ...command(id), + input: z.lazy(() => { + throw new Error("caller lazy secret"); + }), + execute: async (context) => ok({ id: 1 }, context.correlationId), + } as HousekeepingCommand); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(UnsupportedHousekeepingCommandSchemaError); + expect(thrown).toMatchObject({ + name: "UnsupportedHousekeepingCommandSchemaError", + code: "UNSUPPORTED_COMMAND_INPUT_SCHEMA", + commandId: id, + schemaKind: "lazy", + }); + expect(String(thrown)).not.toContain("caller lazy secret"); + }); 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 12f396f8ce..288a68e770 100644 --- a/src/features/housekeeping/foundation/commands/registry.ts +++ b/src/features/housekeeping/foundation/commands/registry.ts @@ -21,6 +21,11 @@ export interface HousekeepingCommand { readonly owner: HousekeepingDomainId; readonly risk: "safe" | "sensitive"; readonly capability: CapabilityRequirement; + /** + * Registration snapshots structural/built-in Zod graphs and resolvable lazy + * edges. Stateful custom refinements, transforms, preprocessors, and other + * executable schema callbacks are rejected. + */ readonly input: z.ZodType; readonly requiresReason: boolean; readonly rateLimit: Readonly<{ attempts: number; windowMs: number }>; @@ -30,6 +35,24 @@ export interface HousekeepingCommand { ) => Promise>; } +export type UnsupportedHousekeepingCommandSchemaKind = + | "custom" + | "transform" + | "lazy" + | "executable"; + +export class UnsupportedHousekeepingCommandSchemaError extends Error { + readonly name = "UnsupportedHousekeepingCommandSchemaError"; + readonly code = "UNSUPPORTED_COMMAND_INPUT_SCHEMA"; + + constructor( + readonly commandId: string, + readonly schemaKind: UnsupportedHousekeepingCommandSchemaKind, + ) { + super(`unsupported command input schema: ${commandId} (${schemaKind})`); + } +} + type RegisteredHousekeepingCommand = HousekeepingCommand; type ZodInternalNode = { @@ -63,7 +86,7 @@ export function registerHousekeepingCommand( owner: command.owner, risk: command.risk, capability, - input: isolateValidationGraph(command.input), + input: isolateValidationGraph(command.input, command.id), requiresReason: command.requiresReason, rateLimit: Object.freeze({ ...command.rateLimit }), execute: command.execute, @@ -143,74 +166,183 @@ function validateCapability( } } -function isolateValidationGraph(schema: T): T { - const seen = new WeakMap(); +function isolateValidationGraph( + schema: T, + commandId: string, +): T { + type SchemaCell = { + current?: z.ZodType; + reference?: z.ZodType; + }; + + const graphValues = new WeakMap(); + const schemaCells = new WeakMap(); + + function unsupported( + schemaKind: UnsupportedHousekeepingCommandSchemaKind, + ): never { + throw new UnsupportedHousekeepingCommandSchemaError(commandId, schemaKind); + } + + function cloneSchema(value: S): S { + const existing = schemaCells.get(value); + if (existing?.current) return existing.current as S; + if (existing) { + existing.reference ??= z.lazy(() => { + if (!existing.current) { + throw new Error("housekeeping schema registration incomplete"); + } + return existing.current; + }); + return existing.reference as S; + } + + const definition = value.def as { type?: unknown; getter?: unknown }; + if (definition.type === "custom") unsupported("custom"); + if (definition.type === "transform") unsupported("transform"); + + const cell: SchemaCell = {}; + schemaCells.set(value, cell); + if (definition.type === "lazy") { + let ownedTarget: z.ZodType | undefined; + const ownedLazy = z.lazy(() => { + if (!ownedTarget) { + throw new Error("housekeeping lazy schema target unavailable"); + } + return ownedTarget; + }); + cell.current = ownedLazy; + if (typeof definition.getter !== "function") unsupported("lazy"); + + let callerTarget: unknown; + try { + callerTarget = definition.getter(); + } catch { + unsupported("lazy"); + } + if (!(callerTarget instanceof z.ZodType)) unsupported("lazy"); + ownedTarget = cloneSchema(callerTarget); + return ownedLazy as unknown as S; + } + + const clonedDefinition = cloneGraph(value.def); + const cloned = value.clone(clonedDefinition as typeof value.def); + cell.current = cloned; + return cloned; + } + + function cloneBuiltInCheck(value: ZodInternalNode): unknown { + const definition = value._zod.def; + if (typeof definition !== "object" || definition === null) { + unsupported("executable"); + } + const checkDefinition = definition as { + check?: unknown; + type?: unknown; + }; + if ( + checkDefinition.check === "custom" || + checkDefinition.type === "custom" + ) { + unsupported("custom"); + } + + const clonedDefinition = cloneRecord(definition, true); + const cloned = new value._zod.constr(clonedDefinition as never) as { + _zod?: { check?: unknown }; + }; + if ( + typeof (value._zod as { check?: unknown }).check === "function" && + typeof cloned._zod?.check !== "function" + ) { + unsupported("executable"); + } + return cloned; + } + + function cloneRecord(value: object, omitBuiltInWhen = false): object { + const cached = graphValues.get(value); + if (cached !== undefined) return cached as object; + + const prototype = Object.getPrototypeOf(value); + const cloned = Object.create(prototype) as Record; + graphValues.set(value, cloned); + for (const key of Reflect.ownKeys(value)) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor) continue; + if ( + omitBuiltInWhen && + key === "when" && + "value" in descriptor && + typeof descriptor.value === "function" + ) { + continue; + } + + let resolved: unknown; + if ("value" in descriptor) { + resolved = descriptor.value; + } else if (descriptor.get) { + try { + resolved = Reflect.get(value, key); + } catch { + unsupported("executable"); + } + } else { + unsupported("executable"); + } + Object.defineProperty(cloned, key, { + configurable: descriptor.configurable, + enumerable: descriptor.enumerable, + value: cloneGraph(resolved), + writable: "writable" in descriptor ? descriptor.writable : false, + }); + } + return cloned; + } function cloneGraph(value: unknown): unknown { + if (typeof value === "function") unsupported("executable"); if (typeof value !== "object" || value === null) return value; - const cached = seen.get(value); - if (cached !== undefined) return cached; + if (value instanceof z.ZodType) return cloneSchema(value); - if (value instanceof z.ZodType) { - const clonedDefinition = cloneGraph(value.def); - const cloned = value.clone(clonedDefinition as typeof value.def); - seen.set(value, cloned); - return cloned; - } - if (isZodInternalNode(value)) { - const clonedDefinition = cloneGraph(value._zod.def); - const cloned = new value._zod.constr(clonedDefinition as never) as { - _zod: { check?: unknown }; - }; - const runtimeCheck = (value._zod as { check?: unknown }).check; - if (runtimeCheck !== undefined && cloned._zod.check === undefined) { - cloned._zod.check = runtimeCheck; - } - seen.set(value, cloned); - return cloned; - } + const cached = graphValues.get(value); + if (cached !== undefined) return cached; + if (isZodInternalNode(value)) return cloneBuiltInCheck(value); if (Array.isArray(value)) { const cloned: unknown[] = []; - seen.set(value, cloned); + graphValues.set(value, cloned); for (const item of value) cloned.push(cloneGraph(item)); return cloned; } if (value instanceof RegExp) { const cloned = new RegExp(value.source, value.flags); - seen.set(value, cloned); + graphValues.set(value, cloned); return cloned; } if (value instanceof Date) { const cloned = new Date(value.getTime()); - seen.set(value, cloned); + graphValues.set(value, cloned); return cloned; } - - const cloned = Object.create(Object.getPrototypeOf(value)) as Record< - PropertyKey, - unknown - >; - seen.set(value, cloned); - for (const key of Reflect.ownKeys(value)) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (!descriptor) continue; - const clonedDescriptor = - "value" in descriptor - ? { ...descriptor, value: cloneGraph(descriptor.value) } - : descriptor.get - ? { - configurable: descriptor.configurable, - enumerable: descriptor.enumerable, - writable: false, - value: cloneGraph(Reflect.get(value, key)), - } - : descriptor; - Object.defineProperty(cloned, key, clonedDescriptor); + if (value instanceof Map) { + const cloned = new Map(); + graphValues.set(value, cloned); + for (const [key, item] of value) { + cloned.set(cloneGraph(key), cloneGraph(item)); + } + return cloned; } - return cloned; + if (value instanceof Set) { + const cloned = new Set(); + graphValues.set(value, cloned); + for (const item of value) cloned.add(cloneGraph(item)); + return cloned; + } + return cloneRecord(value); } - return createReadonlyValidationFacade(cloneGraph(schema) as T); + return createReadonlyValidationFacade(cloneSchema(schema)); } function isZodInternalNode(value: object): value is ZodInternalNode { @@ -227,15 +359,31 @@ function createReadonlyValidationFacade(schema: T): T { const views = new WeakMap(); function readonlyView(value: unknown): unknown { - if (typeof value !== "object" || value === null) return value; + if ( + (typeof value !== "object" && typeof value !== "function") || + value === null + ) { + return value; + } const cached = views.get(value); 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); + } const view = new Proxy(value, { defineProperty: () => false, deleteProperty: () => false, - get: (target, property) => readonlyView(Reflect.get(target, property)), + 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, }); @@ -259,7 +407,7 @@ function createReadonlyValidationFacade(schema: T): T { ? (...args: unknown[]) => { const result = Reflect.apply(raw, target, args); return result instanceof z.ZodType - ? isolateValidationGraph(result) + ? isolateValidationGraph(result, "derived-schema") : result; } : readonlyView(raw);