diff --git a/packages/server/src/broadcast.ts b/packages/server/src/broadcast.ts index 0a5665a..d8412c4 100644 --- a/packages/server/src/broadcast.ts +++ b/packages/server/src/broadcast.ts @@ -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, + 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 diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 7f5dfc2..d9b97f6 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -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; @@ -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; @@ -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; @@ -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; @@ -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]; diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index b8dbbe9..0b7655c 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -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; /** * Queued profile swap awaiting the next turn boundary (T2-ADR-1). * diff --git a/packages/server/src/ws.custom-modifier-register.test.ts b/packages/server/src/ws.custom-modifier-register.test.ts new file mode 100644 index 0000000..86c2f02 --- /dev/null +++ b/packages/server/src/ws.custom-modifier-register.test.ts @@ -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 { + 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 } { + 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 }; + 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 { + 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"); + }); +});