Skip to content

Latest commit

 

History

History
160 lines (105 loc) · 9.59 KB

http-api.md

File metadata and controls

160 lines (105 loc) · 9.59 KB

HTTP API

Everything that touches the filesystem or decides who you are is served over HTTP under /api, on the same port as the WebSocket. Only live game state stays on the socket (WebSocket protocol).

Rule of thumb: touches the filesystem or decides who you are → HTTP. Touches the in-memory registry of a running game → WebSocket.

This API is internal and not version-stabilized. It can change between releases without a deprecation period.

Error contract

The server never sends prose. Every error body is:

{ "error": "errors:quizz.notFound" }

The value is an i18n translation key (the web UI feeds it straight to t()), so treat it as a symbolic error code. Validation failures return the key of the first failing rule, e.g. errors:quizz.subjectEmpty.

This holds for every response the server can produce, including the ones no route handles:

Situation Status Key
Unknown path or method 404 errors:api.notFound
Unparseable or missing request body 400 errors:invalidRequest
Unexpected server-side failure 500 errors:api.serverError

The one bodyless response is 204 No Content, returned by the DELETE routes — that is what the status means.

Authentication

Identity is a JWT signed by the server (see JWT_SECRET). Two roles exist: player (30-day tokens) and manager (12-hour tokens).

Authenticated routes expect the token as a bearer:

Authorization: Bearer <token>

POST /api/auth/session

Body { "token"?: string } (an empty or absent body is fine). Always answers 200 with:

{ "token": "…", "clientId": "…", "role": "player", "expiresAt": 1789017512 }
Input Behaviour
none / unparseable mints a fresh clientId, role player
valid, unexpired returned as-is — idempotent, keeps an active manager role
valid signature, expired re-minted keeping clientId, role forced to player
bad signature mints a fresh session (the secret was rotated)

Keeping clientId on expiry is what preserves a player's seat across a refresh; forcing player is what downgrades a stale manager.

POST /api/auth/manager

Body { "password": string }. Upgrades the caller's session to manager, keeping the same clientId when a bearer is sent. Returns the same shape as /api/auth/session.

  • 401 errors:manager.invalidPassword — wrong password
  • 403 errors:manager.passwordNotConfigured — MANAGER_PASSWORD is not set

There is no rate limit, matching the previous socket behaviour.

POST /api/auth/logout

Bearer required. Mints a fresh player token for the same clientId and returns it.

Logout is stateless: the old manager token stays cryptographically valid until it expires. The 12-hour manager TTL is the mitigation.

Manager routes

All of these require a bearer whose role is manager, and answer 401 errors:auth.unauthorized otherwise.

Route Success Errors
GET /api/quizz 200 { quizz: [{ id, subject }] } —
GET /api/quizz/:id 200 the full quiz 404 errors:quizz.notFound
POST /api/quizz 201 { id } 400 validation key
PATCH /api/quizz/:id 200 { id } 400 validation key, 404
DELETE /api/quizz/:id 204 404 errors:quizz.notFound
GET /api/results 200 { results: [meta] } —
GET /api/results/:id 200 the full result 404 errors:result.notFound
DELETE /api/results/:id 204 404 errors:result.notFound
POST /api/games 201 { gameId, inviteCode } 404 errors:quizz.notFound
GET /api/media 200 { media: [media] } —
POST /api/media 201 the stored media 400, 413, 415
DELETE /api/media/:name 204 400, 404

The quiz body is validated against the same schema as the files in config/quizz — see Quiz.

Media

POST /api/media takes a multipart/form-data body with a single file field, up to 100 MB (413 errors:media.tooLarge beyond). The type is detected from the file's content, not its name or declared MIME: PNG, JPEG, WebP and GIF images, MP3, OGG and WAV audio, MP4 and WebM video. Anything else is refused with 415 errors:media.invalidType.

Files are stored in config/media and served at /media/<name>. A media object is { name, url, type }: name is the stored file name (the uploaded name plus a random suffix) and type either "image", "audio" or "video"; put its url in a question's media.url. The list is sorted newest first. Deleting a file does not update the quizzes that use it.

POST /api/games creates the game but binds no socket to it: the manager enters the room by emitting manager:reconnect { gameId }. A game nobody connects to expires after ~5 minutes, like an abandoned one.

PATCH /api/games/:gameId/settings

Changes the settings of a game you created, before it starts. The body is a partial settings object — send only the keys you are changing:

{
  generatedUsernames?: boolean   // the server names the players instead of letting them type
  answersOnly?: boolean // players only see the answer buttons during SELECT_ANSWER
  autoAdvance?: {
    enable: boolean
    responsesDelay: number       // seconds on the answer reveal, 3-600
    leaderboardDelay: number     // seconds on the leaderboard, 3-600
  }
}

Answers 200 { settings } with the full settings after the change.

  • 404 errors:game.notFound — unknown game, or you are not its manager
  • 409 errors:game.alreadyStarted — settings are locked once the game is running
  • 400 — validation key, e.g. errors:game.invalidAutoAdvanceDelay

The autoAdvance object is nested, so sending it replaces all three of its fields at once.

Public routes

Route Success Notes
GET /api/health 200 { status: "ok" } Liveness probe
POST /api/games/check 200 { generatedUsernames: boolean } Checks a 6-digit invite code before joining

Body { "inviteCode": string }. It is a POST rather than a GET on purpose: the invite code is a room key, and a body keeps it out of access logs, browser history and Referer headers — and out of proxy caches, which matters because the answer changes as games start and end.

generatedUsernames tells a client what the join form should ask for: when it is true there is no username to type.

  • 404 errors:game.notFound — unknown invite code
  • 403 errors:game.locked — the host has locked the room (manager:setLock, see WebSocket protocol). The game still exists, so a player who already holds a seat can reconnect to it.

POST /api/games/join

Any signed session (bearer required, no role). Body { "inviteCode": string, "username"?: string }, answers 200 { gameId, ticket, username }.

  • username is 1-24 characters (validators/auth.ts). It is required unless the game has generatedUsernames enabled, in which case anything you send is ignored and the server picks the name.
  • The response username is the name you asked for, or null when the server will generate one — you then learn it from game:successJoin.

The ticket is a 5-minute JWT with claims { sub, gameId, username }, signed with the same secret. It carries the right to a seat; the seat itself is only created when the socket presents it through player:login { ticket } — which is what keeps player.id === socket.id true at all times server-side.

When the generator is on, the ticket carries no username and the name is drawn at join time, so a ticket minted before the setting was turned on cannot smuggle a chosen name past it.

ticket is null when this client already holds a seat: reconnect with player:reconnect { gameId } instead.

  • 404 errors:game.notFound — unknown invite code
  • 403 errors:game.managerCannotJoin — you are this game's manager
  • 403 errors:game.locked — the host has locked the room; a client that already holds a seat still gets its ticket: null answer
  • 400 — invalid or missing username, with the validation key

POST /api/games/check makes the 6-digit PIN space (10⁶) cheaply enumerable, as it already was over the socket. Keeping the code out of the URL does not change that — it only stops it leaking passively into logs and caches. Worth knowing if you expose Razzia to the open internet.

Reverse proxies

/api must be forwarded to the container like / and /ws — see Reverse Proxy.