fix(modifiers): close client schema/server schema drift + add parity test (Q4.2)

The T3 audit flagged the Zod v3 / v4 hand-mirrored schemas as a
silent-drift risk: a bug where the client accepts what the server
rejects (or vice versa) would silently degrade gameplay rather than
fail loudly.

New cross-package parity test at
packages/server/src/custom-modifier-wire-parity.test.ts imports BOTH
schemas and asserts they agree on an accept/reject matrix of 17 cases
(valid minimal, valid rich, valid optional-field combinations + 12
rejection cases covering type/version/name/description/uiForm/source
literals, bounds, empty id, oversized primitives/description, and
primitive-node empty-kind). A final round-trip case parses on the
client, JSON-serializes, and parses on the server — catching
stringification edge cases too.

First run surfaced a real drift: the client schema was missing the
primitives.max(50) cap that the server schema enforces. A malicious
or buggy client could construct an oversized descriptor, get past
local validation, then hit the server's rejection. Fixed by adding
matching caps (primitives.max(50) + targetAttrs.max(32) +
author.max(80)) to the client schema.

Barrel export: chess/src/index.ts now re-exports
CustomModifierDescriptorSchema + EffectPrimitiveNodeSchema + the
parse/serialize helpers so the server parity test can import them
without reaching into subpaths.

1400 → 1417 unit tests.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-20 17:53:22 -06:00
commit abc5c863fd
No known key found for this signature in database
3 changed files with 193 additions and 3 deletions

View file

@ -87,3 +87,15 @@ export type {
// new ModifierProfile to a live session at a turn boundary without
// reaching into engine internals.
export { reconcileProfileSwap } from "./modifiers/reconcile.js";
// T3 custom modifier schema + parser. Exported so the server
// package can run the cross-package wire-shape parity test (Q4.2)
// — it imports both the v4 client schema and the v3 server schema
// and asserts they agree on the accept/reject matrix.
export {
CustomModifierDescriptorSchema,
EffectPrimitiveNodeSchema,
parseCustomModifierDescriptor,
safeParseCustomModifierDescriptor,
serializeCustomModifierDescriptor,
} from "./modifiers/custom/schema.js";

View file

@ -53,11 +53,15 @@ export const CustomModifierDescriptorSchema = z.object({
name: z.string().min(1).max(40),
description: z.string().max(200),
version: z.literal(1),
primitives: z.array(EffectPrimitiveNodeSchema),
targetAttrs: z.array(z.string()),
// Cap matches the server-side wire schema's .max(50) and the
// validator's MAX_PRIMITIVE_COUNT. Drift between the three caps
// is caught by the cross-package parity test (Q4.2).
primitives: z.array(EffectPrimitiveNodeSchema).max(50),
// Also bounded for parity with the server schema.
targetAttrs: z.array(z.string()).max(32),
uiForm: z.literal("primitive-composer"),
source: z.literal("custom"),
author: z.string().optional(),
author: z.string().max(80).optional(),
createdAt: z.number().int().nonnegative().optional(),
});