feat(server): add protocol schemas + validation (P4.2)

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 17:07:21 -06:00
commit 817b4d95f3
No known key found for this signature in database
2 changed files with 838 additions and 0 deletions

View file

@ -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<string, AnyMessage> = {
"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");
}
});
});

View file

@ -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<typeof ColorSchema>;
export const SquareSchema = z
.string()
.regex(/^[a-h][1-8]$/, "square must be algebraic notation a1..h8");
export type Square = z.infer<typeof SquareSchema>;
export const PromotionPieceSchema = z.enum([
"queen",
"rook",
"bishop",
"knight",
]);
export type PromotionPiece = z.infer<typeof PromotionPieceSchema>;
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<typeof RoomCodeSchema>;
export const FactSchema = z.object({
id: z.number().int(),
attr: z.string(),
value: z.unknown(),
});
export type Fact = z.infer<typeof FactSchema>;
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<typeof ErrorCodeSchema>;
export const GameEndReasonSchema = z.enum([
"checkmate",
"stalemate",
"50-move",
"threefold",
"insufficient",
"player_left",
]);
export type GameEndReason = z.infer<typeof GameEndReasonSchema>;
export const WinnerSchema = z.enum(["white", "black", "draw"]);
export type Winner = z.infer<typeof WinnerSchema>;
// ---------------------------------------------------------------------------
// 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<typeof EnvelopeSchema>;
// ---------------------------------------------------------------------------
// Client → Server payloads
// ---------------------------------------------------------------------------
export const RoomCreatePayloadSchema = z.object({
rulesetIds: z.array(z.string()).optional(),
});
export type RoomCreatePayload = z.infer<typeof RoomCreatePayloadSchema>;
export const RoomJoinPayloadSchema = z.object({
code: RoomCodeSchema,
});
export type RoomJoinPayload = z.infer<typeof RoomJoinPayloadSchema>;
export const RoomLeavePayloadSchema = z.object({}).strict();
export type RoomLeavePayload = z.infer<typeof RoomLeavePayloadSchema>;
export const GameMovePayloadSchema = z.object({
from: SquareSchema,
to: SquareSchema,
promoteTo: PromotionPieceSchema.optional(),
});
export type GameMovePayload = z.infer<typeof GameMovePayloadSchema>;
// ---------------------------------------------------------------------------
// Server → Client payloads
// ---------------------------------------------------------------------------
export const RoomCreatedPayloadSchema = z.object({
code: RoomCodeSchema,
token: z.string().uuid(),
color: ColorSchema,
});
export type RoomCreatedPayload = z.infer<typeof RoomCreatedPayloadSchema>;
export const RoomJoinedPayloadSchema = z.object({
code: RoomCodeSchema,
token: z.string().uuid(),
color: ColorSchema,
activeRules: z.array(z.string()),
});
export type RoomJoinedPayload = z.infer<typeof RoomJoinedPayloadSchema>;
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<typeof GameStatePayloadSchema>;
export const GameOverSchema = z.object({
winner: WinnerSchema,
reason: GameEndReasonSchema,
});
export type GameOver = z.infer<typeof GameOverSchema>;
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<typeof GameDeltaPayloadSchema>;
export const GameEndPayloadSchema = z.object({
winner: WinnerSchema,
reason: z.string(),
finalFen: z.string(),
});
export type GameEndPayload = z.infer<typeof GameEndPayloadSchema>;
export const ErrorPayloadSchema = z.object({
code: ErrorCodeSchema,
message: z.string(),
fatal: z.boolean(),
});
export type ErrorPayload = z.infer<typeof ErrorPayloadSchema>;
// ---------------------------------------------------------------------------
// Discriminated union of full messages (envelope + typed payload)
// ---------------------------------------------------------------------------
const msg = <Type extends string, P extends z.ZodTypeAny>(
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<typeof ClientMessageSchema>;
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<typeof ServerMessageSchema>;
export const AnyMessageSchema = z.discriminatedUnion("type", [
RoomCreateMessageSchema,
RoomJoinMessageSchema,
RoomLeaveMessageSchema,
GameMoveMessageSchema,
RoomCreatedMessageSchema,
RoomJoinedMessageSchema,
GameStateMessageSchema,
GameDeltaMessageSchema,
GameEndMessageSchema,
ErrorMessageSchema,
]);
export type AnyMessage = z.infer<typeof AnyMessageSchema>;
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<T, E> = { ok: true; data: T } | { ok: false; error: E };
const ok = <T>(data: T): Result<T, never> => ({ ok: true, data });
const err = <E>(error: E): Result<never, E> => ({ 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<AnyMessage, string> {
// 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<string, unknown>;
// 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<AnyMessage, string> {
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(".") : "<root>";
return `${path}: ${issue.message}`;
})
.join("; ");
}