7.3 KiB
03 — API Contract
Purpose: REST and WebSocket endpoint reference with exact Zod schemas. Concatenate with 00-project-overview.md and 99-current-task.md. Last updated: 2026-05-15 Depends on: 00-project-overview.md, 02-data-model.md
REST Endpoints
Health
GET /api/v1/health
Response: { "status": "ok" }
Auth: None (public)
Game — Start Session
POST /api/v1/game/start
Request body (GameRequestSchema):
{
source_language: SupportedLanguageCode, // "en" | "it" | "de" | "es" | "fr"
target_language: SupportedLanguageCode,
pos: SupportedPos, // "noun" | "verb" | "adjective" | "adverb"
difficulty: DifficultyLevel, // "easy" | "intermediate" | "hard"
rounds: GameRounds // "3" | "10" (string enum, converted to number in service)
}
Validation rules:
source_language!==target_language- Both languages in
SUPPORTED_LANGUAGE_CODES posinSUPPORTED_POSdifficultyinDIFFICULTY_LEVELSroundsinGAME_ROUNDS
Response (GameSessionSchema):
{
sessionId: string, // UUID
questions: GameQuestion[]
}
GameQuestionSchema:
{
questionId: string, // UUID
prompt: string, // Word in source language
gloss: string | null, // Definition (falls back to English if target lang gloss missing)
options: AnswerOption[] // 4 items, shuffled
}
AnswerOptionSchema:
{
optionId: number, // 0–3
text: string // Translation in target language
}
Note: The correct answer is NOT included in the response. The frontend only sees optionId and text. The server stores questionId → correctOptionId mapping in the GameSessionStore.
Auth: Required (session middleware)
Game — Submit Answer
POST /api/v1/game/answer
Request body (AnswerSubmissionSchema):
{
sessionId: string, // UUID
questionId: string, // UUID
selectedOptionId: number // 0–3
}
Response (AnswerResultSchema):
{
questionId: string,
isCorrect: boolean,
correctOptionId: number, // 0–3
selectedOptionId: number // 0–3
}
Error cases:
- Session not found → 404 NotFoundError
- Question not in session → 404 NotFoundError
- Invalid optionId → 400 ValidationError
Auth: Required
Lobby — Create
POST /api/v1/lobbies
Request body: None (host's auth session determines host_id)
Response:
{
id: string, // UUID
code: string, // Human-readable room code (e.g. "WOLF-42")
host_id: string,
status: "waiting",
max_players: number,
settings: object | null,
created_at: string
}
Auth: Required
Lobby — Join
POST /api/v1/lobbies/:code/join
Path param: code — room code (e.g. "WOLF-42")
Response: Same as create (the lobby object)
Error cases:
- Lobby not found → 404
- Lobby full → 400
- Already joined → 200 (idempotent)
Auth: Required
Auth
ALL /api/auth/* — Better Auth handlers (public)
Better Auth mounts its own router at /api/auth/*. Handles:
/api/auth/signin/social— initiate social login/api/auth/callback/:provider— OAuth callback/api/auth/signout— clear session/api/auth/session— get current session
Auth: Mixed (some public, some require valid session)
WebSocket Protocol
All WS messages are JSON objects with a type field. The type is a discriminated union — the router validates the payload against the schema for that type.
Connection
- Client opens WebSocket to
wss://api.lilastudy.com/ws - Server validates Better Auth session from cookie on upgrade
- Connection established
Client → Server Messages
lobby:join
{
type: "lobby:join",
payload: {
code: string // Room code (e.g. "WOLF-42")
}
}
lobby:leave
{
type: "lobby:leave",
payload: {
code: string
}
}
lobby:start
{
type: "lobby:start",
payload: {
code: string
}
}
Only the host can send this. Triggers game start.
game:answer
{
type: "game:answer",
payload: {
code: string,
questionId: string,
optionId: number // 0–3
}
}
Must be sent within the 15-second server timer.
Server → Client Messages
lobby:state
{
type: "lobby:state",
payload: {
code: string,
players: {
id: string,
display_name: string,
is_host: boolean
}[],
status: "waiting" | "in_progress" | "finished",
settings: object | null
}
}
Broadcast to all players in the lobby on any membership change.
game:question
{
type: "game:question",
payload: {
questionId: string,
prompt: string,
gloss: string | null,
options: { optionId: number, text: string }[],
timeLimit: number // seconds (15)
}
}
Broadcast when the game starts or a new round begins.
game:answer_result
{
type: "game:answer_result",
payload: {
questionId: string,
results: {
playerId: string,
displayName: string,
isCorrect: boolean,
selectedOptionId: number,
score: number
}[]
}
}
Broadcast after all players answer or timer expires.
game:finished
{
type: "game:finished",
payload: {
finalScores: {
playerId: string,
displayName: string,
score: number
}[],
winner: {
playerId: string,
displayName: string
} | null // null for ties
}
}
Broadcast after all rounds complete.
Zod Schema Locations
All schemas live in packages/shared/src/schemas/:
| Schema | File | Used by |
|---|---|---|
| GameRequestSchema | game.ts |
API controller, frontend GameSetup |
| GameSessionSchema | game.ts |
API service, frontend quiz flow |
| GameQuestionSchema | game.ts |
API service, frontend QuestionCard |
| AnswerOptionSchema | game.ts |
API service, frontend OptionButton |
| AnswerSubmissionSchema | game.ts |
API controller, frontend submit handler |
| AnswerResultSchema | game.ts |
API controller, frontend ScoreScreen |
| LobbyCreateSchema | lobby.ts |
API controller |
| LobbyJoinSchema | lobby.ts |
API controller |
| LobbyStateSchema | lobby.ts |
WS handler, frontend lobby UI |
| WebSocketMessageSchema | lobby.ts |
WS router (discriminated union) |
Rule: Never duplicate these schemas. Import from packages/shared in both API and frontend.
Error Responses
All errors follow this shape:
{
error: string, // Human-readable message
statusCode: number // HTTP status
}
Common status codes:
- 400 — ValidationError (bad input, schema mismatch)
- 401 — Unauthorized (no valid session)
- 404 — NotFoundError (session, question, or lobby not found)
- 500 — Unknown error (logged, generic message to client)