feat(server): custom-modifier.register WS handler

T3 Wave 4 (T24). Adds wire protocol + server handler for registering
user-authored custom modifier descriptors on a room's per-engine
custom registry (ADR-4).

Protocol:
- CustomModifierDescriptorSchema (Zod v3 mirror of the chess-side
  schema; structural validation only — primitive-kind semantics stay
  client-side to avoid duplicating the primitive catalog across the
  zod v3/v4 boundary).
- 'custom-modifier.register' client message carrying { roomCode,
  descriptor }.
- 'custom-modifier.registered' server broadcast mirroring the same
  shape out to every connected client in the room.
- New error codes: CUSTOM_MODIFIER_INVALID, CUSTOM_MODIFIER_LIMIT.

Handler:
- Auth + room-match checks matching the rest of the WS surface.
- Lazy per-room Map<id, CustomModifierDescriptorWire> created on first
  register.
- Capacity cap at 10 distinct ids per room (T3 DoS guard). Re-register
  with the same id REPLACES without consuming a new slot.
- On success: broadcasts to every connected client (proposer included
  — confirms server-authoritative state).

6 integration tests (mock ServerWebSocket harness; same pattern as
ws.modifier-profile-update.test.ts): happy-path accept+broadcast,
same-id replace, 11th-distinct rejection, roomCode mismatch, no-auth
rejection, malformed descriptor (schema layer). All green; 1369 unit
tests pass. Engine-side wire-up (late-joiner mirroring, client client.ts
subscriber) deferred to T25/T26 UI work.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 20:01:11 -06:00
commit 109be25be6
No known key found for this signature in database
4 changed files with 483 additions and 1 deletions

View file

@ -29,6 +29,7 @@ import { RateLimiter } from "./middleware.js";
import {
PROTOCOL_VERSION,
validateMessageString,
type CustomModifierRegisterPayload,
type ErrorCode,
type Fact as WireFact,
type GameMovePayload,
@ -273,6 +274,9 @@ export function handleMessage(
case "modifier-profile.consent":
handleModifierProfileConsent(ws, msg.payload);
break;
case "custom-modifier.register":
handleCustomModifierRegister(ws, msg.payload);
break;
case "room.created":
case "room.joined":
case "game.state":
@ -284,6 +288,7 @@ export function handleMessage(
case "modifier-profile.proposal-pending":
case "modifier-profile.rejected":
case "modifier-profile.consent-received":
case "custom-modifier.registered":
case "error":
sendTo(
ws,
@ -1451,6 +1456,94 @@ function bufferDeltaForDisconnected(
}
}
// ---------------------------------------------------------------------------
// T3 — custom-modifier.register handler
// ---------------------------------------------------------------------------
/** Maximum custom modifier descriptors per room (T3 DoS guard). */
const CUSTOM_MODIFIER_ROOM_CAP = 10;
/**
* Register a user-authored custom modifier descriptor on this room.
* Structural validation already happened via `validateMessageString`
* before dispatch — here we only enforce the per-room capacity,
* store into the room's `customModifiers` map (creating it on first
* call), and broadcast to every connected client so opponents'
* engines mirror the same custom registry.
*
* Updates are idempotent keyed on descriptor id: re-registering with
* the same id REPLACES the existing entry and broadcasts the new
* shape; the capacity check counts distinct ids only.
*/
function handleCustomModifierRegister(
ws: ServerWebSocket<ClientData>,
payload: CustomModifierRegisterPayload,
): void {
const { roomCode, token } = ws.data;
if (roomCode === undefined || token === undefined) {
sendTo(
ws,
errorMessage("BAD_TOKEN", "not authenticated into a room", false),
);
return;
}
if (payload.roomCode !== roomCode) {
sendTo(
ws,
errorMessage(
"BAD_TOKEN",
"custom-modifier.register roomCode does not match authenticated room",
false,
),
);
return;
}
const room = roomRegistry.getRoom(roomCode);
if (!room) {
sendTo(
ws,
errorMessage("ROOM_NOT_FOUND", `room ${roomCode} not found`, false),
);
return;
}
// Lazily create the per-room custom registry.
if (room.customModifiers === undefined) {
room.customModifiers = new Map();
}
const existing = room.customModifiers.has(payload.descriptor.id);
if (!existing && room.customModifiers.size >= CUSTOM_MODIFIER_ROOM_CAP) {
sendTo(
ws,
errorMessage(
"CUSTOM_MODIFIER_LIMIT",
`custom modifier cap (${CUSTOM_MODIFIER_ROOM_CAP}) reached for room ${roomCode}`,
false,
),
);
return;
}
room.customModifiers.set(payload.descriptor.id, payload.descriptor);
logger
.child({ roomCode, clientId: ws.data.clientId })
.info({ descriptorId: payload.descriptor.id }, "custom-modifier.register");
// Broadcast to every client (proposer included — confirms the
// server-authoritative state matches what was sent).
broadcastToRoom(
roomCode,
envelope("custom-modifier.registered", {
roomCode,
descriptor: payload.descriptor,
}),
);
}
/**
* Broadcast a game.end to everyone in `code` EXCEPT the player whose
* token is `leaverToken`. Used when a player disconnects or leaves

View file

@ -63,6 +63,9 @@ export const ErrorCodeSchema = z.enum([
"MODIFIER_PROFILE_NO_KING",
"MODIFIER_PROFILE_INVULN_KING",
"MODIFIER_PROFILE_DEADLOCK",
// T3 custom modifier registration failures.
"CUSTOM_MODIFIER_INVALID",
"CUSTOM_MODIFIER_LIMIT",
]);
export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
@ -73,6 +76,8 @@ export const MODIFIER_PROFILE_INVALID = "MODIFIER_PROFILE_INVALID" as const;
export const MODIFIER_PROFILE_NO_KING = "MODIFIER_PROFILE_NO_KING" as const;
export const MODIFIER_PROFILE_INVULN_KING = "MODIFIER_PROFILE_INVULN_KING" as const;
export const MODIFIER_PROFILE_DEADLOCK = "MODIFIER_PROFILE_DEADLOCK" as const;
export const CUSTOM_MODIFIER_INVALID = "CUSTOM_MODIFIER_INVALID" as const;
export const CUSTOM_MODIFIER_LIMIT = "CUSTOM_MODIFIER_LIMIT" as const;
export const GameEndReasonSchema = z.enum([
"checkmate",
@ -453,6 +458,87 @@ export type ModifierProfileConsentReceivedPayload = z.infer<
typeof ModifierProfileConsentReceivedPayloadSchema
>;
// ---------------------------------------------------------------------------
// T3 — Custom modifier descriptor registration
// ---------------------------------------------------------------------------
//
// Users compose custom modifier descriptors from the 15 engine-shipped
// effect primitives (T3-ADR-2). Each descriptor is a structured JSON
// document that travels over the wire as-is and registers into the
// room's per-engine custom registry (T3-ADR-4) — never globally, so
// two rooms can own descriptors with the same id without collision.
//
// The server performs STRUCTURAL validation here (Zod shape + count
// caps). Semantic validation (primitive-kind-in-registry, paramsSchema
// satisfaction, recursion depth ≤ 3) happens on the client via
// `validateCustomDescriptor` before send — rejecting malformed
// descriptors server-side would require mirroring the primitive
// catalog across the Zod v3/v4 boundary and isn't worth the duplication.
// Clients that skip their own validation get the engine's degradation
// behaviour: unknown primitives are silently skipped at apply time.
const EffectPrimitiveNodeWireSchema = z.lazy(() =>
z.object({
kind: z.string().min(1),
// z.unknown() is used rather than z.any() for parity with the client-side
// schema; Zod v3 infers `unknown` fields as optional at the type level,
// but runtime parsing accepts any value (including undefined).
params: z.unknown(),
}),
);
/** Wire shape for a user-authored custom modifier descriptor. */
export const CustomModifierDescriptorSchema = z.object({
type: z.literal("data"),
id: z.string().min(1),
name: z.string().min(1).max(40),
description: z.string().max(200),
version: z.literal(1),
// DoS guard: cap the primitive array length. Deep nesting is the
// client validator's responsibility — the server sees flat arrays
// because nested children are inside `params`.
primitives: z.array(EffectPrimitiveNodeWireSchema).max(50),
targetAttrs: z.array(z.string()).max(32),
uiForm: z.literal("primitive-composer"),
source: z.literal("custom"),
author: z.string().max(80).optional(),
createdAt: z.number().int().nonnegative().optional(),
});
export type CustomModifierDescriptorWire = z.infer<
typeof CustomModifierDescriptorSchema
>;
/**
* Client → server: register a custom modifier descriptor on this
* room's per-engine custom registry. Server validates structurally
* and rejects with `CUSTOM_MODIFIER_INVALID` on shape failure or
* `CUSTOM_MODIFIER_LIMIT` when the room is at capacity (10 custom
* descriptors). On success broadcasts `custom-modifier.registered`
* to every connected client in the room so opponents' engines
* mirror the same custom registry.
*/
export const CustomModifierRegisterPayloadSchema = z.object({
roomCode: RoomCodeSchema,
descriptor: CustomModifierDescriptorSchema,
});
export type CustomModifierRegisterPayload = z.infer<
typeof CustomModifierRegisterPayloadSchema
>;
/**
* Server → all clients in room: a new custom modifier descriptor is
* available for reference in subsequent ModifierProfile entries.
* Clients register the descriptor into their local engine's
* `customModifiers` registry on receipt.
*/
export const CustomModifierRegisteredPayloadSchema = z.object({
roomCode: RoomCodeSchema,
descriptor: CustomModifierDescriptorSchema,
});
export type CustomModifierRegisteredPayload = z.infer<
typeof CustomModifierRegisteredPayloadSchema
>;
export const RoomJoinPayloadSchema = z.object({
code: RoomCodeSchema,
});
@ -631,6 +717,10 @@ export const ModifierProfileConsentMessageSchema = msg(
"modifier-profile.consent",
ModifierProfileConsentPayloadSchema,
);
export const CustomModifierRegisterMessageSchema = msg(
"custom-modifier.register",
CustomModifierRegisterPayloadSchema,
);
export const ClientMessageSchema = z.discriminatedUnion("type", [
RoomCreateMessageSchema,
@ -641,6 +731,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [
ModifierProfileUpdateMessageSchema,
ModifierProfileProposeMessageSchema,
ModifierProfileConsentMessageSchema,
CustomModifierRegisterMessageSchema,
]);
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
@ -679,6 +770,10 @@ export const ModifierProfileConsentReceivedMessageSchema = msg(
"modifier-profile.consent-received",
ModifierProfileConsentReceivedPayloadSchema,
);
export const CustomModifierRegisteredMessageSchema = msg(
"custom-modifier.registered",
CustomModifierRegisteredPayloadSchema,
);
export const ErrorMessageSchema = msg("error", ErrorPayloadSchema);
export const ServerMessageSchema = z.discriminatedUnion("type", [
@ -693,6 +788,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
ModifierProfileProposalPendingMessageSchema,
ModifierProfileRejectedMessageSchema,
ModifierProfileConsentReceivedMessageSchema,
CustomModifierRegisteredMessageSchema,
ErrorMessageSchema,
]);
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
@ -706,6 +802,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
ModifierProfileUpdateMessageSchema,
ModifierProfileProposeMessageSchema,
ModifierProfileConsentMessageSchema,
CustomModifierRegisterMessageSchema,
RoomCreatedMessageSchema,
RoomJoinedMessageSchema,
GameStateMessageSchema,
@ -717,6 +814,7 @@ export const AnyMessageSchema = z.discriminatedUnion("type", [
ModifierProfileProposalPendingMessageSchema,
ModifierProfileRejectedMessageSchema,
ModifierProfileConsentReceivedMessageSchema,
CustomModifierRegisteredMessageSchema,
ErrorMessageSchema,
]);
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
@ -730,6 +828,7 @@ export const KNOWN_MESSAGE_TYPES = [
"modifier-profile.update",
"modifier-profile.propose",
"modifier-profile.consent",
"custom-modifier.register",
"room.created",
"room.joined",
"game.state",
@ -741,6 +840,7 @@ export const KNOWN_MESSAGE_TYPES = [
"modifier-profile.proposal-pending",
"modifier-profile.rejected",
"modifier-profile.consent-received",
"custom-modifier.registered",
"error",
] as const;
export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number];

View file

@ -4,7 +4,10 @@
// six-character upper-case alphanumeric strings ([A-Z0-9]) drawn from a CSPRNG.
// Nothing here persists across restarts — PROTOCOL.md §Auth & Rooms mandates
// in-memory-only storage.
import type { Color } from "./protocol.js";
import type {
Color,
CustomModifierDescriptorWire,
} from "./protocol.js";
import {
CLASSIC_LAYOUT,
type ModifierProfile,
@ -44,6 +47,14 @@ export interface Room {
* and the server replays it onto reconstructed engines.
*/
profile?: ModifierProfile;
/**
* T3: user-authored custom modifier descriptors registered into
* this room via `custom-modifier.register`. Keyed by descriptor id.
* Scoped to the room so per-room isolation (ADR-4) holds across
* server restarts — a room that dies and is re-created is a new
* room, not a resumption. Capped at 10 entries (T3 DoS guard).
*/
customModifiers?: Map<string, CustomModifierDescriptorWire>;
/**
* Queued profile swap awaiting the next turn boundary (T2-ADR-1).
*

View file

@ -0,0 +1,278 @@
/**
* T3 integration tests: `custom-modifier.register` WebSocket handler.
*
* Covers:
* 1. Valid register → room customModifiers populated + broadcast fires.
* 2. Re-register (same id) → replace in place, still broadcasts.
* 3. 11th distinct descriptor → CUSTOM_MODIFIER_LIMIT rejection.
* 4. Room mismatch → BAD_TOKEN.
* 5. No auth → BAD_TOKEN.
* 6. Unknown room → ROOM_NOT_FOUND.
* 7. Malformed descriptor (validator catches it at schema layer).
*/
import type { ServerWebSocket } from "bun";
import { describe, expect, it } from "vitest";
import {
handleMessage,
registerConnection,
roomRegistry,
type ClientData,
} from "./broadcast.js";
import {
PROTOCOL_VERSION,
type ClientMessage,
type CustomModifierDescriptorWire,
} from "./protocol.js";
// ─── Mock ServerWebSocket (same harness as other WS tests) ───────────
interface MockWs extends ServerWebSocket<ClientData> {
readonly sent: unknown[];
readonly closed: boolean;
}
function makeMockWs(clientId: string): MockWs {
const sent: unknown[] = [];
const closedFlag = { value: false };
const ws = {
data: { clientId } as ClientData,
sent,
get closed(): boolean {
return closedFlag.value;
},
send(msg: string | Buffer): number {
const str = typeof msg === "string" ? msg : msg.toString("utf8");
sent.push(JSON.parse(str));
return str.length;
},
close(): void {
closedFlag.value = true;
},
} as unknown as MockWs;
return ws;
}
function nextMsgOfType(
ws: MockWs,
type: string,
): { payload: Record<string, unknown> } {
const idx = ws.sent.findIndex(
(m) =>
typeof m === "object" &&
m !== null &&
(m as { type: unknown }).type === type,
);
if (idx < 0) {
const types = ws.sent.map((m) => (m as { type?: string }).type);
throw new Error(
`no message of type "${type}" in inbox (got ${JSON.stringify(types)})`,
);
}
const msg = ws.sent[idx] as { payload: Record<string, unknown> };
ws.sent.splice(idx, 1);
return msg;
}
function sendClient(
ws: MockWs,
type: ClientMessage["type"],
payload: unknown,
seq = 1,
): void {
handleMessage(
ws,
JSON.stringify({
v: PROTOCOL_VERSION,
seq,
ts: Date.now(),
type,
payload,
}),
);
}
// ─── Descriptor fixtures ─────────────────────────────────────────────
function makeDescriptor(
id: string,
overrides: Partial<CustomModifierDescriptorWire> = {},
): CustomModifierDescriptorWire {
return {
type: "data",
id,
name: id,
description: "",
version: 1,
primitives: [
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } },
],
targetAttrs: ["HpBonus"],
uiForm: "primitive-composer",
source: "custom",
...overrides,
};
}
function setupRoom(): { white: MockWs; black: MockWs; code: string } {
const white = makeMockWs(`T3CM-W-${crypto.randomUUID()}`);
const black = makeMockWs(`T3CM-B-${crypto.randomUUID()}`);
registerConnection(white);
registerConnection(black);
sendClient(white, "room.create", {});
const created = nextMsgOfType(white, "room.created");
const code = created.payload["code"] as string;
sendClient(black, "room.join", { code });
nextMsgOfType(black, "room.joined");
// Drain any game.state broadcasts from both inboxes so subsequent
// assertions only see custom-modifier messages.
while (
white.sent.some(
(m) => (m as { type?: string }).type === "game.state",
)
) {
nextMsgOfType(white, "game.state");
}
while (
black.sent.some(
(m) => (m as { type?: string }).type === "game.state",
)
) {
nextMsgOfType(black, "game.state");
}
return { white, black, code };
}
// ─── Tests ───────────────────────────────────────────────────────────
describe("custom-modifier.register WS handler (T24)", () => {
it("accepts a valid descriptor and broadcasts to both clients", () => {
const { white, black, code } = setupRoom();
const desc = makeDescriptor("custom:t3-accept");
sendClient(white, "custom-modifier.register", {
roomCode: code,
descriptor: desc,
});
// Both receive the broadcast.
const wMsg = nextMsgOfType(white, "custom-modifier.registered");
expect(wMsg.payload["roomCode"]).toBe(code);
expect((wMsg.payload["descriptor"] as { id: string }).id).toBe(
"custom:t3-accept",
);
const bMsg = nextMsgOfType(black, "custom-modifier.registered");
expect((bMsg.payload["descriptor"] as { id: string }).id).toBe(
"custom:t3-accept",
);
// Room state reflects the registration.
const room = roomRegistry.getRoom(code);
expect(room?.customModifiers?.get("custom:t3-accept")?.id).toBe(
"custom:t3-accept",
);
});
it("re-register with same id replaces the stored descriptor", () => {
const { white, black, code } = setupRoom();
const v1 = makeDescriptor("custom:t3-replace", { name: "v1" });
const v2 = makeDescriptor("custom:t3-replace", { name: "v2" });
sendClient(white, "custom-modifier.register", {
roomCode: code,
descriptor: v1,
});
nextMsgOfType(white, "custom-modifier.registered");
nextMsgOfType(black, "custom-modifier.registered");
sendClient(white, "custom-modifier.register", {
roomCode: code,
descriptor: v2,
});
const latest = nextMsgOfType(black, "custom-modifier.registered");
expect((latest.payload["descriptor"] as { name: string }).name).toBe("v2");
const room = roomRegistry.getRoom(code);
expect(room?.customModifiers?.size).toBe(1);
expect(room?.customModifiers?.get("custom:t3-replace")?.name).toBe("v2");
});
it("rejects the 11th distinct descriptor with CUSTOM_MODIFIER_LIMIT", () => {
const { white, code } = setupRoom();
for (let i = 0; i < 10; i += 1) {
sendClient(white, "custom-modifier.register", {
roomCode: code,
descriptor: makeDescriptor(`custom:t3-bulk-${i}`),
});
nextMsgOfType(white, "custom-modifier.registered");
}
sendClient(white, "custom-modifier.register", {
roomCode: code,
descriptor: makeDescriptor("custom:t3-overflow"),
});
const err = nextMsgOfType(white, "error");
expect(err.payload["code"]).toBe("CUSTOM_MODIFIER_LIMIT");
const room = roomRegistry.getRoom(code);
expect(room?.customModifiers?.size).toBe(10);
expect(room?.customModifiers?.has("custom:t3-overflow")).toBe(false);
});
it("rejects roomCode mismatch with BAD_TOKEN", () => {
const { white, code } = setupRoom();
sendClient(white, "custom-modifier.register", {
roomCode: "ZZZZZZ", // doesn't match authenticated room
descriptor: makeDescriptor("custom:t3-wrong-room"),
});
const err = nextMsgOfType(white, "error");
expect(err.payload["code"]).toBe("BAD_TOKEN");
const room = roomRegistry.getRoom(code);
expect(room?.customModifiers?.size ?? 0).toBe(0);
});
it("rejects unauthenticated sockets with BAD_TOKEN", () => {
const stranger = makeMockWs(`T3CM-stranger-${crypto.randomUUID()}`);
registerConnection(stranger);
sendClient(stranger, "custom-modifier.register", {
roomCode: "ABCDEF",
descriptor: makeDescriptor("custom:t3-stranger"),
});
const err = nextMsgOfType(stranger, "error");
expect(err.payload["code"]).toBe("BAD_TOKEN");
});
it("rejects malformed descriptor at the schema layer (INVALID_MESSAGE)", () => {
const { white, code } = setupRoom();
// Missing required `primitives` field.
const malformed = {
type: "data",
id: "custom:t3-malformed",
name: "Bad",
description: "",
version: 1,
// primitives: missing
targetAttrs: [],
uiForm: "primitive-composer",
source: "custom",
};
sendClient(white, "custom-modifier.register", {
roomCode: code,
descriptor: malformed,
});
const err = nextMsgOfType(white, "error");
expect(err.payload["code"]).toBe("INVALID_MESSAGE");
});
});