diff --git a/packages/server/src/protocol.test.ts b/packages/server/src/protocol.test.ts new file mode 100644 index 0000000..dd8c066 --- /dev/null +++ b/packages/server/src/protocol.test.ts @@ -0,0 +1,505 @@ +import { describe, it, expect } from "vitest"; +import { + validateMessage, + validateMessageString, + PROTOCOL_VERSION, + ClientMessageSchema, + ServerMessageSchema, + type AnyMessage, + type ClientMessage, + type ServerMessage, +} from "./protocol.js"; + +// --------------------------------------------------------------------------- +// Fixtures — one minimally-valid example per message type. +// --------------------------------------------------------------------------- + +const UUID = "550e8400-e29b-41d4-a716-446655440000"; +const UUID2 = "661f9500-f30c-42e5-b827-557766550111"; + +const envelope = { v: PROTOCOL_VERSION, seq: 0, ts: 1 } as const; + +const fixtures: Record = { + "room.create": { + ...envelope, + type: "room.create", + payload: { rulesetIds: ["pawns-move-backward"] }, + }, + "room.create (empty payload)": { + ...envelope, + type: "room.create", + payload: {}, + }, + "room.join": { + ...envelope, + type: "room.join", + payload: { code: "ABC123" }, + }, + "room.leave": { + ...envelope, + type: "room.leave", + payload: {}, + }, + "game.move": { + ...envelope, + type: "game.move", + token: UUID, + payload: { from: "e2", to: "e4" }, + }, + "game.move (promotion)": { + ...envelope, + type: "game.move", + token: UUID, + payload: { from: "a7", to: "a8", promoteTo: "queen" }, + }, + "room.created": { + ...envelope, + seq: 1, + type: "room.created", + payload: { code: "ABC123", token: UUID, color: "white" }, + }, + "room.joined": { + ...envelope, + seq: 1, + type: "room.joined", + payload: { + code: "ABC123", + token: UUID2, + color: "black", + activeRules: ["pawns-move-backward"], + }, + }, + "game.state": { + ...envelope, + seq: 2, + type: "game.state", + payload: { + facts: [{ id: 1, attr: "PieceType", value: "pawn" }], + turn: "white", + lastSeq: 42, + moveHistory: ["e2-e4"], + activeRules: [], + fen: "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + }, + }, + "game.delta (ongoing)": { + ...envelope, + seq: 3, + type: "game.delta", + payload: { + inserted: [{ id: 1, attr: "Position", value: 28 }], + retracted: [{ id: 1, attr: "Position", value: 12 }], + moveNotation: "e2e4", + turn: "black", + gameOver: null, + }, + }, + "game.delta (game over)": { + ...envelope, + seq: 99, + type: "game.delta", + payload: { + inserted: [], + retracted: [], + moveNotation: "Qh4#", + turn: "black", + gameOver: { winner: "black", reason: "checkmate" }, + }, + }, + "game.end": { + ...envelope, + seq: 100, + type: "game.end", + payload: { + winner: "white", + reason: "checkmate", + finalFen: "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 3", + }, + }, + "error (non-fatal)": { + ...envelope, + seq: 5, + type: "error", + payload: { + code: "ILLEGAL_MOVE", + message: "Move e2-e5 is not legal", + fatal: false, + }, + }, + "error (fatal)": { + ...envelope, + seq: 6, + type: "error", + payload: { + code: "VERSION_MISMATCH", + message: "expected v=1", + fatal: true, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Round-trip: every fixture must JSON-serialise and re-validate identically. +// --------------------------------------------------------------------------- + +describe("validateMessage — round-trip", () => { + for (const [name, message] of Object.entries(fixtures)) { + it(`round-trips ${name}`, () => { + const encoded = JSON.stringify(message); + const decoded = JSON.parse(encoded) as unknown; + const result = validateMessage(decoded); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data).toEqual(message); + expect(result.data.type).toBe(message.type); + } + }); + } + + it("round-trips with an optional token present", () => { + const msg = { + ...envelope, + token: UUID, + type: "room.leave" as const, + payload: {}, + }; + const result = validateMessage(JSON.parse(JSON.stringify(msg))); + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.token).toBe(UUID); + }); +}); + +// --------------------------------------------------------------------------- +// Rejections +// --------------------------------------------------------------------------- + +describe("validateMessage — rejections", () => { + it("rejects non-object input (null)", () => { + const r = validateMessage(null); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/); + }); + + it("rejects non-object input (array)", () => { + const r = validateMessage([1, 2, 3]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/); + }); + + it("rejects non-object input (string)", () => { + const r = validateMessage("hello"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/); + }); + + it("rejects v=2 with VERSION_MISMATCH", () => { + const r = validateMessage({ + v: 2, + seq: 0, + ts: 1, + type: "room.create", + payload: {}, + }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toMatch(/VERSION_MISMATCH/); + expect(r.error).toContain("expected v=1"); + } + }); + + it("rejects missing v with VERSION_MISMATCH (v is undefined)", () => { + const r = validateMessage({ + seq: 0, + ts: 1, + type: "room.create", + payload: {}, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/VERSION_MISMATCH/); + }); + + it("rejects missing type", () => { + const r = validateMessage({ v: 1, seq: 0, ts: 1, payload: {} }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/INVALID_MESSAGE/); + }); + + it("rejects non-string type", () => { + const r = validateMessage({ + v: 1, + seq: 0, + ts: 1, + type: 42, + payload: {}, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/non-string `type`/); + }); + + it("rejects unknown message type", () => { + const r = validateMessage({ + v: 1, + seq: 0, + ts: 1, + type: "nope.nope", + payload: {}, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/unknown message type "nope.nope"/); + }); + + it("rejects game.move with bad `from` format", () => { + const r = validateMessage({ + ...envelope, + type: "game.move", + payload: { from: "e22", to: "e4" }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/from/); + }); + + it("rejects game.move with bad `to` file", () => { + const r = validateMessage({ + ...envelope, + type: "game.move", + payload: { from: "e2", to: "z9" }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects game.move with bad promotion piece", () => { + const r = validateMessage({ + ...envelope, + type: "game.move", + payload: { from: "a7", to: "a8", promoteTo: "king" }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/promoteTo/); + }); + + it("rejects room.join with 5-char code", () => { + const r = validateMessage({ + ...envelope, + type: "room.join", + payload: { code: "ABC12" }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects room.join with lowercase code", () => { + const r = validateMessage({ + ...envelope, + type: "room.join", + payload: { code: "abc123" }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects negative seq", () => { + const r = validateMessage({ + v: 1, + seq: -1, + ts: 1, + type: "room.leave", + payload: {}, + }); + expect(r.ok).toBe(false); + }); + + it("rejects non-integer seq", () => { + const r = validateMessage({ + v: 1, + seq: 1.5, + ts: 1, + type: "room.leave", + payload: {}, + }); + expect(r.ok).toBe(false); + }); + + it("rejects non-positive ts", () => { + const r = validateMessage({ + v: 1, + seq: 0, + ts: 0, + type: "room.leave", + payload: {}, + }); + expect(r.ok).toBe(false); + }); + + it("rejects token that is not a UUID", () => { + const r = validateMessage({ + ...envelope, + token: "not-a-uuid", + type: "room.leave", + payload: {}, + }); + expect(r.ok).toBe(false); + }); + + it("rejects room.created with non-UUID token", () => { + const r = validateMessage({ + ...envelope, + type: "room.created", + payload: { code: "ABC123", token: "nope", color: "white" }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects room.created with invalid color", () => { + const r = validateMessage({ + ...envelope, + type: "room.created", + payload: { code: "ABC123", token: UUID, color: "green" }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects game.state missing required field", () => { + const r = validateMessage({ + ...envelope, + type: "game.state", + payload: { + facts: [], + turn: "white", + lastSeq: 0, + moveHistory: [], + activeRules: [], + // fen missing + }, + }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/fen/); + }); + + it("rejects error payload with unknown error code", () => { + const r = validateMessage({ + ...envelope, + type: "error", + payload: { code: "UNKNOWN_CODE", message: "x", fatal: false }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects game.delta gameOver with invalid reason", () => { + const r = validateMessage({ + ...envelope, + type: "game.delta", + payload: { + inserted: [], + retracted: [], + moveNotation: "a", + turn: "white", + gameOver: { winner: "white", reason: "???" }, + }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects game.end with invalid winner", () => { + const r = validateMessage({ + ...envelope, + type: "game.end", + payload: { winner: "nobody", reason: "x", finalFen: "x" }, + }); + expect(r.ok).toBe(false); + }); + + it("rejects room.leave with extra payload keys (strict)", () => { + const r = validateMessage({ + ...envelope, + type: "room.leave", + payload: { extra: 1 }, + }); + expect(r.ok).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// validateMessageString — raw-string entry point +// --------------------------------------------------------------------------- + +describe("validateMessageString", () => { + it("parses a valid JSON string frame", () => { + const msg: ClientMessage = { + ...envelope, + type: "room.create", + payload: {}, + }; + const r = validateMessageString(JSON.stringify(msg)); + expect(r.ok).toBe(true); + }); + + it("reports malformed JSON as INVALID_MESSAGE", () => { + const r = validateMessageString("{ this is not json"); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error).toMatch(/INVALID_MESSAGE/); + expect(r.error).toMatch(/malformed JSON/); + } + }); + + it("propagates version mismatch through the string entry point", () => { + const r = validateMessageString( + JSON.stringify({ v: 99, seq: 0, ts: 1, type: "room.leave", payload: {} }), + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/VERSION_MISMATCH/); + }); +}); + +// --------------------------------------------------------------------------- +// Type-narrowing smoke test — ensures discriminated unions are usable. +// (Executed at runtime, but the value comes from the union's narrowing.) +// --------------------------------------------------------------------------- + +describe("schema unions", () => { + it("ClientMessageSchema parses client-originating types only", () => { + const ok = ClientMessageSchema.safeParse({ + ...envelope, + type: "game.move", + payload: { from: "e2", to: "e4" }, + }); + expect(ok.success).toBe(true); + + const bad = ClientMessageSchema.safeParse({ + ...envelope, + type: "game.end", + payload: { winner: "white", reason: "x", finalFen: "x" }, + }); + expect(bad.success).toBe(false); + }); + + it("ServerMessageSchema parses server-originating types only", () => { + const okMsg: ServerMessage = { + ...envelope, + type: "error", + payload: { code: "RATE_LIMIT", message: "too fast", fatal: true }, + }; + const good = ServerMessageSchema.safeParse(okMsg); + expect(good.success).toBe(true); + + const bad = ServerMessageSchema.safeParse({ + ...envelope, + type: "room.create", + payload: {}, + }); + expect(bad.success).toBe(false); + }); + + it("narrows AnyMessage by `type` discriminant", () => { + const r = validateMessage({ + ...envelope, + type: "game.move", + payload: { from: "e2", to: "e4" }, + }); + expect(r.ok).toBe(true); + if (r.ok && r.data.type === "game.move") { + // TS narrowing should let us read these fields without casts. + expect(r.data.payload.from).toBe("e2"); + expect(r.data.payload.to).toBe("e4"); + } + }); +}); diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts new file mode 100644 index 0000000..de6dfa5 --- /dev/null +++ b/packages/server/src/protocol.ts @@ -0,0 +1,333 @@ +// Chess server WebSocket protocol v1 — Zod schemas & validation. +// See PROTOCOL.md for the full spec. +import { z } from "zod"; + +// --------------------------------------------------------------------------- +// Primitives +// --------------------------------------------------------------------------- + +export const PROTOCOL_VERSION = 1 as const; + +export const ColorSchema = z.enum(["white", "black"]); +export type Color = z.infer; + +export const SquareSchema = z + .string() + .regex(/^[a-h][1-8]$/, "square must be algebraic notation a1..h8"); +export type Square = z.infer; + +export const PromotionPieceSchema = z.enum([ + "queen", + "rook", + "bishop", + "knight", +]); +export type PromotionPiece = z.infer; + +export const RoomCodeSchema = z + .string() + .length(6) + .regex(/^[A-Z0-9]{6}$/, "room code must be 6 uppercase [A-Z0-9]"); +export type RoomCode = z.infer; + +export const FactSchema = z.object({ + id: z.number().int(), + attr: z.string(), + value: z.unknown(), +}); +export type Fact = z.infer; + +export const ErrorCodeSchema = z.enum([ + "ILLEGAL_MOVE", + "NOT_YOUR_TURN", + "GAME_OVER", + "ROOM_NOT_FOUND", + "ROOM_FULL", + "SERVER_FULL", + "VERSION_MISMATCH", + "RATE_LIMIT", + "MSG_TOO_LARGE", + "BAD_TOKEN", + "INVALID_MESSAGE", +]); +export type ErrorCode = z.infer; + +export const GameEndReasonSchema = z.enum([ + "checkmate", + "stalemate", + "50-move", + "threefold", + "insufficient", + "player_left", +]); +export type GameEndReason = z.infer; + +export const WinnerSchema = z.enum(["white", "black", "draw"]); +export type Winner = z.infer; + +// --------------------------------------------------------------------------- +// Envelope (common wrapper for every message) +// --------------------------------------------------------------------------- + +const envelopeShape = { + v: z.literal(PROTOCOL_VERSION), + seq: z.number().int().nonnegative(), + ts: z.number().int().positive(), + token: z.string().uuid().optional(), +} as const; + +// A permissive envelope-only parser used to inspect `v` and `type` before +// choosing a discriminated-union branch. Payload is unknown at this layer. +export const EnvelopeSchema = z.object({ + ...envelopeShape, + type: z.string(), + payload: z.unknown(), +}); +export type Envelope = z.infer; + +// --------------------------------------------------------------------------- +// Client → Server payloads +// --------------------------------------------------------------------------- + +export const RoomCreatePayloadSchema = z.object({ + rulesetIds: z.array(z.string()).optional(), +}); +export type RoomCreatePayload = z.infer; + +export const RoomJoinPayloadSchema = z.object({ + code: RoomCodeSchema, +}); +export type RoomJoinPayload = z.infer; + +export const RoomLeavePayloadSchema = z.object({}).strict(); +export type RoomLeavePayload = z.infer; + +export const GameMovePayloadSchema = z.object({ + from: SquareSchema, + to: SquareSchema, + promoteTo: PromotionPieceSchema.optional(), +}); +export type GameMovePayload = z.infer; + +// --------------------------------------------------------------------------- +// Server → Client payloads +// --------------------------------------------------------------------------- + +export const RoomCreatedPayloadSchema = z.object({ + code: RoomCodeSchema, + token: z.string().uuid(), + color: ColorSchema, +}); +export type RoomCreatedPayload = z.infer; + +export const RoomJoinedPayloadSchema = z.object({ + code: RoomCodeSchema, + token: z.string().uuid(), + color: ColorSchema, + activeRules: z.array(z.string()), +}); +export type RoomJoinedPayload = z.infer; + +export const GameStatePayloadSchema = z.object({ + facts: z.array(FactSchema), + turn: ColorSchema, + lastSeq: z.number().int().nonnegative(), + moveHistory: z.array(z.string()), + activeRules: z.array(z.string()), + fen: z.string(), +}); +export type GameStatePayload = z.infer; + +export const GameOverSchema = z.object({ + winner: WinnerSchema, + reason: GameEndReasonSchema, +}); +export type GameOver = z.infer; + +export const GameDeltaPayloadSchema = z.object({ + inserted: z.array(FactSchema), + retracted: z.array(FactSchema), + moveNotation: z.string(), + turn: ColorSchema, + gameOver: GameOverSchema.nullable(), +}); +export type GameDeltaPayload = z.infer; + +export const GameEndPayloadSchema = z.object({ + winner: WinnerSchema, + reason: z.string(), + finalFen: z.string(), +}); +export type GameEndPayload = z.infer; + +export const ErrorPayloadSchema = z.object({ + code: ErrorCodeSchema, + message: z.string(), + fatal: z.boolean(), +}); +export type ErrorPayload = z.infer; + +// --------------------------------------------------------------------------- +// Discriminated union of full messages (envelope + typed payload) +// --------------------------------------------------------------------------- + +const msg = ( + type: Type, + payload: P, +) => + z.object({ + ...envelopeShape, + type: z.literal(type), + payload, + }); + +export const RoomCreateMessageSchema = msg( + "room.create", + RoomCreatePayloadSchema, +); +export const RoomJoinMessageSchema = msg("room.join", RoomJoinPayloadSchema); +export const RoomLeaveMessageSchema = msg("room.leave", RoomLeavePayloadSchema); +export const GameMoveMessageSchema = msg("game.move", GameMovePayloadSchema); + +export const ClientMessageSchema = z.discriminatedUnion("type", [ + RoomCreateMessageSchema, + RoomJoinMessageSchema, + RoomLeaveMessageSchema, + GameMoveMessageSchema, +]); +export type ClientMessage = z.infer; + +export const RoomCreatedMessageSchema = msg( + "room.created", + RoomCreatedPayloadSchema, +); +export const RoomJoinedMessageSchema = msg( + "room.joined", + RoomJoinedPayloadSchema, +); +export const GameStateMessageSchema = msg("game.state", GameStatePayloadSchema); +export const GameDeltaMessageSchema = msg("game.delta", GameDeltaPayloadSchema); +export const GameEndMessageSchema = msg("game.end", GameEndPayloadSchema); +export const ErrorMessageSchema = msg("error", ErrorPayloadSchema); + +export const ServerMessageSchema = z.discriminatedUnion("type", [ + RoomCreatedMessageSchema, + RoomJoinedMessageSchema, + GameStateMessageSchema, + GameDeltaMessageSchema, + GameEndMessageSchema, + ErrorMessageSchema, +]); +export type ServerMessage = z.infer; + +export const AnyMessageSchema = z.discriminatedUnion("type", [ + RoomCreateMessageSchema, + RoomJoinMessageSchema, + RoomLeaveMessageSchema, + GameMoveMessageSchema, + RoomCreatedMessageSchema, + RoomJoinedMessageSchema, + GameStateMessageSchema, + GameDeltaMessageSchema, + GameEndMessageSchema, + ErrorMessageSchema, +]); +export type AnyMessage = z.infer; + +export const KNOWN_MESSAGE_TYPES = [ + "room.create", + "room.join", + "room.leave", + "game.move", + "room.created", + "room.joined", + "game.state", + "game.delta", + "game.end", + "error", +] as const; +export type MessageType = (typeof KNOWN_MESSAGE_TYPES)[number]; + +// --------------------------------------------------------------------------- +// validateMessage — Result-style entry point +// --------------------------------------------------------------------------- + +export type Result = { ok: true; data: T } | { ok: false; error: E }; + +const ok = (data: T): Result => ({ ok: true, data }); +const err = (error: E): Result => ({ ok: false, error }); + +/** + * Validate a decoded JSON value against the protocol. + * + * The input is `unknown` — callers that start from a raw string MUST + * `JSON.parse` first (and catch its throw) before handing a value here. + * + * On success returns `{ ok: true, data }` with the fully-typed, narrowed + * message. On failure returns `{ ok: false, error }` with a descriptive + * string. Version mismatches are surfaced with a `VERSION_MISMATCH:` prefix + * so callers can disconnect fatally without re-parsing. + */ +export function validateMessage(raw: unknown): Result { + // 1. Shape-check the envelope first so we can give precise errors about + // protocol-version mismatch vs. structural problems. + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return err("INVALID_MESSAGE: message must be a JSON object"); + } + + const envelope = raw as Record; + + // 2. Version gate — anything other than v===1 is VERSION_MISMATCH, even + // before we look at `type`. Spec: version mismatch = fatal disconnect. + if (envelope["v"] !== PROTOCOL_VERSION) { + return err( + `VERSION_MISMATCH: expected v=${String(PROTOCOL_VERSION)}, got v=${String( + envelope["v"], + )}`, + ); + } + + // 3. Type gate — must be a known literal before we dispatch to the union. + const type = envelope["type"]; + if (typeof type !== "string") { + return err("INVALID_MESSAGE: missing or non-string `type` field"); + } + if (!(KNOWN_MESSAGE_TYPES as readonly string[]).includes(type)) { + return err(`INVALID_MESSAGE: unknown message type "${type}"`); + } + + // 4. Delegate to the discriminated union for full structural validation. + const parsed = AnyMessageSchema.safeParse(raw); + if (!parsed.success) { + return err(`INVALID_MESSAGE: ${formatZodError(parsed.error)}`); + } + return ok(parsed.data); +} + +/** + * Convenience: parse a raw WebSocket string frame. Handles the JSON.parse + * throw and funnels it into the same Result shape as `validateMessage`. + */ +export function validateMessageString( + raw: string, +): Result { + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return err(`INVALID_MESSAGE: malformed JSON (${msg})`); + } + return validateMessage(decoded); +} + +function formatZodError(error: z.ZodError): string { + // Collapse issues into a compact single-line description. Keeping this + // deterministic is useful for tests and log greppability. + return error.issues + .map((issue) => { + const path = issue.path.length > 0 ? issue.path.join(".") : ""; + return `${path}: ${issue.message}`; + }) + .join("; "); +}