docs: deep-freeze procedures in the Task 3 plan snippet

defineService froze the procedures map but not each procedure inside it, so a
ProcedureDef built by hand rather than through procedure.build() stayed
mutable: svc.procedures.foo.permission = 'hacked' silently succeeded. The
contract is shared between two apps as a single source of truth, and the
guarantee rested on every call site remembering to use the builder.

Same class as the authz catalog's frozenMap, which froze the Map's mutators
but not the values it handed out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:01:52 +05:30
co-authored by Claude Opus 5
parent 34d5bbc810
commit e0bd84247e
@@ -520,6 +520,20 @@ describe("defineService", () => {
).toThrow(/procedure name/i); ).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", () => { test("the builder is immutable — reusing a base does not cross-contaminate", () => {
const base = procedure.permission("a:read"); const base = procedure.permission("a:read");
const one = base.idempotent().build(); const one = base.idempotent().build();
@@ -609,7 +623,18 @@ export function defineService<Procedures extends AnyProcedures>(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<string, ProcedureDef> = {};
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,
});
} }
``` ```