Home / Docs / Developer docs / API and WebSocket contracts

API and WebSocket contracts

The response shapes the SPA parses at the network boundary, including the redacted overlay messages.

Developer docs. You want to read, run, deploy or change the code. Not what you came for? Find the right docs.

What this file is

Response shapes the SPA parses at the network boundary. They live here, not inside the route files, so the Playwright fixtures (app/e2e/lib/mock-api.ts) build their payloads with the very schemas the app validates with and cannot drift from them. Value imports are limited to zod so this file also loads in Node; @mfd/core is imported for types only and each schema is pinned to its core type.

The source is app/src/lib/contracts.ts. The WebSocket message types ClientMsg and ServerMsg are re-exported from @mfd/core; their definitions are in packages/core/src/protocol.ts. Routes, role routing and error codes are specified in docs/for-agents/interfaces.md.

Exports

ExportNotes from the source
FiltersZResponse shapes the SPA parses at the network boundary. They live here, not inside the route files, so the Playwright fixtures (app/e2e/lib/mock-api.ts) build their payloads with the very schemas the app validates with and cannot drift from them. Value imports are limited to zod so this file also loads in Node; @mfd/core is imported for types only and each schema is pinned to its core type.
ChatLogZ 
ChannelInfoZ 
ReportCardZ 
SharedReportZ 
OverlayServerMsgZ 
OverlayServerMsg 

Source

The file as of this build, so the schemas below are exactly what the app validates with.

import { z } from 'zod';
import type { FiltersView, ReportCard } from '@mfd/core';

export type { ClientMsg, ServerMsg } from '@mfd/core';

/**
 * Response shapes the SPA parses at the network boundary. They live here, not
 * inside the route files, so the Playwright fixtures (app/e2e/lib/mock-api.ts)
 * build their payloads with the very schemas the app validates with and cannot
 * drift from them. Value imports are limited to zod so this file also loads in
 * Node; @mfd/core is imported for types only and each schema is pinned to its
 * core type.
 */

export const FiltersZ: z.ZodType<FiltersView> = z.object({
  terms: z.array(z.object({ id: z.string(), text: z.string() })),
  automod: z.record(z.string(), z.number().int().min(0).max(4).nullable()).nullable(),
  fetchedAt: z.number().nullable()
});

export const ChatLogZ = z.object({
  count: z.number().int().nonnegative(),
  oldest: z.number().nullable(),
  newest: z.number().nullable(),
  logging: z.boolean(),
  samples: z.array(z.object({ login: z.string(), text: z.string() })).max(6)
});

export const ChannelInfoZ = z.object({
  broadcasterId: z.string().min(1),
  displayName: z.string(),
  avatarUrl: z.string().nullable(),
  drillOpen: z.boolean()
});

/** The numbers a report card carries; the shared `/r/:id` card omits the drill id. */
const reportCardShape = z.object({
  attacksShown: z.number(),
  slipped: z.number(),
  mods: z.array(z.object({ name: z.string(), isSim: z.boolean(), caught: z.number(), falseBans: z.number(), avgReactionMs: z.number().nullable() })),
  grade: z.object({ passed: z.number(), total: z.number(), engine: z.enum(['twitch', 'local', 'mixed']) }).nullable(),
  termsPushed: z.number(),
  fresh: z.object({ before: z.number(), after: z.number(), total: z.number() }).nullable()
});

export const ReportCardZ: z.ZodType<ReportCard> = reportCardShape.extend({ drillId: z.string() });

export const SharedReportZ = z.object({
  reportId: z.string(),
  channel: z.object({ login: z.string(), displayName: z.string() }),
  createdAt: z.number(),
  card: reportCardShape
});

/**
 * What an overlay socket may ever receive (SRS ยง8.3): redacted lines carry only
 * a length, never text or segments. `.strict()` makes a fixture that smuggles in
 * readable attack text fail to build.
 */
const RedactedLineZ = z.object({ lineId: z.string(), user: z.string(), len: z.number().int().nonnegative() }).strict();
const OverlayScoreZ = z.object({
  mods: z.array(z.object({ participantId: z.string(), name: z.string(), isSim: z.boolean(), caught: z.number(), falseBans: z.number(), avgReactionMs: z.number().nullable() }).strict()),
  slipped: z.number(),
  attacksShown: z.number()
}).strict();

export const OverlayServerMsgZ = z.discriminatedUnion('type', [
  z.object({ type: z.literal('hello'), data: z.object({ role: z.literal('overlay'), serverTime: z.number() }).strict() }).strict(),
  z.object({
    type: z.literal('drill.state'),
    data: z.object({
      drill: z.object({
        drillId: z.string(),
        status: z.enum(['draft', 'voting', 'running', 'ended', 'cancelled']),
        startedAt: z.number().nullable(),
        total: z.number(),
        lines: z.array(z.object({ line: RedactedLineZ, shownAt: z.number(), state: z.enum(['open', 'acted', 'slipped']) }).strict()),
        score: OverlayScoreZ
      }).strict().nullable(),
      participants: z.array(z.object({ id: z.string(), name: z.string(), isSim: z.boolean(), online: z.boolean() }).strict())
    }).strict()
  }).strict(),
  z.object({ type: z.literal('report.ready'), data: z.object({ drillId: z.string(), card: ReportCardZ }).strict() }).strict()
]);
export type OverlayServerMsg = z.infer<typeof OverlayServerMsgZ>;