Base URL: /api (the lone health probe lives at /health).
All request bodies and responses are JSON (application/json). The handlers'
own error responses use a single shape:
{ "error": "<client-safe message>" }Internal error details are logged server-side and never returned.
Not every failure looks like that. Request parsing is done by framework extractors, whose rejections return plain text (not
{"error": ...}): a missing/incorrectContent-Type: application/json→415; a malformed JSON body or a malformed{id}/beforeUUID →400; valid JSON of the wrong shape →422. Clients should parse error bodies defensively.Content-Type: application/jsonis required onregister,login, andPOST /api/rooms.
All endpoints except register, login, and health require a bearer token:
Authorization: Bearer <jwt>
Tokens are HS256 JWTs issued at register and login, signed with
JWT_SECRET and expiring 24 hours after issue (exp is validated on every
use). Set JWT_SECRET to a strong random value before deploying; the server
logs a warning when it is unset (it then uses the committed dev-only default).
The scheme is matched case-insensitively (Bearer/bearer).
Create a user and return a token.
Request:
{ "username": "alice", "password": "correct horse battery staple" }username: 3–32 chars of[a-z0-9_](lowercase ASCII only).password: 8–128 chars (capped because argon2 cost grows with input).
Responses:
| Status | Body |
|---|---|
| 201 | { "token": "<jwt>", "user": { "id": "<uuid>", "username": "alice", "created_at": "<rfc3339>" } } |
| 400 | validation error ({"error": "..."}) |
| 409 | username already taken |
Same request/response shape as register; 200 on success, 401 on bad
credentials.
Returns the authenticated user; 200 with the user object shape above,
401 without a valid token.
Create a room. The creator automatically becomes a member (atomic in storage).
Request: { "name": "general" } — name trimmed, non-empty, ≤ 64 chars, no
control characters.
Response 201:
{
"id": "<uuid>",
"name": "general",
"created_by": "<uuid>",
"created_at": "<rfc3339>",
"member": true,
"unread_count": 0,
"last_message": null
}400 on invalid names, 409 when the caller already owns the maximum number
of rooms (MAX_ROOMS_PER_USER, default 5):
{ "error": "you can create at most 5 rooms" }The cap is enforced under a per-user advisory lock, so a user's concurrent creates are serialized and cannot both slip past it.
List every room with the caller's membership flag (member: true|false),
per-room unread_count, and a last_message preview.
200 with an array of room objects:
{
"id": "<uuid>",
"name": "general",
"created_by": "<uuid>",
"created_at": "<rfc3339>",
"member": true,
"unread_count": 3,
"last_message": {
"id": "<uuid>",
"user_id": "<uuid>",
"username": "alice",
"body": "hello",
"created_at": "<rfc3339>"
}
}unread_count: the caller's unread total — messages from other users newer than the caller's read cursor.0for a fresh room and for non-members; a member who never opened the room sees everything as unread.last_message: the newest message in the room, ornullwhen the room is empty. Present only for member rooms — non-members getnull, so the list previews rooms only after joining (message bodies and unread counts are member-gated, matching history access).
One room; 200 with the room object (same shape as above, including the
member-gated unread_count / last_message), 404 for an unknown id. No
membership required to see a room, but non-members see no preview.
Delete a room and everything scoped to it — memberships, messages, reactions, and read cursors all cascade. Only the room's creator may delete it.
204on success.403when the caller is not the creator (membership is not enough).404for an unknown room (including a second delete of the same room).
Every session currently connected to the room receives a room_deleted
frame so its client can drop the room instead of reconnecting to a room that no
longer exists. The room also disappears from GET /api/rooms.
Join a room. Idempotent: joining twice is still 204. 204 on success,
404 for an unknown room. Joining is open — any authenticated user may join.
Leave a room. Idempotent: 204 even if you are not a member; 404 for an
unknown room.
Mark messages read in a room you are a member of. Advances the caller's read
cursor — never backwards — and broadcasts an ephemeral read receipt to the
room (see Server → client frames).
Request (either form):
{ "message_id": "<uuid>" }{}- With
message_id: mark everything up to (and including) that message read. The message must exist in this room, else400. {}or{ "message_id": null }: mark everything read (the cursor becomes the newest message). A room with no messages is a no-op.
Response 200:
{
"room_id": "<uuid>",
"last_read_message_id": "<uuid>",
"unread_count": 0
}last_read_message_id is null when the cursor did not move — an empty room,
or a late/duplicate/out-of-order mark that cannot move the cursor backwards.
unread_count is the caller's remaining unread total after the cursor moved.
| Status | Meaning |
|---|---|
| 200 | cursor advanced; receipt broadcast |
| 400 | message_id does not belong to this room |
| 401 | missing/invalid token |
| 403 | caller is not a member |
| 404 | unknown room |
Message history for a member. Newest first.
Query parameters:
limit(optional): page size; clamped server-side to 1–100, default 50.before(optional): UUID message-id cursor; returns messages strictly older than that message. A well-formed cursor that matches no message (or belongs to another room) yields an empty page.
Response 200:
[
{
"id": "<uuid>",
"room_id": "<uuid>",
"user_id": "<uuid>",
"username": "alice",
"body": "hello",
"created_at": "<rfc3339>",
"reactions": [
{ "emoji": "👍", "count": 2, "user_ids": ["<uuid>", "<uuid>"] }
]
},
...
]reactions groups one entry per emoji on that message; count always equals
user_ids.length, and user_ids lists every reactor (so the client can
highlight the caller's own reactions). Messages with no reactions carry [].
| Status | Meaning |
|---|---|
| 200 | page of messages (possibly empty) |
| 403 | caller is not a member of the room |
| 404 | unknown room |
Upgrades the connection to a WebSocket. Browsers cannot set an Authorization
header on WS connections, so the JWT travels as a query parameter — keep it out
of logs and URLs where possible.
Handshake checks, in order, before the upgrade:
tokenmust be a valid JWT → else401.- The user must still exist → else
401. - The room must exist → else
404. - The user must be a member of the room → else
403.
A successful upgrade answers 101 Switching Protocols.
The request must be a genuine WebSocket upgrade (Connection: Upgrade,
Upgrade: websocket, Sec-WebSocket-Version, Sec-WebSocket-Key); a plain
GET to this path is rejected by request extraction (400/426) before the
token check runs, and a missing or malformed token/room_id parameter yields
a plain-text 400.
Three message types, as text frames:
{ "type": "send", "body": "hello everyone" }{ "type": "typing" }{ "type": "react", "message_id": "<uuid>", "emoji": "👍" }Validation for send (per message, on every send):
bodyis trimmed — leading/trailing whitespace, including newlines, is dropped — and must be non-empty.- At most 4000 characters, counted after trimming and newline normalization.
- Newlines are allowed (multiline messages).
\r\nand lone\rare normalized to\n. Unicode line separators (U+2028/U+2029) are not control characters and are neither rejected nor normalized. - No other control characters (matches room-name rules; NUL must not reach Postgres). A tab is rejected.
typing carries no payload — the server knows the sender. It is throttled to at
most one relayed event per session every 1.5 s; anything faster is dropped
silently, and the connection stays open.
react toggles emoji on message_id for the calling user: absent → added,
present → removed. emoji is trimmed, must be non-empty, at most 16 chars,
and control-character free. The target message must exist in the caller's
current room; otherwise an error frame is returned and nothing changes.
Reactions are for other people's messages — reacting to your own returns an
error frame and changes nothing.
Text frames, JSON, tagged by type (snake_case):
{ "type": "message", "message": { "id": "<uuid>", "room_id": "<uuid>", "user_id": "<uuid>", "username": "alice", "body": "hello everyone", "created_at": "<rfc3339>", "reactions": [] } }{ "type": "presence", "event": "joined", "user": { "id": "<uuid>", "username": "alice" } }{ "type": "presence", "event": "left", "user": { "id": "<uuid>", "username": "alice" } }{ "type": "error", "message": "message must not be empty" }{ "type": "typing", "user": { "id": "<uuid>", "username": "alice" } }{ "type": "reaction", "message_id": "<uuid>", "emoji": "👍", "user_id": "<uuid>", "username": "alice", "added": true }{ "type": "read", "message_id": "<uuid>", "user": { "id": "<uuid>", "username": "alice" } }{ "type": "room_deleted", "room_id": "<uuid>" }Semantics:
- Save before broadcast: a
sendframe is inserted into Postgres first; the broadcast carries the row'sidandcreated_at(RFC 3339). Every client — including the sender — receives the identicalmessageevent, so live view and reconnect history (/api/rooms/{id}/messages) always agree. - Reactions: toggling
reactpersists the reaction row (ON CONFLICT DO NOTHING for inserts, DELETE on removal); areactionevent is broadcast room-wide (message_id,emoji,user_id,username,added). The event identifies the actor so clients can update their local lists. Rendering usesuser_ids.lengthto computecount. A user cannot react to their own message. - Error frames are non-fatal: a failed
send(blank, too long, malformed JSON, unparseable shape) or an invalidreactproduces anerrorevent and the connection stays open. Invalid messages/reactions are never persisted. - Presence is reference-counted: a user with several open tabs sees
joinedon their first connection andleftafter their last one closes — no spurious chatter when a tab closes. - Membership is enforced on both directions: leaving the room over HTTP
(
POST /api/rooms/{id}/leave) means the next room message closes the session instead of being delivered. Connections stop at ~64 KiB per message (server-enforced frame cap). - Room deletion is terminal: when the room's creator calls
DELETE /api/rooms/{id}, every connected member receives oneroom_deletedframe carrying theroom_id. Clients must remove the room and must not reconnect to it — the room no longer exists, so a re-handshake is rejected. The delete is acknowledged to the caller only after the frame has been handed to the room's broadcast channel, so members already connected are told before the HTTP response returns. - Typing is ephemeral:
typingevents are relayed room-wide, never persisted, and excluded from history. The server throttles them per session (1.5 s) and does not echo a sender's own event back to them; clients should also ignore anytypingframe whoseuser.idis their own. There is no explicit "stopped typing" frame — send a message or let the client timeout do it. - Read receipts are ephemeral:
POST /api/rooms/{id}/readpersists the caller's cursor (the source of truth for unread counts) and broadcasts areadframe (message_id= the new cursor, only when the cursor actually advanced) so room members can show who has seen a message. The receipt is best-effort: a member with no open connections simply misses it, and clients should ignore areadframe whoseuser.idis their own. There is no client → server read frame; marking read is always the HTTP endpoint. - Events are broadcast room-wide only; room A clients never see room B's
traffic. There is no history replay on connect — fetch
GET /api/rooms/{id}/messagesafter upgrading if needed.
Keepalive: the server responds to protocol ping frames; there are no periodic application-level heartbeats.
200 with { "status": "ok", "service": "live-chat-server", "version": "<semver>" }.
RoomResponse:{ id, name, created_by, created_at, member, unread_count, last_message }LastMessageResponse:{ id, user_id, username, body, created_at }ReadResponse:{ room_id, last_read_message_id, unread_count }MessageResponse:{ id, room_id, user_id, username, body, created_at }- Path ids and the
beforecursor must be well-formed UUIDs. - Handler errors:
{ "error": "<message>" }with status 400/401/403/404/409/500. - Extractor rejections (bad content-type/JSON/query/UUID): plain text with
400/415/422 (and 426 for a non-upgrade request to
/api/ws).