From 40625e98ed250d82a4d2abcc9ce306eac6109cb9 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 5 Aug 2026 14:03:40 +0530 Subject: [PATCH] fix(rpc): deep-freeze procedures in defineService, not just the map --- packages/rpc/src/contract.ts | 13 ++++++++++++- packages/rpc/test/contract.test.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/rpc/src/contract.ts b/packages/rpc/src/contract.ts index ad7c27f3..e632d8cf 100644 --- a/packages/rpc/src/contract.ts +++ b/packages/rpc/src/contract.ts @@ -62,5 +62,16 @@ export function defineService(def: { ); } } - return Object.freeze({ name: def.name, procedures: Object.freeze({ ...def.procedures }) }); + // Freeze each procedure, not just the map. AnyProcedures accepts any object + // of ProcedureDef shape, so a hand-built def that never went through + // procedure.build() would otherwise stay mutable and the "single source of + // truth" guarantee would rest on every call site remembering the builder. + const frozen: Record = {}; + for (const [name, value] of Object.entries(def.procedures)) { + frozen[name] = Object.freeze({ ...value }); + } + return Object.freeze({ + name: def.name, + procedures: Object.freeze(frozen) as Procedures, + }); } diff --git a/packages/rpc/test/contract.test.ts b/packages/rpc/test/contract.test.ts index d3acc9fe..79b781fd 100644 --- a/packages/rpc/test/contract.test.ts +++ b/packages/rpc/test/contract.test.ts @@ -48,6 +48,20 @@ describe("defineService", () => { ).toThrow(/procedure name/i); }); + test("a hand-built procedure is frozen too, not just builder output", () => { + // AnyProcedures accepts any ProcedureDef shape; the guarantee must not + // depend on the caller having used procedure.build(). + const contract = defineService({ + name: "demo", + procedures: { ping: { permission: "demo:read" } }, + }); + expect(Object.isFrozen(contract.procedures.ping)).toBe(true); + expect(() => { + (contract.procedures.ping as { permission?: string }).permission = "hacked"; + }).toThrow(); + expect(contract.procedures.ping.permission).toBe("demo:read"); + }); + test("the builder is immutable — reusing a base does not cross-contaminate", () => { const base = procedure.permission("a:read"); const one = base.idempotent().build();