From 00618fb2a30f208fa615d493fe019c889ba37b00 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 27 Aug 2026 19:32:19 +0200 Subject: [PATCH] fix(housekeeping): harden command dispatch boundaries --- src/actions/housekeeping-command.test.ts | 30 ++- src/actions/housekeeping-command.ts | 3 +- .../foundation/commands/bootstrap.test.ts | 23 +++ .../foundation/commands/bootstrap.ts | 17 ++ .../foundation/commands/dispatcher.test.ts | 194 +++++++++++++++++- .../foundation/commands/dispatcher.ts | 190 +++++++++++++++-- .../foundation/commands/registry.test.ts | 31 +++ .../foundation/commands/registry.ts | 150 +++++++++++++- 8 files changed, 608 insertions(+), 30 deletions(-) create mode 100644 src/features/housekeeping/foundation/commands/bootstrap.test.ts create mode 100644 src/features/housekeeping/foundation/commands/bootstrap.ts diff --git a/src/actions/housekeeping-command.test.ts b/src/actions/housekeeping-command.test.ts index f82fc3ce..5febc4fb 100644 --- a/src/actions/housekeeping-command.test.ts +++ b/src/actions/housekeeping-command.test.ts @@ -1,6 +1,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { registerHousekeepingCommand } from "@/features/housekeeping/foundation/commands/registry"; + +vi.mock( + "@/features/housekeeping/foundation/commands/registry", + async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/features/housekeeping/foundation/commands/registry") + >()), + sealHousekeepingCommandRegistry: sealRegistryMock, + }), +); + +vi.mock("@/features/housekeeping/foundation/commands/bootstrap", () => ({ + housekeepingCommandRegistryReady: true, +})); + import { anyCapability, ok, @@ -15,6 +30,7 @@ const { getContextMock, getIpMock, rateLimitCalls, + sealRegistryMock, } = vi.hoisted(() => ({ auditEntries: [] as AuditEntry[], auditWriteMock: vi.fn(), @@ -30,6 +46,7 @@ const { getContextMock: vi.fn(), getIpMock: vi.fn(), rateLimitCalls: [] as Array<[string, number, number]>, + sealRegistryMock: vi.fn(), })); vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({ @@ -77,6 +94,7 @@ beforeEach(() => { rateLimitCalls.length = 0; getContextMock.mockReset().mockResolvedValue(context); getIpMock.mockReset().mockResolvedValue("203.0.113.7"); + sealRegistryMock.mockReset(); auditWriteMock.mockReset().mockImplementation(async (entry: AuditEntry) => { auditEntries.push({ ...entry }); }); @@ -115,6 +133,7 @@ describe("executeHousekeepingCommand", () => { }, ]); expect(auditEntries[0]?.correlationId).toBe(result.correlationId); + expect(sealRegistryMock).not.toHaveBeenCalled(); }); it("strictly rejects spoofed server-owned metadata before execution", async () => { @@ -136,7 +155,16 @@ describe("executeHousekeepingCommand", () => { }); expect(commandExecutions).toEqual([]); expect(rateLimitCalls).toEqual([]); - expect(auditEntries).toEqual([]); + expect(auditEntries).toMatchObject([ + { + userId: 71, + action: "housekeeping.command.dispatch", + target: "request-envelope", + ipAddress: "203.0.113.7", + outcome: "denied", + }, + ]); + expect(JSON.stringify(auditEntries)).not.toContain("forged"); }); it("sanitizes server context acquisition failures into typed results", async () => { diff --git a/src/actions/housekeeping-command.ts b/src/actions/housekeeping-command.ts index fec43c6d..ae456205 100644 --- a/src/actions/housekeeping-command.ts +++ b/src/actions/housekeeping-command.ts @@ -1,8 +1,8 @@ "use server"; +import "@/features/housekeeping/foundation/commands/bootstrap"; import { AuditOutcomePersistenceError } from "@/features/housekeeping/foundation/commands/audit-envelope"; import { dispatchHousekeepingCommand } from "@/features/housekeeping/foundation/commands/dispatcher"; -import { sealHousekeepingCommandRegistry } from "@/features/housekeeping/foundation/commands/registry"; import { fail, type HousekeepingResult, @@ -15,7 +15,6 @@ import { housekeepingAuditWriter } from "@/lib/services/audit"; export async function executeHousekeepingCommand( request: unknown, ): Promise> { - sealHousekeepingCommandRegistry(); try { const [context, ipAddress] = await Promise.all([ getHousekeepingCapabilityContext(), diff --git a/src/features/housekeeping/foundation/commands/bootstrap.test.ts b/src/features/housekeeping/foundation/commands/bootstrap.test.ts new file mode 100644 index 00000000..56901042 --- /dev/null +++ b/src/features/housekeeping/foundation/commands/bootstrap.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { anyCapability, ok } from "../contracts"; +import { housekeepingCommandRegistryReady } from "./bootstrap"; +import { registerHousekeepingCommand } from "./registry"; + +describe("housekeeping command bootstrap", () => { + it("registers the complete current list and seals during module initialization", () => { + expect(housekeepingCommandRegistryReady).toBe(true); + expect(() => + registerHousekeepingCommand({ + id: "system.bootstrap.too-late", + owner: "system", + risk: "safe", + capability: anyCapability("admin.settings.view"), + input: z.object({}), + requiresReason: false, + rateLimit: { attempts: 1, windowMs: 1_000 }, + execute: async (context) => ok(null, context.correlationId), + }), + ).toThrow("command registry is sealed"); + }); +}); diff --git a/src/features/housekeeping/foundation/commands/bootstrap.ts b/src/features/housekeeping/foundation/commands/bootstrap.ts new file mode 100644 index 00000000..38b0e611 --- /dev/null +++ b/src/features/housekeeping/foundation/commands/bootstrap.ts @@ -0,0 +1,17 @@ +import "server-only"; + +import type { HousekeepingCommand } from "./registry"; +import { + registerHousekeepingCommand, + sealHousekeepingCommandRegistry, +} from "./registry"; + +const currentHousekeepingCommands = + [] as const satisfies readonly HousekeepingCommand[]; + +for (const command of currentHousekeepingCommands) { + registerHousekeepingCommand(command); +} +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 f13787d4..b5e4bbfa 100644 --- a/src/features/housekeeping/foundation/commands/dispatcher.test.ts +++ b/src/features/housekeeping/foundation/commands/dispatcher.test.ts @@ -504,7 +504,7 @@ describe("dispatchHousekeepingCommand", () => { it.each([ null, [], - Object.assign(Object.create({ inherited: true }), { + Object.assign(Object.create(Object.freeze({})), { commandId: "system.dispatch.unknown", input: {}, }), @@ -691,4 +691,196 @@ describe("dispatchHousekeepingCommand", () => { expect(JSON.stringify(result)).not.toContain("secret exposed"); expect(attempts).toEqual(["failure"]); }); + it("audits malformed envelopes without copying unvalidated metadata", async () => { + const audit = auditRecorder(); + const result = await dispatchHousekeepingCommand( + { + commandId: "people.client-controlled-target", + input: { password: "secret" }, + reason: "client reason", + domain: "people", + }, + dependencies({ audit: audit.writer }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "VALIDATION" }, + }); + expect(audit.entries).toEqual([ + { + userId: 42, + action: "housekeeping.command.dispatch", + target: "request-envelope", + correlationId: result.correlationId, + outcome: "denied", + ipAddress: "198.51.100.8", + }, + ]); + expect(JSON.stringify(audit.entries)).not.toContain("client-controlled"); + expect(JSON.stringify(audit.entries)).not.toContain("secret"); + expect(JSON.stringify(audit.entries)).not.toContain("client reason"); + }); + + it("uses canonical command-ID grammar before unknown-command evidence", async () => { + const audit = auditRecorder(); + const result = await dispatchHousekeepingCommand( + { commandId: "system.bad\nidentifier", input: {} }, + dependencies({ audit: audit.writer }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "VALIDATION" }, + }); + expect(audit.entries).toMatchObject([ + { + action: "housekeeping.command.dispatch", + target: "request-envelope", + outcome: "denied", + }, + ]); + expect(JSON.stringify(audit.entries)).not.toContain("bad\\nidentifier"); + }); + + it.each([ + ["null", null], + ["missing success data", { ok: true, correlationId: "forged" }], + [ + "invalid success flag", + { ok: "yes", data: null, correlationId: "forged" }, + ], + [ + "invalid failure error", + { ok: false, error: null, correlationId: "forged" }, + ], + [ + "throwing getter", + Object.defineProperty({}, "ok", { + enumerable: true, + get: () => { + throw new Error("result getter secret exposed"); + }, + }), + ], + ] as const)( + "sanitizes malformed command result: %s", + async (label, commandResult) => { + const commandId = `system.dispatch.malformed-result-${label.replaceAll(" ", "-")}`; + const audit = auditRecorder(); + register( + baseCommand(commandId, { + input: z.object({}), + execute: async () => commandResult as never, + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId, input: {} }, + dependencies({ audit: audit.writer }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "INTERNAL", messageKey: "errors.housekeeping.internal" }, + }); + expect(result.correlationId).toMatch(/^[0-9a-f-]{36}$/i); + expect(result.correlationId).not.toBe("forged"); + expect(JSON.stringify(result)).not.toContain("secret exposed"); + expect(audit.entries.map((entry) => entry.outcome)).toEqual(["failure"]); + expect(audit.entries[0]?.correlationId).toBe(result.correlationId); + }, + ); + + it.each([ + ["empty", ""], + ["text", "not-an-ip"], + ["range", "999.1.1.1"], + [ + "pathological", + { + toString: () => { + throw new Error("IP getter secret exposed"); + }, + }, + ], + ])( + "rejects invalid server IP without using it in keys or evidence %s", + async (label, ipAddress) => { + const audit = auditRecorder(); + const rateKeys: string[] = []; + let executed = false; + const commandId = `system.dispatch.invalid-ip-${label}`; + register( + baseCommand(commandId, { + input: z.object({}), + execute: async (context) => { + executed = true; + return ok(null, context.correlationId); + }, + }), + ); + + const result = await dispatchHousekeepingCommand( + { commandId, input: {} }, + dependencies({ + ipAddress: ipAddress as never, + audit: audit.writer, + rateLimit: async (key) => { + rateKeys.push(key); + return true; + }, + }), + ); + + expect(result).toMatchObject({ + ok: false, + error: { code: "INTERNAL", messageKey: "errors.housekeeping.internal" }, + }); + expect(executed).toBe(false); + expect(rateKeys).toEqual([]); + expect(audit.entries).toMatchObject([ + { + action: "housekeeping.command.dispatch", + target: "server-context", + outcome: "failure", + }, + ]); + expect(audit.entries[0]).not.toHaveProperty("ipAddress"); + expect(JSON.stringify(audit.entries)).not.toContain("not-an-ip"); + expect(JSON.stringify(audit.entries)).not.toContain("secret exposed"); + }, + ); + it("canonicalizes IPv6 for command context, rate identity, and audit evidence", async () => { + const audit = auditRecorder(); + const rateKeys: string[] = []; + let executionIp = ""; + register( + baseCommand("system.dispatch.canonical-ip", { + input: z.object({}), + execute: async (context) => { + executionIp = context.ipAddress; + return ok(null, context.correlationId); + }, + }), + ); + + await dispatchHousekeepingCommand( + { commandId: "system.dispatch.canonical-ip", input: {} }, + dependencies({ + ipAddress: "2001:0DB8:0:0:0:0:0:1", + audit: audit.writer, + rateLimit: async (key) => { + rateKeys.push(key); + return true; + }, + }), + ); + + expect(executionIp).toBe("2001:db8::1"); + expect(rateKeys).toEqual([ + "housekeeping-command:42:2001:db8::1:system.dispatch.canonical-ip", + ]); + expect(audit.entries[0]?.ipAddress).toBe("2001:db8::1"); + }); }); diff --git a/src/features/housekeeping/foundation/commands/dispatcher.ts b/src/features/housekeeping/foundation/commands/dispatcher.ts index 292ff88a..05e2a799 100644 --- a/src/features/housekeeping/foundation/commands/dispatcher.ts +++ b/src/features/housekeeping/foundation/commands/dispatcher.ts @@ -1,3 +1,4 @@ +import { isIP } from "node:net"; import { z } from "zod"; import type { AuditEntry, HousekeepingAuditWriter } from "@/lib/services/audit"; import { satisfiesCapability } from "../capability-context"; @@ -18,12 +19,19 @@ import { getHousekeepingCommand, type HousekeepingCommand, type HousekeepingCommandContext, + isHousekeepingCommandId, } from "./registry"; const normalizedCommandId = z .string() .transform((value) => value.normalize("NFC").trim()) - .pipe(z.string().min(1).max(160)); + .pipe( + z + .string() + .min(1) + .max(160) + .refine(isHousekeepingCommandId, "invalid command id"), + ); const normalizedReason = z .string() .transform((value) => value.normalize("NFC").trim()) @@ -33,6 +41,34 @@ const commandRequestSchema = z.strictObject({ input: z.unknown(), reason: normalizedReason.optional(), }); +const housekeepingErrorCodeSchema = z.enum([ + "UNAUTHENTICATED", + "FORBIDDEN", + "VALIDATION", + "NOT_FOUND", + "CONFLICT", + "RATE_LIMITED", + "DEPENDENCY_UNAVAILABLE", + "TIMEOUT", + "INTERNAL", +]); +const housekeepingErrorSchema = z.strictObject({ + code: housekeepingErrorCodeSchema, + messageKey: z.string().min(1).max(160), + fieldErrors: z.record(z.string(), z.array(z.string())).optional(), +}); +const housekeepingResultSchema = z.discriminatedUnion("ok", [ + z.strictObject({ + ok: z.literal(true), + data: z.unknown(), + correlationId: z.string().min(1).max(160), + }), + z.strictObject({ + ok: z.literal(false), + error: housekeepingErrorSchema, + correlationId: z.string().min(1).max(160), + }), +]); export interface HousekeepingCommandRequest { commandId: string; @@ -52,14 +88,50 @@ export async function dispatchHousekeepingCommand( dependencies: HousekeepingCommandDependencies, ): Promise> { const correlationId = createCorrelationId(); + const ipAddress = canonicalizeServerIp(dependencies.ipAddress); + if (ipAddress === undefined) { + return persistReturnedOutcome( + dependencies.audit, + createDispatchAuditEntry( + "server-context", + dependencies.context, + correlationId, + ), + "failure", + mapUnknownError(new Error("invalid server IP"), correlationId), + ); + } + + if (!isPlainRecord(request)) { + return persistMalformedRequestOutcome( + dependencies, + ipAddress, + correlationId, + ); + } + let parsedRequest: ReturnType; try { parsedRequest = commandRequestSchema.safeParse(request); } catch (error) { - return mapUnknownError(error, correlationId); + return persistReturnedOutcome( + dependencies.audit, + createDispatchAuditEntry( + "request-envelope", + dependencies.context, + correlationId, + ipAddress, + ), + "failure", + mapUnknownError(error, correlationId), + ); } if (!parsedRequest.success) { - return fail("VALIDATION", "errors.housekeeping.validation", correlationId); + return persistMalformedRequestOutcome( + dependencies, + ipAddress, + correlationId, + ); } const commandRequest = parsedRequest.data; @@ -74,7 +146,8 @@ export async function dispatchHousekeepingCommand( dependencies.audit, createUnknownCommandAuditEntry( commandRequest.commandId, - dependencies, + dependencies.context, + ipAddress, correlationId, ), "denied", @@ -85,7 +158,7 @@ export async function dispatchHousekeepingCommand( const auditEntry = createAuditEntry( command, dependencies.context, - dependencies.ipAddress, + ipAddress, correlationId, commandRequest.reason || undefined, ); @@ -167,11 +240,7 @@ export async function dispatchHousekeepingCommand( let allowed: boolean; try { allowed = await dependencies.rateLimit( - createRateLimitKey( - command.id, - dependencies.context, - dependencies.ipAddress, - ), + createRateLimitKey(command.id, dependencies.context, ipAddress), command.rateLimit.attempts, command.rateLimit.windowMs, ); @@ -203,11 +272,12 @@ export async function dispatchHousekeepingCommand( const commandContext: HousekeepingCommandContext = { capability: dependencies.context, correlationId, - ipAddress: dependencies.ipAddress, + ipAddress, }; - let result: HousekeepingResult; + let correlatedResult: HousekeepingResult; try { - result = await command.execute(commandContext, parsedInput.data); + const rawResult = await command.execute(commandContext, parsedInput.data); + correlatedResult = validateAndCorrelateResult(rawResult, correlationId); } catch (error) { return persistReturnedOutcome( dependencies.audit, @@ -217,7 +287,6 @@ export async function dispatchHousekeepingCommand( ); } - const correlatedResult = withCorrelation(result, correlationId); if (!correlatedResult.ok) { return persistReturnedOutcome( dependencies.audit, @@ -251,17 +320,33 @@ function createAuditEntry( }; } +function createDispatchAuditEntry( + target: "request-envelope" | "server-context", + context: HousekeepingCapabilityContext, + correlationId: string, + ipAddress?: string, +): AuditEntry { + return { + userId: context.actor.id, + action: "housekeeping.command.dispatch", + target, + correlationId, + ...(ipAddress === undefined ? {} : { ipAddress }), + }; +} + function createUnknownCommandAuditEntry( commandId: string, - dependencies: HousekeepingCommandDependencies, + context: HousekeepingCapabilityContext, + ipAddress: string, correlationId: string, ): AuditEntry { return { - userId: dependencies.context.actor.id, + userId: context.actor.id, action: "housekeeping.command.dispatch", target: commandId, correlationId, - ipAddress: dependencies.ipAddress, + ipAddress, }; } @@ -273,11 +358,56 @@ function createRateLimitKey( return `housekeeping-command:${context.actor.id}:${ipAddress}:${commandId}`; } -function withCorrelation( - result: HousekeepingResult, +function canonicalizeServerIp(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const candidate = value.trim(); + const version = isIP(candidate); + if (version === 4) { + return candidate + .split(".") + .map((part) => String(Number(part))) + .join("."); + } + if (version === 6) { + try { + return new URL(`http://[${candidate}]/`).hostname.slice(1, -1); + } catch { + return undefined; + } + } + return undefined; +} + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + try { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; + } catch { + return false; + } +} + +function validateAndCorrelateResult( + value: unknown, correlationId: string, -): HousekeepingResult { - return { ...result, correlationId }; +): HousekeepingResult { + if (!isPlainRecord(value) || !Object.hasOwn(value, "ok")) { + throw new Error("invalid housekeeping command result"); + } + if (value.ok === true && !Object.hasOwn(value, "data")) { + throw new Error("invalid housekeeping command result"); + } + if (value.ok === false && !Object.hasOwn(value, "error")) { + throw new Error("invalid housekeeping command result"); + } + const parsed = housekeepingResultSchema.safeParse(value); + if (!parsed.success) { + throw new Error("invalid housekeeping command result"); + } + return { ...parsed.data, correlationId }; } function outcomeForFailure( @@ -290,6 +420,24 @@ function outcomeForFailure( : "failure"; } +function persistMalformedRequestOutcome( + dependencies: HousekeepingCommandDependencies, + ipAddress: string, + correlationId: string, +): Promise> { + return persistReturnedOutcome( + dependencies.audit, + createDispatchAuditEntry( + "request-envelope", + dependencies.context, + correlationId, + ipAddress, + ), + "denied", + fail("VALIDATION", "errors.housekeeping.validation", correlationId), + ); +} + async function persistReturnedOutcome( writer: HousekeepingAuditWriter, entry: AuditEntry, diff --git a/src/features/housekeeping/foundation/commands/registry.test.ts b/src/features/housekeeping/foundation/commands/registry.test.ts index 929dd5f1..b64e22d9 100644 --- a/src/features/housekeeping/foundation/commands/registry.test.ts +++ b/src/features/housekeeping/foundation/commands/registry.test.ts @@ -123,6 +123,37 @@ describe("housekeeping command registry", () => { expect(() => registerHousekeepingCommand(invalid)).toThrow(); }); + it("keeps registered validation unchanged after original shape and child mutation", () => { + const child = z.string().min(3); + const input = z.object({ value: child, guard: child }); + registerHousekeepingCommand({ + ...command("people.registry.deep-validation"), + input, + execute: async (context, value) => + ok({ id: value.value.length }, context.correlationId), + } as HousekeepingCommand<{ value: string; guard: string }, { id: number }>); + const registered = getHousekeepingCommand( + "people.registry.deep-validation", + ); + if (!registered) throw new Error("registered command missing"); + + (input.def as { shape: { value: z.ZodType } }).shape.value = z.number(); + const minCheck = (child.def as { checks: unknown[] }).checks[0] as { + _zod: { def: { minimum: number } }; + }; + minCheck._zod.def.minimum = 0; + + expect(input.safeParse({ value: 4, guard: "a" }).success).toBe(true); + expect( + registered.input.safeParse({ value: "abcd", guard: "ab" }).success, + ).toBe(false); + expect( + registered.input.safeParse({ value: "abcd", guard: "abcd" }).success, + ).toBe(true); + expect( + registered.input.safeParse({ value: 4, guard: "abcd" }).success, + ).toBe(false); + }); 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 cb6678ac..12f396f8 100644 --- a/src/features/housekeeping/foundation/commands/registry.ts +++ b/src/features/housekeeping/foundation/commands/registry.ts @@ -32,6 +32,13 @@ export interface HousekeepingCommand { type RegisteredHousekeepingCommand = HousekeepingCommand; +type ZodInternalNode = { + readonly _zod: { + readonly constr: new (definition: never) => unknown; + readonly def: unknown; + }; +}; + const approvedOwners = new Set(HOUSEKEEPING_DOMAIN_IDS); const knownCapabilitySlugs = new Set(Object.values(PERMS)); const commandIdPattern = /^[a-z][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/; @@ -56,7 +63,7 @@ export function registerHousekeepingCommand( owner: command.owner, risk: command.risk, capability, - input: Object.freeze(command.input.clone()), + input: isolateValidationGraph(command.input), requiresReason: command.requiresReason, rateLimit: Object.freeze({ ...command.rateLimit }), execute: command.execute, @@ -74,11 +81,12 @@ export function getHousekeepingCommand( return commands.get(id); } +export function isHousekeepingCommandId(value: unknown): value is string { + return isNormalizedIdentifier(value) && commandIdPattern.test(value); +} + function validateCommand(command: HousekeepingCommand): void { - if ( - !isNormalizedIdentifier(command.id) || - !commandIdPattern.test(command.id) - ) { + if (!isHousekeepingCommandId(command.id)) { throw new Error(`invalid command id: ${String(command.id)}`); } if ( @@ -135,6 +143,138 @@ function validateCapability( } } +function isolateValidationGraph(schema: T): T { + const seen = new WeakMap(); + + function cloneGraph(value: unknown): unknown { + if (typeof value !== "object" || value === null) return value; + const cached = seen.get(value); + if (cached !== undefined) return cached; + + 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; + } + if (Array.isArray(value)) { + const cloned: unknown[] = []; + seen.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); + return cloned; + } + if (value instanceof Date) { + const cloned = new Date(value.getTime()); + seen.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); + } + return cloned; + } + + return createReadonlyValidationFacade(cloneGraph(schema) as T); +} + +function isZodInternalNode(value: object): value is ZodInternalNode { + const internal = (value as { _zod?: unknown })._zod; + return ( + typeof internal === "object" && + internal !== null && + typeof (internal as { constr?: unknown }).constr === "function" && + "def" in internal + ); +} + +function createReadonlyValidationFacade(schema: T): T { + const views = new WeakMap(); + + function readonlyView(value: unknown): unknown { + if (typeof value !== "object" || value === null) return value; + const cached = views.get(value); + if (cached !== undefined) return cached; + if (value instanceof z.ZodType) return schemaFacade(value); + + const view = new Proxy(value, { + defineProperty: () => false, + deleteProperty: () => false, + get: (target, property) => readonlyView(Reflect.get(target, property)), + set: () => false, + setPrototypeOf: () => false, + }); + views.set(value, view); + return view; + } + + 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< + PropertyKey, + unknown + >; + 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) + : result; + } + : readonlyView(raw); + Object.defineProperty(facade, key, { + configurable: false, + enumerable: Object.prototype.propertyIsEnumerable.call(target, key), + value: exposed, + writable: false, + }); + } + return Object.freeze(facade) as S; + } + + return schemaFacade(schema); +} function isNormalizedIdentifier(value: unknown): value is string { return ( typeof value === "string" &&