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:
@@ -520,6 +520,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();
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user