/** * src/protocol.ts — WebSocket message codec + validation (T5, pure functions) * * Invariant #3 (ARCHITECTURE §8): parseClientMessage NEVER throws. * All invalid input flows through ParseResult.ok=false. * * Imports only from src/types.ts (frozen shared contract, T2). */ import type { ClientMessage, ServerMessage, ParseResult } from './types.js' // ─── SESSION_ID_RE (M7) ─────────────────────────────────────────────────────── /** * UUID v4 pattern. * - 8-4-4-4-12 hex groups * - version nibble must be '4' * - variant nibble must be 8, 9, a, or b (10xx in binary) * * M7: strings like "abc123" must be rejected; crypto.randomUUID() values must pass. */ export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i // ─── Type whitelist ─────────────────────────────────────────────────────────── const ALLOWED_TYPES = new Set(['attach', 'input', 'resize', 'blur', 'approve', 'reject']) // ─── parseClientMessage ─────────────────────────────────────────────────────── /** * Parse and validate a raw WS text frame as a ClientMessage. * * Steps (ARCHITECTURE §3.2 / TECH_DOC §5.3): * 1. JSON.parse — bad JSON → ok=false * 2. Must be a plain object (not null, array, etc.) * 3. type must be in whitelist {'attach','input','resize'} * 4. Per-type field validation: * - resize: cols and rows are integers in [1, 1000] * - input: data is a string (passed through verbatim, invariant #9) * - attach: sessionId is null OR a string matching SESSION_ID_RE (M7) * * NEVER throws — all errors return { ok: false, error: }. */ export function parseClientMessage(raw: string): ParseResult { // Step 1: JSON parse let parsed: unknown try { parsed = JSON.parse(raw) } catch { return { ok: false, error: 'invalid JSON' } } // Step 2: Must be a plain object if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { return { ok: false, error: 'message must be a JSON object' } } const obj = parsed as Record // Step 3: type whitelist const type = obj['type'] if (typeof type !== 'string' || !ALLOWED_TYPES.has(type)) { return { ok: false, error: `unknown or missing type: ${String(type)}` } } // Step 4: per-type validation if (type === 'resize') { return validateResize(obj) } if (type === 'input') { return validateInput(obj) } // blur (v0.4) carries no payload — withdraw this client's size vote. if (type === 'blur') { return { ok: true, message: { type: 'blur' } } } // approve/reject (H3) carry no payload. if (type === 'approve') { return { ok: true, message: { type: 'approve' } } } if (type === 'reject') { return { ok: true, message: { type: 'reject' } } } // type === 'attach' return validateAttach(obj) } // ─── Per-type validators ────────────────────────────────────────────────────── function validateResize(obj: Record): ParseResult { const { cols, rows } = obj if (!isValidDimension(cols)) { return { ok: false, error: `resize.cols must be an integer in [1, 1000], got ${String(cols)}`, } } if (!isValidDimension(rows)) { return { ok: false, error: `resize.rows must be an integer in [1, 1000], got ${String(rows)}`, } } return { ok: true, message: { type: 'resize', cols: cols as number, rows: rows as number }, } } function isValidDimension(value: unknown): value is number { return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000 } function validateInput(obj: Record): ParseResult { const { data } = obj if (typeof data !== 'string') { return { ok: false, error: `input.data must be a string, got ${typeof data}` } } // Invariant #9: data passed through verbatim, no content filtering return { ok: true, message: { type: 'input', data } } } function validateAttach(obj: Record): ParseResult { const { sessionId } = obj // sessionId must be explicitly present as null or a UUID v4 string (M7) if (!Object.prototype.hasOwnProperty.call(obj, 'sessionId')) { return { ok: false, error: 'attach.sessionId field is required' } } // Optional cwd (M6): must be an absolute path string if present. const rawCwd = obj['cwd'] let cwd: string | undefined if (rawCwd !== undefined) { if (typeof rawCwd !== 'string' || !rawCwd.startsWith('/')) { return { ok: false, error: 'attach.cwd must be an absolute path string' } } cwd = rawCwd } if (sessionId === null) { return { ok: true, message: cwd !== undefined ? { type: 'attach', sessionId: null, cwd } : { type: 'attach', sessionId: null }, } } if (typeof sessionId !== 'string') { return { ok: false, error: `attach.sessionId must be null or a UUID v4 string, got ${typeof sessionId}`, } } if (!SESSION_ID_RE.test(sessionId)) { return { ok: false, error: `attach.sessionId is not a valid UUID v4: ${sessionId}`, } } return { ok: true, message: cwd !== undefined ? { type: 'attach', sessionId, cwd } : { type: 'attach', sessionId }, } } // ─── serialize ──────────────────────────────────────────────────────────────── /** * Serialize a ServerMessage to a JSON text frame. * * Internal trusted data — JSON.stringify directly (ARCHITECTURE §3.2). */ export function serialize(msg: ServerMessage): string { return JSON.stringify(msg) }