/** * Frame protocol (TASK-0091 R3) — the wire shapes for `apps/realtime`. * * The schemas themselves live in `packages/shared/realtime/frames.ts` * (client + server share them); this module is the server's framing * layer: encode/decode of text + binary frames, the hostile-frame * validation result codes, and the 10-strike policy (R3: 10 bad frames * in a sliding 10 s window → close 4008 + audit). * * Framing (frozen — `packages/collaboration/src/types.ts` mirror): * - **Text**: one JSON object per frame, `type` discriminant. * - **Binary**: 1-byte kind prefix + raw Yjs bytes * `0x01` doc update · `0x02` awareness update. */ import { BINARY_KIND, ClientFrameSchema, REALTIME_LIMITS, type ClientFrame, type ServerFrame, } from "@factory/shared/realtime/frames"; // --- close codes (R6) — re-exported so server handlers import one module ------- export { WS_CLOSE } from "@factory/shared/realtime/frames"; import { WS_CLOSE } from "@factory/shared/realtime/frames"; export type CloseCode = (typeof WS_CLOSE)[keyof typeof WS_CLOSE]; // --- client frame decoding ------------------------------------------------------- /** A decoded inbound frame. */ export type DecodedFrame = | { readonly kind: "text"; readonly frame: ClientFrame } | { readonly kind: "binary"; readonly binary: "update" | "awareness"; readonly bytes: Uint8Array; }; /** Why a frame was rejected (all count as a strike except `unknown_frame` * policy is identical — R3: any bad frame is a strike). */ export type FrameRejectCode = | "invalid_json" | "unknown_frame" | "invalid_frame" | "bad_binary_kind" | "bad_update" | "frame_too_large"; /** * Decode one inbound WS frame (text = JSON control frame, binary = * prefixed Yjs bytes). Pure — validation of a binary UPDATE's Yjs * well-formedness happens at apply time (a corrupt update is a * `bad_update` strike; the throwaway-doc probe is in server.ts because * it allocates a `Y.Doc`). */ export function decodeClientFrame( data: string | Uint8Array, ): { ok: true; frame: DecodedFrame } | { ok: false; code: FrameRejectCode } { if (typeof data === "string") { let parsed: unknown; try { parsed = JSON.parse(data); } catch { return { ok: false, code: "invalid_json" }; } const result = ClientFrameSchema.safeParse(parsed); if (!result.success) { // Distinguish a wholly-unknown `type` (unknown_frame) from a known // type with a bad payload (invalid_frame) — the test matrix asserts // both codes. const t = (parsed as { type?: unknown } | null)?.type; const known = t === "sync.request" || t === "ping" || t === "outbox.pending" || t === "subscribe" || t === "unsubscribe" || t === "presence" || t === "presence-leave"; return { ok: false, code: known ? "invalid_frame" : "unknown_frame" }; } return { ok: true, frame: { kind: "text", frame: result.data } }; } // Binary: prefix byte + payload. if (data.byteLength < 1) { return { ok: false, code: "bad_binary_kind" }; } const kind = data[0]; if (kind === BINARY_KIND.Update) { return { ok: true, frame: { kind: "binary", binary: "update", bytes: data.subarray(1) } }; } if (kind === BINARY_KIND.Awareness) { return { ok: true, frame: { kind: "binary", binary: "awareness", bytes: data.subarray(1) } }; } return { ok: false, code: "bad_binary_kind" }; } /** Encode an outbound server frame (always a text/JSON frame — binary * server→client frames don't exist in the R3 contract: doc updates are * relayed as binary 0x01 frames built here from raw bytes). */ export function encodeServerFrame(frame: ServerFrame): string { return JSON.stringify(frame); } /** Encode an outbound binary doc update (the relay of an inbound 0x01). */ export function encodeBinaryUpdate(bytes: Uint8Array): Uint8Array { const out = new Uint8Array(bytes.byteLength + 1); out[0] = BINARY_KIND.Update; out.set(bytes, 1); return out; } /** Encode an outbound binary awareness update (0x02). */ export function encodeBinaryAwareness(bytes: Uint8Array): Uint8Array { const out = new Uint8Array(bytes.byteLength + 1); out[0] = BINARY_KIND.Awareness; out.set(bytes, 1); return out; } // --- the 10-strike policy (R3) ----------------------------------------------------- /** * Per-socket bad-frame strike tracker. 10 strikes inside a sliding 10 s * window → policy violation (the socket closes 4008 + audit * `realtime.connection.abuse`). A strike that falls out of the window no * longer counts (the counter is a timestamp list, not a running total). */ export class StrikeTracker { private readonly limit: number; private readonly windowMs: number; private readonly now: () => number; private strikes: number[] = []; constructor(opts?: { limit?: number; windowMs?: number; now?: () => number }) { this.limit = opts?.limit ?? REALTIME_LIMITS.strikeLimit; this.windowMs = opts?.windowMs ?? REALTIME_LIMITS.strikeWindowMs; this.now = opts?.now ?? Date.now; } /** * Record a bad frame. Returns `violated` when the sliding window now * holds `limit` strikes (the caller closes + audits exactly once — * subsequent frames are ignored by the server for that socket). */ record(): { violated: boolean; strikes: number } { const t = this.now(); const cutoff = t - this.windowMs; this.strikes = this.strikes.filter((s) => s > cutoff); this.strikes.push(t); const violated = this.strikes.length >= this.limit; return { violated, strikes: this.strikes.length }; } /** Current in-window strike count (metrics/tests). */ count(): number { const t = this.now(); const cutoff = t - this.windowMs; return this.strikes.filter((s) => s > cutoff).length; } reset(): void { this.strikes = []; } } // --- frame size guard (R6: 256 KB cap) ---------------------------------------------- /** The server-side max inbound frame size (ws `maxPayload`). */ export const MAX_FRAME_BYTES = REALTIME_LIMITS.maxFrameBytes;