diff --git a/README.md b/README.md index 5489b9a..a3b05d2 100644 --- a/README.md +++ b/README.md @@ -1,1451 +1,195 @@ # neoirc -**IRC semantics, structured message metadata, cryptographic signing, and -server-held session state with per-client delivery queues. All over HTTP+JSON.** +neoirc is an MIT-licensed chat server written in Go by +[@sneak](https://sneak.berlin). It carries IRC commands and replies as JSON over +HTTP, and keeps each user's session on the server. -An IRC-inspired server written in Go that decouples session state from transport -connections, enabling mobile-friendly persistent sessions over plain HTTP. - -The **HTTP API is the primary interface**. It's designed to be simple enough -that writing a terminal IRC-style client against it is straightforward — just -`curl` and `jq` get you surprisingly far. The server also ships an embedded web -client as a convenience/reference implementation, but the API comes first. - ---- - -## Table of Contents - -- [Motivation](#motivation) -- [Why Not IRC / XMPP / Matrix?](#why-not-just-use-irc--xmpp--matrix) -- [Design Decisions](#design-decisions) -- [Architecture](#architecture) -- [Protocol Specification](#protocol-specification) -- [API Reference](#api-reference) -- [Message Flow](#message-flow) -- [Canonicalization & Signing](#canonicalization-and-signing) -- [Security Model](#security-model) -- [Federation](#federation-server-to-server) -- [Storage](#storage) -- [Configuration](#configuration) -- [IRC Protocol Listener](#irc-protocol-listener) -- [Entrypoints](#entrypoints) -- [Deployment](#deployment) -- [Client Development Guide](#client-development-guide) -- [Rate Limiting & Abuse Prevention](#rate-limiting--abuse-prevention) -- [Roadmap](#roadmap) -- [Project Structure](#project-structure) -- [Design Principles](#design-principles) -- [Status](#status) +- [What neoirc is](#what-neoirc-is) +- [Why it exists](#why-it-exists) +- [Getting started](#getting-started) +- [How it works](#how-it-works) +- [Using the HTTP API](#using-the-http-api) +- [The IRC listener](#the-irc-listener) +- [The web client](#the-web-client) +- [neoirc-cli](#neoirc-cli) +- [Running the server](#running-the-server) +- [Development](#development) - [License](#license) +- [Author](#author) ---- +## What neoirc is -## Motivation +neoirc is IRC with a different transport. The commands are IRC's: `PRIVMSG`, +`JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`, `KICK`. The replies are IRC's numeric +replies, such as `353` and `433`. Channels start with `#`. The first user to +join a channel creates it, and a channel disappears when its last member leaves. -IRC is in decline because session state is tied to the TCP connection. In a -mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer or -pay for IRCCloud. +Four things differ from IRC: -This project builds a server that: +- A client talks to the server over HTTP. It sends commands with one `POST` + request and reads its messages with one `GET` request. +- The server holds the session. A session keeps its nick, its channels and its + queued messages while no client is connected. +- One session can have several clients at once, such as a phone and a laptop. + Each client has its own message queue. +- A message body is a JSON array of lines, not one line of text. -- Holds session state server-side (message queues, presence, channel membership) -- Exposes a minimal, clean HTTP+JSON API — easy to build clients against -- Supports multiple concurrent clients per user session -- Provides IRC-like semantics: channels, nicks, topics, modes -- Uses structured JSON messages with IRC command names and numeric reply codes -- Enables optional cryptographic message signing with deterministic - canonicalization +The server also has a plain IRC listener on port 6667. Standard IRC clients +connect to it and share channels with HTTP clients. -The entire client read/write loop is two HTTP endpoints. If a developer can't -build a working IRC-style TUI client against this API in an afternoon, the API -is too complex. +The repository ships three programs: ---- +- `neoircd`, the server. +- A web client, which the server serves at `/`. +- `neoirc-cli`, a terminal client. -## Why Not Just Use IRC / XMPP / Matrix? +## Why it exists -This isn't a new protocol that borrows IRC terminology for familiarity. This -**is** IRC — the same command model, the same semantics, the same numeric reply -codes from RFC 1459/2812 — carried over HTTP+JSON instead of raw TCP. +IRC ties a session to one TCP connection. When a phone drops the connection, the +user loses the nick, the channels and every message sent in between. neoirc +keeps the session on the server, so a client can disconnect and come back +without losing anything. Everything else stays IRC: the same commands, the same +replies, the same channel modes. -The question isn't "why build something new?" It's "what's the minimum set of -changes to make IRC work on modern devices?" The answer turned out to be four -things: +## Getting started -### 1. HTTP transport instead of persistent TCP +Clone the repository, build the Docker image and run it: -IRC requires a persistent TCP connection. That's fine on a desktop. On a phone, -the OS kills your background socket, you lose your session, you miss messages. -Bouncers exist but add complexity and a second point of failure. - -HTTP solves this cleanly: clients poll when they're awake, messages queue when -they're not. Works through firewalls, proxies, CDNs. Every language has an HTTP -client. No custom protocol parsers, no connection state machines. - -### 2. Server-held session state - -In IRC, the TCP connection _is_ the session. Disconnect and you're gone — your -nick is released, you leave all channels, messages sent while you're offline are -lost forever. This is IRC's fundamental mobile problem. - -Here, sessions persist independently of connections. Your nick, channel -memberships, and message queue survive disconnects. Multiple devices can share a -session simultaneously, each with its own delivery queue. - -### 3. Structured message bodies - -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), 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 - -The `meta` field on every message envelope carries extensible attributes — -cryptographic signatures, content hashes, whatever clients want to attach. IRC -has no equivalent; bolting signatures onto IRC requires out-of-band mechanisms -or stuffing data into CTCP. - -### What didn't change - -Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`, -`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`. -Joining a nonexistent channel creates it. Channels disappear when empty. Nicks -are unique per server. Identity starts with a key — a nick is a display name. -Accounts are optional: you can create an anonymous session instantly, or set a -password via the PASS command for multi-client access to a single session. - -### On the resemblance to JSON-RPC - -All C2S commands go through `POST /api/v1/messages` with a `command` field that -dispatches the action. This looks like JSON-RPC, but the resemblance is -incidental. It's IRC's command model — `PRIVMSG #channel :hello` becomes -`{"command": "PRIVMSG", "to": "#channel", "body": ["hello"]}` — encoded as JSON -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. This keeps the protocol simple and makes -signing consistent — you sign the same structure you send. - -### Why not XMPP or Matrix? - -XMPP is XML-based, overengineered for messaging, and the ecosystem is fragmented -across incompatible extensions (XEPs). Matrix is a federated append-only event -graph with a spec that runs to hundreds of pages. Both are fine protocols, but -they're solving different problems at different scales. - -This project wants IRC's simplicity with four specific fixes. That's it. - ---- - -## Design Decisions - -This section documents every major design decision and its rationale. These are -not arbitrary choices — each one follows from the project's core thesis that -IRC's command model is correct and only the transport and session management -need to change. - -### Identity & Sessions — Cookie-Based Authentication - -The server uses **HTTP cookies** for all authentication. There is no separate -registration step — sessions start anonymous and can optionally set a password -for multi-client access. - -#### Session Creation - -- **Session creation**: client sends `POST /api/v1/session` with a desired nick - 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 - handle cookies automatically. **CLI clients (curl, custom HTTP clients) must - explicitly save and send cookies** — e.g., using curl's `-c`/`-b` flags or an - HTTP cookie jar in their language's HTTP library. -- Sessions start anonymous — no password required. When the session expires or - the user QUITs, the nick is released. - -#### Setting a Password (Optional, for Multi-Client Access) - -For users who want to access the same session from multiple devices: - -- **Set password via IRC PASS command**: the authenticated client sends - `POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`. The - server hashes the password with bcrypt and stores it on the session. Password - must be 8 to 72 bytes. -- **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 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 - -- Nicks are changeable via the `NICK` command; the server-assigned user ID is - the stable identity. -- Server-assigned IDs — clients do not choose their own IDs. -- Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry - encoded in the cookie value, no client-side decode. The server is the sole - authority on cookie validity. - -**Rationale:** IRC has no accounts. You connect, pick a nick, and talk. -Anonymous sessions preserve that simplicity — instant access, zero friction. But -some users want to access the same session from multiple devices without a -bouncer. The PASS command enables multi-client login without adding friction for -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 -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) - -Each session has an IRC-style hostmask composed of three parts: - -- **nick** — the user's current nick (changes with `NICK` command) -- **username** — an ident-like identifier set at session creation (optional - `username` field in the session request; defaults to the nick) -- **hostname** — the reverse DNS name of the creating client's IP address, - looked up at session creation time, or the IP address itself when the lookup - gives no name -- **ip** — the real IP address of the session creator, extracted from - `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr` - -Each **client connection** (created at session creation or login) also stores -its own **ip** and **hostname**, found the same way. The session's hostname is -the hostname of the client that created it, and every user can see it: in WHOIS -(311), WHO (352), USERHOST (302), NAMES (353) over the HTTP API, and -`GET /api/v1/channels/{name}/members`. Only `RPL_WHOISACTUALLY` (338) is for -**server operators** (o-line) alone: it gives the IP address and hostname of the -target's newest client. - -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 IP address and hostname of the target's newest client - (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 (`nick!user@host`) is built from these fields when needed, and is -what channel bans (`+b` mode) match against. - -### Nick Semantics - -- Nicks are **unique per server at any point in time** — two sessions cannot - hold the same nick simultaneously. -- Nicks are **case-sensitive** (unlike traditional IRC). `Alice` and `alice` are - different nicks. -- Nick length: 1–32 characters. A nick starts with a letter or `_`, followed by - letters, digits and `` _-[]\^{}|` ``. The HTTP API and the IRC listener both - apply this rule. On the IRC listener, a `NICK` with any other nick, before or - after registration, gets ERR_ERRONEUSNICKNAME (432) and leaves the nick as it - was. -- 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 - a `NICK` event message. - -**Rationale:** IRC nick semantics, simplified. Case-insensitive nick comparison -is a perpetual source of IRC bugs (different servers use different case-folding -rules). Case-sensitive comparison is unambiguous. - -### Multi-Client Model - -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 messages and events from other users to every client queue - for that user session. -- When the user sends a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, `NICK`, `TOPIC`, - `MODE` or `KICK` from one of its clients, the resulting event goes to every - client queue of the user's own session, as well as to the other users it is - meant for. The one exception is an IRC listener connection that sent the - command, which gets nothing queued for it: it has already sent its user the - echo, or, for `PRIVMSG` and `NOTICE`, IRC sends none. -- 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 - doesn't affect others. - -``` -User Session -├── Client A (cookie_a, queue_a) -├── Client B (cookie_b, queue_b) -└── Client C (cookie_c, queue_c) +```bash +make docker +docker run -d --name neoirc \ + -p 8080:8080 -p 6667:6667 \ + -v neoirc-data:/var/lib/neoirc \ + -e NEOIRC_HASHCASH_BITS=0 \ + neoirc ``` -**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new client -to an existing session (one that has a password set via PASS command), enabling -true multi-client support (multiple cookies sharing one nick/session with -independent message queues). Sessions without a password cannot be logged into. +The server listens for HTTP on port 8080 and for IRC on port 6667. It keeps its +data in `/var/lib/neoirc/state.db`. `NEOIRC_HASHCASH_BITS=0` turns off the proof +of work that session creation otherwise needs (see [Hashcash](#hashcash)). -**Rationale:** The fundamental IRC mobile problem is that you can't have your -phone and laptop connected simultaneously without a bouncer. Server-side -per-client queues solve this cleanly. +Open `http://localhost:8080/` for the web client, or talk to the API with +`curl`: -### Message Immutability +```bash +# Create a session. -c saves the auth cookie, -b sends it. +curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ + -H 'Content-Type: application/json' \ + -d '{"nick":"alice"}' -Messages are **immutable** — no editing, no deletion by clients. There are no -edit or delete API endpoints and there never will be. +# Join a channel. +curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ + -H 'Content-Type: application/json' \ + -d '{"command":"JOIN","to":"#general"}' -**Rationale:** Cryptographic signing requires immutability. If a message could -be modified after signing, signatures would be meaningless. This is a feature, -not a limitation. Chat platforms that allow editing signed messages have -fundamentally broken their trust model. If you said something wrong, send a -correction — that's what IRC's culture has always been. +# Say something. +curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ + -H 'Content-Type: application/json' \ + -d '{"command":"PRIVMSG","to":"#general","body":["hello"]}' -### Message Delivery Model - -The server uses a **fan-out queue** model: - -1. Client sends a command (e.g., `PRIVMSG` to `#general`) -2. Server determines all recipients (all members of `#general`) -3. Server stores the message once in the `messages` table -4. Server creates one entry per recipient in the `client_queues` table -5. Server notifies all waiting long-poll connections for those recipients -6. Each recipient's next `GET /messages` poll returns the queued message - -Key properties: - -- **At-least-once delivery**: Messages are queued until the client polls for - them. The client advances its cursor (`after` parameter) to acknowledge - receipt. Messages are not deleted from the queue on read — the cursor-based - model means clients can re-read by providing an earlier `after` value. -- **Ordered**: Queue entries have monotonically increasing IDs. Messages are - always delivered in order within a client's queue. -- **No delivery/read receipts** for channel messages. DM receipts are planned. -- **Client output queue depth**: Server-configurable via `QUEUE_MAX_AGE`. - Default is 30 days. Entries older than this are pruned. - -### Long-Polling - -The server implements HTTP long-polling for real-time message delivery: - -1. Client sends `GET /api/v1/messages?after=&timeout=15` -2. If messages are immediately available, server responds instantly -3. If no messages are available, server holds the connection open -4. Server responds when either: - - A message arrives for this client (via the in-memory broker) - - The timeout expires (returns empty array) - - The client disconnects (connection closed, no response needed) - -**Implementation detail:** The server maintains an in-memory broker with -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 -60 seconds to accommodate long-poll connections. - -**Rationale:** Long-polling over HTTP is the simplest real-time transport that -works everywhere. WebSockets add connection state, require different proxy -configuration, break in some corporate firewalls, and don't work with standard -HTTP middleware. SSE (Server-Sent Events) is one-directional and poorly -supported by some HTTP client libraries. Long-polling is just regular HTTP -requests that sometimes take longer to respond. Every HTTP client, proxy, load -balancer, and CDN handles it correctly. - -### Channels - -- **Any user can create channels** — joining a nonexistent channel creates it, - exactly like IRC. -- **Ephemeral** — channels disappear when the last member leaves. There is no - persistent channel registration. -- **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` - field. - -### Direct Messages (DMs) - -- DMs are addressed by **nick at send time** — the server resolves the nick to a - user ID internally. -- DMs are **fan-out to both sender and recipient** — the sender sees their own - DM echoed back in their message queue, enabling multi-client consistency (your - laptop sees DMs you sent from your phone). -- DM history is stored in the `messages` table with the recipient's session as - the `to_session_id` field. A user's DM history therefore follows them across - nick changes, and a user who later takes a released nick sees none of the DMs - sent to its previous holder. -- DMs are **not stored long-term** by default — they follow the same rotation - policy as channel messages. - -### JSON, Not Binary - -All messages are JSON. No CBOR, no protobuf, no MessagePack, no custom binary -framing. - -**Rationale:** JSON is human-readable, universally supported, and debuggable -with `curl | jq`. Binary formats save bandwidth at the cost of debuggability and -ecosystem compatibility. Chat messages are small — the overhead of JSON over -binary is measured in bytes per message, not meaningful bandwidth. The -canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which -matters for signing. - -### Why Opaque Cookies Instead of JWTs - -JWTs encode claims that clients can decode and potentially rely on. This creates -a coupling between token format and client behavior. If the server needs to -revoke a token, change the expiry model, or add/remove claims, JWT clients may -break or behave incorrectly. - -Opaque auth cookies are simpler: - -- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash → sets - raw hex as an HttpOnly cookie -- On each request, server hashes the cookie value and looks it up -- Revocation is a database delete (cookie becomes invalid immediately) -- No clock skew issues, no algorithm confusion, no "none" algorithm attacks -- Cookie format can change without breaking clients -- Browsers and HTTP cookie jars manage cookies automatically; CLI clients must - explicitly save and resend cookies (e.g., curl `-c`/`-b` flags) - ---- - -## Architecture - -### Transport: HTTP Only - -All client↔server and server↔server communication uses HTTP/1.1+ with JSON -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 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** (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. - -### Session Lifecycle - -#### Session Creation - -``` -┌─ Client ──────────────────────────────────────────────────┐ -│ │ -│ 1. POST /api/v1/session │ -│ {"nick":"alice","pow_token":""} │ -│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ -│ → {"id":1, "nick":"alice"} │ -│ │ -│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │ -│ → {"status":"joined","channel":"#gen"} │ -│ (Cookie must be sent on all subsequent requests) │ -│ │ -│ 3. POST /api/v1/messages {"command":"PRIVMSG", │ -│ "to":"#gen","body":["hello"]} │ -│ → {"id":"uuid-...","status":"sent"} │ -│ (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) │ -│ → {"messages":[...], "last_id": 42} │ -│ │ -│ 5. GET /api/v1/messages?after=42&timeout=15 │ -│ ← (recursive long-poll, using last_id as cursor) │ -│ │ -│ 6. POST /api/v1/messages {"command":"QUIT"} │ -│ → {"status":"quit"} │ -│ (Server broadcasts QUIT, removes from channels, │ -│ deletes session, releases nick, clears cookie) │ -│ │ -└────────────────────────────────────────────────────────────┘ +# Read your queue. The request waits up to 15 seconds for messages. +curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" ``` -#### Multi-Client via Password +Or connect a standard IRC client to `localhost` port 6667. + +In production, run the server behind a reverse proxy that terminates TLS (see +[Running the server](#running-the-server)). The auth cookie is marked `Secure`, +so browsers send it only over HTTPS, or to `localhost`. + +## How it works + +This section defines the terms the rest of this document uses. + +A **session** is one user on the server. It has a nick, a username, a hostname, +the channels it has joined and, optionally, a password. The server creates a +session when a client sends `POST /api/v1/session`, or when an IRC client +registers on the IRC listener. + +A **client** is one connection to a session: a browser, a `curl` cookie jar, an +IRC connection. Each client has its own auth cookie and its own **queue** of +messages waiting to be read. A session starts with one client. +`POST /api/v1/login` adds another client to a session that has a password. + +A **message** is one JSON object (see +[The message envelope](#the-message-envelope)). The server stores each message +once. For every client that should receive it, the server adds one entry to that +client's queue. A client reads its queue with `GET /api/v1/messages`, which can +wait for new messages. Queue entries have increasing numbers. The client passes +the last number it has seen as `after`, so it reads each entry once. + +Three kinds of message reach a queue: + +- Chat messages: `PRIVMSG` and `NOTICE`, from other users and echoes of the + client's own. +- **Events**: `JOIN`, `PART`, `NICK`, `QUIT`, `TOPIC`, `MODE`, `KICK`, `INVITE` + and `WALLOPS`, telling the client what other users did. A client's own + commands are echoed to all clients of its session too. +- **Numeric replies**: the server's answers to commands, such as `RPL_TOPIC` or + `ERR_NOSUCHNICK`. A numeric reply goes only to the client that sent the + command. + +A **channel** is a named room. Its name starts with `#`. The first user to join +creates it and becomes its **channel operator**. A channel operator can set the +channel's modes, change its topic, kick members and invite users. The channel is +deleted when its last member leaves. + +A **server operator** is a user who has sent `OPER` with the name and password +from the server's configuration. A server operator can use `KILL` and `WALLOPS`, +and sees the IP address of other users in `WHOIS`. + +Nicks and channel names compare by exact case. `Alice` and `alice` are two +nicks. `#Upper` and `#upper` are two channels. The `005` reply still advertises +`CASEMAPPING=ascii`: IRC has no value for names that compare by exact case. + +Messages are never edited or deleted by clients. There is no endpoint for +either. + +## Using the HTTP API + +### Conventions + +- Request and response bodies are JSON. +- Endpoints marked "needs auth" need the `neoirc_auth` cookie. + `POST /api/v1/session` and `POST /api/v1/login` set it. A request without a + valid cookie gets `401` with `{"error": "not registered", "numeric": 451}`. +- An error at the HTTP level is a `4xx` or `5xx` status with + `{"error": ""}`. +- An error in a command is not an HTTP error. The request gets `200` with + `{"status": "error"}`, and the reason arrives as a numeric reply in the + client's queue. +- Request bodies for `POST /api/v1/session`, `POST /api/v1/login` and + `POST /api/v1/messages` are limited to `MAX_MESSAGE_SIZE` bytes (default + 4096). A larger body gets `400` `invalid request body`. +- The server allows cross-origin requests from any origin, with credentials. The + cookie is `SameSite=Strict`, so a browser sends it only from the same site. + +### The auth cookie + +`POST /api/v1/session` and `POST /api/v1/login` answer with a `Set-Cookie` +header: ``` -┌─ Client A ────────────────────────────────────────────────┐ -│ │ -│ 1. POST /api/v1/session │ -│ {"nick":"alice","pow_token":""} │ -│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ -│ → {"id":1, "nick":"alice"} │ -│ │ -│ 2. POST /api/v1/messages │ -│ {"command":"PASS","body":["s3cret!!"]} │ -│ → {"status":"ok"} │ -│ (Password set via IRC PASS command) │ -│ │ -│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │ -│ │ -└────────────────────────────────────────────────────────────┘ - -┌─ Client B (another device, while session is still active) ┐ -│ │ -│ 3. POST /api/v1/login │ -│ {"nick":"alice", "password":"s3cret!!"} │ -│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ -│ → {"id":1, "nick":"alice"} │ -│ (New client added to existing session — channels │ -│ are kept; the new client gets its own queue.) │ -│ │ -└────────────────────────────────────────────────────────────┘ +Set-Cookie: neoirc_auth=<64 hex characters>; Path=/; HttpOnly; Secure; SameSite=Strict ``` -### Queue Architecture +The value is 32 random bytes in hex. The server stores only its SHA-256 hash and +compares that on every request. The cookie is the only credential; the JSON body +of the response does not repeat it. A browser sends the cookie by itself. Any +other client must save it and send it back, as `curl -c` and `curl -b` do. -``` - ┌─────────────────┐ - │ messages table │ (one row per message, shared) - │ id | uuid | cmd│ - │ from | to | .. │ - └────────┬────────┘ - │ - ┌──────────────┼──────────────┐ - │ │ │ - ┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐ - │client_queue│ │client_queue│ │client_queue│ - │ client_id=1│ │ client_id=2│ │ client_id=3│ - │ msg_id=N │ │ msg_id=N │ │ msg_id=N │ - └────────────┘ └────────────┘ └────────────┘ - alice bob carol +The cookie is `Secure`. Chrome, Firefox, `curl` and `neoirc-cli` send it to a +server on `localhost` over plain HTTP too; Python's `requests` does not. For +every other host the server needs HTTPS, which a reverse proxy provides. -Each message is stored ONCE. One queue entry per recipient client. -``` +`QUIT`, `POST /api/v1/logout`, `KILL` and the idle cleanup invalidate the +cookie. After that, every request with it gets `401`. -The `client_queues` table contains `(client_id, message_id)` pairs. When a -client polls with `GET /messages?after=`, the server queries for queue -entries with `id > after` for that client, joins against the messages table, and -returns the results. The `queue_id` (auto-incrementing primary key of -`client_queues`) serves as a monotonically increasing cursor. +### Endpoints -### In-Memory Broker +#### `POST /api/v1/session`: create a session -The server maintains an in-memory notification broker to avoid database polling. -The broker is a map of `session_id → []chan struct{}`. When a message is -enqueued for a session: - -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 - -If the server restarts, the broker is empty — but this is fine because clients -that reconnect will poll immediately and get any queued messages from the -database. The broker is purely an optimization to avoid polling latency. - ---- - -## Protocol Specification - -### Message Envelope - -Every message — client-to-server, server-to-client, and server-to-server — uses -the same JSON envelope: - -```json -{ - "id": "string (uuid)", - "command": "string", - "code": integer, - "from": "string", - "to": "string", - "params": ["string", ...], - "body": ["string", ...] | {...}, - "ts": "string (ISO 8601)", - "meta": {...} -} -``` - -#### 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 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:** - -- `body` is **always** an array or object, **never** a raw string. This enables - deterministic canonicalization via RFC 8785 JCS. -- `from` is **always set by the server** on S2C messages. Clients may include - `from` on C2S messages, but it is ignored and overwritten. -- `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, and the server reads nothing in it except - `meta.hashcash` on a `PRIVMSG` to a `+H` channel. - -### Commands (C2S and S2C) - -All commands use the same envelope format regardless of direction. A `PRIVMSG` -from a client to the server has the same shape as the `PRIVMSG` relayed from the -server to other clients. The only differences are which fields the server fills -in (`id`, `ts`, `from`). - -#### PRIVMSG — Send Message - -Send a message to a channel or user. This is the primary messaging command. - -**C2S:** - -```json -{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]} -{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]} -{"command": "PRIVMSG", "to": "bob", "body": ["hey, DM"]} -{"command": "PRIVMSG", "to": "#general", "body": ["signed message"], - "meta": {"sig": "base64...", "alg": "ed25519"}} -``` - -**S2C (as delivered to recipients):** - -```json -{ - "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["hello world"], - "ts": "2026-02-10T20:00:00.123456789Z", - "meta": {} -} -``` - -**Behavior:** - -- If `to` starts with `#`, the message is sent to a channel. The server fans out - to all channel members (including the sender — the sender sees their own - 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; 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:** `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 - -Identical to PRIVMSG but **must not trigger auto-replies** from bots or clients. -This prevents infinite loops between automated systems. - -**C2S:** - -```json -{ - "command": "NOTICE", - "to": "#general", - "body": ["server maintenance in 5 min"] -} -``` - -**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 - -#### JOIN — Join Channel - -Join a channel. If the channel doesn't exist, it is created. - -**C2S:** - -```json -{"command": "JOIN", "to": "#general"} -{"command": "JOIN", "to": "general"} -{"command": "JOIN", "to": "#general", "body": ["secretpass"]} -``` - -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):** - -```json -{ - "id": "...", - "command": "JOIN", - "from": "alice", - "to": "#general", - "body": ["#general"], - "ts": "2026-02-10T20:00:00.123456789Z", - "meta": {} -} -``` - -**Behavior:** - -- 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 operator (`+o`). Bans, `+i`, `+k` - and `+l` can refuse anyone else (see [Channel Modes](#channel-modes)). - -**Response:** `200 OK` - -```json -{ "status": "joined", "channel": "#general" } -``` - -**IRC reference:** RFC 1459 §4.2.1 - -#### PART — Leave Channel - -Leave a channel. - -**C2S:** - -```json -{"command": "PART", "to": "#general"} -{"command": "PART", "to": "#general", "body": ["goodbye"]} -``` - -**S2C (broadcast to all channel members, including the departing user):** - -```json -{ - "id": "...", - "command": "PART", - "from": "alice", - "to": "#general", - "body": ["goodbye"], - "ts": "...", - "meta": {} -} -``` - -**Behavior:** - -- The PART event goes to every channel member, the departing user's own clients - included. (An IRC listener connection that sends a PART echoes it itself.) -- 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. -- The `body` field is optional and contains a part message (reason). - -**Response:** `200 OK` - -```json -{ "status": "parted", "channel": "#general" } -``` - -**IRC reference:** RFC 1459 §4.2.2 - -#### NICK — Change Nickname - -Change the user's nickname. - -**C2S:** - -```json -{ "command": "NICK", "body": ["newnick"] } -``` - -**S2C (broadcast to all users sharing a channel with the changer):** - -```json -{ - "id": "...", - "command": "NICK", - "from": "oldnick", - "body": ["newnick"], - "ts": "...", - "meta": {} -} -``` - -**Behavior:** - -- `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 sends ERR_NICKNAMEINUSE (433). - -**Response:** `200 OK` - -```json -{ "status": "ok", "nick": "newnick" } -``` - -When the server sends an error numeric instead, the response is -`{"status": "error"}`. - -**IRC reference:** RFC 1459 §4.1.2 - -#### PASS — Set Session Password - -Set a password on the current session, enabling multi-client login via -`POST /api/v1/login`. The password is hashed with bcrypt and stored server-side. - -**C2S:** - -```json -{ "command": "PASS", "body": ["mypassword"] } -``` - -**Behavior:** - -- `body[0]` is the password. Must be 8 to 72 bytes; bcrypt cannot hash a longer - one. -- On success, the server responds with `{"status": "ok"}`. -- If the password is too short, too long or missing, the server sends - ERR_NEEDMOREPARAMS (461) via the message queue. -- Calling PASS again overwrites the previous password. -- Once a password is set, `POST /api/v1/login` can be used with the nick and - password to create additional clients on the same session. - -**Response:** `200 OK` - -```json -{ "status": "ok" } -``` - -**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for session -password management. - -#### TOPIC — Set Channel Topic - -Set or change a channel's topic. - -**C2S:** - -```json -{ "command": "TOPIC", "to": "#general", "body": ["Welcome to #general"] } -``` - -**S2C (broadcast to all channel members):** - -```json -{ - "id": "...", - "command": "TOPIC", - "from": "alice", - "to": "#general", - "body": ["Welcome to #general"], - "ts": "...", - "meta": {} -} -``` - -**Behavior:** - -- Updates the channel's topic in the database. -- 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 - `ERR_CHANOPRIVSNEEDED` (482). - -**Response:** `200 OK` - -```json -{ "status": "ok", "topic": "Welcome to #general" } -``` - -**IRC reference:** RFC 1459 §4.2.4 - -#### QUIT — Disconnect - -Destroy the session and disconnect from the server. - -**C2S:** - -```json -{"command": "QUIT"} -{"command": "QUIT", "body": ["leaving"]} -``` - -**S2C (broadcast to all users sharing channels with the quitter):** - -```json -{ - "id": "...", - "command": "QUIT", - "from": "alice", - "body": ["leaving"], - "ts": "...", - "meta": {} -} -``` - -**Behavior:** - -- The QUIT event is broadcast to all users who share a channel with the quitting - 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 cookies of all its clients are - invalidated, the nick is released. -- Subsequent requests with the old auth cookie return HTTP 401. - -**Response:** `200 OK` - -```json -{ "status": "quit" } -``` - -**IRC reference:** RFC 1459 §4.1.6 - -#### PING — Keepalive - -Client keepalive. Server responds synchronously with PONG. - -**C2S:** - -```json -{ "command": "PING" } -``` - -**Response (synchronous, not via the queue):** `200 OK` - -```json -{ "command": "PONG", "from": "servername" } -``` - -**Note:** PING/PONG is synchronous — the PONG is the HTTP response body, not a -queued message. This is deliberate: keepalives should be low-latency and not -pollute the message queue. - -**IRC reference:** RFC 1459 §4.6.2, §4.6.3 - -#### MODE — Query and Change Modes - -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"]} -``` - -**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": "RPL_CHANNELMODEIS", "code": 324, "to": "alice", "params": ["#general", "+nt"]} -{"command": "RPL_CREATIONTIME", "code": 329, "to": "alice", "params": ["#general", "1709251200"]} -``` - -**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 to every channel member as one -`MODE` line, e.g. `+o-v alice bob`; the operator gets that line once. - -**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; over the HTTP API, a letter that is not accepted gets 472 instead | -| `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 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": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } -``` - -**Response:** `200 OK` with `{"status": "ok"}`, or `{"status": "error"}` when -the reply is an error numeric. - -**IRC reference:** RFC 1459 §4.2.3 - -#### NAMES — Channel Member List - -Request the member list for a channel. Returns RPL_NAMREPLY (353) and -RPL_ENDOFNAMES (366). - -**C2S:** - -```json -{ "command": "NAMES", "to": "#general" } -``` - -**IRC reference:** RFC 1459 §4.2.5 - -#### LIST — List Channels - -Request a list of all channels with member counts. Returns RPL_LIST (322) for -each channel followed by RPL_LISTEND (323). - -**C2S:** - -```json -{ "command": "LIST" } -``` - -**IRC reference:** RFC 1459 §4.2.6 - -#### WHOIS — User Information - -Query information about a user. Returns RPL_WHOISUSER (311), RPL_WHOISSERVER -(312), RPL_WHOISOPERATOR (313, if target is oper), RPL_WHOISIDLE (317), -RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318). - -If the querying user is a **server operator** (authenticated via `OPER`), the -response over the HTTP API additionally includes RPL_WHOISACTUALLY (338) with -the IP address and hostname of the target's newest client. The IRC listener's -WHOIS does not send it. - -**C2S:** - -```json -{ "command": "WHOIS", "to": "alice" } -``` - -**IRC reference:** RFC 1459 §4.5.2 - -#### WHO — Channel User List - -Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed by -RPL_ENDOFWHO (315). - -**C2S:** - -```json -{ "command": "WHO", "to": "#general" } -``` - -**IRC reference:** RFC 1459 §4.5.1 - -#### LUSERS — Server Statistics - -Request server user/channel statistics. Returns RPL_LUSERCLIENT (251), -RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and RPL_LUSERME (255). - -**C2S:** - -```json -{ "command": "LUSERS" } -``` - -LUSERS replies are also sent automatically during connection registration. - -**IRC reference:** RFC 1459 §4.3.2 - -#### OPER — Gain Server Operator Status - -Authenticate as a server operator (o-line). On success, the session gains oper -privileges: it can use `KILL` and `WALLOPS`, and over the HTTP API its WHOIS -responses include more (the IP address and hostname of the target user's newest -client). - -**C2S:** - -```json -{ "command": "OPER", "body": ["opername", "operpassword"] } -``` - -**S2C (via message queue on success):** - -```json -{ - "command": "RPL_YOUREOPER", - "code": 381, - "to": "alice", - "body": ["You are now an IRC operator"] -} -``` - -**Behavior:** - -- `body[0]` is the operator name, `body[1]` is the operator password. -- The server checks against the configured `NEOIRC_OPER_NAME` and - `NEOIRC_OPER_PASSWORD` environment variables. -- On success, the session's `is_oper` flag is set and `381 RPL_YOUREOPER` is - returned. -- On failure (wrong credentials or no o-line configured), `491 ERR_NOOPERHOST` - is returned. -- 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 - -#### KICK — Kick User - -Remove a user from a channel. Only channel operators (`+o`) can use this -command. The kicked user and all channel members receive the KICK message. - -**C2S:** - -```json -{ "command": "KICK", "to": "#general", "body": ["bob", "misbehaving"] } -``` - -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; 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 - -**IRC reference:** RFC 1459 §4.2.8 - -#### PUBKEY — Announce Signing Key - -Distribute a public signing key to channel members. - -**C2S:** - -```json -{ - "command": "PUBKEY", - "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } -} -``` - -**S2C (relayed to channel members):** - -```json -{ - "id": "...", - "command": "PUBKEY", - "from": "alice", - "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" }, - "ts": "...", - "meta": {} -} -``` - -**Behavior:** The server relays PUBKEY messages verbatim. It does not verify, -store, or interpret the key material. See [Security Model](#security-model) for -the full key distribution protocol. - -**Status:** Not yet implemented. - -### 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). Over the HTTP API, which the table below -describes, the `command` field holds the reply's name and `code` its number; 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 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"]}` | - -### Channel Modes - -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` 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 (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`; not by `GET /api/v1/channels` | -| `+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**; the IRC listener refuses every PRIVMSG to the channel, as it cannot carry a stamp | - -**User channel modes (set per-user per-channel):** - -| Mode | Meaning | Display prefix | Status | -| ---- | -------- | ------------------ | ------------ | -| `+o` | Operator | `@` in NAMES reply | **Enforced** | -| `+v` | Voice | `+` in NAMES reply | **Enforced** | - -**Channel creator auto-op:** The first user to JOIN a channel (creating it) -automatically receives `+o` operator status. - -**Ban system (+b):** Operators can ban users by hostmask pattern with wildcard -matching (`*` and `?`, ignoring letter case). `+b` with no mask lists current -bans. Bans prevent both joining and sending messages. - -```json -{"command": "MODE", "to": "#channel", "body": ["+b", "*!*@*.example.com"]} -{"command": "MODE", "to": "#channel", "body": ["-b", "*!*@*.example.com"]} -{"command": "MODE", "to": "#channel", "body": ["+b"]} -``` - -These ban all users from example.com, remove that ban, and list all bans -(RPL_BANLIST 367, RPL_ENDOFBANLIST 368). - -**Invite-only (+i):** When set, users must be invited before joining. `INVITE` -records an invite that is consumed on JOIN; any member can invite, and on a `+i` -channel only an operator can. - -```json -{"command": "MODE", "to": "#channel", "body": ["+i"]} -{"command": "INVITE", "body": ["nick", "#channel"]} -``` - -**Channel key (+k):** Requires a password to join the channel. Over the HTTP API -the key is `body[0]` of the `JOIN`. On the IRC listener it follows the channel -name, as in `JOIN #channel secretpass`; `JOIN #a,#b keya,keyb` gives each -channel its own 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. - -```json -{"command": "MODE", "to": "#channel", "body": ["+l", "50"]} -{"command": "MODE", "to": "#channel", "body": ["-l"]} -``` - -**Secret (+s):** `LIST` hides the channel from non-members, and `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:** A NOTICE gets no hashcash check on `+H` channels, but it gets the -same error numerics as PRIVMSG (411, 412, 401, 403, 404). Over the HTTP API it -gets no RPL_AWAY; the IRC listener sends RPL_AWAY for a NOTICE to a user who is -away, as it does for PRIVMSG. - -**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. An IRC listener client has no way to attach a stamp, so its `PRIVMSG` -to such a channel always gets ERR_CANNOTSENDTOCHAN (404). - -**Setting the requirement:** - -``` -MODE #channel +H — require leading zero bits (1-40) -MODE #channel -H — disable hashcash requirement -``` - -**Stamp format:** `1:bits:YYMMDD:channel:bodyhash:counter` - -- `bits` — difficulty (leading zero bits in SHA-256 hash of the stamp) -- `YYMMDD` — current date (prevents old token reuse) -- `channel` — channel name (prevents cross-channel reuse) -- `bodyhash` — hex-encoded SHA-256 of the message body (binds stamp to message) -- `counter` — hex nonce - -**Sending a message to a hashcash-protected channel:** - -Include the hashcash stamp in the `meta` field: - -```json -{ - "command": "PRIVMSG", - "to": "#general", - "body": ["hello world"], - "meta": { - "hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a" - } -} -``` - -**Server validation:** The server checks that the stamp is well-formed, meets -the required difficulty, is bound to the correct channel and message body, has a -recent date, and has not been previously used. Spent stamps are cached for 1 -year to prevent replay attacks. The stamp is checked after membership, bans and -`+m`, so a message refused for one of those does not spend it. - -**Error responses:** If the channel requires hashcash and the stamp is missing, -invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a -descriptive reason. - -**Client minting:** The CLI provides `MintChannelHashcash(bits, channel, body)` -to compute stamps. Higher bit counts take exponentially longer to compute. - ---- - -## API Reference - -All endpoints accept and return `application/json`. Authenticated endpoints -require the `neoirc_auth` cookie, which is set automatically by -`POST /api/v1/session` and `POST /api/v1/login`. - -All API responses include appropriate HTTP status codes. Error responses have -the format: - -```json -{ "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. - -If the server requires hashcash proof-of-work (see -[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a -valid stamp in the `pow_token` field of the JSON request body. The required -difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field. - -**Request Body:** +No auth. Request body: ```json { @@ -1455,138 +199,69 @@ difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field. } ``` -| 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) | +- `nick` (required): the nick. A nick is 1 to 32 characters: a letter or `_`, + then letters, digits and `` _-[]\^{}|` ``. It must not be in use. +- `username` (optional): the user part of the hostmask (see + [Hostmasks](#hostmasks)). 1 to 32 characters out of letters, digits and + `` _-[]\^{}|` ``. Default: the nick. +- `pow_token`: a hashcash stamp (see [Hashcash](#hashcash)). Required unless the + server runs with `NEOIRC_HASHCASH_BITS=0`. -The `username` field sets the user portion of the IRC hostmask -(`nick!user@host`). The hostname is the reverse DNS name of the connecting -client's IP address, looked up at session creation time, or the IP address -itself when the lookup gives no name; every user can see it (see -[Hostmask](#hostmask-nickuserhost)). Together these form the hostmask used in -WHOIS, WHO, NAMES, and ban matching (`+b`). - -**Response:** `201 Created` - -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; Secure; SameSite=Strict -``` +Response `201`, with the cookie: ```json -{ - "id": 1, - "nick": "alice" -} +{ "id": 1, "nick": "alice" } ``` -| Field | Type | Description | -| ------ | ------- | -------------------------------------------------- | -| `id` | integer | Server-assigned user ID | -| `nick` | string | Confirmed nick (always matches request on success) | +`id` is the session's number. The new client's queue already holds the welcome +replies `001` to `005`, the `LUSERS` replies and the message of the day (see +[Numeric replies](#numeric-replies)). -**Cookie properties:** +Errors: -| Property | Value | -| ---------- | --------------------------------------- | -| `Name` | `neoirc_auth` | -| `HttpOnly` | `true` (not accessible from JavaScript) | -| `SameSite` | `Strict` (prevents CSRF) | -| `Secure` | `true` (sent only over HTTPS) | -| `Path` | `/` | +- `400` `invalid request body`: not JSON, or too large. +- `400` `invalid nick format`. +- `400` `invalid username format`. +- `402` `hashcash proof-of-work required`: no `pow_token`, and the server + requires one. +- `402` `invalid hashcash stamp: `. +- `409` `nick already taken`. -**Errors:** +The server checks the stamp before the nick, and spends it only when the session +is created. A request refused with `400` or `409` leaves the stamp unspent. -| Status | Error | When | -| ------ | --------------------------------- | ------------------------------------------------------------------ | -| 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.) | -| 409 | `nick already taken` | Another active session holds this nick | +#### `POST /api/v1/login`: add a client to a session -**curl example:** - -```bash -# Use -c to save cookies, -b to send them -curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ - -H 'Content-Type: application/json' \ - -d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' -``` - -### 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. 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 -new client's queue, so the client can immediately restore its UI state. - -**Request Body:** +No auth. The session must have a password, set with the `PASS` command. Request +body: ```json { "nick": "alice", "password": "mypassword" } ``` -| Field | Type | Required | Constraints | -| ---------- | ------ | -------- | ------------------------------------------------ | -| `nick` | string | Yes | Must match an active session with a password set | -| `password` | string | Yes | Must match the session's password | - -**Response:** `200 OK` - -The response sets an `neoirc_auth` HttpOnly cookie for the new client. +Response `200`, with a new cookie for the new client: ```json -{ - "id": 1, - "nick": "alice" -} +{ "id": 1, "nick": "alice" } ``` -| Field | Type | Description | -| ------ | ------- | ------------ | -| `id` | integer | Session ID | -| `nick` | string | Current nick | +The new client's queue holds the welcome replies and the message of the day. It +then holds, for each channel of the session, a `JOIN` event from the session's +own nick with an empty `body`, the topic replies and the member list. A client +can rebuild its channel windows from them. -**Errors:** +Errors: -| 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` | +- `429` `too many login attempts, try again later`, with `Retry-After: 1`. See + [Login rate limit](#login-rate-limit). +- `400` `invalid request body`. +- `400` `nick and password required`. +- `401` `invalid credentials`: wrong password, unknown nick, or a session + without a password. -**curl example:** +#### `GET /api/v1/state`: the session's state -```bash -curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \ - -H 'Content-Type: application/json' \ - -d '{"nick":"alice","password":"mypassword"}' -``` - -### GET /api/v1/state — Get Session State - -Return the current user's session state. - -**Request:** No body. Requires auth. - -**Query Parameters:** - -| Parameter | Type | Default | Description | -| ------------------ | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. | - -**Response:** `200 OK` +Needs auth. Response `200`: ```json { @@ -1599,64 +274,32 @@ Return the current user's session state. } ``` -| Field | Type | Description | -| ---------- | ------- | -------------------------------- | -| `id` | integer | User ID | -| `nick` | string | Current nick | -| `channels` | array | Channels the user is a member of | +`channels` lists the channels the session has joined, sorted by name. With +`?initChannelState=1`, the server also adds to the calling client's queue, for +each channel, a `JOIN` event from the session's own nick with an empty `body`, +the topic replies and the member list. The web client uses this when it loads, +to restore its channel tabs. -Each channel object: +`GET /api/v1/users/me` is the same endpoint under another path. -| Field | Type | Description | -| ------- | ------- | ------------------------------------- | -| `id` | integer | Channel ID | -| `name` | string | Channel name (e.g., `#general`) | -| `topic` | string | Channel topic (empty string if unset) | +#### `GET /api/v1/messages`: read the queue -**curl example:** +Needs auth. Query parameters: -```bash -curl -s http://localhost:8080/api/v1/state \ - -b cookies.txt | jq . -``` +- `after` (default `0`): return only queue entries with a number greater than + this. Pass the `last_id` of the previous response. +- `timeout` (default `0`): how many seconds to wait for a message when the queue + has none. `0` answers at once. The server caps it at `30`. -**Reconnect with channel state initialization:** - -```bash -curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \ - -b cookies.txt | jq . -``` - -### GET /api/v1/messages — Poll Messages (Long-Poll) - -Retrieve messages from the client's delivery queue. This is the primary -real-time endpoint — clients call it in a loop. - -**Query Parameters:** - -| Param | Type | Default | Description | -| --------- | ------- | ------- | ----------------------------------------------------------------------------------------- | -| `after` | integer | `0` | Return only queue entries with ID > this value. Use `last_id` from the previous response. | -| `timeout` | integer | `0` | Long-poll timeout in seconds. `0` = return immediately. Max `30`. Recommended: `15`. | - -**Response:** `200 OK` +Response `200`: ```json { "messages": [ { "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "command": "JOIN", - "from": "bob", - "to": "#general", - "body": ["#general"], - "ts": "2026-02-10T20:00:00.123456789Z", - "meta": {} - }, - { - "id": "b7c8210f-849c-4b90-9ee8-d99c8889358e", "command": "PRIVMSG", - "from": "alice", + "from": "bob", "to": "#general", "body": ["hello world"], "ts": "2026-02-10T20:00:01.234567891Z", @@ -1667,260 +310,90 @@ real-time endpoint — clients call it in a loop. } ``` -| 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. | +`messages` holds at most 100 messages, oldest first. `last_id` is the number of +the last entry returned, or the `after` value when there was none. -**Long-poll behavior:** +When the queue has nothing after `after` and `timeout` is above `0`, the server +holds the request. It answers as soon as a message arrives for the session, or +with an empty `messages` array when the time is up. A client that closes the +connection first gets no answer. -1. If messages are immediately available (queue entries with ID > `after`), the - server responds instantly. -2. If no messages are available and `timeout` > 0, the server holds the - connection open. -3. The server responds when: - - A message arrives for this user (instantly via in-memory broker) - - The timeout expires (returns `{"messages":[], "last_id": }`) - - The client disconnects (no response) +A client loop is: call with `after=0`, then call again with `after` set to each +response's `last_id`. Passing an earlier `after` reads the entries again. Queue +entries are deleted after `QUEUE_MAX_AGE` (default 30 days), not when read. -**curl example (immediate):** +#### `POST /api/v1/messages`: send a command -```bash -curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq . -``` - -**curl example (long-poll, 15s):** - -```bash -curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq . -``` - -### POST /api/v1/messages — Send Command - -Send any client-to-server command. The `command` field determines the action. -This is the unified write endpoint — there are no separate endpoints for join, -part, nick, etc. - -**Request body:** An IRC message envelope with `command` and relevant fields: +Needs auth. The body is a message envelope with at least `command`: ```json { "command": "PRIVMSG", "to": "#general", "body": ["hello world"] } ``` -See [Commands (C2S and S2C)](#commands-c2s-and-s2c) for the full command -reference with all required and optional fields. +The server reads `command`, `to`, `body` and `meta` and ignores the rest. +`command` is case-insensitive. [Commands](#commands) describes each command, its +fields, its replies and its HTTP response. A command the server does not know +gets `ERR_UNKNOWNCOMMAND` (421) in the queue and `{"status": "error"}`. -**Command dispatch table:** +Errors at the HTTP level: -| Command | Required Fields | Optional | Response Status | -| ---------- | --------------- | -------- | --------------- | -| `PRIVMSG` | `to`, `body` | `meta` | 200 OK | -| `NOTICE` | `to`, `body` | `meta` | 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 | -| `WHO` | `to` | | 200 OK | -| `LUSERS` | | | 200 OK | -| `USERHOST` | `body` | | 200 OK | -| `VERSION` | | | 200 OK | -| `ADMIN` | | | 200 OK | -| `INFO` | | | 200 OK | -| `TIME` | | | 200 OK | -| `OPER` | `body` | | 200 OK | -| `KILL` | `body` | | 200 OK | -| `WALLOPS` | `body` | | 200 OK | -| `QUIT` | | `body` | 200 OK | -| `PING` | | | 200 OK | +- `400` `invalid request body`: not JSON, or too large. +- `400` `command required`. +- `401` `not registered`. +- `500` `internal error`. -All IRC commands return HTTP 200 OK, usually with `{"status": "error"}` when the -reply is an error numeric (a WHOIS for an unknown nick still gets -`{"status": "ok"}`), and otherwise a status of the command's own (see each -command). **Numeric replies**, for success and for errors, are delivered through -the message queue (see [Numeric Reply Codes](#numeric-reply-codes-s2c-only)); a -command that has none, such as a successful `PRIVMSG`, is answered by the HTTP -response alone, and `PING`'s `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). +#### `GET /api/v1/history`: past messages -**HTTP errors (transport-level only):** +Needs auth. Query parameters: -| 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 | +- `target` (required): a channel name, or the caller's own nick. +- `before` (optional): the `id` of a message in this history. Only older + messages are returned. +- `limit` (default `50`): at most this many messages, from 1 to 500. Any other + value means 50. -**IRC numeric error replies (delivered via message queue):** +Response `200`: an array of `PRIVMSG` messages, oldest first. Only `PRIVMSG` is +kept in history; events and `NOTICE` are not. -| 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 | +For a channel, the caller must be a member. For a nick, only the caller's own +nick is allowed. The result is then the direct messages sent to the caller's +session, under any nick it has held. A session that takes a released nick sees +none of the messages sent to the nick's earlier holder. A channel created again +after it was deleted starts with no history. -**IRC numeric success replies (delivered via message queue):** +To page back, pass the `id` of the oldest message of a page as `before`. An +empty array means there is nothing older. -| 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 | +Errors: -### GET /api/v1/history — Message History +- `400` `target required`. +- `400` `unknown before id`: no `PRIVMSG` with that `id` in this history. +- `403` `not a member of this channel`. +- `403` `forbidden`: `target` is another user's nick. +- `404` `channel not found`. -Fetch historical messages for a channel. Returns messages in chronological order -(oldest first). - -**Query Parameters:** - -| Param | Type | Default | Description | -| -------- | ------- | ---------- | ----------------------------------------------------------------------------------------------- | -| `target` | string | (required) | Channel name (e.g., `#general`) | -| `before` | string | (none) | The `id` of a message in this history; return only messages older than it. Omit for the latest. | -| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. | - -To page back, pass the `id` of the first (oldest) message of a page as `before` -for the next; an empty page means there is nothing older. - -`target` may also be your own nick, which returns the DMs sent to your session, -including those sent under its earlier nicks. A channel created again after its -last member left starts with no history, and a user who takes a released nick -gets none of the DMs sent to its previous holder. Errors: `400` -`target required`, `400` `unknown before id` when no message with that `id` is -in this history, `404` `channel not found`, `403` -`not a member of this channel`, and `403` `forbidden` for any other nick. - -**Response:** `200 OK` - -```json -[ - { - "id": "uuid-1", - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["first message"], - "ts": "2026-02-10T19:00:00.123456789Z", - "meta": {} - }, - { - "id": "uuid-2", - "command": "PRIVMSG", - "from": "bob", - "to": "#general", - "body": ["second message"], - "ts": "2026-02-10T19:01:00.234567891Z", - "meta": {} - } -] -``` - -**Note:** History currently returns only PRIVMSG messages (not JOIN/PART/etc. -events). Event messages are delivered via the live queue only. - -**curl example:** +Example: ```bash -# Latest 50 messages in #general -curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \ - -b cookies.txt | jq . - -# The 50 messages before uuid-1 (pagination) -curl -s "http://localhost:8080/api/v1/history?target=%23general&before=uuid-1&limit=50" \ - -b cookies.txt | jq . +curl -s -b cookies.txt "http://localhost:8080/api/v1/history?target=%23general&limit=50" +curl -s -b cookies.txt "http://localhost:8080/api/v1/history?target=%23general&before=&limit=50" ``` -### GET /api/v1/channels — List Channels +#### `GET /api/v1/channels`: all channels -List all channels on the server. - -**Response:** `200 OK` +Needs auth. Response `200`: every channel on the server, sorted by name, secret +(`+s`) channels included: ```json [ { "id": 1, "name": "#general", "topic": "Welcome!" }, - { "id": 2, "name": "#dev", "topic": "Development discussion" } + { "id": 2, "name": "#dev", "topic": "" } ] ``` -### GET /api/v1/channels/{name}/members — Channel Members +#### `GET /api/v1/channels/{name}/members`: a channel's members -List members of a channel. The `{name}` parameter is the channel name -**without** the `#` prefix (it's added by the server). - -**Response:** `200 OK` +Needs auth. `{name}` is the channel name without the `#`. Response `200`: ```json [ @@ -1936,106 +409,51 @@ List members of a channel. The `{name}` parameter is the channel name ] ``` -An unknown channel gets `404` `channel not found`. +`id` is the member's session number. `lastSeen` is the time of the session's +last request. An unknown channel gets `404` `channel not found`. -**curl example:** +#### `POST /api/v1/logout`: remove this client -```bash -curl -s http://localhost:8080/api/v1/channels/general/members \ - -b cookies.txt | jq . -``` +Needs auth. Deletes the calling client and clears its cookie. Response `200` +`{"status": "ok"}`. -### POST /api/v1/logout — Logout +When the session has no other client, the session ends as it does on +[`QUIT`](#quit-end-the-session). Its channel peers see the reason +`Connection closed`. -Destroy the current client's session cookie and server-side client record. If no -other clients remain on the session, the user is fully cleaned up: parted from -all channels (with QUIT broadcast to members), session deleted, nick released. -The auth cookie is cleared in the response. +#### `GET /api/v1/server`: server information -**Request:** No body. Requires auth cookie. - -**Response:** `200 OK` - -The response clears the `neoirc_auth` cookie. - -```json -{ "status": "ok" } -``` - -**Errors:** - -| Status | Error | When | -| ------ | ---------------- | ------------------------------ | -| 401 | `not registered` | Missing or invalid auth cookie | - -**curl example:** - -```bash -curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq . -``` - -### GET /api/v1/users/me — Current User Info - -Return the current user's session state. This is an alias for -`GET /api/v1/state`. - -**Request:** No body. Requires auth. - -**Response:** `200 OK` +No auth. Response `200`: ```json { - "id": 1, - "nick": "alice", - "channels": [{ "id": 1, "name": "#general", "topic": "Welcome!" }] -} -``` - -**curl example:** - -```bash -curl -s http://localhost:8080/api/v1/users/me \ - -b cookies.txt | jq . -``` - -### GET /api/v1/server — Server Info - -Return server metadata. No authentication required. - -**Response:** `200 OK` - -```json -{ - "name": "My NeoIRC Server", - "version": "0.1.0", - "motd": "Welcome! Be nice.", + "name": "My Server", + "version": "", + "motd": "Welcome!", "users": 42, "hashcash_bits": 20 } ``` -| Field | Type | Description | -| --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `name` | string | Server display name | -| `version` | string | Server version | -| `motd` | string | Message of the day | -| `users` | integer | Number of currently active user sessions | -| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). | +- `name`: `SERVER_NAME`. An empty string when it is not set. +- `version`: the version compiled into the binary. An empty string in a binary + built without one. +- `motd`: the message of the day. +- `users`: the number of sessions. +- `hashcash_bits`: the difficulty a session stamp needs. Absent when the server + requires no stamp. -### GET /.well-known/healthcheck.json — Health Check +#### `GET /.well-known/healthcheck.json`: health check -Standard health check endpoint. No authentication required. Returns server -health status and runtime statistics. - -**Response:** `200 OK` +No auth. Response `200`: ```json { "status": "ok", - "now": "2024-01-15T12:00:00.123456789Z", + "now": "2026-02-10T12:00:00.123456789Z", "uptimeSeconds": 3600, "uptimeHuman": "1h0m0s", - "version": "0.1.0", + "version": "", "appname": "neoirc", "maintenanceMode": false, "sessions": 42, @@ -2048,780 +466,1193 @@ 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` | 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 | +- `sessions`, `clients`, `channels`: current counts. +- `queuedLines`: the number of entries in all client queues. +- `connectionsSinceBoot`: sessions created and logins over the HTTP API since + the server started. +- `sessionsSinceBoot`: sessions created over the HTTP API since the server + started. +- `messagesSinceBoot`: `PRIVMSG` and `NOTICE` commands with a target and a body + received over the HTTP API since the server started, delivered or refused. +- `maintenanceMode`: the value of `MAINTENANCE_MODE`. ---- +The Docker image's `HEALTHCHECK` calls this endpoint every 30 seconds. -## Message Flow +#### `GET /metrics`: Prometheus metrics -### Channel Message Flow +Served only when `METRICS_USERNAME` is set, behind HTTP basic auth with +`METRICS_USERNAME` and `METRICS_PASSWORD`. The server records a request metric +for every HTTP request when metrics are on. -``` -Alice Server Bob - │ │ │ - │ POST /messages │ │ - │ {PRIVMSG, #gen, "hi"} │ │ - │───────────────────────>│ │ - │ │ 1. Store in messages │ - │ │ 2. Query #gen members │ - │ │ → [alice, bob] │ - │ │ 3. Enqueue for alice │ - │ │ 4. Enqueue for bob │ - │ │ 5. Notify alice broker │ - │ │ 6. Notify bob broker │ - │ 200 {"status":"sent"} │ │ - │<───────────────────────│ │ - │ │ │ - │ GET /messages?after=N │ GET /messages?after=M │ - │ (long-poll wakes up) │ (long-poll wakes up) │ - │───────────────────────>│<───────────────────────│ - │ │ │ - │ {messages: [{PRIVMSG, │ {messages: [{PRIVMSG, │ - │ from:alice, "hi"}]} │ from:alice, "hi"}]} │ - │<───────────────────────│───────────────────────>│ +#### Other paths + +Every other path serves the web client. A path that names one of its files gets +that file; any other path gets its `index.html`. + +### The message envelope + +Every message in a queue, and every command a client sends, is one JSON object +with these fields: + +- `id`: the message's UUID. The server assigns it and ignores one sent by a + client. +- `command`: the command name, such as `PRIVMSG`, or the name of a numeric + reply, such as `RPL_WELCOME`. The server upper-cases what a client sends. +- `code`: the number of a numeric reply, such as `1` for `RPL_WELCOME` or `433` + for `ERR_NICKNAMEINUSE`. Absent on other messages. +- `from`: the sender's nick, or the server name on numeric replies. The server + sets it and ignores one sent by a client. +- `to`: the target: a channel name or a nick. Absent on `NICK` and `QUIT` + events. +- `params`: an array of strings with further parameters. `KICK` events and many + numeric replies have it. The server ignores one sent by a client. +- `body`: an array of strings. For chat it is the lines of text, one string per + line. A `body` is never a bare string. Every numeric reply has a `body`; a + few, such as `RPL_CHANNELMODEIS`, carry one empty string. +- `ts`: when the server stored the message: UTC, RFC 3339, with fractional + seconds and trailing zeros dropped, such as `2026-02-10T20:00:00.123456789Z`. + The server assigns it. +- `meta`: an object. A client may put anything in it. The server stores and + relays it unchanged, and `{}` when the client sent none. The server reads one + key: `meta.hashcash` on a `PRIVMSG` to a channel with mode `+H`. + +### Commands + +A client sends a command as the body of `POST /api/v1/messages`. For each +command this section gives the fields it takes, what the server does, the event +other users receive, the numeric replies the sender receives, and the HTTP +response. Every numeric reply goes into the sender's own queue only. Unless +stated otherwise, an error numeric comes with the HTTP response +`{"status": "error"}`. + +#### `PRIVMSG`: send a message + +```json +{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]} +{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]} +{"command": "PRIVMSG", "to": "bob", "body": ["hey"]} ``` -### DM Flow +- `to` (required): a channel name, or a nick for a direct message. +- `body` (required): one or more lines. +- `meta` (optional): relayed unchanged. A `PRIVMSG` to a `+H` channel carries + its stamp in `meta.hashcash`. -``` -Alice Server Bob - │ │ │ - │ POST /messages │ │ - │ {PRIVMSG, "bob", "yo"} │ │ - │───────────────────────>│ │ - │ │ 1. Resolve nick "bob" │ - │ │ 2. Store in messages │ - │ │ 3. Enqueue for bob │ - │ │ 4. Enqueue for alice │ - │ │ (echo to sender) │ - │ │ 5. Notify both │ - │ 200 {"status":"sent"} │ │ - │<───────────────────────│ │ - │ │ │ - │ (alice sees her own DM │ (bob sees DM from │ - │ on all her clients) │ alice) │ -``` +A channel message goes to every member of the channel, the sender included: +every client of the sender's session sees it in its queue. The sender must be a +member. A direct message goes to the target's session and to the sender's own +session, so every client of both sees it. -### JOIN Flow - -``` -Alice Server Bob (already in #gen) - │ │ │ - │ POST /messages │ │ - │ {JOIN, "#general"} │ │ - │───────────────────────>│ │ - │ │ 1. Get/create #general │ - │ │ 2. Add alice to members│ - │ │ 3. Store JOIN message │ - │ │ 4. Fan out to all │ - │ │ members (alice, bob) │ - │ 200 {"joined"} │ │ - │<───────────────────────│ │ - │ │ │ - │ (alice's queue gets │ (bob's queue gets │ - │ JOIN from alice) │ JOIN from alice) │ -``` - ---- - -## Canonicalization and Signing - -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: - -1. Start with the full message envelope (including `id`, `ts`, `from`, etc.) -2. Remove `meta.sig` from the message (the signature itself is not signed) -3. Serialize using - [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785): - - Object keys sorted lexicographically (Unicode code point order) - - No insignificant whitespace - - Numbers serialized in shortest form (no trailing zeros) - - Strings escaped per JSON spec (no unnecessary escapes) - - UTF-8 encoding throughout -4. The resulting byte string is the signing input - -**Example:** - -Given this message: +The message as the recipients receive it: ```json { - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["hello"], - "id": "abc-123", - "ts": "2026-02-10T20:00:00Z", - "meta": { "alg": "ed25519" } -} -``` - -The JCS canonical form is: - -``` -{"body":["hello"],"command":"PRIVMSG","from":"alice","id":"abc-123","meta":{"alg":"ed25519"},"to":"#general","ts":"2026-02-10T20:00:00Z"} -``` - -This is why `body` must be an object or array — raw strings would be ambiguous -under canonicalization (a bare string `hello` is not valid JSON, and `"hello"` -has different canonical forms depending on escaping rules). - -### Signing Flow - -1. Client generates an Ed25519 keypair (32-byte seed → 64-byte secret key, - 32-byte public key) -2. Client announces public key via PUBKEY command: - ```json - { - "command": "PUBKEY", - "body": { "alg": "ed25519", "key": "base64url-encoded-pubkey" } - } - ``` -3. Server relays PUBKEY to channel members and/or stores for the session -4. When sending a message, client: a. Constructs the complete message envelope - **without** `meta.sig` b. Canonicalizes per JCS (step above) c. Signs the - canonical bytes with the Ed25519 private key d. Adds `meta.sig` - (base64url-encoded signature) and `meta.alg` ("ed25519") -5. Server stores and relays the message including `meta` verbatim -6. Recipients verify by: a. Extracting and removing `meta.sig` from the received - message b. Canonicalizing the remaining message per JCS c. Verifying the - Ed25519 signature against the sender's announced public key - -### PUBKEY Distribution - -```json -{ - "command": "PUBKEY", - "from": "alice", - "body": { "alg": "ed25519", "key": "base64url-encoded-32-byte-pubkey" } -} -``` - -- Servers relay PUBKEY messages to all channel members -- Clients cache public keys locally, indexed by (server, nick) -- Key distribution uses **TOFU** (trust on first use): the first key seen for a - nick is trusted; subsequent different keys trigger a warning -- **There is no key revocation mechanism** — if a key is compromised, the user - must change their nick or wait for the old key's TOFU cache to expire - -### Signed Message Example - -```json -{ - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["this message is signed"], "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello world"], "ts": "2026-02-10T20:00:00.123456789Z", - "meta": { - "alg": "ed25519", - "sig": "base64url-encoded-64-byte-signature" + "meta": {} +} +``` + +Replies and errors: + +- `ERR_NORECIPIENT` (411): no `to`. +- `ERR_NOTEXTTOSEND` (412): no `body`, or an empty one. +- `ERR_NOSUCHCHANNEL` (403): the channel does not exist. +- `ERR_CANNOTSENDTOCHAN` (404): the message is not allowed. The `body` says why: + - the sender is not a member; + - the sender is banned (`+b`); + - the channel is moderated (`+m`) and the sender has neither `+o` nor `+v`; + - the channel is `+H` and the message has no valid hashcash stamp. +- `ERR_NOSUCHNICK` (401): no session has the target nick. +- `RPL_AWAY` (301): the target of a direct message is away. The message is still + delivered. `params` holds the nick and `body` the away message. + +HTTP response: `{"id": "", "status": "sent"}`. + +#### `NOTICE`: send a notice + +The same as `PRIVMSG`, with two differences: the server sends no `RPL_AWAY` for +a notice, and a notice to a `+H` channel needs no hashcash stamp. A client that +receives a `NOTICE` must not answer it automatically. That is the IRC rule that +keeps two bots from answering each other forever. + +#### `JOIN`: join a channel + +```json +{"command": "JOIN", "to": "#general"} +{"command": "JOIN", "to": "general"} +{"command": "JOIN", "to": "#private", "body": ["secretkey"]} +``` + +- `to` (required): the channel. The server adds a missing `#`. The name must + then be `#` followed by 1 to 63 letters, digits, `_` or `-`. +- `body[0]` (optional): the key of a `+k` channel. + +A channel that does not exist is created with modes `+nt` and no topic. The +joining user becomes its channel operator. Every member, the joiner included, +receives the event: + +```json +{ "command": "JOIN", "from": "alice", "to": "#general", "body": ["#general"] } +``` + +The joiner then receives the topic, as `RPL_TOPIC` (332) with `RPL_TOPICWHOTIME` +(333) or as `RPL_NOTOPIC` (331), and the member list, as `RPL_NAMREPLY` (353) +and `RPL_ENDOFNAMES` (366). A `JOIN` to a channel the user is already in is +checked, broadcast and answered again; the membership does not change. + +Errors: + +- `ERR_NEEDMOREPARAMS` (461): no `to`. +- `ERR_NOSUCHCHANNEL` (403): the name is not allowed. +- `ERR_BANNEDFROMCHAN` (474): a ban matches the user's hostmask. +- `ERR_INVITEONLYCHAN` (473): the channel is `+i` and the user has no invite. +- `ERR_BADCHANNELKEY` (475): the channel is `+k` and the key is missing or + wrong. +- `ERR_CHANNELISFULL` (471): the channel has its `+l` number of members. + +None of the last four applies to the user who creates the channel. + +HTTP response: `{"status": "joined", "channel": "#general"}`. + +#### `PART`: leave a channel + +```json +{"command": "PART", "to": "#general"} +{"command": "PART", "to": "#general", "body": ["goodbye"]} +``` + +- `to` (required): the channel. The server adds a missing `#`. +- `body[0]` (optional): the reason. + +Every member, the leaver included, receives the event. Its `body` holds the +reason, or one empty string: + +```json +{ "command": "PART", "from": "alice", "to": "#general", "body": ["goodbye"] } +``` + +The channel is deleted when it has no members left. + +Errors: `ERR_NEEDMOREPARAMS` (461) without `to`; `ERR_NOSUCHCHANNEL` (403) for +an unknown channel; `ERR_NOTONCHANNEL` (442) when the user is not a member. + +HTTP response: `{"status": "parted", "channel": "#general"}`. + +#### `NICK`: change nick + +```json +{ "command": "NICK", "body": ["newnick"] } +``` + +`body[0]` is the new nick. Every user who shares a channel with the user, and +the user's own session, receives one event. `from` is the old nick and `body[0]` +the new one; the event has no `to`: + +```json +{ "command": "NICK", "from": "oldnick", "body": ["newnick"] } +``` + +A `NICK` to the nick the session already has changes nothing and sends no event. + +Errors: `ERR_NEEDMOREPARAMS` (461) without a body; `ERR_ERRONEUSNICKNAME` (432) +for a nick that is not allowed; `ERR_NICKNAMEINUSE` (433) for a nick in use. + +HTTP response: `{"status": "ok", "nick": "newnick"}`. + +#### `PASS`: set the session password + +```json +{ "command": "PASS", "body": ["mypassword"] } +``` + +`body[0]` is the password, 8 to 72 bytes. The server stores a bcrypt hash of it. +From then on `POST /api/v1/login` with the nick and the password adds a client +to the session. A second `PASS` replaces the password. The password disappears +with the session. + +Error: `ERR_NEEDMOREPARAMS` (461), with `body` `["Not enough parameters"]` for a +missing password and `["Password must be 8 to 72 bytes"]` for a wrong length. + +HTTP response: `{"status": "ok"}`. + +#### `TOPIC`: set a channel's topic + +```json +{ "command": "TOPIC", "to": "#general", "body": ["Welcome to #general"] } +``` + +- `to` (required): the channel. The server adds a missing `#`. +- `body` (required): the topic. Several strings are joined with spaces. + +There is no topic query over the HTTP API; `GET /api/v1/state` and the `JOIN` +replies carry the topic. Every member receives the event: + +```json +{ + "command": "TOPIC", + "from": "alice", + "to": "#general", + "body": ["Welcome to #general"] +} +``` + +The setter also receives `RPL_TOPIC` (332) and `RPL_TOPICWHOTIME` (333). + +Errors: `ERR_NEEDMOREPARAMS` (461) without `to` or `body`; `ERR_NOSUCHCHANNEL` +(403); `ERR_NOTONCHANNEL` (442) when the user is not a member; +`ERR_CHANOPRIVSNEEDED` (482) when the channel is `+t` and the user is not a +channel operator. New channels are `+t`. + +HTTP response: `{"status": "ok", "topic": "Welcome to #general"}`. + +#### `QUIT`: end the session + +```json +{"command": "QUIT"} +{"command": "QUIT", "body": ["leaving"]} +``` + +The session ends: + +- The user leaves every channel. A channel left empty is deleted. +- The nick is released. +- The cookie of every client of the session stops working. +- The password is gone. + +Every user who shared a channel receives the event; the quitting user does not. +`body[0]` is the reason, `Client quit` when none was given: + +```json +{ "command": "QUIT", "from": "alice", "body": ["leaving"] } +``` + +HTTP response: `{"status": "quit"}`, and the cookie is cleared. + +#### `PING`: keepalive + +```json +{ "command": "PING" } +``` + +The answer is the HTTP response itself, not a queued message: + +```json +{ "command": "PONG", "from": "neoirc" } +``` + +`from` is the server name. + +#### `MODE`: query or change modes + +A `to` that starts with `#` names a channel; any other `to` is a nick. +[Channel modes](#channel-modes) and [User modes](#user-modes) explain each mode. + +```json +{"command": "MODE", "to": "#general"} +{"command": "MODE", "to": "#general", "body": ["+m"]} +{"command": "MODE", "to": "#general", "body": ["+o", "bob"]} +{"command": "MODE", "to": "#general", "body": ["+k", "secretkey"]} +{"command": "MODE", "to": "alice"} +{"command": "MODE", "to": "alice", "body": ["+w"]} +``` + +**Channel query.** With no `body`, anyone, member or not, receives +`RPL_CHANNELMODEIS` (324) and `RPL_CREATIONTIME` (329). The mode string lists +the set flags out of `nimst`, then `k`, `l` and `H` with their values after it, +such as `+ntkl secretkey 50`: + +```json +{"command": "RPL_CHANNELMODEIS", "code": 324, "to": "alice", "params": ["#general", "+nt"], "body": [""]} +{"command": "RPL_CREATIONTIME", "code": 329, "to": "alice", "params": ["#general", "1709251200"], "body": [""]} +``` + +**Channel change.** Only a channel operator can change a channel's modes. +`body[0]` is one change: a sign and one letter. `body[1]` is its value where the +mode takes one. A string of several letters, such as `+mt`, is not accepted. The +changes: + +- `+o ` / `-o `: give or take channel operator. +- `+v ` / `-v `: 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`: set or clear the channel key. +- `+l ` / `-l`: set or clear the member limit. The number must be a + positive whole number. +- `+b ` / `-b `: add or remove a ban. `+b` with no mask lists the + bans; anyone may do that. +- `+H ` / `-H`: require a hashcash stamp of `bits` leading zero bits on + every `PRIVMSG`, from 1 to 40, or stop requiring one. + +Every member, the operator included, receives a `MODE` event. For `o`, `v`, `i`, +`m`, `s` and `t` its `body` is one string: `["+o bob"]`, `["+m"]`. For `k`, `l` +and `b` it is the change and its value as separate strings: +`["+k", "secretkey"]`, `["-k", "*"]`, `["+l", "50"]`, `["-l"]`, +`["+b", "*!*@*.example.com"]`. + +```json +{ "command": "MODE", "from": "alice", "to": "#general", "body": ["+o bob"] } +``` + +A `+H` or `-H` change sends no event. The operator receives `RPL_CHANNELMODEIS` +(324) with the new mode string. + +Listing bans sends one `RPL_BANLIST` (367) per ban, with `params` +`[channel, mask, nick who set it, Unix time]`, then `RPL_ENDOFBANLIST` (368). + +Errors of a channel `MODE`: + +- `ERR_NEEDMOREPARAMS` (461): no `to`, or `+o`, `-o`, `+v`, `-v`, `+k`, `+l`, + `-b` or `+H` without its value. +- `ERR_NOSUCHCHANNEL` (403): the channel does not exist. +- `ERR_CHANOPRIVSNEEDED` (482): a change by a user who is not a channel + operator. +- `ERR_UNKNOWNMODE` (472): a change the list above does not have, a `+H` value + that is not a whole number from 1 to 40, or a `+l` value that is not a + positive whole number. +- `ERR_NOSUCHNICK` (401): the `+o`, `-o`, `+v` or `-v` nick does not exist. +- `ERR_USERNOTINCHANNEL` (441): that nick is not a member. + +**User modes.** A user can query and change only their own modes; any other nick +gets `ERR_USERSDONTMATCH` (502). With no `body`, the server sends `RPL_UMODEIS` +(221) with the modes in `body`: `+`, `+o`, `+w` or `+ow`. With a `body`, +`body[0]` is a mode string starting with `+` or `-`, such as `+w` or `-o`. `w` +can be set and unset. `o` can only be unset; `OPER` sets it. A string with any +other letter, or without a letter, gets `ERR_UMODEUNKNOWNFLAG` (501) and changes +nothing. An accepted change is answered with `RPL_UMODEIS` (221) and the new +modes, and nobody else is told. + +```json +{ "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } +``` + +HTTP response: `{"status": "ok"}`, or `{"status": "error"}` with an error +numeric. + +#### `INVITE`: invite a user + +```json +{ "command": "INVITE", "body": ["bob", "#private"] } +``` + +`body[0]` is the nick and `body[1]` the channel; the server adds a missing `#`. +The inviter must be a member. On a `+i` channel the inviter must be a channel +operator. The invite lets the user join a `+i` channel once; the join uses it +up. + +The inviter receives `RPL_INVITING` (341) with `params` `[nick, channel]`. The +invited user receives the event, and nobody else does: + +```json +{ "command": "INVITE", "from": "alice", "to": "bob", "body": ["#private"] } +``` + +Errors: `ERR_NEEDMOREPARAMS` (461) with fewer than two strings; +`ERR_NOSUCHCHANNEL` (403); `ERR_NOTONCHANNEL` (442) when the inviter is not a +member; `ERR_CHANOPRIVSNEEDED` (482) on a `+i` channel; `ERR_NOSUCHNICK` (401); +`ERR_USERONCHANNEL` (443) when the user is already a member. + +HTTP response: `{"status": "ok"}`. + +#### `KICK`: remove a user from a channel + +```json +{ "command": "KICK", "to": "#general", "body": ["bob", "misbehaving"] } +``` + +- `to` (required): the channel. The server adds a missing `#`. +- `body[0]` (required): the nick to remove. +- `body[1]` (optional): the reason. Default: the kicker's nick. + +Only a channel operator can kick. Every member, the kicked user included, +receives the event. The kicker is in `from`, the channel in `to`, the kicked +nick in `params` and the reason in `body`: + +```json +{ + "command": "KICK", + "from": "alice", + "to": "#general", + "params": ["bob"], + "body": ["misbehaving"] +} +``` + +The channel is deleted when it has no members left. + +Errors: `ERR_NEEDMOREPARAMS` (461) without `to` or `body`; `ERR_NOSUCHCHANNEL` +(403); `ERR_CHANOPRIVSNEEDED` (482); `ERR_NOSUCHNICK` (401); +`ERR_USERNOTINCHANNEL` (441) when the nick is not a member. + +HTTP response: `{"status": "ok"}`. + +#### `AWAY`: set or clear an away message + +```json +{"command": "AWAY", "body": ["back in an hour"]} +{"command": "AWAY"} +``` + +A `body` sets the away message; several strings are joined with spaces. No +`body` clears it. The reply is `RPL_NOWAWAY` (306) or `RPL_UNAWAY` (305). A user +who sends a `PRIVMSG` to an away user receives `RPL_AWAY` (301). `USERHOST` +marks an away user with `-`. + +HTTP response: `{"status": "ok"}`. + +#### `NAMES`: a channel's members + +```json +{ "command": "NAMES", "to": "#general" } +``` + +The server adds a missing `#`. The reply is `RPL_NAMREPLY` (353), when the +channel has members, then `RPL_ENDOFNAMES` (366). `RPL_NAMREPLY` has `params` +`["=", channel]` and a `body` with one string: the members' hostmasks separated +by spaces, each with `@` before a channel operator and `+` before a voiced +member: + +```json +{ + "command": "RPL_NAMREPLY", + "code": 353, + "to": "alice", + "params": ["=", "#general"], + "body": ["@op1!op1@host1 alice!alice@host2 +bob!bob@host3"] +} +``` + +Errors: `ERR_NEEDMOREPARAMS` (461) without `to`; `ERR_NOSUCHCHANNEL` (403). + +HTTP response: `{"status": "ok"}`. + +#### `LIST`: all channels + +```json +{ "command": "LIST" } +``` + +The reply is one `RPL_LIST` (322) per channel, with `params` +`[channel, member count]` and the topic in `body`, then `RPL_LISTEND` (323). +Secret (`+s`) channels the user is not a member of are left out. + +HTTP response: `{"status": "ok"}`. + +#### `WHOIS`: information about a user + +```json +{"command": "WHOIS", "to": "bob"} +{"command": "WHOIS", "body": ["bob"]} +``` + +The nick comes from `to`, or from `body[0]` when `to` is empty. The replies, in +order: + +- `RPL_WHOISUSER` (311): `params` `[nick, username, hostname, "*"]`, `body` + `[nick]`. +- `RPL_WHOISSERVER` (312): `params` `[nick, server name]`. +- `RPL_WHOISOPERATOR` (313): only when the user is a server operator. +- `RPL_WHOISIDLE` (317): `params` + `[nick, seconds since the user's last request, Unix time of the session's creation]`. +- `RPL_WHOISCHANNELS` (319): `body` with the user's channels separated by + spaces. Secret channels the asker is not in are left out. Absent when there is + none to show. +- `RPL_WHOISACTUALLY` (338): only when the asker is a server operator. `params` + `[nick, IP address]` and `body` `["is actually using host "]`, for + the user's newest client. +- `RPL_ENDOFWHOIS` (318). + +An unknown nick gets `ERR_NOSUCHNICK` (401) and `RPL_ENDOFWHOIS` (318). No nick +at all gets `ERR_NEEDMOREPARAMS` (461). + +HTTP response: `{"status": "ok"}`, also for an unknown nick. + +#### `WHO`: a channel's members, one reply each + +```json +{ "command": "WHO", "to": "#general" } +``` + +The server adds a missing `#`. The reply is one `RPL_WHOREPLY` (352) per member, +with `params` `[channel, username, hostname, server name, nick, "H"]` and `body` +`["0 "]`, then `RPL_ENDOFWHO` (315). An unknown channel gets +`RPL_ENDOFWHO` alone. No `to` gets `ERR_NEEDMOREPARAMS` (461). + +HTTP response: `{"status": "ok"}`. + +#### `LUSERS`: user and channel counts + +```json +{ "command": "LUSERS" } +``` + +The replies are `RPL_LUSERCLIENT` (251), `RPL_LUSEROP` (252), +`RPL_LUSERCHANNELS` (254) and `RPL_LUSERME` (255). The server sends them to +every new client too. + +HTTP response: `{"status": "ok"}`. + +#### `MOTD`: the message of the day + +```json +{ "command": "MOTD" } +``` + +The server sends the welcome replies `001` to `005`, the `LUSERS` replies and +the message of the day again: `RPL_MOTDSTART` (375), one `RPL_MOTD` (372) per +line, `RPL_ENDOFMOTD` (376). With an empty `MOTD` setting, the welcome and +`LUSERS` replies come alone. + +HTTP response: `{"status": "ok"}`. + +#### `USERHOST`: hostmasks of up to five users + +```json +{ "command": "USERHOST", "body": ["alice", "bob"] } +``` + +The `body` holds up to five nicks; further ones are ignored. The reply is +`RPL_USERHOST` (302) with one string in `body`: for each nick that exists, +`nick=+username@hostname`, separated by spaces. A `*` after the nick marks a +server operator. A `-` instead of the `+` marks an away user. Unknown nicks are +left out. + +Error: `ERR_NEEDMOREPARAMS` (461) without a body. + +HTTP response: `{"status": "ok"}`. + +#### `VERSION`, `ADMIN`, `INFO`, `TIME`: server details + +Each takes no fields. + +- `VERSION`: `RPL_VERSION` (351) with `params` + `["neoirc-.", server name]`. +- `ADMIN`: `RPL_ADMINME` (256) with `params` `[server name]`, then + `RPL_ADMINLOC1` (257) `neoirc server`, `RPL_ADMINLOC2` (258) `IRC over HTTP` + and `RPL_ADMINEMAIL` (259) `admin@`. +- `INFO`: four `RPL_INFO` (371) lines, naming the program, its version, its + language and its start time, then `RPL_ENDOFINFO` (374). +- `TIME`: `RPL_TIME` (391) with `params` `[server name]` and the server's time + in `body`, in RFC 1123 form. + +HTTP response: `{"status": "ok"}`. + +#### `OPER`: become a server operator + +```json +{ "command": "OPER", "body": ["opername", "operpassword"] } +``` + +`body[0]` and `body[1]` are compared with `NEOIRC_OPER_NAME` and +`NEOIRC_OPER_PASSWORD`. On success the session gets user mode `+o` and +`RPL_YOUREOPER` (381). The status lasts until the session ends or the user sends +`MODE -o`. + +Errors: `ERR_NEEDMOREPARAMS` (461) with fewer than two strings; `ERR_NOOPERHOST` +(491) when the name or password is wrong, or the server has neither setting. + +HTTP response: `{"status": "ok"}`. + +#### `KILL`: end another user's session + +```json +{ "command": "KILL", "body": ["bob", "spamming"] } +``` + +Only a server operator can kill. `body[0]` is the nick and `body[1]` the reason, +`KILLed` when none is given. The target's session ends as with `QUIT`. Its +channel peers receive a `QUIT` event with the reason +`Killed ( ())`. A target connected through the IRC +listener is sent `KILL` and `ERROR` lines and disconnected. + +Errors: `ERR_NEEDMOREPARAMS` (461) without a nick; `ERR_NOPRIVILEGES` (481) for +a user who is not a server operator; `ERR_NOSUCHNICK` (401); +`ERR_CANTKILLSERVER` (483) for the operator's own nick. + +HTTP response: `{"status": "ok"}`. + +#### `WALLOPS`: message every user with `+w` + +```json +{ "command": "WALLOPS", "body": ["server restarts in five minutes"] } +``` + +Only a server operator can send it. The strings of `body` are joined with +spaces. Every user with user mode `+w` receives the event, with `to` `*`: + +```json +{ + "command": "WALLOPS", + "from": "alice", + "to": "*", + "body": ["server restarts in five minutes"] +} +``` + +Errors: `ERR_NEEDMOREPARAMS` (461) without a body; `ERR_NOPRIVILEGES` (481). + +HTTP response: `{"status": "ok"}`. + +### Numeric replies + +A numeric reply is a message from the server to one client. Over the HTTP API, +`command` holds the reply's name and `code` its number; `from` is the server +name and `to` the client's nick. `005` arrives as `RPL_BOUNCE`, its name in +RFC 2812. Every reply has a `body` with one string, the reply's text; a few +carry an empty string. + +Sent to every new client, on `POST /api/v1/session`, `POST /api/v1/login` and +`MOTD`: + +- `001` `RPL_WELCOME`: `Welcome to the network, `. +- `002` `RPL_YOURHOST`: `Your host is , running version `. +- `003` `RPL_CREATED`: `This server was created `. +- `004` `RPL_MYINFO`: `params` `[server name, version, "", "ikmnostl"]`. +- `005` `RPL_BOUNCE`: `params` + `["CHANTYPES=#", "NICKLEN=32", "PREFIX=(ov)@+", "CHANMODES=b,k,Hl,imnst", "NETWORK=neoirc", "CASEMAPPING=ascii"]`, + `body` `["are supported by this server"]`. +- `251` `RPL_LUSERCLIENT`: + `There are users and 0 invisible on 1 servers`. +- `252` `RPL_LUSEROP`: `params` `[server operators]`, `body` + `["operator(s) online"]`. +- `254` `RPL_LUSERCHANNELS`: `params` `[channels]`, `body` + `["channels formed"]`. +- `255` `RPL_LUSERME`: `I have clients and 1 servers`. +- `375` `RPL_MOTDSTART`: `- Message of the Day -`. +- `372` `RPL_MOTD`: one per line of the message of the day, each `- `. +- `376` `RPL_ENDOFMOTD`: `End of /MOTD command.` + +Answers to commands, as described under each command: + +- `221` `RPL_UMODEIS`: user `MODE`. +- `256` `RPL_ADMINME`, `257` `RPL_ADMINLOC1`, `258` `RPL_ADMINLOC2`, `259` + `RPL_ADMINEMAIL`: `ADMIN`. +- `301` `RPL_AWAY`: a `PRIVMSG` to an away user. +- `302` `RPL_USERHOST`: `USERHOST`. +- `305` `RPL_UNAWAY`, `306` `RPL_NOWAWAY`: `AWAY`. +- `311` `RPL_WHOISUSER`, `312` `RPL_WHOISSERVER`, `313` `RPL_WHOISOPERATOR`, + `317` `RPL_WHOISIDLE`, `318` `RPL_ENDOFWHOIS`, `319` `RPL_WHOISCHANNELS`, + `338` `RPL_WHOISACTUALLY`: `WHOIS`. +- `315` `RPL_ENDOFWHO`, `352` `RPL_WHOREPLY`: `WHO`. +- `322` `RPL_LIST`, `323` `RPL_LISTEND`: `LIST`. +- `324` `RPL_CHANNELMODEIS`, `329` `RPL_CREATIONTIME`: a channel `MODE` query; + `324` also after `+H` and `-H`. +- `331` `RPL_NOTOPIC`, `332` `RPL_TOPIC`, `333` `RPL_TOPICWHOTIME`: `JOIN`, + `TOPIC` and the channel state after login. `333` has `params` + `[channel, nick who set the topic, Unix time]`. +- `341` `RPL_INVITING`: `INVITE`. +- `351` `RPL_VERSION`: `VERSION`. +- `353` `RPL_NAMREPLY`, `366` `RPL_ENDOFNAMES`: `JOIN`, `NAMES` and the channel + state after login. +- `367` `RPL_BANLIST`, `368` `RPL_ENDOFBANLIST`: `MODE #channel +b`. +- `371` `RPL_INFO`, `374` `RPL_ENDOFINFO`: `INFO`. +- `381` `RPL_YOUREOPER`: `OPER`. +- `391` `RPL_TIME`: `TIME`. + +Errors. `params` holds the thing the error is about where the command's section +says so: + +- `401` `ERR_NOSUCHNICK`: no session has that nick. +- `403` `ERR_NOSUCHCHANNEL`: no such channel, or a name `JOIN` does not allow. +- `404` `ERR_CANNOTSENDTOCHAN`: a channel message that is not allowed. +- `411` `ERR_NORECIPIENT`: `PRIVMSG` or `NOTICE` without `to`. +- `412` `ERR_NOTEXTTOSEND`: `PRIVMSG` or `NOTICE` without text. +- `421` `ERR_UNKNOWNCOMMAND`: a command the server does not know; `params` holds + it. +- `432` `ERR_ERRONEUSNICKNAME`: a nick that is not allowed. +- `433` `ERR_NICKNAMEINUSE`: a nick in use. +- `441` `ERR_USERNOTINCHANNEL`: the nick in a `KICK` or a `MODE +o`/`+v` is not + a member. +- `442` `ERR_NOTONCHANNEL`: the sender is not a member. +- `443` `ERR_USERONCHANNEL`: an `INVITE` of a member. +- `461` `ERR_NEEDMOREPARAMS`: a required field is missing; `params` holds the + command. +- `471` `ERR_CHANNELISFULL`, `473` `ERR_INVITEONLYCHAN`, `474` + `ERR_BANNEDFROMCHAN`, `475` `ERR_BADCHANNELKEY`: a refused `JOIN`. +- `472` `ERR_UNKNOWNMODE`: a channel mode change that is not accepted. +- `481` `ERR_NOPRIVILEGES`: `KILL` or `WALLOPS` by a user who is not a server + operator. +- `482` `ERR_CHANOPRIVSNEEDED`: an action only a channel operator may take. +- `483` `ERR_CANTKILLSERVER`: a `KILL` of oneself. +- `491` `ERR_NOOPERHOST`: a failed `OPER`. +- `501` `ERR_UMODEUNKNOWNFLAG`: a user mode string that is not accepted. +- `502` `ERR_USERSDONTMATCH`: a user `MODE` for another nick. + +### Channel modes + +A channel's modes are set with `MODE` (see +[`MODE`](#mode-query-or-change-modes)). A new channel has `+n` and `+t`. + +- `+b `: ban. A user whose hostmask matches the mask cannot join or send. + The mask is `nick!user@host` with `*` for any run of characters and `?` for + one character; the match ignores letter case. A channel can have any number of + bans. +- `+i`: invite-only. Only a user with an invite can join. On a `+i` channel only + a channel operator can invite. +- `+k `: channel key. A `JOIN` must carry the key in `body[0]`. +- `+l `: member limit. A `JOIN` that would exceed it is refused. +- `+m`: moderated. Only channel operators and voiced members can send. +- `+n`: no external messages. New channels have it. The IRC listener can set and + clear it; the HTTP API cannot. It changes nothing: nobody who is not a member + can send to a channel, with or without it. +- `+s`: secret. `LIST` and `WHOIS` leave the channel out for users who are not + members. `GET /api/v1/channels` still shows it. +- `+t`: topic lock. Only a channel operator can set the topic. +- `+H `: hashcash. Every `PRIVMSG` needs a stamp of at least `bits` + leading zero bits (see [Hashcash](#hashcash)). An IRC listener client cannot + send to the channel at all. + +Two modes belong to a member rather than the channel: + +- `+o`: channel operator, shown as `@` before the name in `RPL_NAMREPLY`. +- `+v`: voice, shown as `+`. A voiced member can send on a moderated channel. + +### User modes + +- `+o`: server operator. `OPER` sets it; `MODE -o` clears it. +- `+w`: receives `WALLOPS`. `MODE +w` sets it and `MODE -w` clears + it. + +### Hostmasks + +Every session has a hostmask in IRC's form, `nick!username@hostname`: + +- `nick`: the session's current nick. +- `username`: the `username` sent at session creation, or the nick. +- `hostname`: the reverse DNS name of the IP address the session was created + from, or that IP address when the lookup gives no name. The lookup waits up to + three seconds. + +The server takes the client's IP address from the first entry of the +`X-Forwarded-For` header, then `X-Real-IP`, then the TCP connection (see +[Reverse proxy](#reverse-proxy)). + +Every user can see a hostmask: in `RPL_WHOISUSER` (311), `RPL_WHOREPLY` (352), +`RPL_USERHOST` (302), `RPL_NAMREPLY` (353) and +`GET /api/v1/channels/{name}/members`. Bans match against it. Only a server +operator sees IP addresses, in `RPL_WHOISACTUALLY` (338), and only that of the +user's newest client. + +### Hashcash + +Hashcash is a proof of work. The client finds a string whose SHA-256 hash starts +with a given number of zero bits, and the server checks the hash. Finding one +takes work that doubles with each bit; checking takes one hash. neoirc uses it +in two places. + +#### Session stamps + +Creating a session over the HTTP API needs a stamp unless `NEOIRC_HASHCASH_BITS` +is `0`. `GET /api/v1/server` tells the difficulty in `hashcash_bits`. The stamp +has six fields separated by colons: + +``` +1:bits:date:resource::counter +``` + +- `1`: the version. Always `1`. +- `bits`: the difficulty the stamp claims. It must be at least the server's + `hashcash_bits`. +- `date`: the current date in UTC, as `YYMMDD` or `YYMMDDHHMMSS`. +- `resource`: the server name, as `name` in `GET /api/v1/server`, or `neoirc` + when that is empty. +- An empty field. +- `counter`: a hex number the client varies until the hash fits. + +The SHA-256 hash of the whole stamp string must start with at least `bits` zero +bits. Example: `1:20:260310:neoirc::3a2f1b`. In pseudocode: + +``` +counter = 0 +loop: + stamp = "1:20:" + date + ":neoirc::" + hex(counter) + if leading_zero_bits(sha256(stamp)) >= 20: return stamp + counter = counter + 1 +``` + +At 20 bits this takes about a million hashes, around a second on a current +computer. Each further bit doubles the work. + +The server accepts a stamp when all of these hold: + +- the version is `1`; +- `bits` is at least the required difficulty; +- `resource` is the server name; +- the date is at most 48 hours in the past and at most one hour in the future; +- the hash has the zero bits; +- the stamp has not been spent. + +Spent stamps are kept in memory for 48 hours; a restart forgets them. The client +sends the stamp as `pow_token` in `POST /api/v1/session`. The web client and +`neoirc-cli` compute it on their own. + +#### Channel stamps + +A channel with mode `+H` needs a stamp on every `PRIVMSG`, in `meta.hashcash`. +The stamp is bound to the channel and to the message: + +``` +1:bits:date:channel:bodyhash:counter +``` + +- `channel`: the channel name, with the `#`. +- `bodyhash`: the SHA-256 hash, in lowercase hex, of the message's `body` value, + byte for byte as the request spells it. + +Example: + +```json +{ + "command": "PRIVMSG", + "to": "#general", + "body": ["hello world"], + "meta": { "hashcash": "1:20:260317:#general:a1b2c3...:1f4a" } +} +``` + +The server checks the stamp after membership, bans and `+m`, so a message +refused for one of those does not spend it. It then records the stamp's hash in +the database and refuses the stamp for a year. A missing, invalid or spent stamp +gets `ERR_CANNOTSENDTOCHAN` (404), with the reason in `body`. `NOTICE` needs no +stamp. The web client and `neoirc-cli` do not compute channel stamps. + +### Login rate limit + +`POST /api/v1/login` is limited per IP address: `LOGIN_RATE_LIMIT` requests per +second (default `1`) with a burst of `LOGIN_RATE_BURST` (default `5`). A request +over the limit gets `429` `too many login attempts, try again later` with +`Retry-After: 1`. The server forgets an address that has made no attempt for 15 +minutes. Session creation has no rate limit; the hashcash stamp is its cost. + +The IP address comes from `X-Forwarded-For` or `X-Real-IP` when present. Without +a reverse proxy that sets those headers, a client can set them itself and so +pick any address (see [Reverse proxy](#reverse-proxy)). + +### Writing a client + +A complete client needs four requests: + +1. `POST /api/v1/session` to get a cookie, with a stamp if the server requires + one. +2. `POST /api/v1/messages` with `JOIN` for each channel. +3. `GET /api/v1/messages` in a loop, with `timeout=15` and `after` set to the + last `last_id`. +4. `POST /api/v1/messages` to send anything else. + +A loop in Python, against a server started with `NEOIRC_HASHCASH_BITS=0`: + +```python +import requests, time + +BASE = "http://localhost:8080/api/v1" +session = requests.Session() + +resp = session.post(f"{BASE}/session", json={"nick": "pybot"}) +# requests sends a Secure cookie only over HTTPS. For a plain-HTTP +# server on localhost, send it by hand on every request. +session.headers["Cookie"] = f"neoirc_auth={resp.cookies['neoirc_auth']}" + +session.post(f"{BASE}/messages", json={"command": "JOIN", "to": "#general"}) + +last_id = 0 +while True: + try: + resp = session.get(f"{BASE}/messages", + params={"after": last_id, "timeout": 15}, + timeout=20) # longer than the long-poll timeout + data = resp.json() + last_id = data.get("last_id", last_id) + for msg in data.get("messages", []): + print(f"[{msg['command']}] <{msg.get('from', '')}> " + f"{' '.join(msg.get('body', []))}") + except requests.exceptions.Timeout: + continue + except Exception as exc: + print(f"error: {exc}") + time.sleep(2) +``` + +The same loop in a browser, where the cookie is sent automatically: + +```javascript +async function pollLoop() { + let lastId = 0; + while (true) { + try { + const resp = await fetch( + `/api/v1/messages?after=${lastId}&timeout=15`, + { credentials: "same-origin" }, + ); + if (resp.status === 401) break; // the session is gone + const data = await resp.json(); + lastId = data.last_id; + for (const msg of data.messages) handleMessage(msg); + } catch (e) { + await new Promise((r) => setTimeout(r, 2000)); + } } } ``` ---- - -## Security Model - -### Threat Model - -The server is **trusted for metadata** (it knows who sent what, when, to whom) -but **untrusted for message integrity** (signatures let clients verify that -messages haven't been tampered with). This is the same trust model as email with -PGP/DKIM — the mail server sees everything, but signatures prove authenticity. - -### Authentication - -- **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of - 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 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. -- **Password security**: Passwords are never stored in plain text. bcrypt - handles salting and key stretching automatically. Sessions without a password - cannot be logged into via `/login`. -- **Cookie security**: Auth cookies should only be transmitted over HTTPS in - production. If a cookie is compromised, the attacker has full access to the - session until QUIT or expiry. - -### Message Integrity - -- **Optional signing**: Clients may sign messages using Ed25519. The server - relays signatures verbatim in the `meta` field. -- **Server does not verify signatures**: Verification is purely client-side. - This means the server cannot selectively reject forged messages, but it also - means the server cannot be compelled to enforce a signing policy. -- **Canonicalization**: Messages are canonicalized via RFC 8785 JCS before - signing, ensuring deterministic byte representation regardless of JSON - serialization differences between implementations. - -### Key Management - -- **TOFU (Trust On First Use)**: Clients trust the first public key they see for - a nick. This is the same model as SSH host keys. It's simple and works well - when users don't change keys frequently. -- **No key revocation**: Deliberate omission. Key revocation systems are complex - (CRLs, OCSP, key servers) and rarely work well in practice. If your key is - compromised, change your nick. -- **No CA / PKI**: There is no certificate authority. Identity is a key, not a - name bound to a key by a third party. - -### DM Privacy - -- **DMs are not end-to-end encrypted** in the current implementation. The server - can read DM content. E2E encryption for DMs is planned (see - [Roadmap](#roadmap)). -- **DMs are stored** in the messages table, subject to the same rotation policy - as channel messages. - -### Transport Security - -- **Clients need HTTPS to keep a session.** The auth cookie is always `Secure`, - and clients send a `Secure` cookie only over HTTPS. The server itself serves - plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS termination. -- **A server on `localhost`**, as in this document's examples, also works over - plain HTTP with curl, `neoirc-cli`, Chrome and Firefox, which treat - `localhost` as secure. Safari does not. Python's `requests` does not either, - so the [Python example](#implementing-long-poll-in-code) sends the cookie - itself. -- **CORS**: The server allows all origins with credentials - (`Access-Control-Allow-Credentials: true`), reflecting the request Origin. - 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 - without `'unsafe-inline'` for scripts or styles. - ---- - -## Federation (Server-to-Server) - -Federation allows multiple neoirc servers to link together, forming a network -where users on different servers can share channels — similar to IRC server -linking. - -**Status:** Not yet implemented. This section documents the design. - -### Link Establishment - -Server links are **manually configured** by operators. There is no -autodiscovery, no mesh networking, no DNS-based lookup. Operators on both -servers must agree to link and configure shared authentication credentials. - -``` -POST /api/v1/federation/link -{ - "server_name": "peer.example.com", - "shared_key": "pre-shared-secret" -} -``` - -Both servers must configure the link. Authentication uses a pre-shared key -(hashed, never transmitted in plain text after initial setup). - -### Message Relay - -Once linked, servers relay messages using the same IRC envelope format: - -``` -POST /api/v1/federation/relay -{ - "command": "PRIVMSG", - "from": "alice@server1.example.com", - "to": "#shared-channel", - "body": ["hello from server1"], - "meta": {"sig": "base64...", "alg": "ed25519"} -} -``` - -Key properties: - -- **Signatures are relayed verbatim** — federated servers do not strip, modify, - or re-sign messages. A signature from a user on server1 can be verified by a - user on server2. -- **Nick namespacing**: In federated mode, nicks include a server suffix - (`nick@server`) to prevent collisions. Within a single server, bare nicks are - used. - -### State Synchronization - -After link establishment, servers exchange a **burst** of state: - -1. `NICK` commands for all connected users -2. `JOIN` commands for all shared channel memberships -3. `TOPIC` commands for all channel topics -4. `MODE` commands for all channel modes - -This mirrors IRC's server burst protocol. - -### S2S Commands - -| Command | Description | -| -------- | ---------------------------------- | -| `RELAY` | Relay a message from a remote user | -| `LINK` | Establish server link | -| `UNLINK` | Tear down server link | -| `SYNC` | Request full state synchronization | -| `PING` | Inter-server keepalive | -| `PONG` | Inter-server keepalive response | - -### Federation Endpoints - -``` -POST /api/v1/federation/link — Establish server link -POST /api/v1/federation/relay — Relay messages between linked servers -GET /api/v1/federation/status — Link status and peer list -POST /api/v1/federation/unlink — Tear down a server link -``` - ---- - -## Storage - -### Database - -SQLite by default (single-file, zero-config). The server uses -[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite), a pure-Go SQLite -implementation — no CGO required, cross-compiles cleanly. - -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. `000.sql` -creates `schema_migrations`, which records the version of each migration -applied; `001_initial.sql` creates the tables below. - -**Current tables:** - -#### `sessions` - -| Column | Type | Description | -| --------------- | -------- | -------------------------------------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `uuid` | TEXT | Unique session UUID | -| `nick` | TEXT | Unique nick | -| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) | -| `hostname` | TEXT | Reverse DNS name of the creating client's IP, or the IP itself when it has none | -| `ip` | TEXT | Real IP address of the session creator | -| `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; 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 login, authenticated HTTP API request or IRC listener command (or creation) | - -Index on `(uuid)`. - -#### `clients` - -| Column | Type | Description | -| ------------ | -------- | ------------------------------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `uuid` | TEXT | Unique client UUID | -| `session_id` | INTEGER | FK → sessions.id (cascade delete) | -| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) | -| `ip` | TEXT | Real IP address of this client connection | -| `hostname` | TEXT | Reverse DNS name of this client's IP, or the IP itself when it has none | -| `created_at` | DATETIME | Client creation time | -| `last_seen` | DATETIME | Last authenticated HTTP API request or IRC listener command (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 (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 change to the topic or to a mode stored in this row | - -#### `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) | -| `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)`. - -#### `messages` - -| Column | Type | Description | -| --------------- | -------- | ----------------------------------------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. | -| `uuid` | TEXT | UUID v4, exposed to clients as the message `id` | -| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) | -| `msg_from` | TEXT | Sender nick | -| `msg_to` | TEXT | Target (`#channel` or nick) | -| `to_session_id` | INTEGER | FK → sessions.id (set to null on delete): the session a DM or `INVITE` was sent to | -| `to_channel_id` | INTEGER | FK → channels.id (set to null on delete): the channel a channel message was sent to | -| `params` | TEXT | JSON-encoded IRC-style positional parameters | -| `body` | TEXT | JSON-encoded body (array or object) | -| `meta` | TEXT | JSON-encoded metadata | -| `created_at` | DATETIME | Server timestamp | - -Indexes on `(to_session_id, id)`, `(to_channel_id, id)` and `(created_at)`. -`to_session_id` and `to_channel_id` are null on a message sent to neither, such -as `QUIT`. `GET /api/v1/history` selects by one of these two columns rather than -by `msg_to`, because nicks and channel names are reused once released, and -session and channel ids are not. - -#### `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 | -| ------------ | -------- | ------------------------------------------------------ | -| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. | -| `client_id` | INTEGER | FK → clients.id (cascade delete) | -| `message_id` | INTEGER | FK → messages.id (cascade delete) | -| `created_at` | DATETIME | When the entry was queued | - -Unique constraint on `(client_id, message_id)`. Index on `(client_id, id)`. - -The `client_queues.id` is the monotonically increasing cursor used by -`GET /messages?after=`. This is more reliable than timestamps (no clock skew -issues) and simpler than UUIDs (integer comparison vs. string comparison). - -### Data Lifecycle - -- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE` (default - 30 days). -- **Client output queue entries**: Pruned automatically when older than - `QUEUE_MAX_AGE` (default 30 days). -- **Channels**: Deleted when the last member leaves (ephemeral). -- **Sessions**: Both anonymous and password-protected sessions are deleted on - `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: the session is parted from its channels, - deleted, and its QUIT queued to its channel peers in one database transaction, - so when several QUITs or logouts end one session at once only the one that - deletes it broadcasts QUIT, and whatever a new holder of the nick sends - reaches those peers after the QUIT. Idle clients are automatically removed - after `SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP - API request or, on the IRC listener, a command — 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. -- **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`, but at least a minute apart (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 - -Configuration comes from 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), -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 wins over the file. An environment variable set to the -empty string counts as unset, so the file's value or the default applies; the -one exception is `IRC_LISTEN_ADDR`, where the empty string disables the IRC -listener. - -| Variable | Type | Default | Description | -| ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | int | `8080` | HTTP listen port | -| `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. It must not set `foreign_keys`. See [SQLite Considerations](#sqlite-considerations). | -| `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 or IRC listener command 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. Set it to the empty string, in the environment or in `neoirc.yaml`, to disable the listener. | -| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | - -### Example `.env` file +Advice for client authors: + +- Set the HTTP timeout above the long-poll timeout. With `timeout=15`, give the + request at least 20 seconds. +- Keep `after` moving. Reset it to `0` only to read the queue again. +- Expect your own messages. The server echoes a session's `PRIVMSG`, `NOTICE` + and events to all its clients. Show them, or drop them by `id`. +- Key a direct-message window by the other user's nick: `to` when `from` is you, + `from` otherwise. +- On `401`, the session is gone or the cookie is wrong. Create a new session, or + log in again when the session has a password. +- Show a message by its `command`: `PRIVMSG` as ` text`, `NOTICE` as + `-nick- text`, `JOIN` as `*** nick has joined #channel`, `NICK` as + `*** oldnick is now known as newnick`, `TOPIC` as `*** nick set topic: text`, + and a numeric reply by its `body`. + +## The IRC listener + +The server also accepts plain IRC connections, as in RFC 1459 and RFC 2812, on +`IRC_LISTEN_ADDR` (default `:6667`). Clients such as irssi, WeeChat and HexChat +connect to it. IRC listener users and HTTP API users share the same channels, +nicks and messages: the listener and the HTTP API use the same database and the +same queues. The listener has no TLS. + +### Connecting + +Lines end in CR LF and are at most 512 bytes long; a longer line ends the +connection. A connection registers by sending `NICK` and `USER`. Registering +creates a session with the connection as its only client; no hashcash stamp is +needed. The hostname is the reverse DNS name of the connection's address, looked +up when it connects. Before registration the server accepts only `PASS`, `NICK`, +`USER`, `PING`, `PONG`, `QUIT` and `CAP`; anything else gets `ERR_NOTREGISTERED` +(451). + +A `PASS` before registration sets the new session's password, as the `PASS` +command does over the HTTP API. It does not log in to an existing session. A +password of the wrong length is refused with a `NOTICE` once registration +completes. + +After registration the client receives `001` to `005`, the `LUSERS` replies and +the message of the day, or `ERR_NOMOTD` (422) when `MOTD` is empty. The `004` +modes are `mnst` and the `005` tokens `CHANTYPES=#`, `NICKLEN=32`, +`PREFIX=(ov)@+`, `CHANMODES=,,H,imnst` and `NETWORK=`. + +`CAP LS` is answered with an empty capability list; other `CAP` subcommands are +ignored. + +When a connection has sent nothing for 90 seconds, the server sends `PING`. When +nothing arrives in the 30 seconds after that, the server closes the connection. +Any line, such as the client's `PONG`, starts both waits again. + +Closing the connection, with or without `QUIT`, ends the session, as `QUIT` does +over the HTTP API. Its channel peers see a `QUIT` with the reason +`Connection closed`; the reason given to `QUIT` is not relayed. Every command +after registration counts as activity for the idle cleanup. + +### Commands + +The listener accepts these commands: + +- Connection: `NICK`, `USER`, `PASS`, `QUIT`, `PING`, `PONG`, `CAP`. +- Channels: `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE`. +- Messages: `PRIVMSG`, `NOTICE`. +- Information: `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, + `ADMIN`, `INFO`, `TIME`. +- Server operator: `OPER`, `KILL`, `WALLOPS`. + +They behave as over the HTTP API, with the IRC syntax and these differences: + +- `JOIN` and `PART` take several channels separated by commas. + `JOIN #a,#b keya,keyb` gives each channel its key. `JOIN` adds a missing `#`; + other commands use the name as given. +- `TOPIC #channel` without a topic queries it. +- `MODE #channel [values...]` takes several letters in one string, such + as `+mt-i` or `+ov alice bob`. It accepts `i`, `m`, `n`, `s`, `t`, `o`, `v` + and `H`, but not `b`, `k` or `l`. A letter whose value is missing is skipped; + any other letter gets `ERR_UNKNOWNMODE` (472). The changes made go to every + member as one `MODE` line, such as `+o-v alice bob`. +- A `PRIVMSG` to a `+H` channel always gets `ERR_CANNOTSENDTOCHAN` (404): an IRC + line cannot carry a stamp. +- A `NOTICE` to an away user gets `RPL_AWAY` (301), as a `PRIVMSG` does. +- `RPL_NAMREPLY` (353) lists nicks, not hostmasks. +- `LIST` starts with `RPL_LISTSTART` (321). +- `WHOIS` sends `RPL_WHOISCHANNELS` (319) with `@` and `+` before the channel + names, then `RPL_WHOISIDLE` (317), then `RPL_AWAY` (301) for an away user. It + never sends `RPL_WHOISACTUALLY` (338). `WHOIS` with two parameters uses the + second as the nick. No parameter gets `ERR_NONICKNAMEGIVEN` (431). +- `WHO ` answers for one user. `RPL_WHOREPLY` (352) flags are `H@` for a + channel operator and `H+` for a voiced member. +- `NAMES` and `WHO` without a parameter, or for an unknown channel, get only the + end reply. +- `NICK` without a nick gets `ERR_NONICKNAMEGIVEN` (431). +- `KICK` without a reason uses the kicked user's nick as the reason. +- `MODE ` for another nick gets `ERR_USERSDONTMATCH` (502), for a query as + for a change. + +### What IRC clients see + +Each line of a multi-line `body` reaches an IRC client as its own `PRIVMSG` or +`NOTICE` line. A CR, LF or NUL inside a line is removed first. Other users +appear with the prefix `nick!nick@*`. A mode change made over the HTTP API +arrives as an IRC `MODE` line with each value as its own parameter, such as +`MODE #general +k secretkey`. An `INVITE` arrives as +`:alice!alice@* INVITE bob #general`. Numeric replies, `KICK`, `TOPIC`, `NICK`, +`QUIT` (as `Quit: `) and `WALLOPS` arrive in their IRC forms. + +A client's own commands are not echoed back through its queue. The listener +sends the echo itself, as IRC does, and for `PRIVMSG` and `NOTICE` sends none. +Other clients of the same session do see them. + +An operator's `KILL` of an IRC listener user sends the user `KILL` and +`ERROR :Closing Link` and closes the connection, whichever transport the `KILL` +came from. + +## The web client + +The server serves a web client at `/`. It is a single page written with Preact, +built into the binary from `web/src/`. It uses the HTTP API with the browser's +cookie. On loading it calls `GET /api/v1/state?initChannelState=1` to rebuild +its channel tabs. When the server requires a hashcash stamp, it computes one +with the browser's Web Crypto API before creating a session. + +## neoirc-cli + +`neoirc-cli` is a terminal client for the HTTP API. The Docker build compiles it +to check that it builds and leaves it out of the image. The `Makefile` has no +target for it. Build it from the source tree: ```bash +go build -o bin/neoirc-cli ./cmd/neoirc-cli +``` + +It starts disconnected. Commands start with `/`: + +- `/connect `: create a session on the server at ``, such as + `http://localhost:8080`, with the current nick. The default nick is `guest`. + The client fetches `GET /api/v1/server` first and computes a hashcash stamp + when the server needs one. +- `/nick `: change the nick, or set the nick to use before connecting. +- `/join #channel`, `/part [#channel]`. +- `/msg `: send a direct message. `/query `: open a window + for a user. +- `/topic `: set the current channel's topic. +- `/names`: the members of the current channel. `/list`: all channels. + `/who [#channel]`, `/whois `, `/motd`. +- `/window `, `/w ` or Alt and a digit: switch to window `n`. Window `0` + is the status window. +- `/quit`: send `QUIT` and exit. `/help`: the command list. + +Text without a `/` goes as a `PRIVMSG` to the current window's channel or user. +The client reads its queue with `timeout=15` and retries after two seconds on an +error. It sends no hashcash stamps for `+H` channels. It sends the auth cookie +to a server on `localhost` over plain HTTP, and otherwise only over HTTPS. + +## Running the server + +### Configuration + +Every setting is an environment variable. The server also reads a `.env` file in +its working directory, and a YAML file `neoirc.yaml` in `/etc/neoirc/` or +`$HOME/.config/neoirc/` with the same names as keys. An environment variable +wins over the file. An environment variable set to the empty string counts as +unset, except `IRC_LISTEN_ADDR`, where the empty string turns the IRC listener +off. A `neoirc.yaml` that is not valid YAML stops the server at startup. + +- `PORT` (default `8080`): the HTTP port. +- `IRC_LISTEN_ADDR` (default `:6667`): the address of the IRC listener. The + empty string turns the listener off. +- `DBURL` (default + `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`): + the SQLite database. A `DBURL` of your own should keep both `_pragma` + parameters: write-ahead logging, and a wait of 5000 ms for a locked database. + It must not set `foreign_keys`; the server adds `_pragma=foreign_keys(1)` + itself and refuses to start otherwise. See [The database](#the-database). +- `SERVER_NAME` (default empty): the server's name in replies, in + `GET /api/v1/server` and as the hashcash resource. When empty, replies and the + hashcash resource use `neoirc`, and `GET /api/v1/server` returns an empty + string. +- `MOTD` (default: a built-in banner): the message of the day. Lines are + separated by newlines. +- `NEOIRC_HASHCASH_BITS` (default `20`): the difficulty of the stamp + `POST /api/v1/session` needs. `0` turns the requirement off. +- `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD` (default empty): the name and + password `OPER` accepts. `OPER` fails while either is empty. +- `MAX_MESSAGE_SIZE` (default `4096`): the largest request body, in bytes, for + `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`. +- `LOGIN_RATE_LIMIT` (default `1`) and `LOGIN_RATE_BURST` (default `5`): login + attempts allowed per second per IP address, and the burst above that rate. A + value of `0` or less means the default. +- `SESSION_IDLE_TIMEOUT` (default `720h`, 30 days): a Go duration such as `720h` + or `24h`. A client with no request for this long is removed, and a session + left with no client ends. See [Data lifecycle](#data-lifecycle). A value that + is not a duration is logged, and the default applies. +- `MESSAGE_MAX_AGE` (default `720h`): messages older than this are deleted. A + value that is not a duration is logged, and no message is deleted. +- `QUEUE_MAX_AGE` (default `720h`): queue entries older than this are deleted. A + value that is not a duration is logged, and no entry is deleted. +- `DEBUG` (default `false`): `true` turns on debug logging. +- `SENTRY_DSN` (default empty): when set, panics in HTTP requests are reported + to Sentry. +- `METRICS_USERNAME` and `METRICS_PASSWORD` (default empty): the HTTP basic auth + credentials of `GET /metrics`. The endpoint exists only when + `METRICS_USERNAME` is set. +- `MAINTENANCE_MODE` (default `false`): reported as `maintenanceMode` in the + health check. Nothing else reads it. + +An example `.env`: + +``` PORT=8080 -SERVER_NAME=My NeoIRC Server +SERVER_NAME=My Server MOTD=Welcome! Be excellent to each other. -DEBUG=false DBURL=file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000) SESSION_IDLE_TIMEOUT=720h NEOIRC_HASHCASH_BITS=20 ``` ---- +### Docker -## IRC Protocol Listener +`make docker` builds the image, tagged `neoirc`. The image: -neoirc includes an optional traditional IRC wire protocol listener (RFC -1459/2812) that allows standard IRC clients to connect directly. This enables -backward compatibility with existing IRC clients like irssi, weechat, hexchat, -and others. +- is Alpine with the `neoircd` binary and CA certificates; +- runs as the user `neoirc`; +- exposes ports 8080 and 6667; +- keeps its database in `/var/lib/neoirc`; +- has a `HEALTHCHECK` that calls `/.well-known/healthcheck.json` every 30 + seconds. -### Configuration - -The IRC listener is **enabled by default** on `:6667`. To disable it, set -`IRC_LISTEN_ADDR` to an empty string (see [Configuration](#configuration)), -either in the environment: +Run it with a volume for the database and the settings as environment variables: ```bash -IRC_LISTEN_ADDR= -``` - -or in `neoirc.yaml`: - -```yaml -IRC_LISTEN_ADDR: "" -``` - -### Supported Commands - -| Category | Commands | -| ---------- | ---------------------------------------------------------------------------------------- | -| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | -| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` | -| Messaging | `PRIVMSG`, `NOTICE` | -| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` | -| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | - -### Protocol Details - -- **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 (8 to 72 - bytes); one outside that range is refused with a `NOTICE` once registration - completes, as `PASS` after registration is. Registering creates a session - without a hashcash stamp; closing the connection ends the session, as `QUIT` - does. -- **PING**: a connection that sends nothing for 90 seconds is sent a `PING`, and - is closed if it sends nothing in the 30 seconds after that; any line, such as - the client's `PONG`, starts both waits again. -- **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. As over the HTTP API, `JOIN` - answers a name that is not `#` followed by 1–63 letters, digits, `_` or `-` - with ERR_NOSUCHCHANNEL (403). -- **First joiner**: The first user to join a channel is automatically granted - operator status (`@`). -- **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. -- **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel - peers, deletes its session, and then sends the victim a `KILL` and - `ERROR :Closing Link` before closing its socket. This applies to victims on - the IRC listener regardless of whether the `KILL` arrived over IRC or the HTTP - API. - -### Bridge to HTTP API - -Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients -and vice versa. The IRC listener and HTTP API share the same database, broker, -and session infrastructure, so a user connected via IRC and a user connected via -the HTTP API can talk in the same channels. - -Each line of a multi-line `body` reaches an IRC listener user as its own -`PRIVMSG` or `NOTICE` line. A CR, LF or NUL inside a line is removed before it -is sent, so it cannot start a line of its own. A mode change made over the HTTP -API reaches IRC listener members as an IRC `MODE` line with its value as a -separate parameter, e.g. `MODE #general +k secretpass` or -`MODE #general +o bob`. An `INVITE` from either transport reaches the invited -IRC listener user as an IRC `INVITE` line from the inviter, e.g. -`:alice!alice@* INVITE bob #general`. - -### Docker Usage - -To expose the IRC port in Docker (the listener is enabled by default on -`:6667`): - -```bash -docker run -d \ - -p 8080:8080 \ - -p 6667:6667 \ - -v neoirc-data:/var/lib/neoirc \ - neoirc -``` - ---- - -## Entrypoints - -This repository adheres to the -[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) -standard. Each script below has a `make` target of the same name that calls it, -except `script/install-precommit`, which is `make hooks`, and `script/cibuild`, -`script/precommit` and `script/projectname`, which have none. - -- `script/bootstrap`: installs what development needs: make, git, Node and yarn - (for prettier), and Go. -- `script/setup`: makes a fresh clone ready for development: runs - `script/bootstrap`, then `script/install-precommit`. -- `script/test`: builds the `Dockerfile`'s `test` phase, which runs the tests - with `-race`. -- `script/lint`: builds the `Dockerfile`'s `lint` phase, which runs - golangci-lint. -- `script/fmt`: formats the Go code with gofmt and the Markdown with prettier. -- `script/fmt-check`: the same, read-only; fails if anything would change. -- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`. -- `script/docker`: builds the image, tagged `neoirc`. -- `script/cibuild`: the CI build: `script/bootstrap`, `script/check`, then the - image. -- `script/precommit`: run by the git pre-commit hook: fails if `go mod tidy` - changes `go.mod` or `go.sum`, then runs `script/check`. -- `script/install-precommit`: installs that hook. -- `script/projectname`: prints the project name, `neoirc`. - ---- - -## Deployment - -### Docker (Recommended) - -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 -make docker - -# Run -docker run -p 8080:8080 \ +docker run -d --name neoirc \ + -p 8080:8080 -p 6667:6667 \ -v neoirc-data:/var/lib/neoirc \ -e SERVER_NAME="My Server" \ -e MOTD="Welcome!" \ neoirc ``` -The Dockerfile is a five-stage build: +### Reverse proxy -1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX → - bundled JS via esbuild) into `web/dist/` -2. **lint**: Runs golangci-lint against the Go source (uses empty placeholder - files for `web/dist/` so it runs independently of web-builder for fast - feedback) -3. **test**: Runs the tests with `-race` on the Debian Go image, with the same - placeholder files -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 the `neoircd` binary and CA certificates +Run the server behind a reverse proxy that terminates TLS. The server itself +speaks plain HTTP, and the auth cookie is `Secure`, so browsers send it only +over HTTPS. The proxy must: -### Binary +- Set `X-Forwarded-For` or `X-Real-IP` to the client's address, replacing any + value the client sent. The server trusts these headers from anyone. Without a + proxy that overwrites them, a client can choose its own address, which sets + its hostname and lets it dodge the login rate limit. +- Wait at least 60 seconds for a response. A long poll can take 30 seconds, and + the server's own write timeout is 60 seconds. -```bash -# Build from source -make build -# Binary at ./bin/neoircd - -# Run -./bin/neoircd -# Listens on :8080 (HTTP) and :6667 (IRC), writes to /var/lib/neoirc/state.db -``` - -### Reverse Proxy (Production) - -For production, run behind a TLS-terminating reverse proxy. - -**Caddy:** +Caddy: ``` neoirc.example.com { @@ -2829,7 +1660,7 @@ neoirc.example.com { } ``` -**nginx:** +nginx: ```nginx server { @@ -2844,650 +1675,201 @@ server { 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 + proxy_read_timeout 60s; } } ``` -**Important:** Set `proxy_read_timeout` (nginx) or equivalent to at least 60 -seconds to accommodate long-poll connections. - -### SQLite Considerations - -- **Journal mode**: the default connection string runs the database in WAL mode - with a 5000 ms busy timeout, through the SQLite driver's - `_pragma=journal_mode(WAL)` and `_pragma=busy_timeout(5000)` parameters. A - `DBURL` of your own should keep both. -- **Single writer**: the server uses one database connection, so reads and - writes take turns. For high-traffic servers, Postgres support is planned. -- **Backup**: Back the database up with - `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`, or copy `state.db` - while the server is stopped, together with `state.db-wal` if one is left - beside it: in WAL mode that file holds recent writes not yet folded into - `state.db`. -- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the - `DBURL` env var to place it elsewhere. - ---- - -## Client Development Guide - -This section explains how to write a client against the neoirc API. The API is -designed to be simple enough that a basic client can be written in any language -with an HTTP client library. - -### Minimal Client Loop - -A complete client needs only four HTTP calls: - -``` -1. POST /api/v1/session → get auth cookie -2. POST /api/v1/messages (JOIN) → join channels -3. GET /api/v1/messages (loop) → receive messages -4. POST /api/v1/messages → send messages -``` - -### 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 \ - -H 'Content-Type: application/json' \ - -d '{"nick":"testuser"}' - -# 1b. Optionally set a password for multi-client access -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"PASS","body":["mypassword"]}' - -# 1c. Login from another device (saves new cookie) -curl -s -c cookies2.txt -X POST http://localhost:8080/api/v1/login \ - -H 'Content-Type: application/json' \ - -d '{"nick":"testuser","password":"mypassword"}' - -# 2. Join a channel -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"JOIN","to":"#general"}' - -# 3. Send a message -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}' - -# 4. Poll for messages (one-shot) -curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq . - -# 5. Long-poll (blocks up to 15s waiting for messages) -curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq . - -# 6. Send a DM -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}' - -# 7. Change nick -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"NICK","body":["newnick"]}' - -# 8. Set channel topic -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"TOPIC","to":"#general","body":["New topic!"]}' - -# 9. Leave a channel -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"PART","to":"#general","body":["goodbye"]}' - -# 10. Disconnect -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"QUIT","body":["leaving"]}' -``` - -### Implementing Long-Poll in Code - -The key to real-time messaging is the poll loop. Here's the pattern: - -```python -# Python example — using requests.Session for cookie handling -import requests, json, time - -BASE = "http://localhost:8080/api/v1" -session = requests.Session() -last_id = 0 - -# Create session (the server sets the auth cookie via Set-Cookie) -resp = session.post(f"{BASE}/session", json={"nick": "pybot"}) -print(f"Session: {resp.json()}") -# The auth cookie is Secure, and requests sends it only over HTTPS. -# For a plain-HTTP server on localhost, send it on every request: -session.headers["Cookie"] = f"neoirc_auth={resp.cookies['neoirc_auth']}" - -# Join channel -session.post(f"{BASE}/messages", - json={"command": "JOIN", "to": "#general"}) - -# Poll loop -while True: - try: - resp = session.get(f"{BASE}/messages", - params={"after": last_id, "timeout": 15}, - timeout=20) # HTTP timeout > long-poll timeout - data = resp.json() - if data.get("last_id"): - last_id = data["last_id"] - for msg in data.get("messages", []): - print(f"[{msg['command']}] <{msg.get('from','')}> " - f"{' '.join(msg.get('body', []))}") - except requests.exceptions.Timeout: - continue # Normal — just re-poll - except Exception as e: - print(f"Error: {e}") - time.sleep(2) # Back off on errors -``` - -```javascript -// JavaScript/browser example — cookies sent automatically -async function pollLoop() { - let lastId = 0; - while (true) { - try { - const resp = await fetch( - `/api/v1/messages?after=${lastId}&timeout=15`, - { credentials: "same-origin" }, // Include cookies - ); - if (resp.status === 401) { - /* session expired */ break; - } - const data = await resp.json(); - if (data.last_id) lastId = data.last_id; - for (const msg of data.messages || []) { - handleMessage(msg); - } - } catch (e) { - await new Promise((r) => setTimeout(r, 2000)); // back off - } - } -} -``` - -### Handling Message Types - -Clients should handle these message commands from the queue: - -| Command | Display As | -| --------- | ---------------------------------------------------------- | -| `PRIVMSG` | ` message text` | -| `NOTICE` | `-nick- message text` (do not auto-reply) | -| `JOIN` | `*** nick has joined #channel` | -| `PART` | `*** nick has left #channel (reason)` | -| `QUIT` | `*** nick has quit (reason)` | -| `NICK` | `*** oldnick is now known as newnick` | -| `TOPIC` | `*** nick set topic: new topic` | -| Numerics | Display body text (e.g., welcome messages, error messages) | - -### Error Handling - -- **HTTP 401**: Auth cookie expired or invalid. Re-create session or re-login - (if a password was set). -- **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). - -### Tips for Client Authors - -1. **Set HTTP timeout > long-poll timeout**: If your long-poll timeout is 15s, - set your HTTP client timeout to at least 20s to avoid cutting off valid - responses. -2. **Always use `after` parameter**: Start with `after=0`, then use `last_id` - from each response. Never reset to 0 unless you want to re-read history. -3. **Handle your own echoed messages**: Channel messages and DMs are echoed back - to the sender. Your client will receive its own messages. Either deduplicate - by `id` or show them (which confirms delivery). -4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel (no - `#` prefix), the DM tab should be keyed by the **other** user's nick: if - `from` is you, use `to`; if `from` is someone else, use `from`. -5. **Reconnection**: If the poll loop fails with 401, the auth cookie is - invalid. For sessions without a password, create a new session. For sessions - with a password set (via PASS command), log in again via `POST /api/v1/login` - to get a fresh cookie on the same session. If it fails with a network error, - retry with backoff. - ---- - -## Rate Limiting & Abuse Prevention - -### Hashcash Proof-of-Work - -Session creation (`POST /api/v1/session`) requires a -[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 - -1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits` - field (e.g., `20`) indicating the required difficulty. -2. Client computes a hashcash stamp: find a counter value such that the SHA-256 - hash of the stamp string has the required number of leading zero bits. -3. Client includes the stamp in the `pow_token` field of the JSON request body - when creating a session: `POST /api/v1/session`. -4. Server validates the stamp: - - Version is `1` - - Claimed bits ≥ required bits - - Resource matches the server name - - 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; the spent stamps are - kept in memory, so a restart forgets them) - - The stamp is spent only when the session is created. A request refused with - 400 or 409 leaves it unspent, and the client can send the same stamp again - with another nick or username. - -### Stamp Format - -Standard hashcash format: - -``` -1:bits:date:resource::counter -``` - -| Field | Description | -| ---------- | ----------------------------------------------------------------- | -| `1` | Version (always `1`) | -| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) | -| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) | -| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) | -| (empty) | Extension field (unused) | -| `counter` | Hex counter value found by the client to satisfy the PoW | - -**Example stamp:** `1:20:260310:neoirc::3a2f1b` - -The SHA-256 hash of this entire string must have at least 20 leading zero bits. - -### Computing a Stamp - -```bash -# Pseudocode -bits = 20 -resource = "neoirc" -date = "260310" # YYMMDD in UTC -counter = 0 - -loop: - stamp = "1:{bits}:{date}:{resource}::{hex(counter)}" - hash = SHA-256(stamp) - if leading_zero_bits(hash) >= bits: - return stamp - counter++ -``` - -At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on -average, taking roughly 0.5–2 seconds on modern hardware. - -### Client Integration - -Both the embedded web SPA and the CLI client automatically handle hashcash: - -1. Fetch `GET /api/v1/server` to read `hashcash_bits` -2. If `hashcash_bits > 0`, compute a valid stamp -3. Include the stamp in the `pow_token` field of the JSON body on - `POST /api/v1/session` - -The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256 -computation with batched parallelism. The CLI client uses Go's `crypto/sha256`. - -### Configuration - -Set `NEOIRC_HASHCASH_BITS` to control difficulty: - -| Value | Effect | Approx. Client CPU | -| ----- | ------------------------------------ | ------------------ | -| `0` | Disabled (no proof-of-work required) | — | -| `16` | Light protection | ~1ms | -| `20` | Default — good balance | ~0.5–2s | -| `24` | Strong protection | ~10–30s | -| `28` | Very strong (may frustrate users) | ~2–10min | - -Each additional bit doubles the expected work. An attacker creating 1000 -sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user -creating one session pays once and keeps their session. - -### Why Hashcash and Not Rate Limits? - -- **Little state to track**: No IP tables, no token buckets, no sliding windows. - The server hashes the stamp 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. -- **Cost falls on the requester**: The server's verification cost is constant - regardless of difficulty: at most two SHA-256 hashes of the stamp per session - request, one to check it and one to spend it. Only the client does more work. -- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No - registration, no email, no phone number, no CAPTCHA. Just compute. -- **Language-agnostic**: SHA-256 is available in every programming language. The - proof computation is trivially implementable in any client. - -### Login Rate Limiting - -The login endpoint (`POST /api/v1/login`) has per-IP rate limiting to prevent -brute-force password attacks. This uses a token-bucket algorithm -(`golang.org/x/time/rate`) with configurable rate and burst. - -| Environment Variable | Default | Description | -| -------------------- | ------- | ---------------------------------------- | -| `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP | -| `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP | - -When the limit is exceeded, the server returns **429 Too Many Requests** with a -`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up every -10 minutes. - -> **⚠️ Security: Reverse Proxy Required for Production Use** -> -> The rate limiter extracts the client IP by checking the `X-Forwarded-For` -> header first, then `X-Real-IP`, and finally falling back to the TCP -> `RemoteAddr`. Both `X-Forwarded-For` and `X-Real-IP` are **client-controlled -> request headers** — any client can set them to arbitrary values. -> -> Without a properly configured reverse proxy in front of this server: -> -> - An attacker can **bypass rate limiting entirely** by rotating -> `X-Forwarded-For` values on each request (each value is treated as a -> distinct IP). -> - An attacker can **deny service to a specific user** by spoofing that user's -> IP in the `X-Forwarded-For` header, exhausting their rate limit bucket. -> -> **Recommendation:** Always deploy behind a reverse proxy (e.g. nginx, Caddy, -> Traefik) that strips or overwrites incoming `X-Forwarded-For` and `X-Real-IP` -> headers with the actual client IP. If running without a reverse proxy, be -> aware that the rate limiting provides no meaningful protection against a -> targeted attack. - -**Why rate limits here but not on session creation?** Session creation is -protected by hashcash proof-of-work (no IP tracking needed). Login involves -bcrypt password verification against a registered account — a fundamentally -different threat model where an attacker targets a specific account. Per-IP rate -limiting is appropriate here because the cost of a wrong guess is borne by the -server (bcrypt), not the client. - ---- - -## Roadmap - -### Implemented (MVP) - -- [x] Session creation with nick claim -- [x] All core commands: PRIVMSG, JOIN, PART, NICK, TOPIC, QUIT, PING -- [x] IRC message envelope format (command, from, to, body, ts, meta) -- [x] Per-client delivery queues with fan-out -- [x] Long-polling with in-memory broker -- [x] Channel messages and DMs -- [x] Ephemeral channels (deleted when empty) -- [x] NICK change with broadcast -- [x] QUIT with broadcast and cleanup -- [x] Embedded web SPA client -- [x] CLI client (neoirc-cli) -- [x] SQLite storage -- [x] Docker deployment -- [x] Prometheus metrics endpoint -- [x] Health check endpoint -- [x] Session expiry — auto-expire idle sessions, release nicks -- [x] Logout endpoint (`POST /api/v1/logout`) -- [x] Current user endpoint (`GET /api/v1/users/me`) -- [x] User count in server info (`GET /api/v1/server`) - -### Post-MVP (Planned) - -- [x] **Hashcash proof-of-work** for session creation (abuse prevention) -- [x] **Client output queue pruning** — delete old client output queue entries - per `QUEUE_MAX_AGE` -- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` -- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock); only - members can send to a channel, so `+n` (no external) is shown but changes - nothing -- [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), - `+b` (ban), `+k` (key), `+l` (limit) -- [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes -- [x] **KICK command** — operator-only channel kick with broadcast -- [x] **MODE command** — query and set channel/user modes -- [x] **NAMES command** — query channel member list with @/+ prefixes -- [x] **LIST command** — list all channels with member counts -- [x] **WHOIS command** — query user information and channel membership -- [x] **WHO command** — query channel user list -- [x] **LUSERS command** — query server statistics -- [x] **Connection registration numerics** — 001-005 sent on session creation -- [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS -- [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-502 errors) -- [x] **Max message size enforcement** — reject request bodies over - `MAX_MESSAGE_SIZE` bytes -- [x] **NOTICE command** — distinct from PRIVMSG over the HTTP API (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 - authentication -- [x] **Tier 3 utility commands** — `USERHOST` (302), `VERSION` (351), `ADMIN` - (256–259), `INFO` (371/374), `TIME` (391), `KILL` (operator-only forced - disconnect), `WALLOPS` (operator-only broadcast to `+w` users) -- [x] **User mode +w** — receive `WALLOPS`, set with `MODE +w` / `-w` - -### Future (1.0+) - -- [ ] **PUBKEY command** — public key distribution -- [ ] **Message signing** — Ed25519 signatures with JCS canonicalization -- [ ] **TOFU key management** — client-side key caching and verification -- [ ] **E2E encryption for DMs** — end-to-end encrypted direct messages using - X25519 key exchange -- [ ] **Federation** — server-to-server linking, message relay, state sync -- [ ] **Postgres support** — for high-traffic deployments -- [ ] **Image/file upload** — inline media via a separate upload endpoint, - referenced in message `meta` -- [ ] **Push notifications** — optional webhook/push for mobile clients when - messages arrive during disconnect -- [ ] **Message search** — full-text search over channel history -- [x] **User info command** — WHOIS for querying user info and channels -- [ ] **Connection flood protection** — per-IP connection limits as a complement - to hashcash -- [x] **Invite system** — `INVITE` command for `+i` channels -- [x] **Ban system** — channel-level bans by hostmask pattern - ---- - -## Project Structure - -Following -[gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md): - -``` -neoirc/ -├── cmd/ -│ ├── neoircd/ # Server binary entry point -│ │ └── main.go -│ └── neoirc-cli/ # TUI client entry point -│ └── main.go # Minimal bootstrapping (calls internal/cli) -├── internal/ -│ ├── broker/ # In-memory pub/sub for long-poll notifications -│ │ └── broker.go -│ ├── cli/ # TUI client implementation -│ │ ├── app.go # App struct, command handling, poll loop -│ │ ├── ui.go # tview-based terminal UI -│ │ └── api/ -│ │ ├── client.go # HTTP API client library -│ │ ├── types.go # Request/response types -│ │ └── hashcash.go # Hashcash proof-of-work minting -│ ├── config/ # Viper-based configuration -│ │ └── config.go -│ ├── db/ # Database access and migrations -│ │ ├── db.go # Connection, migration runner -│ │ ├── 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 -│ ├── dbtest/ # Gives each test its own database; only tests import it -│ ├── globals/ # Application-wide metadata -│ │ └── globals.go -│ ├── handlers/ # HTTP request 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 -│ │ └── logger.go -│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth) -│ │ └── middleware.go -│ └── server/ # HTTP server, routing, lifecycle -│ ├── server.go # fx lifecycle, Sentry, signal handling -│ ├── routes.go # chi router setup, all routes -│ └── http.go # HTTP timeouts -├── web/ -│ ├── embed.go # go:embed directive for SPA -│ ├── build.sh # SPA build script (esbuild, runs in Docker) -│ ├── package.json # Node dependencies (preact, esbuild) -│ ├── yarn.lock -│ ├── src/ # SPA source files (JSX + HTML + CSS) -│ │ ├── app.jsx -│ │ ├── index.html -│ │ └── style.css -│ └── dist/ # Generated at Docker build time (not committed) -├── pkg/ -│ └── irc/ # IRC command names and numeric reply codes -├── schema/ # JSON Schema definitions -├── script/ # Entrypoints that the Makefile targets call -├── go.mod -├── go.sum -├── package.json # prettier, for make fmt and make fmt-check -├── Makefile -├── Dockerfile -├── CONVENTIONS.md -└── README.md -``` - -### Required Libraries - -| Purpose | Library | -| ---------- | ------------------------------------------------------- | -| DI | `go.uber.org/fx` | -| Router | `github.com/go-chi/chi/v5` | -| Logging | `log/slog` (stdlib) | -| Config | `github.com/spf13/viper` | -| Env | `github.com/joho/godotenv/autoload` | -| CORS | `github.com/go-chi/cors` | -| Metrics | `github.com/prometheus/client_golang` | -| DB | `modernc.org/sqlite` + `database/sql` | -| UUIDs | `github.com/google/uuid` | -| Errors | `github.com/getsentry/sentry-go` (optional) | -| TUI Client | `github.com/rivo/tview` + `github.com/gdamore/tcell/v2` | - ---- - -## Design Principles - -1. **API-first** — the HTTP API is the product. Clients are thin. If you can't - build a working IRC-style TUI client against this API in an afternoon, the - API is too complex. - -2. **Passwords optional** — anonymous sessions are instant: pick a nick and - talk. No registration, no email verification. The cost of entry is a hashcash - proof, not bureaucracy. For users who want multi-client access (multiple - devices sharing one session), the PASS command sets a password on the session - — but it's never required. Identity verification at the message layer uses - cryptographic signing, independent of password status. - -3. **IRC semantics over HTTP** — command names and numeric codes from RFC - 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 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. - The server is the source of truth for session state, channel membership, and - message history. - -6. **Structured messages** — JSON with extensible metadata. Bodies are always - objects or arrays, never raw strings. This enables deterministic - canonicalization (JCS) for signing and multiline messages without escape - sequences. - -7. **Immutable messages** — no editing, no deletion. Ever. This fits naturally - with cryptographic signatures and creates a trustworthy audit trail. IRC - culture already handles corrections inline ("s/typo/fix/"). - -8. **Simple deployment** — single binary, SQLite default, zero mandatory - external dependencies. `docker run` and you're done. No Redis, no RabbitMQ, - no Kubernetes, no configuration management. - -9. **No eternal logs** — history rotates. Chat should be ephemeral by default. - Channels disappear when empty. Sessions expire when idle. The server does not - aspire to be an archive. - -10. **Federation optional** — a single server works standalone. Linking is - manual and opt-in, like IRC. There is no requirement to participate in a - network. - -11. **Signable messages** — optional Ed25519 signatures with TOFU key - distribution. Servers relay signatures without verification. Trust decisions - are made by clients, not servers. - -12. **No magic** — the protocol has no special cases, no content-type - negotiation, no feature flags. Every message uses the same envelope. Every - command goes through the same endpoint. The simplest implementation is also - the correct one. - ---- - -## Status - -**Implementation in progress.** Core API is functional with: - -- 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 -- Embedded web SPA client -- TUI client (neoirc-cli) -- Docker image -- Prometheus metrics - -See [Roadmap](#roadmap) for what's next. - ---- +The IRC listener has no TLS. + +### Limits and headers + +- The server reads a request within 10 seconds and writes a response within 60 + seconds. A request is cancelled after 60 seconds. Headers may be up to 1 MiB. +- A long poll waits at most 30 seconds. +- Every response carries a `Content-Security-Policy` header: + `default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`. + The web client works within it. +- Cross-origin requests are allowed from any origin, with credentials, for the + methods `GET`, `POST`, `PUT`, `DELETE` and `OPTIONS` and the headers `Accept`, + `Content-Type` and `X-CSRF-Token`. The cookie's `SameSite=Strict` still keeps + it to the same site. + +### Logging + +The server logs with Go's `slog`, one line per event, to standard output. The +lines are JSON, or plain text when standard output is a terminal. Every HTTP +request is logged with its method, URL, status, latency, remote address, user +agent and request ID. `DEBUG=true` adds debug-level lines. + +### The database + +The server stores everything in one SQLite database, through the pure Go driver +`modernc.org/sqlite`. It uses one database connection, so reads and writes take +turns. On startup it creates the tables from +`internal/db/schema/001_initial.sql` if they do not exist, and records the +schema version in `schema_migrations`. + +The tables: + +- `sessions`: one row per session: nick, username, hostname, IP address, the + server operator and `+w` flags, the bcrypt password hash, the away message, + the creation time and the time of the last request. +- `clients`: one row per client: the SHA-256 hash of its cookie, its IP address + and hostname, its creation time and the time of its last request. +- `channels`: name, topic, who set the topic and when, every channel mode and + the creation time. +- `channel_members`: which sessions are in which channel, with their `+o` and + `+v` flags. +- `channel_bans` and `channel_invites`: the bans and the pending invites of each + channel. +- `messages`: every message once: command, sender, target, the session or + channel it was sent to, `params`, `body`, `meta` and the time. +- `client_queues`: one row per queued message per client. Its row number is the + `last_id` of `GET /api/v1/messages`. +- `spent_hashcash`: the hashes of channel stamps already used. + +Back up the database with +`sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`. Or stop the server and +copy `state.db` together with `state.db-wal`, if that file exists: in +write-ahead logging mode it holds recent writes not yet in `state.db`. + +An in-memory database (`file::memory:`) is not usable, even for testing. SQLite +deletes an in-memory database when its last connection closes, and the server's +connection pool replaces its connection whenever a query is cancelled. +Everything stored until then is lost. + +### Data lifecycle + +- A channel is deleted when its last member leaves, is kicked, quits or is + removed by the idle cleanup. +- A session ends when any of these happens: + - `QUIT` or `KILL`; + - its last client logs out; + - the connection of an IRC listener user closes; + - the idle cleanup removes its last client. +- With the session go its nick, its password and its channel memberships. Its + messages stay until they age out. +- A session's last request time is updated on every authenticated HTTP request + and on every command on the IRC listener. +- A cleanup runs every half `SESSION_IDLE_TIMEOUT`, but at least a minute apart. + With the default that is every 15 days, the first time 15 days after the + server starts. It deletes clients whose last request is older than + `SESSION_IDLE_TIMEOUT` and ends the sessions left with no client; their + channel peers see a `QUIT` with the reason `Connection closed`. It deletes + messages older than `MESSAGE_MAX_AGE`, queue entries older than + `QUEUE_MAX_AGE` and channel hashcash stamps older than a year. A row can + therefore outlive its age limit by up to one cleanup interval. + +### Shutdown + +On `SIGINT` or `SIGTERM` the server stops. It waits up to five seconds for HTTP +requests in progress, and up to five seconds for the IRC connections to close +and their sessions to end, then closes the database. + +## Development + +### Layout + +- `cmd/neoircd/`: the server's `main`, which wires the packages with `fx`. +- `cmd/neoirc-cli/`: the terminal client's `main`. +- `internal/config/`: the settings, read with Viper. +- `internal/server/`: the HTTP server, its routes and its middleware. +- `internal/handlers/`: the HTTP API handlers and the cleanup loop. +- `internal/service/`: the command logic the HTTP API and the IRC listener + share. +- `internal/ircserver/`: the IRC listener: connections, the line parser, and the + relay that turns queued messages into IRC lines. +- `internal/db/`: the database: connection, migrations, queries, and the schema + in `schema/`. +- `internal/broker/`: wakes waiting long polls and IRC relays when a session + gets a message. +- `internal/hashcash/`: stamp checking and minting. +- `internal/ratelimit/`: the per-address login rate limiter. +- `internal/middleware/`: request logging, CORS, CSP and metrics. +- `internal/healthcheck/` and `internal/stats/`: the health check and its + counters. +- `internal/logger/` and `internal/globals/`: logging setup and build + information. +- `internal/cli/`: the terminal client, with its API client in `api/`. +- `internal/dbtest/`: a database per test; only tests import it. +- `pkg/irc/`: the IRC command names and numeric reply codes. +- `web/`: the web client's source, build script and `embed.go`. +- `schema/`: JSON Schema files for the message envelope, with their own + `README.md`. +- `script/`: the entrypoints below. +- `CONVENTIONS.md`, `REPO_POLICIES.md` and `AGENTS.md`: the architecture + conventions, the repository policies and the rules for coding agents. + +### Entrypoints + +This repository adheres to the +[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) +standard. Each script has a `make` target of the same name, except +`script/install-precommit`, which is `make hooks`, and `script/cibuild`, +`script/precommit` and `script/projectname`, which have none. + +- `script/bootstrap`: installs what development needs: `make`, `git`, Node and + `yarn` (for prettier) and Go. +- `script/setup`: makes a fresh clone ready: runs `script/bootstrap`, then + `script/install-precommit`. +- `script/test`: builds the `Dockerfile`'s `test` stage, which runs the tests + with `-race`. +- `script/lint`: builds the `Dockerfile`'s `lint` stage, which runs + golangci-lint. +- `script/fmt`: formats the Go code with `gofmt` and the Markdown with prettier. +- `script/fmt-check`: the same, read-only; fails if anything would change. +- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`. +- `script/docker`: builds the image, tagged `neoirc`. +- `script/cibuild`: the CI build: `script/bootstrap`, `script/check`, then the + image. +- `script/precommit`: run by the git pre-commit hook; fails if `go mod tidy` + changes `go.mod` or `go.sum`, then runs `script/check`. +- `script/install-precommit`: installs that hook. +- `script/projectname`: prints the project name, `neoirc`. + +### Building and running from source + +- `make build`: compiles `bin/neoircd`. When `web/dist/` does not exist, it + creates empty placeholder files there, so the binary serves an empty page at + `/`. To include the web client, build it first: in `web/`, run + `yarn install --frozen-lockfile` and `sh build.sh`. +- `make run`: builds and starts the server. `make debug` does the same with + `DEBUG=1`. +- `make clean`: removes `bin/`. +- `make check`: runs `script/check`. `make test`, `make lint`, `make fmt` and + `make fmt-check` run the script of the same name. +- `make docker`: builds the image. +- `make hooks`: installs the pre-commit hook. + +### Tests and checks + +The tests and the linter run inside Docker, as stages of the `Dockerfile`, so +every developer and the CI run the same toolchain. `make test` builds the `test` +stage: `go test -race` over every package, with a 90-second limit. `make lint` +builds the `lint` stage, which runs golangci-lint with the shared +`.golangci.yml`. `make fmt-check` runs `gofmt` and prettier on the host. Tests +live next to the code in `_test` packages. A test that needs a database gets an +in-memory one of its own from `internal/dbtest`. + +The `Dockerfile` has five stages: + +1. `web-builder`: installs the web client's Node dependencies and bundles + `web/src/app.jsx` with esbuild into `web/dist/`. +2. `lint`: runs golangci-lint, with placeholder files in `web/dist/` so that it + does not wait for the web build. +3. `test`: runs the tests, with the same placeholders. +4. `builder`: runs only after `lint` and `test` have passed. It compiles + `neoircd` with the real web client, and compiles `neoirc-cli` to check that + it builds. +5. The runtime stage: Alpine with `neoircd`. + +The CI workflow in `.gitea/workflows/check.yml` runs `script/cibuild` on every +push. ## License -MIT +MIT. See `LICENSE`. ## Author