Canonical reference for the Anemoia API: WebSocket JSON protocol and REST auth. Used by the Preact client and any future native clients.
Operator configuration for registration modes, SMTP, rate limits, and reverse-proxy trust is documented in .env.example (production) and .env.dev.example (development). Gmail / app passwords: docs/smtp.md.
- URL:
/ws - Auth: Token in query string:
/ws?token=<session_token>. The token comes fromPOST /api/auth/loginorPOST /api/auth/registerwhen a web session is created (see Registration modes — pending flows do not return a token until the user is active and logs in). The IRC bridge password is sent as an HTTP-only cookie on the upgrade request; the cookie is set on login/register when a web session is created. Both a valid session token and the credential cookie are required to connect. - Format: JSON text frames. One JSON object per frame.
- Close codes:
4401— Unauthorized: missing or invalid token, missing credential cookie, or user not allowed to use the bridge (client should clear session and redirect to login).4000— Server error (e.g. session service unavailable).
| Type | Payload | Meaning |
|---|---|---|
message |
target, text |
Send a chat message to channel or DM (target = channel name or nick). |
join |
channel |
Join a channel. |
part |
channel, reason? |
Part a channel. |
typing |
target |
Typing indicator (may be ignored). |
history |
channel, before?, limit? |
Request scrollback; limit default 50, max 200. |
| Type | Payload | Meaning |
|---|---|---|
status |
connected, reason? |
Connection state. |
message |
from, target, text, time, id? |
Chat message (time ISO). ACTION: text \u0001ACTION ...\u0001. |
join |
nick, channel |
User joined channel. |
part |
nick, channel, reason? |
User left channel. |
userlist |
channel, users |
users = [{ nick, away }]. |
topic |
channel, text, set_by? |
Channel topic. |
history |
channel, messages |
Scrollback response. |
error |
message |
Error string. |
channels_changed |
(none) | Refetch GET /api/channels and refresh the sidebar. |
Controlled by REGISTRATION_MODE: open (default), email_code, or approval.
| Mode | On register | Session / cookie | Ergo account |
|---|---|---|---|
open |
User is active immediately | Token + credential cookie | Created immediately (SAREGISTER) |
email_code |
User pending until email verified | No token or cookie | Created after successful verify-email |
approval |
User pending until admin approves | No token or cookie | Created after admin approve |
email_coderequires SMTP andPENDING_REGISTRATION_KEYat API startup; the process exits if either is misconfigured.approvalrequiresPENDING_REGISTRATION_KEYat startup; SMTP is optional (used only to email admins when new registrations arrive, if configured).
Send credentials: 'same-origin' on register/login so the credential cookie is set when applicable. Use the returned token as Authorization: Bearer <token> and in /ws?token=... when a token is returned.
Body: { nick, email, password } — nick must be a valid IRC nick (same rules as login); display_name in the DB is stored as a copy of nick for schema compatibility.
open mode — success 201: { userId, nick, token, role } (and credential cookie).
email_code — success 201: { userId, nick, pendingVerification: true } — no token, no cookie.
approval — success 201: { userId, nick, pendingApproval: true } — no token, no cookie.
Errors: 400 validation (including invalid nick), 409 duplicate email or nick unavailable (already taken in Ergo or the API DB), 500 if Ergo account creation fails (open).
Rate-limited per IP (see env RATE_LIMIT_REGISTER_*). With TRUST_PROXY=1, the API trusts X-Forwarded-For / X-Real-IP for the client IP used by rate limiting.
Body: { nick, password, logOutOtherSessions? }
Success 200: { userId, nick, token, role } + credential cookie. role is admin or user.
Errors:
401— wrong nick/password.403— correct password but account not active:{ code: 'pending_verification' | 'pending_approval', message }.
Body: { email, code } (six-digit code; normalize whitespace server-side).
| HTTP | Body |
|---|---|
200 |
{ verified: true } — user is now active; Ergo account created. Client should then POST /api/auth/login with nick + password. |
400 |
{ code: 'verification_failed', message } — generic (wrong code, unknown email, expired, not pending); do not distinguish cases (enumeration-safe). |
409 |
{ code: 'nick_unavailable', message } — nick already registered in Ergo before SAREGISTER. |
500 |
Server/oper failure; user stays pending. |
Rate-limited: RATE_LIMIT_VERIFY_EMAIL_*. No 401 on this route.
Body: { email }
Always 200 with { ok: true } whether the email exists, is pending, or not (enumeration-safe). If the user exists and status is pending_email, a new code is emailed and any previous code is invalidated.
Rate-limited: RATE_LIMIT_RESEND_CODE_*.
Header: Authorization: Bearer <token>
200:{ userId, nick, email, display_name, avatar_url, bio, role }— user is active and session valid;roleisadminoruser.avatar_urlandbiomay benull.display_nameis the stored label (defaults tonickwhen unset).401— missing/invalid/expired token.403:{ code: 'account_inactive', message }— token matches a session but the user is not active (e.g. pending approval); client should clear UI state / show appropriate screen.
Header: Authorization: Bearer <token>
Body: JSON object with any of display_name (string), avatar_url (string or null), bio (string or null). Omitted keys are left unchanged. display_name is trimmed; empty string clears to the default (server may store nick). avatar_url must be a valid URL per server validation (strict-url-sanitise). bio is trimmed; max 500 Unicode codepoints; markdown allowed; no raw HTML.
Success 200: Same shape as GET /api/auth/me.
Errors: 400 validation (invalid URL, bio too long, etc.). 401 invalid session.
Body: { email } — trimmed; match is case-insensitive on stored email.
Success 200: { ok: true } — if an active account exists for that email, sends a one-hour reset link; if not, same response (enumeration-safe).
Errors:
503{ code: 'smtp_not_configured', message }— SMTP env vars not set; password reset email cannot be sent.503{ code: 'public_url_not_configured', message }— cannot build the link (setPUBLIC_URLorREGISTRATION_URL, or ensure reverse proxy sendsHost/X-Forwarded-*).500— email send failure after creating a token (token is rolled back).
Rate-limited: RATE_LIMIT_FORGOT_PASSWORD_*. Reset links point to {PUBLIC or REGISTRATION base}/reset-password?token=... (see server getPublicWebBaseUrl).
Body: { token, newPassword } — token from the email link query string; newPassword min 8 characters.
Success 204. Updates DB and Ergo (PASSWD via oper when oper client is configured); deletes all sessions for that user and clears the credential cookie.
Errors: 400 { code: 'invalid_token', message } — unknown, expired, or already-used token.
Header: Authorization: Bearer <token>
Body: { currentPassword, newPassword }
Success 204. Updates DB and Ergo (PASSWD via oper); refreshes credential cookie when applicable.
Header: Authorization: Bearer <token> and/or body: { token }
204. Clears credential cookie and deletes the session.
Requires Bearer token and admin role.
| Method | Path | Success | Notes |
|---|---|---|---|
GET |
/api/admin/pending-registrations |
200 — list of { id, nick, email, created_at } |
Users with pending_approval only |
POST |
/api/admin/pending-registrations/:userId/approve |
204 |
Activates user, SAREGISTER on Ergo; emails the user Your account has been approved when SMTP is configured (failure to send is logged only; response is still 204); 409 { code: 'nick_unavailable', message } if nick taken |
POST |
/api/admin/pending-registrations/:userId/reject |
204 |
Deletes pending user (and verification rows); frees nick |
| Method | Path | Success | Notes |
|---|---|---|---|
GET |
/api/admin/users |
200 — array of { id, nick, email, role, status, created_at } |
Query: limit (default 50, max 200), search (optional substring on nick, case-insensitive). Excludes pending_approval (use pending-registrations). |
| Method | Path | Success | Notes |
|---|---|---|---|
GET |
/api/admin/motd |
200 { text } |
Plain text from ERGO_MOTD_PATH or repo ergo/ergo.motd relative to cwd |
PATCH |
/api/admin/motd |
200 { text } |
Body { text }; writes file then REHASH via oper when available; 503 if save succeeded but reload failed |
| Method | Path | Success | Notes |
|---|---|---|---|
GET |
/api/admin/channels |
200 — array of { name, topic, created_at, description, ops_count, member_count } |
description mirrors topic for v1; member_count is null (not tracked in DB); ops_count from channel_ops |
GET |
/api/admin/channels/:name/members |
200 { members: [{ userId, nick, access }] } |
access: op or super_op (from Anemoia DB only; not full IRC NAMES) |
Public list for the client sidebar.
200: JSON array of{ name, topic }, e.g.[{ "name": "#general", "topic": "" }].
These routes do not require a session (unless noted). Used for the chat header, display-name hydration, and profile popovers.
200:{ name, channels, userCount }—namefromCOMMUNITY_NAME(defaultAnemoia).channelsmirrors the sidebar shape ({ name, topic }[]).userCountis a best-effort count from the IRC bridge health check when available, else0.
Body: { "nicks": string[] } — deduped server-side; max 50 nicks per request.
Success 200: { users: [{ nick, display_name, avatar_url }] } — only active users present in the DB; omitted nicks are not listed.
Rate limit: 120 requests per IP per minute (see server config).
Canonical URL for the web client — lives under /api, so it is reverse-proxied to the API in development (Vite) and production (Caddy). Same JSON as below.
Success 200: { nick, display_name, avatar_url, bio } for an active user (case-insensitive nick in the path). bio may be null. No email.
404 — unknown or inactive user.
Same JSON as GET /api/users/profile/:nick, for direct hits against the API process (e.g. curl to the API port). Do not use this path from the browser on the same origin as the SPA: the static server’s SPA fallback will serve index.html instead of JSON unless you add a dedicated reverse-proxy rule.
The web client converts inline markdown (remark-gfm subset) to mIRC-style formatting bytes before sending on the WebSocket, so traditional IRC clients see bold/italic/monospace and plain URLs. Block features such as tables are rejected in the composer. URLs are validated with strict-url-sanitise; unsafe URLs are replaced with a placeholder token on the wire. Incoming messages are parsed for the same formatting codes and rendered in the web UI (links and images after sanitisation).
Authenticated upload for chat attachments. Files are stored on disk (UPLOAD_DIR); the reverse proxy serves them at /uploads/<filename> (see deployment.md).
Header: Authorization: Bearer <token>
Body: multipart/form-data with one file part (field name file).
Success 201: { "url": "https://<host>/uploads/<id>.<ext>" } — url uses PUBLIC_URL if set (no trailing slash), otherwise https://$DOMAIN, otherwise the API’s own origin in dev.
Errors: 401 missing/invalid session, 400 no file in body, 413 over size limit (UPLOAD_MAX_BYTES, default 5 MiB), 415 unsupported MIME type.
Allowed MIME types (v1): image/jpeg, image/png, image/gif, image/webp, image/svg+xml, application/pdf.
IRC_CREDENTIAL_KEY: Base64; decodes to ≥32 bytes; first 32 bytes encrypt the IRC password in the HTTP-only cookie (packages/api/src/services/credentialCookie.js).PENDING_REGISTRATION_KEY: Same loading rules asIRC_CREDENTIAL_KEY(separate secret). Used for AES-256-GCM of the deferred registration password (same wire format as the credential cookie: 12-byte IV + ciphertext + 16-byte auth tag, base64) and for HMAC-SHA256 of email verification codes (preimageanemoia:v1:email_verify:<userId>:<code>). Required at startup whenREGISTRATION_MODEisemail_codeorapproval.
The API serves Swagger UI at /api/docs and the raw spec at /api/docs/json (YAML: /api/docs/yaml). The default server entry in the spec follows OPENAPI_SERVER_URL, then https://$DOMAIN, then http://127.0.0.1:$PORT. Use Authorize in Swagger UI with a Bearer token from login/register when testing protected routes.
Follow-ups (non-blocking improvements): polish-list.md.