From 1fdd412689daffa7d329eba2abaf31c9f6d0fded Mon Sep 17 00:00:00 2001 From: clawbot Date: Thu, 8 Oct 2026 05:54:16 +0000 Subject: [PATCH] Bring README.md in line with the modes and the schema (closes #119) The MODE section described a query-only command. It now covers channel and user mode queries and changes on the HTTP API and the IRC listener, the letters each accepts, what is broadcast, and the error numerics. The schema section now lists every table and column in 001_initial.sql. The rest of the README was checked against the code, and each statement the code contradicts is corrected: polled numerics carry their name in command and their number in code; command errors are numerics with HTTP 200, not 404 or 409; the broker is keyed by session; WAL is never on; an empty IRC_LISTEN_ADDR does not disable the listener; and the IRC listener handles modes, INVITE, +s and +H more narrowly. Model: opus-5-5 --- README.md | 1032 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 648 insertions(+), 384 deletions(-) diff --git a/README.md b/README.md index 6080c68..7ce2d92 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,10 @@ IRC messages are single lines of text. That's a protocol constraint from 1988, not a deliberate design choice. It forces multiline content through ugly workarounds (multiple PRIVMSG commands, paste flood). -Message bodies here are JSON arrays (one string per line) or objects (for -structured data like key material). This also enables deterministic -canonicalization via RFC 8785 JCS — you can't reliably sign something if the -wire representation is ambiguous. +Message bodies here are JSON arrays (one string per line), with objects planned +for structured data like key material (see `PUBKEY`). This also enables +deterministic canonicalization via RFC 8785 JCS — you can't reliably sign +something if the wire representation is ambiguous. ### 4. Key/value metadata on messages @@ -129,8 +129,7 @@ rather than space-delimited text. The command vocabulary is IRC's, not an invention. The message envelope is deliberately identical for C2S and S2C. A `PRIVMSG` is a -`PRIVMSG` regardless of direction. A `JOIN` from a client is the same shape as -the `JOIN` relayed to channel members. This keeps the protocol simple and makes +`PRIVMSG` regardless of direction. This keeps the protocol simple and makes signing consistent — you sign the same structure you send. ### Why not XMPP or Matrix? @@ -160,9 +159,11 @@ for multi-client access. #### Session Creation - **Session creation**: client sends `POST /api/v1/session` with a desired nick - → server sets an **HttpOnly auth cookie** (`neoirc_auth`) containing a - cryptographically random value (64 hex characters) and returns the user ID and - nick in the JSON response body. No auth credential appears in the JSON body. + and, unless the server has hashcash turned off, a proof-of-work stamp (see + [Hashcash Proof-of-Work](#hashcash-proof-of-work)) → server sets an **HttpOnly + auth cookie** (`neoirc_auth`) containing a cryptographically random value (64 + hex characters) and returns the user ID and nick in the JSON response body. No + auth credential appears in the JSON body. - The auth cookie is HttpOnly, SameSite=Strict, and Secure: clients send it only over HTTPS, which the TLS-terminating reverse proxy provides, or to a server on `localhost` (see [Transport Security](#transport-security)). Browsers @@ -182,10 +183,10 @@ For users who want to access the same session from multiple devices: must be at least 8 characters. - **Login from another client**: `POST /api/v1/login` with nick and password → server verifies the password, creates a new client for the existing session, - and sets an auth cookie. Channel memberships and message queues are shared. - Login only works while the session still exists — if all clients have logged - out or the user has sent QUIT, the session is deleted and the password is - lost. + and sets an auth cookie. Channel memberships are shared; the new client gets + its own message queue. Login only works while the session still exists — if + all clients have logged out or the user has sent QUIT, the session is deleted + and the password is lost. #### Common Properties @@ -204,8 +205,8 @@ casual users: if you don't need multi-client, just create a session and go. Cookie-based auth simplifies credential management — browsers handle cookies automatically, and CLI clients just need a cookie jar (e.g., curl's `-c`/`-b` flags). Note: both anonymous and password-protected sessions are deleted when -the last client disconnects (QUIT or logout). Identity verification at the -message layer via cryptographic signatures (see +any client sends QUIT, or when the last client logs out. Identity verification +at the message layer via cryptographic signatures (see [Security Model](#security-model)) remains independent of password status. ### Hostmask (nick!user@host) @@ -232,12 +233,13 @@ The hostmask appears in: - **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains `[nick, username, hostname, "*"]` - **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a server - operator, includes the target's current client IP and hostname + operator, includes the target's current client IP and hostname (HTTP API only; + the IRC listener's WHOIS does not send it) - **WHO** (`352 RPL_WHOREPLY`) — `params` contains `[channel, username, hostname, server, nick, flags]` -The hostmask format (`nick!user@host`) is stored for future use in ban matching -(`+b` mode) and other access control features. +The hostmask (`nick!user@host`) is built from these fields when needed, and is +what channel bans (`+b` mode) match against. ### Nick Semantics @@ -245,8 +247,9 @@ The hostmask format (`nick!user@host`) is stored for future use in ban matching hold the same nick simultaneously. - Nicks are **case-sensitive** (unlike traditional IRC). `Alice` and `alice` are different nicks. -- Nick length: 1–32 characters. No further character restrictions in the current - implementation. +- Nick length: 1–32 characters. Over the HTTP API a nick starts with a letter or + `_`, followed by letters, digits and `` _-[]\^{}|` ``. The IRC listener cuts a + longer nick to 32 characters and checks nothing else. - Nicks are **released when a session is destroyed** (via `QUIT` command or session expiry). There is no nick registration or reservation system. - Nick changes are broadcast to all users sharing a channel with the changer, as @@ -261,8 +264,9 @@ rules). Case-sensitive comparison is unambiguous. A single user session can have multiple clients (phone, laptop, terminal). - Each client gets a **separate server-to-client (S2C) message queue**. -- The server fans out all S2C messages to every active client queue for that - user session. +- The server fans out messages and events from other users, and the user's own + channel messages and DMs, to every client queue for that user session. Replies + to a command (numerics) go only to the client that sent it. - `GET /api/v1/messages` delivers from the calling client's specific queue, identified by the auth cookie. - Client queues have **independent expiry/pruning** — one client going offline @@ -331,10 +335,10 @@ The server implements HTTP long-polling for real-time message delivery: - The client disconnects (connection closed, no response needed) **Implementation detail:** The server maintains an in-memory broker with -per-client notification channels. When a message is enqueued for a client, the -broker closes all waiting channels for that client, waking up any blocked -long-poll handlers. This is O(1) notification — no polling loops, no database -scanning. +per-session notification channels. When a message is enqueued for a session, the +broker signals every waiting channel for that session, waking up any blocked +long-poll handlers of its clients. This is O(1) notification — no polling loops, +no database scanning. **Timeout limits:** The server caps the `timeout` parameter at 30 seconds. Clients should use 15 seconds as the default. The HTTP write timeout is set to @@ -354,7 +358,7 @@ balancer, and CDN handles it correctly. exactly like IRC. - **Ephemeral** — channels disappear when the last member leaves. There is no persistent channel registration. -- **No channel size limits** in the current implementation. +- **No channel size limits** unless an operator sets one with mode `+l`. - **Channel names** must start with `#`. If a client sends a `JOIN` without the `#` prefix, the server adds it. - **No channel-level encryption** — encryption is per-message via the `meta` @@ -410,15 +414,18 @@ Opaque auth cookies are simpler: ### Transport: HTTP Only All client↔server and server↔server communication uses HTTP/1.1+ with JSON -request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP. +request/response bodies. No WebSockets, no gRPC — just plain HTTP. The one +exception is the [IRC Protocol Listener](#irc-protocol-listener), a raw TCP +listener for standard IRC clients, which is on by default. - **Client reading**: Long-poll `GET /api/v1/messages` — server holds the - connection for up to 15s until messages arrive or timeout. One endpoint for - everything — channel messages, DMs, system events, numeric replies. + connection for up to the `timeout` the client asks for (at most 30s) until + messages arrive. One endpoint for everything — channel messages, DMs, system + events, numeric replies. - **Client writing**: `POST /api/v1/messages` with a `command` field. One endpoint for everything — PRIVMSG, JOIN, PART, NICK, TOPIC, etc. -- **Server federation**: Servers exchange messages via HTTP to enable - multi-server networks (like IRC server linking). +- **Server federation** (planned, not yet implemented): Servers exchange + messages via HTTP to enable multi-server networks (like IRC server linking). The entire read/write loop for a client is two endpoints. Everything else (state, history, channels, members, server info) is ancillary. @@ -435,13 +442,13 @@ The entire read/write loop for a client is two endpoints. Everything else │ → {"id":1, "nick":"alice"} │ │ │ │ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │ -│ → {"status":"joined","channel":"#general"} │ +│ → {"status":"joined","channel":"#gen"} │ │ (Cookie must be sent on all subsequent requests) │ │ │ │ 3. POST /api/v1/messages {"command":"PRIVMSG", │ -│ "to":"#general","body":["hello"]} │ +│ "to":"#gen","body":["hello"]} │ │ → {"id":"uuid-...","status":"sent"} │ -│ (Server fans out to all #general members' queues) │ +│ (Server fans out to all #gen members' queues) │ │ │ │ 4. GET /api/v1/messages?after=0&timeout=15 │ │ ← (held open up to 15s until messages arrive) │ @@ -483,7 +490,7 @@ The entire read/write loop for a client is two endpoints. Everything else │ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ │ → {"id":1, "nick":"alice"} │ │ (New client added to existing session — channels │ -│ and message queues are preserved.) │ +│ are kept; the new client gets its own queue.) │ │ │ └────────────────────────────────────────────────────────────┘ ``` @@ -518,11 +525,12 @@ returns the results. The `queue_id` (auto-incrementing primary key of ### In-Memory Broker The server maintains an in-memory notification broker to avoid database polling. -The broker is a map of `client_id → []chan struct{}`. When a message is enqueued -for a client: +The broker is a map of `session_id → []chan struct{}`. When a message is +enqueued for a session: -1. The handler calls `broker.Notify(clientID)` -2. The broker closes all waiting channels for that client +1. The handler calls `broker.Notify(sessionID)` +2. The broker sends a signal on each waiting channel for that session and + forgets them 3. Any goroutines blocked in `select` on those channels wake up 4. The woken handler queries the database for new queue entries 5. Messages are returned to the client @@ -544,6 +552,7 @@ the same JSON envelope: { "id": "string (uuid)", "command": "string", + "code": integer, "from": "string", "to": "string", "params": ["string", ...], @@ -555,16 +564,17 @@ the same JSON envelope: #### Field Reference -| Field | Type | C2S | S2C | Description | -| --------- | ----------------- | --------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | string (UUID v4) | Ignored | Always | Server-assigned unique message identifier. | -| `command` | string | Required | Always | IRC command name (`PRIVMSG`, `JOIN`, etc.) or 3-digit numeric reply code (`001`, `433`, etc.). Case-insensitive on input; server normalizes to uppercase. | -| `from` | string | Ignored | Usually | Sender's nick (for user messages) or server name (for server messages). Server always overwrites this field — clients cannot spoof the sender. | -| `to` | string | Usually | Usually | Destination: `#channel` for channel targets, bare nick for DMs/user targets. | -| `params` | array of strings | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). | -| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** | -| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. Always UTC. | -| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. | +| Field | Type | C2S | S2C | Description | +| --------- | ----------------- | -------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | string (UUID v4) | Ignored | Always | Server-assigned unique message identifier. | +| `command` | string | Required | Always | IRC command name (`PRIVMSG`, `JOIN`, etc.), or for a numeric reply its name (`RPL_WELCOME`, `ERR_NICKNAMEINUSE`, etc.). Case-insensitive on input; server normalizes to uppercase. | +| `code` | integer | Ignored | Numerics | The number of a numeric reply (`1` for `RPL_WELCOME`, `433` for `ERR_NICKNAMEINUSE`); absent on other messages. | +| `from` | string | Ignored | Usually | Sender's nick (for user messages) or server name (for server messages). Server always overwrites this field — clients cannot spoof the sender. | +| `to` | string | Usually | Usually | Destination: `#channel` for channel targets, bare nick for DMs/user targets. | +| `params` | array of strings | Ignored | Sometimes | Additional IRC-style positional parameters, used by `KICK` and numeric replies like `353` (NAMES). Commands take their arguments from `to` and `body`. | +| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). Objects, for structured data such as `PUBKEY`, are planned; no command accepts one yet. **Never a raw string.** | +| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with up to nanosecond precision, trailing zeros dropped. Example: `"2026-02-10T20:00:00.123456789Z"`. Always UTC. | +| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. | **Important invariants:** @@ -575,7 +585,8 @@ the same JSON envelope: - `id` and `ts` are **always set by the server**. Client-supplied values are ignored. - `meta` is **relayed verbatim**. The server stores it as-is and includes it in - S2C messages. It is never modified, validated, or interpreted by the server. + S2C messages. It is never modified, and the server reads nothing in it except + `meta.hashcash` on a `PRIVMSG` to a `+H` channel. ### Commands (C2S and S2C) @@ -607,7 +618,7 @@ Send a message to a channel or user. This is the primary messaging command. "from": "alice", "to": "#general", "body": ["hello world"], - "ts": "2026-02-10T20:00:00.000000000Z", + "ts": "2026-02-10T20:00:00.123456789Z", "meta": {} } ``` @@ -619,16 +630,25 @@ Send a message to a channel or user. This is the primary messaging command. message echoed back via the queue). - If `to` is a bare nick, the message is a DM. The server fans out to the recipient and the sender (so all of the sender's clients see the DM). -- `body` must be a non-empty array of strings. -- If the channel doesn't exist, the server returns HTTP 404. -- If the DM target nick doesn't exist, the server returns HTTP 404. +- `body` must be a non-empty array of strings; otherwise the server sends + ERR_NOTEXTTOSEND (412). A missing `to` gets ERR_NORECIPIENT (411). +- Only channel members can send to a channel. A non-member, a banned user, and + on a `+m` channel a user with neither `+o` nor `+v`, get ERR_CANNOTSENDTOCHAN + (404). +- If the channel doesn't exist, the server sends ERR_NOSUCHCHANNEL (403). +- If the DM target nick doesn't exist, the server sends ERR_NOSUCHNICK (401). +- The sender of a DM to a user who is away gets RPL_AWAY (301) with their away + message. -**Response:** `201 Created` +**Response:** `200 OK` ```json { "id": "uuid-string", "status": "sent" } ``` +When the server sends an error numeric instead, the response is +`{"status": "error"}`. + **IRC reference:** RFC 1459 §4.4.1 #### NOTICE — Send Notice @@ -646,8 +666,9 @@ This prevents infinite loops between automated systems. } ``` -**Behavior:** Same as PRIVMSG in all respects, except clients receiving a NOTICE -must not send an automatic reply. +**Behavior:** Same as PRIVMSG, except that clients receiving a NOTICE must not +send an automatic reply, and over the HTTP API the server sends no RPL_AWAY for +it and does not check hashcash on `+H` channels. **IRC reference:** RFC 1459 §4.4.2 @@ -660,9 +681,13 @@ Join a channel. If the channel doesn't exist, it is created. ```json {"command": "JOIN", "to": "#general"} {"command": "JOIN", "to": "general"} +{"command": "JOIN", "to": "#general", "body": ["secretpass"]} ``` -If the `#` prefix is omitted, the server adds it. +If the `#` prefix is omitted, the server adds it. The name must then be `#` +followed by 1–63 letters, digits, `_` or `-`; anything else gets +ERR_NOSUCHCHANNEL (403). `body[0]`, if present, is the channel key for a `+k` +channel. **S2C (broadcast to all channel members, including the joiner):** @@ -672,22 +697,25 @@ If the `#` prefix is omitted, the server adds it. "command": "JOIN", "from": "alice", "to": "#general", - "body": [], - "ts": "2026-02-10T20:00:00.000000000Z", + "body": ["#general"], + "ts": "2026-02-10T20:00:00.123456789Z", "meta": {} } ``` **Behavior:** -- If the channel doesn't exist, it is created with no topic and no modes. -- If the user is already in the channel, the JOIN is a no-op (no error, no - duplicate broadcast). +- If the channel doesn't exist, it is created with no topic and modes `+nt`. +- The joining client then gets the topic (RPL_TOPIC 332 with RPL_TOPICWHOTIME + 333, or RPL_NOTOPIC 331) and the member list (RPL_NAMREPLY 353, RPL_ENDOFNAMES + 366). +- A JOIN by a user already in the channel goes through the same checks and is + broadcast and answered again; the membership does not change. - The JOIN event is broadcast to **all** channel members, including the user who joined. This lets the client confirm the join succeeded and lets other members update their member lists. -- The first user to join a channel becomes its implicit operator (not yet - enforced in current implementation). +- The first user to join a channel becomes its operator (`+o`). Bans, `+i`, `+k` + and `+l` can refuse anyone else (see [Channel Modes](#channel-modes)). **Response:** `200 OK` @@ -708,7 +736,7 @@ Leave a channel. {"command": "PART", "to": "#general", "body": ["goodbye"]} ``` -**S2C (broadcast to all channel members, including the leaver):** +**S2C (broadcast to the other channel members):** ```json { @@ -724,8 +752,9 @@ Leave a channel. **Behavior:** -- The PART event is broadcast **before** the member is removed, so the departing - user receives their own PART event. +- The PART event goes to the other channel members, not to the departing user; + the HTTP response confirms the part. (The IRC listener echoes the PART to its + own connection.) - If the channel is empty after the user leaves, the channel is **deleted** (ephemeral channels). - If the user is not in the channel, the server returns an error. @@ -756,7 +785,6 @@ Change the user's nickname. "id": "...", "command": "NICK", "from": "oldnick", - "to": "", "body": ["newnick"], "ts": "...", "meta": {} @@ -765,13 +793,15 @@ Change the user's nickname. **Behavior:** -- `body[0]` is the new nick. Must be 1–32 characters. +- `body[0]` is the new nick. It must be a valid nick (see + [Nick Semantics](#nick-semantics)); otherwise the server sends + ERR_ERRONEUSNICKNAME (432). - The `from` field in the broadcast contains the **old** nick. - The `body[0]` in the broadcast contains the **new** nick. - The NICK event is broadcast to the user themselves and to all users who share at least one channel with the changer. Each recipient receives the event exactly once, even if they share multiple channels. -- If the new nick is already taken, the server returns HTTP 409 Conflict. +- If the new nick is already taken, the server sends ERR_NICKNAMEINUSE (433). **Response:** `200 OK` @@ -779,11 +809,8 @@ Change the user's nickname. { "status": "ok", "nick": "newnick" } ``` -**Error (nick taken):** `409 Conflict` - -```json -{ "error": "nick already in use" } -``` +When the server sends an error numeric instead, the response is +`{"status": "error"}`. **IRC reference:** RFC 1459 §4.1.2 @@ -844,7 +871,10 @@ Set or change a channel's topic. **Behavior:** - Updates the channel's topic in the database. -- The TOPIC event is broadcast to all channel members. +- The TOPIC event is broadcast to all channel members, and the setter's client + also gets RPL_TOPIC (332) and RPL_TOPICWHOTIME (333). +- `body` is required: there is no topic query over the HTTP API, and a TOPIC + without one gets ERR_NEEDMOREPARAMS (461). - If the channel doesn't exist, the server returns an error. - If the channel has mode `+t` (topic lock, default: ON for new channels), only operators (`+o`) can change the topic. Non-operators receive @@ -876,7 +906,6 @@ Destroy the session and disconnect from the server. "id": "...", "command": "QUIT", "from": "alice", - "to": "", "body": ["leaving"], "ts": "...", "meta": {} @@ -889,8 +918,8 @@ Destroy the session and disconnect from the server. user. The quitting user does **not** receive their own QUIT. - The user is removed from all channels. - Empty channels are deleted (ephemeral). -- The user's session is destroyed — the auth cookie is invalidated, the nick is - released. +- The user's session is destroyed — the auth cookies of all its clients are + invalidated, the nick is released. - Subsequent requests with the old auth cookie return HTTP 401. **Response:** `200 OK` @@ -923,36 +952,94 @@ pollute the message queue. **IRC reference:** RFC 1459 §4.6.2, §4.6.3 -#### MODE — Query Modes +#### MODE — Query and Change Modes -Query channel or user modes. Returns the current mode string and, for channels, -the creation timestamp. +Query or change a channel's modes, or query or change your own user modes. A +`to` that starts with `#` is a channel; any other `to` is a nick. **C2S:** ```json {"command": "MODE", "to": "#general"} +{"command": "MODE", "to": "#general", "body": ["+m"]} +{"command": "MODE", "to": "#general", "body": ["+o", "bob"]} {"command": "MODE", "to": "alice"} +{"command": "MODE", "to": "alice", "body": ["+w"]} ``` -**S2C (via message queue):** - -For channels, the server sends RPL_CHANNELMODEIS (324) and RPL_CREATIONTIME -(329): +**Channel query:** with no `body`, the server sends RPL_CHANNELMODEIS (324) and +RPL_CREATIONTIME (329). Anyone may query, member or not. The mode string lists +the set flags out of `nimst`, then `k`, `l` and `H` with their values (key, +member limit, bits) after it, e.g. `+ntkl secretpass 50`. A new channel has +`+nt`: ```json -{"command": "324", "to": "alice", "params": ["#general", "+n"]} -{"command": "329", "to": "alice", "params": ["#general", "1709251200"]} +{"command": "RPL_CHANNELMODEIS", "code": 324, "to": "alice", "params": ["#general", "+nt"]} +{"command": "RPL_CREATIONTIME", "code": 329, "to": "alice", "params": ["#general", "1709251200"]} ``` -For users, the server sends RPL_UMODEIS (221): +**Channel changes:** only channel operators can change channel modes. Over the +HTTP API, `body[0]` is one change, a sign and one letter, and `body[1]` is its +value where it takes one; a string of several letters such as `+mt` is not +accepted. The letters: + +| Change | Value | Effect | +| ----------- | --------------------------- | ----------------------------------------------------------------- | +| `+o` / `-o` | nick | Give or take channel operator | +| `+v` / `-v` | nick | Give or take voice | +| `+i` / `-i` | | Invite-only on or off | +| `+m` / `-m` | | Moderated on or off | +| `+s` / `-s` | | Secret on or off | +| `+t` / `-t` | | Topic lock on or off | +| `+k` / `-k` | key, for `+k` | Set or clear the channel key | +| `+l` / `-l` | positive integer, for `+l` | Set or clear the member limit | +| `+b` / `-b` | `nick!user@host` mask | Add or remove a ban; `+b` with no mask lists the bans, for anyone | +| `+H` / `-H` | bits from 1 to 40, for `+H` | Require hashcash on `PRIVMSG`, or stop requiring it | + +A change made over the HTTP API is sent to every channel member, the operator +included, as a `MODE` message from the operator to the channel. Its `body` is +the change as one string for `o`, `v`, `i`, `m`, `s` and `t` (`["+o bob"]`, +`["+m"]`), and as the change and its value for `k`, `l` and `b` +(`["+k", "secretpass"]`, `["-k", "*"]`, `["+l", "50"]`, `["-l"]`, +`["+b", "*!*@*.example.com"]`). A `+H` or `-H` change is not sent to the +members; the operator gets RPL_CHANNELMODEIS (324) with the new mode string. +Listing bans sends RPL_BANLIST (367) for each ban, with the mask, who set it and +when, then RPL_ENDOFBANLIST (368). + +On the IRC listener, `MODE #channel [values...]` takes several letters +in one string, e.g. `MODE #general +mt-i` or `MODE #general +ov alice bob`. It +accepts `i`, `m`, `n`, `s`, `t`, `o` and `v` (with a nick) and `H` (with bits +from 1 to 40 when setting), but not `b`, `k` or `l`. A letter whose value is +missing is skipped. The changes made are sent back to the operator and to every +channel member as one `MODE` line, e.g. `+o-v alice bob`. + +**Channel errors:** + +| Numeric | Name | When | +| ------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `403` | ERR_NOSUCHCHANNEL | The channel does not exist | +| `482` | ERR_CHANOPRIVSNEEDED | A change by someone who is not a channel operator | +| `461` | ERR_NEEDMOREPARAMS | No `to`; over the HTTP API, also `+o`, `-o`, `+v`, `-v`, `+k`, `+l`, `-b` or `+H` without its value | +| `472` | ERR_UNKNOWNMODE | A letter that is not accepted, `+H` bits that are not a whole number from 1 to 40, or over the HTTP API a `+l` value that is not a positive whole number | +| `401` | ERR_NOSUCHNICK | The `+o`/`+v` nick does not exist | +| `441` | ERR_USERNOTINCHANNEL | The `+o`/`+v` nick is not in the channel | + +**User modes:** you can query and change only your own; a nick other than your +own, compared without regard to letter case, gets ERR_USERSDONTMATCH (502). With +no mode string the server sends RPL_UMODEIS (221) with your modes: `+`, `+o`, +`+w` or `+ow`. A change is a mode string, `body[0]` over the HTTP API or the +parameter after the nick on the IRC listener, that starts with `+` or `-`: `w` +(receive `WALLOPS`) can be set or unset, and `o` (server operator) only unset, +since only `OPER` sets it. Any other letter, or a string that names no mode, +gets ERR_UMODEUNKNOWNFLAG (501) and changes nothing. A change that is accepted +is answered with 221 and the new modes; it is not sent to anyone else. ```json -{ "command": "221", "to": "alice", "body": ["+"] } +{ "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } ``` -**Note:** Mode changes (setting/unsetting modes) are not yet implemented. -Currently only query is supported. +**Response:** `200 OK` with `{"status": "ok"}`, or `{"status": "error"}` when +the reply is an error numeric. **IRC reference:** RFC 1459 §4.2.3 @@ -989,8 +1076,9 @@ Query information about a user. Returns RPL_WHOISUSER (311), RPL_WHOISSERVER RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318). If the querying user is a **server operator** (authenticated via `OPER`), the -response additionally includes RPL_WHOISACTUALLY (338) with the target's current -client IP address and hostname. +response over the HTTP API additionally includes RPL_WHOISACTUALLY (338) with +the target's current client IP address and hostname. The IRC listener's WHOIS +does not send it. **C2S:** @@ -1031,8 +1119,8 @@ LUSERS replies are also sent automatically during connection registration. #### OPER — Gain Server Operator Status Authenticate as a server operator (o-line). On success, the session gains oper -privileges, which currently means additional information is visible in WHOIS -responses (e.g., target user's current client IP and hostname). +privileges: it can use `KILL` and `WALLOPS`, and over the HTTP API its WHOIS +responses include more (the target user's current client IP and hostname). **C2S:** @@ -1043,7 +1131,12 @@ responses (e.g., target user's current client IP and hostname). **S2C (via message queue on success):** ```json -{ "command": "381", "to": "alice", "body": ["You are now an IRC operator"] } +{ + "command": "RPL_YOUREOPER", + "code": 381, + "to": "alice", + "body": ["You are now an IRC operator"] +} ``` **Behavior:** @@ -1055,7 +1148,8 @@ responses (e.g., target user's current client IP and hostname). returned. - On failure (wrong credentials or no o-line configured), `491 ERR_NOOPERHOST` is returned. -- Oper status persists for the session lifetime. There is no de-oper command. +- Oper status lasts until the session ends or the user drops it with user mode + `-o` (see [MODE](#mode--query-and-change-modes)). **IRC reference:** RFC 1459 §4.1.5 @@ -1071,11 +1165,13 @@ command. The kicked user and all channel members receive the KICK message. ``` The first element of `body` is the target nick, the second (optional) is the -reason. If no reason is provided, the kicker's nick is used as the default. +reason. If no reason is provided, the kicker's nick is used as the default; the +IRC listener uses the kicked user's nick instead. **Errors:** - `482` (ERR_CHANOPRIVSNEEDED) — kicker is not a channel operator +- `401` (ERR_NOSUCHNICK) — target nick does not exist - `441` (ERR_USERNOTINCHANNEL) — target is not in the channel - `403` (ERR_NOSUCHCHANNEL) — channel does not exist @@ -1116,50 +1212,57 @@ the full key distribution protocol. ### Numeric Reply Codes (S2C Only) Numeric replies follow IRC conventions from RFC 1459/2812. They are sent from -the server to the client (never C2S) and use 3-digit string codes in the -`command` field. +the server to the client (never C2S). Over the HTTP API the `command` field +holds the reply's name and `code` its number, as in the examples below; the IRC +listener sends the usual 3-digit code. Over the HTTP API, 005 arrives named +`RPL_BOUNCE`, its name in RFC 2812. -| Code | Name | When Sent | Example | -| ----- | -------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | -| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` | -| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | -| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","ikmnostl"]}` | -| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc"],"body":["are supported by this server"]}` | -| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` | -| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | -| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` | -| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` | -| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` | -| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` | -| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | -| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"313","to":"alice","params":["bob"],"body":["is an IRC operator"]}` | -| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` | -| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | -| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` | -| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"338","to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` | -| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` | -| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | -| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` | -| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` | -| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` | -| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | -| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | -| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | -| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | -| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | -| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` | -| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` | -| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"381","to":"alice","body":["You are now an IRC operator"]}` | -| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | -| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | -| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` | -| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` | -| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` | -| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | -| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | -| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | -| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"491","to":"alice","body":["No O-lines for your host"]}` | +| Code | Name | When Sent | Example | +| ----- | -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `001` | RPL_WELCOME | After session creation or login, on MOTD | `{"command":"RPL_WELCOME","code":1,"to":"alice","body":["Welcome to the network, alice"]}` | +| `002` | RPL_YOURHOST | After session creation or login, on MOTD | `{"command":"RPL_YOURHOST","code":2,"to":"alice","body":["Your host is neoirc, running version 0.1"]}` | +| `003` | RPL_CREATED | After session creation or login, on MOTD | `{"command":"RPL_CREATED","code":3,"to":"alice","body":["This server was created 2026-02-10"]}` | +| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","","ikmnostl"]}` | +| `005` | RPL_ISUPPORT | After session creation or login, on MOTD | `{"command":"RPL_BOUNCE","code":5,"to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc","CASEMAPPING=ascii"],"body":["are supported by this server"]}` | +| `221` | RPL_UMODEIS | In response to user MODE | `{"command":"RPL_UMODEIS","code":221,"to":"alice","body":["+"]}` | +| `251` | RPL_LUSERCLIENT | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCLIENT","code":251,"to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | +| `252` | RPL_LUSEROP | On connect, MOTD or LUSERS | `{"command":"RPL_LUSEROP","code":252,"to":"alice","params":["0"],"body":["operator(s) online"]}` | +| `254` | RPL_LUSERCHANNELS | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCHANNELS","code":254,"to":"alice","params":["3"],"body":["channels formed"]}` | +| `255` | RPL_LUSERME | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERME","code":255,"to":"alice","body":["I have 5 clients and 1 servers"]}` | +| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"RPL_WHOISUSER","code":311,"to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` | +| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"RPL_WHOISSERVER","code":312,"to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | +| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"RPL_WHOISOPERATOR","code":313,"to":"alice","params":["bob"],"body":["is an IRC operator"]}` | +| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"RPL_ENDOFWHO","code":315,"to":"alice","params":["#general"],"body":["End of /WHO list"]}` | +| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"RPL_ENDOFWHOIS","code":318,"to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | +| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"RPL_WHOISCHANNELS","code":319,"to":"alice","params":["bob"],"body":["#general #dev"]}` | +| `322` | RPL_LIST | In response to LIST | `{"command":"RPL_LIST","code":322,"to":"alice","params":["#general","5"],"body":["General discussion"]}` | +| `323` | RPL_LISTEND | End of LIST response | `{"command":"RPL_LISTEND","code":323,"to":"alice","body":["End of /LIST"]}` | +| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"RPL_CHANNELMODEIS","code":324,"to":"alice","params":["#general","+nt"]}` | +| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"RPL_CREATIONTIME","code":329,"to":"alice","params":["#general","1709251200"]}` | +| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"RPL_NOTOPIC","code":331,"to":"alice","params":["#general"],"body":["No topic is set"]}` | +| `332` | RPL_TOPIC | On JOIN or TOPIC change | `{"command":"RPL_TOPIC","code":332,"to":"alice","params":["#general"],"body":["Welcome!"]}` | +| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"RPL_WHOISACTUALLY","code":338,"to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` | +| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"RPL_WHOREPLY","code":352,"to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | +| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"RPL_NAMREPLY","code":353,"to":"alice","params":["=","#general"],"body":["@op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | +| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"RPL_ENDOFNAMES","code":366,"to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | +| `367` | RPL_BANLIST | One ban, on a ban list query | `{"command":"RPL_BANLIST","code":367,"to":"alice","params":["#general","*!*@*.example.com","alice","1709251200"]}` | +| `368` | RPL_ENDOFBANLIST | End of a ban list | `{"command":"RPL_ENDOFBANLIST","code":368,"to":"alice","params":["#general"],"body":["End of channel ban list"]}` | +| `372` | RPL_MOTD | MOTD line | `{"command":"RPL_MOTD","code":372,"to":"alice","body":["- Welcome to the server"]}` | +| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"RPL_MOTDSTART","code":375,"to":"alice","body":["- neoirc Message of the Day -"]}` | +| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"RPL_ENDOFMOTD","code":376,"to":"alice","body":["End of /MOTD command."]}` | +| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"RPL_YOUREOPER","code":381,"to":"alice","body":["You are now an IRC operator"]}` | +| `401` | ERR_NOSUCHNICK | Nick does not exist | `{"command":"ERR_NOSUCHNICK","code":401,"to":"alice","params":["bob"],"body":["No such nick"]}` | +| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"ERR_NOSUCHCHANNEL","code":403,"to":"alice","params":["#nope"],"body":["No such channel"]}` | +| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"ERR_UNKNOWNCOMMAND","code":421,"to":"alice","params":["FOO"],"body":["Unknown command"]}` | +| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"ERR_ERRONEUSNICKNAME","code":432,"to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` | +| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"ERR_NICKNAMEINUSE","code":433,"to":"alice","params":["bob"],"body":["Nickname is already in use"]}` | +| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"ERR_NOTONCHANNEL","code":442,"to":"alice","params":["#general"],"body":["You're not on that channel"]}` | +| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"ERR_NEEDMOREPARAMS","code":461,"to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | +| `472` | ERR_UNKNOWNMODE | Channel mode not accepted | `{"command":"ERR_UNKNOWNMODE","code":472,"to":"alice","params":["+q"],"body":["is unknown mode char to me"]}` | +| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"ERR_CHANOPRIVSNEEDED","code":482,"to":"alice","params":["#general"],"body":["You're not channel operator"]}` | +| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"ERR_NOOPERHOST","code":491,"to":"alice","body":["No O-lines for your host"]}` | +| `501` | ERR_UMODEUNKNOWNFLAG | User mode not accepted | `{"command":"ERR_UMODEUNKNOWNFLAG","code":501,"to":"alice","body":["Unknown MODE flag"]}` | +| `502` | ERR_USERSDONTMATCH | User MODE for another nick | `{"command":"ERR_USERSDONTMATCH","code":502,"to":"alice","body":["Can't change mode for other users"]}` | **Note:** Numeric replies are now implemented. All IRC command responses (success and error) are delivered as numeric replies through the message queue. @@ -1169,19 +1272,21 @@ carries IRC-style parameters (e.g., channel name, target nick). ### Channel Modes -Inspired by IRC, simplified: +Inspired by IRC, simplified. See [MODE](#mode--query-and-change-modes) for how +to set them; `b`, `k` and `l` can be set only over the HTTP API, and `n` only on +the IRC listener. -| Mode | Name | Meaning | Status | -| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ------------ | -| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | -| `+i` | Invite-only | Only invited users can join; use `INVITE nick #channel` to invite | **Enforced** | -| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** | -| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | -| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | -| `+n` | No external | Only channel members can send messages to the channel | **Enforced** | -| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** | -| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | -| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** | +| Mode | Name | Meaning | Status | +| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | +| `+i` | Invite-only | Only invited users can join; use `INVITE` to invite | **Enforced**; only an invite made over the HTTP API lets a user in | +| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced**; the IRC listener sends no key, so its users cannot join a channel that has one | +| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | +| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | +| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set | +| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS` over the HTTP API; not by `GET /api/v1/channels` or the IRC listener | +| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | +| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** over the HTTP API only | **User channel modes (set per-user per-channel):** @@ -1194,58 +1299,72 @@ Inspired by IRC, simplified: automatically receives `+o` operator status. **Ban system (+b):** Operators can ban users by hostmask pattern with wildcard -matching (`*` and `?`). `MODE #channel +b` with no argument lists current bans. -Bans prevent both joining and sending messages. +matching (`*` and `?`, ignoring letter case). `+b` with no mask lists current +bans. Bans prevent both joining and sending messages. -``` -MODE #channel +b *!*@*.example.com — ban all users from example.com -MODE #channel -b *!*@*.example.com — remove the ban -MODE #channel +b — list all bans (RPL_BANLIST 367/368) +```json +{"command": "MODE", "to": "#channel", "body": ["+b", "*!*@*.example.com"]} +{"command": "MODE", "to": "#channel", "body": ["-b", "*!*@*.example.com"]} +{"command": "MODE", "to": "#channel", "body": ["+b"]} ``` -**Invite-only (+i):** When set, users must be invited by an operator before -joining. The `INVITE` command records an invite that is consumed on JOIN. +These ban all users from example.com, remove that ban, and list all bans +(RPL_BANLIST 367, RPL_ENDOFBANLIST 368). -``` -MODE #channel +i — set invite-only -INVITE nick #channel — invite a user (operator only on +i channels) +**Invite-only (+i):** When set, users must be invited before joining. Over the +HTTP API, `INVITE` records an invite that is consumed on JOIN; any member can +invite, and on a `+i` channel only an operator can. `INVITE` on the IRC listener +tells the user about the invite but records none. + +```json +{"command": "MODE", "to": "#channel", "body": ["+i"]} +{"command": "INVITE", "body": ["nick", "#channel"]} ``` -**Channel key (+k):** Requires a password to join the channel. +**Channel key (+k):** Requires a password to join the channel. Over the HTTP API +the key is `body[0]` of the `JOIN`. -``` -MODE #channel +k secretpass — set a channel key -MODE #channel -k * — remove the key -JOIN #channel secretpass — join with key +```json +{"command": "MODE", "to": "#channel", "body": ["+k", "secretpass"]} +{"command": "MODE", "to": "#channel", "body": ["-k"]} +{"command": "JOIN", "to": "#channel", "body": ["secretpass"]} ``` **User limit (+l):** Caps the number of members in the channel. -``` -MODE #channel +l 50 — set limit to 50 members -MODE #channel -l — remove the limit +```json +{"command": "MODE", "to": "#channel", "body": ["+l", "50"]} +{"command": "MODE", "to": "#channel", "body": ["-l"]} ``` -**Secret (+s):** Hides the channel from `LIST` for non-members and from `WHOIS` -channel lists when the querier is not in the same channel. +**Secret (+s):** The HTTP API's `LIST` hides the channel from non-members, and +its `WHOIS` leaves it out of the channel list when the querier is not in the +same channel. + +**Join errors:** a user who is banned gets ERR_BANNEDFROMCHAN (474), one not +invited to a `+i` channel ERR_INVITEONLYCHAN (473), one with a missing or wrong +key ERR_BADCHANNELKEY (475), and one joining a full channel ERR_CHANNELISFULL +(471). None of these apply to the user who creates the channel. **KICK command:** Channel operators can remove users with `KICK #channel nick [:reason]`. The kicked user and all channel members receive the KICK message. -**NOTICE:** Follows RFC 2812 — NOTICE never triggers auto-replies (including -RPL_AWAY), and skips hashcash validation on +H channels (servers and services -use NOTICE). +**NOTICE:** Follows RFC 2812 over the HTTP API — NOTICE never triggers +auto-replies (including RPL_AWAY), and skips hashcash validation on +H channels +(servers and services use NOTICE). On the IRC listener, a NOTICE to a user who +is away does get RPL_AWAY. -**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and `CHANMODES=b,k,Hl,imnst` -in RPL_ISUPPORT (005). +**ISUPPORT:** The server advertises `PREFIX=(ov)@+` in RPL_ISUPPORT (005), with +`CHANMODES=b,k,Hl,imnst` over the HTTP API and `CHANMODES=,,H,imnst` on the IRC +listener. ### Per-Channel Hashcash (Anti-Spam) -Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an -anti-spam mechanism: channel operators set a difficulty level, and clients must -compute a proof-of-work stamp bound to the specific channel and message before -sending. +Channels can require hashcash proof-of-work for every `PRIVMSG` sent over the +HTTP API; the IRC listener does not check it. This is an anti-spam mechanism: +channel operators set a difficulty level, and clients must compute a +proof-of-work stamp bound to the specific channel and message before sending. **Setting the requirement:** @@ -1304,6 +1423,9 @@ the format: { "error": "human-readable error message" } ``` +A request to an authenticated endpoint without a valid cookie gets `401` with +`{"error": "not registered", "numeric": 451}`. + ### POST /api/v1/session — Create Session Create a new user session. This is the entry point for all clients. @@ -1323,16 +1445,16 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field. } ``` -| Field | Type | Required | Constraints | -| ----------- | ------ | ----------- | -------------------------------------------------------------- | -| `nick` | string | Yes | 1–32 characters, must be unique on the server | -| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. | -| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | +| Field | Type | Required | Constraints | +| ----------- | ------ | ----------- | -------------------------------------------------------------------------- | +| `nick` | string | Yes | A valid nick (see [Nick Semantics](#nick-semantics)), unique on the server | +| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. | +| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | The `username` field sets the user portion of the IRC hostmask (`nick!user@host`). The hostname is automatically resolved via reverse DNS of the connecting client's IP address at session creation time. Together these form -the hostmask used in WHOIS, WHO, and future ban matching (`+b`). +the hostmask used in WHOIS, WHO, and ban matching (`+b`). **Response:** `201 Created` @@ -1340,7 +1462,7 @@ The response sets an `neoirc_auth` HttpOnly cookie containing an opaque auth value. The JSON body does **not** include the auth credential. ``` -Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict +Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; Secure; SameSite=Strict ``` ```json @@ -1369,7 +1491,8 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict | Status | Error | When | | ------ | --------------------------------- | ------------------------------------------------------------------ | -| 400 | `nick must be 1-32 characters` | Empty or too-long nick | +| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | +| 400 | `invalid nick format` | Nick is not a valid nick | | 400 | `invalid username format` | Username doesn't match allowed format | | 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled | | 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | @@ -1387,9 +1510,9 @@ curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ ### POST /api/v1/login — Login to Account Authenticate with a nick and password (set via the PASS IRC command). Creates a -new client for the existing session, preserving channel memberships and message -queues. This is how multi-client access works: each login adds a new client to -the session with its own auth cookie and message delivery queue. +new client for the existing session, preserving channel memberships. This is how +multi-client access works: each login adds a new client to the session with its +own auth cookie and message delivery queue. On successful login, the server enqueues MOTD messages and synthetic channel state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the @@ -1424,10 +1547,12 @@ The response sets an `neoirc_auth` HttpOnly cookie for the new client. **Errors:** -| Status | Error | When | -| ------ | ---------------------------- | -------------------------------------------------------------- | -| 400 | `nick and password required` | Missing nick or password | -| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set | +| Status | Error | When | +| ------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | +| 400 | `nick and password required` | Missing nick or password | +| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set | +| 429 | `too many login attempts, try again later` | Per-IP rate limit exceeded (see [Login Rate Limiting](#login-rate-limiting)); comes with `Retry-After: 1` | **curl example:** @@ -1512,8 +1637,8 @@ real-time endpoint — clients call it in a loop. "command": "JOIN", "from": "bob", "to": "#general", - "body": [], - "ts": "2026-02-10T20:00:00.000000000Z", + "body": ["#general"], + "ts": "2026-02-10T20:00:00.123456789Z", "meta": {} }, { @@ -1522,7 +1647,7 @@ real-time endpoint — clients call it in a loop. "from": "alice", "to": "#general", "body": ["hello world"], - "ts": "2026-02-10T20:00:01.000000000Z", + "ts": "2026-02-10T20:00:01.234567891Z", "meta": {} } ], @@ -1530,10 +1655,10 @@ real-time endpoint — clients call it in a loop. } ``` -| Field | Type | Description | -| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------- | -| `messages` | array | Array of IRC message envelopes (see [Protocol Specification](#protocol-specification)). Empty array if no messages. | -| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. | +| Field | Type | Description | +| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `messages` | array | Array of IRC message envelopes (see [Protocol Specification](#protocol-specification)), at most 100 per response. Empty array if no messages. | +| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. | **Long-poll behavior:** @@ -1579,12 +1704,16 @@ reference with all required and optional fields. | ---------- | --------------- | -------- | --------------- | | `PRIVMSG` | `to`, `body` | `meta` | 200 OK | | `NOTICE` | `to`, `body` | `meta` | 200 OK | -| `JOIN` | `to` | | 200 OK | +| `JOIN` | `to` | `body` | 200 OK | | `PART` | `to` | `body` | 200 OK | | `NICK` | `body` | | 200 OK | | `PASS` | `body` | | 200 OK | | `TOPIC` | `to`, `body` | | 200 OK | | `MODE` | `to` | `body` | 200 OK | +| `INVITE` | `body` | | 200 OK | +| `KICK` | `to`, `body` | | 200 OK | +| `AWAY` | | `body` | 200 OK | +| `MOTD` | | | 200 OK | | `NAMES` | `to` | | 200 OK | | `LIST` | | | 200 OK | | `WHOIS` | `to` or `body` | | 200 OK | @@ -1601,77 +1730,100 @@ reference with all required and optional fields. | `QUIT` | | `body` | 200 OK | | `PING` | | | 200 OK | -All IRC commands return HTTP 200 OK. IRC-level success and error responses are -delivered as **numeric replies** through the message queue (see -[Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are -reserved for transport-level problems: malformed JSON (400), missing/invalid -auth cookies (401), and server errors (500). +All IRC commands return HTTP 200 OK: `{"status": "error"}` when the reply is an +error numeric, and otherwise a status of the command's own (see each command). +IRC-level success and error responses are delivered as **numeric replies** +through the message queue (see +[Numeric Reply Codes](#numeric-reply-codes-s2c-only)); `PING` is the exception, +its `PONG` is the HTTP response body. HTTP error codes (4xx/5xx) are reserved +for transport-level problems: malformed JSON (400), missing/invalid auth cookies +(401), and server errors (500). **HTTP errors (transport-level only):** -| Status | Error | When | -| ------ | ----------------- | ------------------------------- | -| 400 | `invalid request` | Malformed JSON or empty command | -| 401 | `unauthorized` | Missing or invalid auth cookie | -| 500 | `internal error` | Server-side failure | +| Status | Error | When | +| ------ | ---------------------- | ------------------------------------------------------- | +| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | +| 400 | `command required` | Empty command | +| 401 | `not registered` | Missing or invalid auth cookie | +| 500 | `internal error` | Server-side failure | **IRC numeric error replies (delivered via message queue):** -| Numeric | Name | When | -| ------- | -------------------- | -------------------------------------------- | -| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist | -| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name | -| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command | -| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format | -| 433 | ERR_NICKNAMEINUSE | NICK target is taken | -| 442 | ERR_NOTONCHANNEL | Not a member of the target channel | -| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | -| 481 | ERR_NOPRIVILEGES | KILL or WALLOPS by a non-operator | -| 483 | ERR_CANTKILLSERVER | KILL of yourself | -| 491 | ERR_NOOPERHOST | Failed OPER authentication | -| 501 | ERR_UMODEUNKNOWNFLAG | User MODE string not accepted | -| 502 | ERR_USERSDONTMATCH | User MODE for a nick other than your own | +| Numeric | Name | When | +| ------- | -------------------- | ----------------------------------------------------------------- | +| 401 | ERR_NOSUCHNICK | Target nick doesn't exist | +| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name | +| 404 | ERR_CANNOTSENDTOCHAN | Message to a channel not allowed (not a member, `+b`, `+m`, `+H`) | +| 411 | ERR_NORECIPIENT | PRIVMSG or NOTICE without `to` | +| 412 | ERR_NOTEXTTOSEND | PRIVMSG or NOTICE without `body` | +| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command | +| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format | +| 433 | ERR_NICKNAMEINUSE | NICK target is taken | +| 441 | ERR_USERNOTINCHANNEL | KICK or MODE `+o`/`+v` target is not in the channel | +| 442 | ERR_NOTONCHANNEL | Not a member of the target channel | +| 443 | ERR_USERONCHANNEL | INVITE of a user already in the channel | +| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | +| 471 | ERR_CHANNELISFULL | JOIN to a full `+l` channel | +| 472 | ERR_UNKNOWNMODE | Channel MODE not accepted | +| 473 | ERR_INVITEONLYCHAN | JOIN to a `+i` channel without an invite | +| 474 | ERR_BANNEDFROMCHAN | JOIN by a banned user | +| 475 | ERR_BADCHANNELKEY | JOIN to a `+k` channel with a missing or wrong key | +| 481 | ERR_NOPRIVILEGES | KILL or WALLOPS by a non-operator | +| 482 | ERR_CHANOPRIVSNEEDED | Operator-only action by a non-operator | +| 483 | ERR_CANTKILLSERVER | KILL of yourself | +| 491 | ERR_NOOPERHOST | Failed OPER authentication | +| 501 | ERR_UMODEUNKNOWNFLAG | User MODE string not accepted | +| 502 | ERR_USERSDONTMATCH | User MODE for a nick other than your own | **IRC numeric success replies (delivered via message queue):** -| Numeric | Name | When | -| ------- | ----------------- | ------------------------------------ | -| 001 | RPL_WELCOME | Sent on session creation/login | -| 002 | RPL_YOURHOST | Sent on session creation/login | -| 003 | RPL_CREATED | Sent on session creation/login | -| 004 | RPL_MYINFO | Sent on session creation/login | -| 005 | RPL_ISUPPORT | Sent on session creation/login | -| 221 | RPL_UMODEIS | In response to user MODE | -| 251 | RPL_LUSERCLIENT | On connect or LUSERS command | -| 252 | RPL_LUSEROP | On connect or LUSERS command | -| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command | -| 255 | RPL_LUSERME | On connect or LUSERS command | -| 256–259 | RPL_ADMINME etc. | ADMIN info | -| 302 | RPL_USERHOST | USERHOST reply | -| 311 | RPL_WHOISUSER | WHOIS user info | -| 312 | RPL_WHOISSERVER | WHOIS server info | -| 313 | RPL_WHOISOPERATOR | WHOIS target is oper | -| 315 | RPL_ENDOFWHO | End of WHO list | -| 318 | RPL_ENDOFWHOIS | End of WHOIS list | -| 319 | RPL_WHOISCHANNELS | WHOIS channels list | -| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | -| 351 | RPL_VERSION | VERSION reply | -| 371 | RPL_INFO | INFO line | -| 374 | RPL_ENDOFINFO | End of INFO | -| 391 | RPL_TIME | TIME reply | -| 322 | RPL_LIST | Channel in LIST response | -| 323 | RPL_LISTEND | End of LIST | -| 324 | RPL_CHANNELMODEIS | Channel mode query response | -| 329 | RPL_CREATIONTIME | Channel creation timestamp | -| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) | -| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) | -| 352 | RPL_WHOREPLY | User in WHO response | -| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) | -| 366 | RPL_ENDOFNAMES | End of NAMES list | -| 375 | RPL_MOTDSTART | Start of MOTD | -| 372 | RPL_MOTD | MOTD line | -| 376 | RPL_ENDOFMOTD | End of MOTD | -| 381 | RPL_YOUREOPER | Successful OPER authentication | +| Numeric | Name | When | +| ------- | ----------------- | --------------------------------------- | +| 001 | RPL_WELCOME | Sent on session creation/login and MOTD | +| 002 | RPL_YOURHOST | Sent on session creation/login and MOTD | +| 003 | RPL_CREATED | Sent on session creation/login and MOTD | +| 004 | RPL_MYINFO | Sent on session creation/login and MOTD | +| 005 | RPL_ISUPPORT | Sent on session creation/login and MOTD | +| 221 | RPL_UMODEIS | In response to user MODE | +| 251 | RPL_LUSERCLIENT | On connect, MOTD or LUSERS command | +| 252 | RPL_LUSEROP | On connect, MOTD or LUSERS command | +| 254 | RPL_LUSERCHANNELS | On connect, MOTD or LUSERS command | +| 255 | RPL_LUSERME | On connect, MOTD or LUSERS command | +| 256–259 | RPL_ADMINME etc. | ADMIN info | +| 301 | RPL_AWAY | PRIVMSG to a user who is away | +| 302 | RPL_USERHOST | USERHOST reply | +| 305 | RPL_UNAWAY | AWAY with no message | +| 306 | RPL_NOWAWAY | AWAY with a message | +| 311 | RPL_WHOISUSER | WHOIS user info | +| 312 | RPL_WHOISSERVER | WHOIS server info | +| 313 | RPL_WHOISOPERATOR | WHOIS target is oper | +| 315 | RPL_ENDOFWHO | End of WHO list | +| 317 | RPL_WHOISIDLE | WHOIS idle time and signon time | +| 318 | RPL_ENDOFWHOIS | End of WHOIS list | +| 319 | RPL_WHOISCHANNELS | WHOIS channels list | +| 322 | RPL_LIST | Channel in LIST response | +| 323 | RPL_LISTEND | End of LIST | +| 324 | RPL_CHANNELMODEIS | Channel mode query, `+H`/`-H` change | +| 329 | RPL_CREATIONTIME | Channel creation timestamp | +| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) | +| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) | +| 333 | RPL_TOPICWHOTIME | Who set the topic, and when | +| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | +| 341 | RPL_INVITING | INVITE sent | +| 351 | RPL_VERSION | VERSION reply | +| 352 | RPL_WHOREPLY | User in WHO response | +| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) | +| 366 | RPL_ENDOFNAMES | End of NAMES list | +| 367 | RPL_BANLIST | One ban, on a ban list query | +| 368 | RPL_ENDOFBANLIST | End of a ban list | +| 371 | RPL_INFO | INFO line | +| 372 | RPL_MOTD | MOTD line | +| 374 | RPL_ENDOFINFO | End of INFO | +| 375 | RPL_MOTDSTART | Start of MOTD | +| 376 | RPL_ENDOFMOTD | End of MOTD | +| 381 | RPL_YOUREOPER | Successful OPER authentication | +| 391 | RPL_TIME | TIME reply | ### GET /api/v1/history — Message History @@ -1684,7 +1836,11 @@ Fetch historical messages for a channel. Returns messages in chronological order | -------- | ------- | ---------- | -------------------------------------------------------------------------------- | | `target` | string | (required) | Channel name (e.g., `#general`) | | `before` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. | -| `limit` | integer | `50` | Maximum messages to return. | +| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. | + +`target` may also be your own nick, which returns the DMs sent to you. Errors: +`400` `target required`, `404` `channel not found`, `403` +`not a member of this channel`, and `403` `forbidden` for any other nick. **Response:** `200 OK` @@ -1696,7 +1852,7 @@ Fetch historical messages for a channel. Returns messages in chronological order "from": "alice", "to": "#general", "body": ["first message"], - "ts": "2026-02-10T19:00:00.000000000Z", + "ts": "2026-02-10T19:00:00.123456789Z", "meta": {} }, { @@ -1705,7 +1861,7 @@ Fetch historical messages for a channel. Returns messages in chronological order "from": "bob", "to": "#general", "body": ["second message"], - "ts": "2026-02-10T19:01:00.000000000Z", + "ts": "2026-02-10T19:01:00.234567891Z", "meta": {} } ] @@ -1748,11 +1904,20 @@ List members of a channel. The `{name}` parameter is the channel name ```json [ - { "id": 1, "nick": "alice", "lastSeen": "2026-02-10T20:00:00Z" }, - { "id": 2, "nick": "bob", "lastSeen": "2026-02-10T19:55:00Z" } + { + "id": 1, + "nick": "alice", + "username": "alice", + "hostname": "host.example.com", + "isOperator": true, + "isVoiced": false, + "lastSeen": "2026-02-10T20:00:00Z" + } ] ``` +An unknown channel gets `404` `channel not found`. + **curl example:** ```bash @@ -1779,9 +1944,9 @@ The response clears the `neoirc_auth` cookie. **Errors:** -| Status | Error | When | -| ------ | -------------- | ------------------------------ | -| 401 | `unauthorized` | Missing or invalid auth cookie | +| Status | Error | When | +| ------ | ---------------- | ------------------------------ | +| 401 | `not registered` | Missing or invalid auth cookie | **curl example:** @@ -1847,7 +2012,7 @@ health status and runtime statistics. ```json { "status": "ok", - "now": "2024-01-15T12:00:00.000000000Z", + "now": "2024-01-15T12:00:00.123456789Z", "uptimeSeconds": 3600, "uptimeHuman": "1h0m0s", "version": "0.1.0", @@ -1863,15 +2028,15 @@ health status and runtime statistics. } ``` -| Field | Description | -| ---------------------- | ----------------------------------------------------- | -| `sessions` | Current number of active sessions | -| `clients` | Current number of connected clients | -| `queuedLines` | Total entries in client output queues | -| `channels` | Current number of channels | -| `connectionsSinceBoot` | Total client connections since server start | -| `sessionsSinceBoot` | Total sessions created since server start | -| `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start | +| Field | Description | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `sessions` | Current number of active sessions | +| `clients` | Current number of connected clients | +| `queuedLines` | Total entries in client output queues | +| `channels` | Current number of channels | +| `connectionsSinceBoot` | Session creations and logins over the HTTP API since server start | +| `sessionsSinceBoot` | Sessions created over the HTTP API since server start | +| `messagesSinceBoot` | PRIVMSG/NOTICE commands with a target and body received over the HTTP API since server start, whether delivered or refused | --- @@ -1892,7 +2057,7 @@ Alice Server Bob │ │ 4. Enqueue for bob │ │ │ 5. Notify alice broker │ │ │ 6. Notify bob broker │ - │ 201 {"status":"sent"} │ │ + │ 200 {"status":"sent"} │ │ │<───────────────────────│ │ │ │ │ │ GET /messages?after=N │ GET /messages?after=M │ @@ -1918,7 +2083,7 @@ Alice Server Bob │ │ 4. Enqueue for alice │ │ │ (echo to sender) │ │ │ 5. Notify both │ - │ 201 {"status":"sent"} │ │ + │ 200 {"status":"sent"} │ │ │<───────────────────────│ │ │ │ │ │ (alice sees her own DM │ (bob sees DM from │ @@ -1953,6 +2118,10 @@ Messages support optional cryptographic signatures for integrity verification. Servers relay signatures verbatim without verifying them — verification is purely a client-side concern. +**Status:** this section documents the design. `PUBKEY` and message signing are +not yet implemented (see [Roadmap](#roadmap)); today the server relays `meta`, +and any signature in it, verbatim. + ### Canonicalization (RFC 8785 JCS) To produce a deterministic byte representation of a message for signing: @@ -2041,7 +2210,7 @@ has different canonical forms depending on escaping rules). "to": "#general", "body": ["this message is signed"], "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "ts": "2026-02-10T20:00:00.000000000Z", + "ts": "2026-02-10T20:00:00.123456789Z", "meta": { "alg": "ed25519", "sig": "base64url-encoded-64-byte-signature" @@ -2066,8 +2235,9 @@ PGP/DKIM — the mail server sees everything, but signatures prove authenticity. entropy). Cookie values are hashed (SHA-256) before storage and validated on every request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict (CSRF protection), and Secure (sent only over HTTPS). -- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No - password, instant access. The auth cookie is the sole credential. +- **Anonymous sessions**: `POST /api/v1/session` requires only a nick and, with + hashcash on (the default), a proof-of-work stamp. No password. The auth cookie + is the sole credential. - **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed password on the session. `POST /api/v1/login` authenticates against the stored hash and issues a new client cookie. @@ -2120,8 +2290,10 @@ PGP/DKIM — the mail server sees everything, but signatures prove authenticity. itself. - **CORS**: The server allows all origins with credentials (`Access-Control-Allow-Credentials: true`), reflecting the request Origin. - This enables cookie-based auth from cross-origin clients. Restrict origins in - production via reverse proxy configuration if needed. + Browsers still send the `SameSite=Strict` auth cookie only on requests from + the same site, so cookie-based auth works from other origins of the same site + but not from other sites. Restrict origins in production via reverse proxy + configuration if needed. - **Content-Security-Policy**: The server sets a strict CSP header on all responses, restricting resource loading to same-origin and disabling dangerous features (object embeds, framing, base tag injection). The embedded SPA works @@ -2224,7 +2396,9 @@ Postgres support is planned for larger deployments but not yet implemented. ### Schema The database schema is managed via embedded SQL migration files in -`internal/db/schema/`. Migrations run automatically on server start. +`internal/db/schema/`. Migrations run automatically on server start. `000.sql` +creates `schema_migrations`, which records the version of each migration +applied; `001_initial.sql` creates the tables below. **Current tables:** @@ -2238,12 +2412,13 @@ The database schema is managed via embedded SQL migration files in | `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) | | `hostname` | TEXT | Reverse DNS hostname of the connecting client IP | | `ip` | TEXT | Real IP address of the session creator | -| `is_oper` | INTEGER | Server operator (o-line) status (0 = no, 1 = yes) | +| `is_oper` | INTEGER | Server operator, user mode `+o` (0 = no, 1 = yes) | +| `is_wallops` | INTEGER | User mode `+w`, receives `WALLOPS` (0 = no, 1 = yes) | | `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) | -| `signing_key` | TEXT | Public signing key (empty string if unset) | +| `signing_key` | TEXT | Public signing key; nothing sets or reads it yet | | `away_message` | TEXT | Away message (empty string if not away) | | `created_at` | DATETIME | Session creation time | -| `last_seen` | DATETIME | Last API request time | +| `last_seen` | DATETIME | Last login or authenticated HTTP API request | Index on `(uuid)`. @@ -2258,30 +2433,65 @@ Index on `(uuid)`. | `ip` | TEXT | Real IP address of this client connection | | `hostname` | TEXT | Reverse DNS hostname of this client connection | | `created_at` | DATETIME | Client creation time | -| `last_seen` | DATETIME | Last API request time | +| `last_seen` | DATETIME | Last authenticated HTTP API request (or creation) | Indexes on `(token)` and `(session_id)`. #### `channels` -| Column | Type | Description | -| -------------- | -------- | -------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `name` | TEXT | Unique channel name (e.g., `#general`) | -| `topic` | TEXT | Channel topic (default empty) | -| `topic_set_by` | TEXT | Nick of the user who set the topic (default empty) | -| `topic_set_at` | DATETIME | When the topic was last set | -| `created_at` | DATETIME | Channel creation time | -| `updated_at` | DATETIME | Last modification time | +| Column | Type | Description | +| ----------------- | -------- | --------------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `name` | TEXT | Unique channel name (e.g., `#general`) | +| `topic` | TEXT | Channel topic (default empty) | +| `topic_set_by` | TEXT | Nick of the user who set the topic (default empty) | +| `topic_set_at` | DATETIME | When the topic was last set (null until then) | +| `hashcash_bits` | INTEGER | Mode `+H`: bits a `PRIVMSG` stamp must have (0 = off) | +| `is_moderated` | INTEGER | Mode `+m` (default 0) | +| `is_topic_locked` | INTEGER | Mode `+t` (default 1) | +| `is_invite_only` | INTEGER | Mode `+i` (default 0) | +| `is_secret` | INTEGER | Mode `+s` (default 0) | +| `is_no_external` | INTEGER | Mode `+n` (default 1) | +| `channel_key` | TEXT | Mode `+k`: the key needed to join (empty string = no key) | +| `user_limit` | INTEGER | Mode `+l`: maximum number of members (0 = no limit) | +| `created_at` | DATETIME | Channel creation time | +| `updated_at` | DATETIME | Last topic or mode change | -#### `channel_members` +#### `channel_bans` + +| Column | Type | Description | +| ------------ | -------- | --------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `channel_id` | INTEGER | FK → channels.id (cascade delete) | +| `mask` | TEXT | Banned `nick!user@host` pattern (`*` and `?` match) | +| `set_by` | TEXT | Nick of the operator who set the ban | +| `created_at` | DATETIME | When the ban was set | + +Unique constraint on `(channel_id, mask)`. Index on `(channel_id)`. + +#### `channel_invites` | Column | Type | Description | | ------------ | -------- | --------------------------------- | | `id` | INTEGER | Primary key (auto-increment) | | `channel_id` | INTEGER | FK → channels.id (cascade delete) | | `session_id` | INTEGER | FK → sessions.id (cascade delete) | -| `joined_at` | DATETIME | When the user joined | +| `invited_by` | TEXT | Nick of the user who invited | +| `created_at` | DATETIME | When the invite was made | + +Unique constraint on `(channel_id, session_id)`. Index on `(channel_id)`. An +invite is deleted when the invited user joins the channel. + +#### `channel_members` + +| Column | Type | Description | +| ------------- | -------- | --------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `channel_id` | INTEGER | FK → channels.id (cascade delete) | +| `session_id` | INTEGER | FK → sessions.id (cascade delete) | +| `is_operator` | INTEGER | Channel operator, mode `+o` (0 = no, 1 = yes) | +| `is_voiced` | INTEGER | Voice, mode `+v` (0 = no, 1 = yes) | +| `joined_at` | DATETIME | When the user joined | Unique constraint on `(channel_id, session_id)`. @@ -2301,6 +2511,18 @@ Unique constraint on `(channel_id, session_id)`. Indexes on `(msg_to, id)` and `(created_at)`. +#### `spent_hashcash` + +| Column | Type | Description | +| ------------ | -------- | ---------------------------------------------------- | +| `id` | INTEGER | Primary key (auto-increment) | +| `stamp_hash` | TEXT | Unique SHA-256 (hex) of a channel stamp already used | +| `created_at` | DATETIME | When the stamp was used | + +Index on `(created_at)`. Only the stamps of `+H` channel messages are recorded +here, and rows older than a year are pruned; session creation stamps are tracked +in memory. + #### `client_queues` | Column | Type | Description | @@ -2327,47 +2549,57 @@ issues) and simpler than UUIDs (integer comparison vs. string comparison). `QUIT` or when the last client logs out (`POST /api/v1/logout` with no remaining clients triggers session cleanup). There is no distinction between session types in the cleanup path — `handleQuit` and `cleanupUser` both call - `DeleteSession` unconditionally. Idle sessions are automatically expired after - `SESSION_IDLE_TIMEOUT` (default 30 days) — the server runs a background - cleanup loop that parts idle users from all channels, broadcasts QUIT, and - releases their nicks. + `DeleteSession` unconditionally. Idle clients are automatically removed after + `SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP API + request — the server runs a background cleanup loop that, for a session left + with no clients, parts the user from all channels, broadcasts QUIT, and + releases the nick. A client on the IRC listener never makes such a request, so + it counts as idle from when it connected. - **Clients**: Individual client auth cookies are invalidated on `POST /api/v1/logout`. A session can have multiple clients; removing one doesn't affect others. However, when the last client is removed (via logout), the entire session is deleted — the user is parted from all channels, QUIT is broadcast, and the nick is released. +The cleanup loop that does all of this pruning and expiry runs every half +`SESSION_IDLE_TIMEOUT` (every 15 days by default), the first time that long +after the server starts, so rows can outlive their maximum age by up to that +interval. + --- ## Configuration All configuration is via environment variables, read by [Viper](https://github.com/spf13/viper). A `.env` file in the working directory -is also loaded automatically via [godotenv](https://github.com/joho/godotenv). +is also loaded automatically via [godotenv](https://github.com/joho/godotenv), +and Viper also reads a YAML file `neoirc.yaml` from `/etc/neoirc/` or +`$HOME/.config/neoirc/` if one exists, with the same names as keys. An +environment variable set to the empty string counts as unset, so the default +applies. -| Variable | Type | Default | Description | -| ---------------------- | ------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | int | `8080` | HTTP listen port | -| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | -| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | -| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | -| `SESSION_IDLE_TIMEOUT` | string | `720h` | Session idle timeout as a Go duration string (e.g. `720h`, `24h`). Sessions with no activity for this long are expired and the nick is released. Default is 30 days. | -| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | -| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum message body size in bytes (planned enforcement) | -| `LONG_POLL_TIMEOUT` | int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | -| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | -| `SERVER_NAME` | string | `""` | Server display name. Defaults to hostname if empty. | -| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) | -| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | -| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | -| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | -| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | -| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. | -| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | -| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | -| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | -| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set to empty string to disable. | -| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | +| Variable | Type | Default | Description | +| ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | int | `8080` | HTTP listen port | +| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. The SQLite driver ignores `_journal_mode`, so this does not turn on WAL mode. | +| `DEBUG` | bool | `false` | Switch logging to debug level | +| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | +| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. | +| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | +| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` | +| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` | +| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. | +| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) | +| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | +| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | +| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | +| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | +| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. | +| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | +| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | +| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | +| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Only an empty value in `neoirc.yaml` disables it; an empty environment variable does not. | +| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | ### Example `.env` file @@ -2393,10 +2625,12 @@ and others. ### Configuration The IRC listener is **enabled by default** on `:6667`. To disable it, set -`IRC_LISTEN_ADDR` to an empty string: +`IRC_LISTEN_ADDR` to an empty string in `neoirc.yaml` (see +[Configuration](#configuration)); an empty environment variable counts as unset +and leaves it on `:6667`: -```bash -IRC_LISTEN_ADDR= +```yaml +IRC_LISTEN_ADDR: "" ``` ### Supported Commands @@ -2414,15 +2648,18 @@ IRC_LISTEN_ADDR= - **Wire format**: CR-LF delimited lines, max 512 bytes per line - **Connection registration**: Clients must send `NICK` and `USER` to register. An optional `PASS` before registration sets the session password (minimum 8 - characters). -- **CAP negotiation**: `CAP LS` and `CAP END` are silently handled for - compatibility with modern clients. No capabilities are advertised. -- **Channel prefixes**: Channels must start with `#`. If omitted, it's - automatically prepended. + characters). Registering creates a session without a hashcash stamp; closing + the connection ends the session, as `QUIT` does. +- **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is + ignored, for compatibility with modern clients. +- **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if + omitted; other commands use the name as given. - **First joiner**: The first user to join a channel is automatically granted operator status (`@`). -- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), `+v` - (voice) +- **Channel modes**: `+i` (invite-only), `+m` (moderated), `+n` (no external), + `+s` (secret), `+t` (topic lock), `+H` (hashcash), `+o` (operator), `+v` + (voice); see [MODE](#mode--query-and-change-modes). `+b`, `+k` and `+l` can be + set only over the HTTP API. - **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives `WALLOPS`). `MODE` for any nick other than your own is rejected with `ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison is @@ -2488,7 +2725,9 @@ except `script/install-precommit`, which is `make hooks`, and `script/cibuild`, ### Docker (Recommended) -The Docker image contains a single static binary (`neoircd`) and nothing else. +The Docker image is Alpine with a single static binary (`neoircd`) and CA +certificates, run as the user `neoirc`, with a `HEALTHCHECK` on the health check +endpoint. ```bash # Build @@ -2514,7 +2753,7 @@ The Dockerfile is a five-stage build: 4. **builder**: Runs only once lint and test have passed; compiles static `neoircd` and `neoirc-cli` binaries with the real SPA assets from web-builder (CLI built to verify compilation, not included in final image) -5. **final**: Minimal Alpine image with only the `neoircd` binary +5. **final**: Minimal Alpine image with the `neoircd` binary and CA certificates ### Binary @@ -2525,7 +2764,7 @@ make build # Run ./bin/neoircd -# Listens on :8080, writes to /var/lib/neoirc/state.db +# Listens on :8080 (HTTP) and :6667 (IRC), writes to /var/lib/neoirc/state.db ``` ### Reverse Proxy (Production) @@ -2554,6 +2793,7 @@ server { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; proxy_read_timeout 60s; # Must be > long-poll timeout } } @@ -2564,13 +2804,14 @@ seconds to accommodate long-poll connections. ### SQLite Considerations -- **WAL mode** is enabled by default (`?_journal_mode=WAL` in the connection - string). This allows concurrent reads during writes. -- **Single writer**: SQLite allows only one writer at a time. For high-traffic - servers, Postgres support is planned. +- **Journal mode**: the default connection string carries `?_journal_mode=WAL`, + but the SQLite driver ignores that parameter, so the database runs in SQLite's + default rollback-journal mode, not WAL. +- **Single writer**: the server uses one database connection, so reads and + writes take turns. For high-traffic servers, Postgres support is planned. - **Backup**: The database is a single file. Back it up with - `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file - (safe with WAL mode). + `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`, or copy the file while + the server is stopped. - **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the `DBURL` env var to place it elsewhere. @@ -2595,6 +2836,10 @@ A complete client needs only four HTTP calls: ### Step-by-Step with curl +These examples, and the Python one below, assume a server run with +`NEOIRC_HASHCASH_BITS=0`. With the default of 20, session creation also needs a +`pow_token` (see [Hashcash Proof-of-Work](#hashcash-proof-of-work)). + ```bash # 1a. Create a session (cookie saved automatically with -c) curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ @@ -2739,8 +2984,11 @@ Clients should handle these message commands from the queue: - **HTTP 401**: Auth cookie expired or invalid. Re-create session or re-login (if a password was set). -- **HTTP 404**: Channel or user not found. -- **HTTP 409**: Nick already taken (on session creation or NICK change). +- **HTTP 404**: Channel not found (`GET /api/v1/channels/{name}/members` and + `GET /api/v1/history`). +- **HTTP 409**: Nick already taken on session creation. A NICK change to a taken + nick, like every other command error, is a numeric in the queue + (ERR_NICKNAMEINUSE 433) with `{"status": "error"}` as the response. - **HTTP 400**: Malformed request. Check the `error` field in the response. - **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s). @@ -2770,9 +3018,10 @@ Clients should handle these message commands from the queue: ### Hashcash Proof-of-Work Session creation (`POST /api/v1/session`) requires a -[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token. -This is the primary defense against resource exhaustion — no CAPTCHAs, no -account registration, no IP-based rate limits that punish shared networks. +[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token +(registering on the IRC listener does not). This is the primary defense against +resource exhaustion — no CAPTCHAs, no account registration, no IP-based rate +limits that punish shared networks. ### How It Works @@ -2786,9 +3035,10 @@ account registration, no IP-based rate limits that punish shared networks. - Version is `1` - Claimed bits ≥ required bits - Resource matches the server name - - Date is within 48 hours (not expired, not too far in the future) + - Date is no more than 48 hours old and no more than 1 hour in the future - SHA-256 hash has the required leading zero bits - - Stamp has not been used before (replay prevention) + - Stamp has not been used before (replay prevention; the spent stamps are + kept in memory, so a restart forgets them) ### Stamp Format @@ -2861,8 +3111,8 @@ creating one session pays once and keeps their session. ### Why Hashcash and Not Rate Limits? -- **No state to track**: No IP tables, no token buckets, no sliding windows. The - server only needs to verify a single hash. +- **Little state to track**: No IP tables, no token buckets, no sliding windows. + The server verifies a single hash and remembers the stamps already spent. - **Works through NATs and proxies**: Doesn't punish shared IPs (university campuses, corporate networks, Tor exits). Every client computes their own proof independently. @@ -2933,7 +3183,7 @@ guess is borne by the server (bcrypt), not the client. - [x] QUIT with broadcast and cleanup - [x] Embedded web SPA client - [x] CLI client (neoirc-cli) -- [x] SQLite storage with WAL mode +- [x] SQLite storage - [x] Docker deployment - [x] Prometheus metrics endpoint - [x] Health check endpoint @@ -2965,9 +3215,11 @@ guess is borne by the server (bcrypt), not the client. - [x] **KICK command** — remove users from channels (operator-only) - [x] **Numeric replies** — send IRC numeric codes via the message queue (001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE, - 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors) -- [ ] **Max message size enforcement** — reject oversized messages -- [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag) + 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-502 errors) +- [x] **Max message size enforcement** — reject request bodies over + `MAX_MESSAGE_SIZE` bytes +- [x] **NOTICE command** — distinct from PRIVMSG (no RPL_AWAY, no hashcash + check) - [x] **Multi-client sessions** — set a password via PASS command, then login from additional devices via `POST /api/v1/login` - [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API @@ -2994,8 +3246,8 @@ guess is borne by the server (bcrypt), not the client. - [x] **User info command** — WHOIS for querying user info and channels - [ ] **Connection flood protection** — per-IP connection limits as a complement to hashcash -- [ ] **Invite system** — `INVITE` command for `+i` channels -- [ ] **Ban system** — channel-level bans by nick pattern +- [x] **Invite system** — `INVITE` command for `+i` channels +- [x] **Ban system** — channel-level bans by hostmask pattern --- @@ -3025,17 +3277,26 @@ neoirc/ │ │ └── config.go │ ├── db/ # Database access and migrations │ │ ├── db.go # Connection, migration runner -│ │ ├── queries.go # All SQL queries and data types +│ │ ├── auth.go # Passwords and login +│ │ ├── errors.go # Error helpers +│ │ ├── queries.go # The other SQL queries and data types │ │ └── schema/ +│ │ ├── 000.sql # schema_migrations bootstrap │ │ └── 001_initial.sql │ ├── globals/ # Application-wide metadata │ │ └── globals.go │ ├── handlers/ # HTTP request handlers -│ │ ├── handlers.go # Deps, JSON response helper -│ │ ├── api.go # All API endpoint handlers +│ │ ├── handlers.go # Deps, JSON response helper, cleanup loop +│ │ ├── api.go # Most API endpoint handlers +│ │ ├── auth.go # Login and PASS +│ │ ├── utility.go # Operator commands, user MODE, tier 3 commands │ │ └── healthcheck.go # Health check handler +│ ├── hashcash/ # Hashcash stamp validation │ ├── healthcheck/ # Health check logic │ │ └── healthcheck.go +│ ├── ircserver/ # IRC protocol listener +│ ├── ratelimit/ # Per-IP login rate limiter +│ ├── service/ # Command logic shared by HTTP API and IRC listener │ ├── stats/ # Runtime statistics (atomic counters) │ │ └── stats.go │ ├── logger/ # slog-based logging @@ -3056,7 +3317,9 @@ neoirc/ │ │ ├── index.html │ │ └── style.css │ └── dist/ # Generated at Docker build time (not committed) -├── schema/ # JSON Schema definitions (planned) +├── pkg/ +│ └── irc/ # IRC command names and numeric reply codes +├── schema/ # JSON Schema definitions ├── script/ # Entrypoints that the Makefile targets call ├── go.mod ├── go.sum @@ -3072,7 +3335,7 @@ neoirc/ | Purpose | Library | | ---------- | ------------------------------------------------------- | | DI | `go.uber.org/fx` | -| Router | `github.com/go-chi/chi` | +| Router | `github.com/go-chi/chi/v5` | | Logging | `log/slog` (stdlib) | | Config | `github.com/spf13/viper` | | Env | `github.com/joho/godotenv/autoload` | @@ -3102,10 +3365,11 @@ neoirc/ 1459/2812. If you've built an IRC client or bot, you already know the command vocabulary. The only new things are the JSON encoding and the HTTP transport. -4. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol - negotiation. HTTP is universal, proxy-friendly, CDN-friendly, and works on - every device and network. Long-polling provides real-time delivery without - any of the complexity of persistent connections. +4. **HTTP is the only transport** — no WebSockets, no protocol negotiation, and + no raw TCP apart from the [IRC Protocol Listener](#irc-protocol-listener) + kept for standard IRC clients. HTTP is universal, proxy-friendly, + CDN-friendly, and works on every device and network. Long-polling provides + real-time delivery without any of the complexity of persistent connections. 5. **Server holds state** — clients are stateless. Reconnect, switch devices, lose connectivity for hours — your messages are waiting in your client queue. @@ -3148,7 +3412,7 @@ neoirc/ **Implementation in progress.** Core API is functional with: -- SQLite storage with WAL mode +- SQLite storage - All core IRC commands (PRIVMSG, JOIN, PART, NICK, TOPIC, QUIT, PING) - IRC message envelope format with per-client queue fan-out - Long-polling with in-memory broker