check / check (push) Waiting to run
The MODE section described a query-only command. It now covers channel and user mode queries and changes on the HTTP API and the IRC listener, the letters each accepts, what is broadcast, and the error numerics. The schema section now lists every table and column in 001_initial.sql. Every other statement this change touches was checked against the code: polled numerics carry their name in command and their number in code; command errors are numerics with HTTP 200; the broker is keyed by session; WAL is never on; a hostname falls back to the IP and is public; and the IRC listener handles modes, NOTICE, INVITE, +s and +H more narrowly, with gaps in its bridge to the HTTP API. Model: opus-5-5
3454 lines
166 KiB
Markdown
3454 lines
166 KiB
Markdown
# neoirc
|
||
|
||
**IRC semantics, structured message metadata, cryptographic signing, and
|
||
server-held session state with per-client delivery queues. All over HTTP+JSON.**
|
||
|
||
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)
|
||
- [License](#license)
|
||
|
||
---
|
||
|
||
## Motivation
|
||
|
||
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.
|
||
|
||
This project builds a server that:
|
||
|
||
- 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 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.
|
||
|
||
---
|
||
|
||
## Why Not Just Use IRC / XMPP / Matrix?
|
||
|
||
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.
|
||
|
||
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:
|
||
|
||
### 1. HTTP transport instead of persistent TCP
|
||
|
||
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 at least 8 characters.
|
||
- **Login from another client**: `POST /api/v1/login` with nick and password →
|
||
server verifies the password, creates a new client for the existing session,
|
||
and sets an auth cookie. Channel memberships 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. Over the HTTP API a nick starts with a letter or
|
||
`_`, followed by letters, digits and `` _-[]\^{}|` ``. The IRC listener cuts a
|
||
longer nick to 32 bytes and checks nothing else.
|
||
- Nicks are **released when a session is destroyed** (via `QUIT` command or
|
||
session expiry). There is no nick registration or reservation system.
|
||
- Nick changes are broadcast to all users sharing a channel with the changer, as
|
||
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, and the user's own
|
||
DMs and the channel messages it sends over the HTTP API, to every client queue
|
||
for that user session. Replies to a command (numerics) go only to the client
|
||
that sent it.
|
||
- `GET /api/v1/messages` delivers from the calling client's specific queue,
|
||
identified by the auth cookie.
|
||
- Client queues have **independent expiry/pruning** — one client going offline
|
||
doesn't affect others.
|
||
|
||
```
|
||
User Session
|
||
├── Client A (cookie_a, queue_a)
|
||
├── Client B (cookie_b, queue_b)
|
||
└── Client C (cookie_c, queue_c)
|
||
```
|
||
|
||
**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.
|
||
|
||
**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.
|
||
|
||
### Message Immutability
|
||
|
||
Messages are **immutable** — no editing, no deletion by clients. There are no
|
||
edit or delete API endpoints and there never will be.
|
||
|
||
**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.
|
||
|
||
### 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=<last_id>&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 nick as the
|
||
`msg_to` field. This means DM history is queryable per-nick, but if a user
|
||
changes their nick, old DMs are associated with the old nick.
|
||
- 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":"<hashcash stamp>"} │
|
||
│ → Set-Cookie: neoirc_auth=<random_hex>; 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) │
|
||
│ │
|
||
└────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
#### Multi-Client via Password
|
||
|
||
```
|
||
┌─ Client A ────────────────────────────────────────────────┐
|
||
│ │
|
||
│ 1. POST /api/v1/session │
|
||
│ {"nick":"alice","pow_token":"<hashcash stamp>"} │
|
||
│ → Set-Cookie: neoirc_auth=<cookie_a>; 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=<cookie_b>; HttpOnly; ... │
|
||
│ → {"id":1, "nick":"alice"} │
|
||
│ (New client added to existing session — channels │
|
||
│ are kept; the new client gets its own queue.) │
|
||
│ │
|
||
└────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### Queue Architecture
|
||
|
||
```
|
||
┌─────────────────┐
|
||
│ 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
|
||
|
||
Each message is stored ONCE. One queue entry per recipient client.
|
||
```
|
||
|
||
The `client_queues` table contains `(client_id, message_id)` pairs. When a
|
||
client polls with `GET /messages?after=<queue_id>`, 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.
|
||
|
||
### In-Memory Broker
|
||
|
||
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 the other channel members):**
|
||
|
||
```json
|
||
{
|
||
"id": "...",
|
||
"command": "PART",
|
||
"from": "alice",
|
||
"to": "#general",
|
||
"body": ["goodbye"],
|
||
"ts": "...",
|
||
"meta": {}
|
||
}
|
||
```
|
||
|
||
**Behavior:**
|
||
|
||
- The PART event goes to the other channel members, not to the departing user;
|
||
the HTTP response confirms the part. (The IRC listener echoes the PART to its
|
||
own connection.)
|
||
- If the channel is empty after the user leaves, the channel is **deleted**
|
||
(ephemeral channels).
|
||
- If the user is not in the channel, the server returns an error.
|
||
- 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 at least 8 characters.
|
||
- On success, the server responds with `{"status": "ok"}`.
|
||
- If the password is too short 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 <modes> [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 echoed to the operator's connection and
|
||
also sent to every channel member, the operator included, as one `MODE` line,
|
||
e.g. `+o-v alice bob`.
|
||
|
||
**Channel errors:**
|
||
|
||
| Numeric | Name | When |
|
||
| ------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `403` | ERR_NOSUCHCHANNEL | The channel does not exist |
|
||
| `482` | ERR_CHANOPRIVSNEEDED | A change by someone who is not a channel operator; 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, compared without regard to letter case, gets ERR_USERSDONTMATCH (502). With
|
||
no mode string the server sends RPL_UMODEIS (221) with your modes: `+`, `+o`,
|
||
`+w` or `+ow`. A change is a mode string, `body[0]` over the HTTP API or the
|
||
parameter after the nick on the IRC listener, that starts with `+` or `-`: `w`
|
||
(receive `WALLOPS`) can be set or unset, and `o` (server operator) only unset,
|
||
since only `OPER` sets it. Any other letter, or a string that names no mode,
|
||
gets ERR_UMODEUNKNOWNFLAG (501) and changes nothing. A change that is accepted
|
||
is answered with 221 and the new modes; it is not sent to anyone else.
|
||
|
||
```json
|
||
{ "command": "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**; only an invite made over the HTTP API lets a user in |
|
||
| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced**; the IRC listener sends no key, so its users cannot join a channel that has one |
|
||
| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** |
|
||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
|
||
| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set |
|
||
| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS` over the HTTP API; not by `GET /api/v1/channels` or the IRC listener |
|
||
| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** |
|
||
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced** over the HTTP API only |
|
||
|
||
**User channel modes (set per-user per-channel):**
|
||
|
||
| 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. Over the
|
||
HTTP API, `INVITE` records an invite that is consumed on JOIN; any member can
|
||
invite, and on a `+i` channel only an operator can. `INVITE` on the IRC listener
|
||
tells the user about the invite but records none.
|
||
|
||
```json
|
||
{"command": "MODE", "to": "#channel", "body": ["+i"]}
|
||
{"command": "INVITE", "body": ["nick", "#channel"]}
|
||
```
|
||
|
||
**Channel key (+k):** Requires a password to join the channel. Over the HTTP API
|
||
the key is `body[0]` of the `JOIN`.
|
||
|
||
```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):** The HTTP API's `LIST` hides the channel from non-members, and
|
||
its `WHOIS` leaves it out of the channel list when the querier is not in the
|
||
same channel.
|
||
|
||
**Join errors:** a user who is banned gets ERR_BANNEDFROMCHAN (474), one not
|
||
invited to a `+i` channel ERR_INVITEONLYCHAN (473), one with a missing or wrong
|
||
key ERR_BADCHANNELKEY (475), and one joining a full channel ERR_CHANNELISFULL
|
||
(471). None of these apply to the user who creates the channel.
|
||
|
||
**KICK command:** Channel operators can remove users with
|
||
`KICK #channel nick [:reason]`. The kicked user and all channel members receive
|
||
the KICK message.
|
||
|
||
**NOTICE:** Over the HTTP API, a NOTICE gets no RPL_AWAY and no hashcash check
|
||
on `+H` channels, but it gets the same error numerics as PRIVMSG (411, 412, 401,
|
||
403, 404). The IRC listener handles NOTICE exactly as PRIVMSG, so a NOTICE to a
|
||
user who is away gets RPL_AWAY there.
|
||
|
||
**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` sent over the
|
||
HTTP API; the IRC listener does not check it. This is an anti-spam mechanism:
|
||
channel operators set a difficulty level, and clients must compute a
|
||
proof-of-work stamp bound to the specific channel and message before sending.
|
||
|
||
**Setting the requirement:**
|
||
|
||
```
|
||
MODE #channel +H <bits> — require <bits> 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.
|
||
|
||
**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:**
|
||
|
||
```json
|
||
{
|
||
"nick": "alice",
|
||
"username": "alice",
|
||
"pow_token": "1:20:260310:neoirc::3a2f1"
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Constraints |
|
||
| ----------- | ------ | ----------- | -------------------------------------------------------------------------- |
|
||
| `nick` | string | Yes | A valid nick (see [Nick Semantics](#nick-semantics)), unique on the server |
|
||
| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. |
|
||
| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||
|
||
The `username` field sets the user portion of the IRC hostmask
|
||
(`nick!user@host`). The hostname is 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
|
||
```
|
||
|
||
```json
|
||
{
|
||
"id": 1,
|
||
"nick": "alice"
|
||
}
|
||
```
|
||
|
||
| Field | Type | Description |
|
||
| ------ | ------- | -------------------------------------------------- |
|
||
| `id` | integer | Server-assigned user ID |
|
||
| `nick` | string | Confirmed nick (always matches request on success) |
|
||
|
||
**Cookie properties:**
|
||
|
||
| Property | Value |
|
||
| ---------- | --------------------------------------- |
|
||
| `Name` | `neoirc_auth` |
|
||
| `HttpOnly` | `true` (not accessible from JavaScript) |
|
||
| `SameSite` | `Strict` (prevents CSRF) |
|
||
| `Secure` | `true` (sent only over HTTPS) |
|
||
| `Path` | `/` |
|
||
|
||
**Errors:**
|
||
|
||
| 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 |
|
||
|
||
**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:**
|
||
|
||
```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.
|
||
|
||
```json
|
||
{
|
||
"id": 1,
|
||
"nick": "alice"
|
||
}
|
||
```
|
||
|
||
| Field | Type | Description |
|
||
| ------ | ------- | ------------ |
|
||
| `id` | integer | Session ID |
|
||
| `nick` | string | Current nick |
|
||
|
||
**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` |
|
||
|
||
**curl example:**
|
||
|
||
```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`
|
||
|
||
```json
|
||
{
|
||
"id": 1,
|
||
"nick": "alice",
|
||
"channels": [
|
||
{ "id": 1, "name": "#general", "topic": "Welcome!" },
|
||
{ "id": 2, "name": "#dev", "topic": "" }
|
||
]
|
||
}
|
||
```
|
||
|
||
| Field | Type | Description |
|
||
| ---------- | ------- | -------------------------------- |
|
||
| `id` | integer | User ID |
|
||
| `nick` | string | Current nick |
|
||
| `channels` | array | Channels the user is a member of |
|
||
|
||
Each channel object:
|
||
|
||
| Field | Type | Description |
|
||
| ------- | ------- | ------------------------------------- |
|
||
| `id` | integer | Channel ID |
|
||
| `name` | string | Channel name (e.g., `#general`) |
|
||
| `topic` | string | Channel topic (empty string if unset) |
|
||
|
||
**curl example:**
|
||
|
||
```bash
|
||
curl -s http://localhost:8080/api/v1/state \
|
||
-b cookies.txt | jq .
|
||
```
|
||
|
||
**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`
|
||
|
||
```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",
|
||
"to": "#general",
|
||
"body": ["hello world"],
|
||
"ts": "2026-02-10T20:00:01.234567891Z",
|
||
"meta": {}
|
||
}
|
||
],
|
||
"last_id": 42
|
||
}
|
||
```
|
||
|
||
| Field | Type | Description |
|
||
| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `messages` | array | Array of IRC message envelopes (see [Protocol Specification](#protocol-specification)), at most 100 per response. Empty array if no messages. |
|
||
| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. |
|
||
|
||
**Long-poll behavior:**
|
||
|
||
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": <same>}`)
|
||
- The client disconnects (no response)
|
||
|
||
**curl example (immediate):**
|
||
|
||
```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:
|
||
|
||
```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.
|
||
|
||
**Command dispatch table:**
|
||
|
||
| 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 |
|
||
|
||
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).
|
||
|
||
**HTTP errors (transport-level only):**
|
||
|
||
| Status | Error | When |
|
||
| ------ | ---------------------- | ------------------------------------------------------- |
|
||
| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes |
|
||
| 400 | `command required` | Empty command |
|
||
| 401 | `not registered` | Missing or invalid auth cookie |
|
||
| 500 | `internal error` | Server-side failure |
|
||
|
||
**IRC numeric error replies (delivered via message queue):**
|
||
|
||
| Numeric | Name | When |
|
||
| ------- | -------------------- | ----------------------------------------------------------------- |
|
||
| 401 | ERR_NOSUCHNICK | Target nick doesn't exist |
|
||
| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name |
|
||
| 404 | ERR_CANNOTSENDTOCHAN | Message to a channel not allowed (not a member, `+b`, `+m`, `+H`) |
|
||
| 411 | ERR_NORECIPIENT | PRIVMSG or NOTICE without `to` |
|
||
| 412 | ERR_NOTEXTTOSEND | PRIVMSG or NOTICE without `body` |
|
||
| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command |
|
||
| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format |
|
||
| 433 | ERR_NICKNAMEINUSE | NICK target is taken |
|
||
| 441 | ERR_USERNOTINCHANNEL | KICK or MODE `+o`/`+v` target is not in the channel |
|
||
| 442 | ERR_NOTONCHANNEL | Not a member of the target channel |
|
||
| 443 | ERR_USERONCHANNEL | INVITE of a user already in the channel |
|
||
| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) |
|
||
| 471 | ERR_CHANNELISFULL | JOIN to a full `+l` channel |
|
||
| 472 | ERR_UNKNOWNMODE | Channel MODE not accepted |
|
||
| 473 | ERR_INVITEONLYCHAN | JOIN to a `+i` channel without an invite |
|
||
| 474 | ERR_BANNEDFROMCHAN | JOIN by a banned user |
|
||
| 475 | ERR_BADCHANNELKEY | JOIN to a `+k` channel with a missing or wrong key |
|
||
| 481 | ERR_NOPRIVILEGES | KILL or WALLOPS by a non-operator |
|
||
| 482 | ERR_CHANOPRIVSNEEDED | Operator-only action by a non-operator |
|
||
| 483 | ERR_CANTKILLSERVER | KILL of yourself |
|
||
| 491 | ERR_NOOPERHOST | Failed OPER authentication |
|
||
| 501 | ERR_UMODEUNKNOWNFLAG | User MODE string not accepted |
|
||
| 502 | ERR_USERSDONTMATCH | User MODE for a nick other than your own |
|
||
|
||
**IRC numeric success replies (delivered via message queue):**
|
||
|
||
| Numeric | Name | When |
|
||
| ------- | ----------------- | --------------------------------------- |
|
||
| 001 | RPL_WELCOME | Sent on session creation/login and MOTD |
|
||
| 002 | RPL_YOURHOST | Sent on session creation/login and MOTD |
|
||
| 003 | RPL_CREATED | Sent on session creation/login and MOTD |
|
||
| 004 | RPL_MYINFO | Sent on session creation/login and MOTD |
|
||
| 005 | RPL_ISUPPORT | Sent on session creation/login and MOTD |
|
||
| 221 | RPL_UMODEIS | In response to user MODE |
|
||
| 251 | RPL_LUSERCLIENT | On connect, MOTD or LUSERS command |
|
||
| 252 | RPL_LUSEROP | On connect, MOTD or LUSERS command |
|
||
| 254 | RPL_LUSERCHANNELS | On connect, MOTD or LUSERS command |
|
||
| 255 | RPL_LUSERME | On connect, MOTD or LUSERS command |
|
||
| 256–259 | RPL_ADMINME etc. | ADMIN info |
|
||
| 301 | RPL_AWAY | PRIVMSG to a user who is away |
|
||
| 302 | RPL_USERHOST | USERHOST reply |
|
||
| 305 | RPL_UNAWAY | AWAY with no message |
|
||
| 306 | RPL_NOWAWAY | AWAY with a message |
|
||
| 311 | RPL_WHOISUSER | WHOIS user info |
|
||
| 312 | RPL_WHOISSERVER | WHOIS server info |
|
||
| 313 | RPL_WHOISOPERATOR | WHOIS target is oper |
|
||
| 315 | RPL_ENDOFWHO | End of WHO list |
|
||
| 317 | RPL_WHOISIDLE | WHOIS idle time and signon time |
|
||
| 318 | RPL_ENDOFWHOIS | End of WHOIS list |
|
||
| 319 | RPL_WHOISCHANNELS | WHOIS channels list |
|
||
| 322 | RPL_LIST | Channel in LIST response |
|
||
| 323 | RPL_LISTEND | End of LIST |
|
||
| 324 | RPL_CHANNELMODEIS | Channel mode query, `+H`/`-H` change |
|
||
| 329 | RPL_CREATIONTIME | Channel creation timestamp |
|
||
| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) |
|
||
| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) |
|
||
| 333 | RPL_TOPICWHOTIME | Who set the topic, and when |
|
||
| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) |
|
||
| 341 | RPL_INVITING | INVITE sent |
|
||
| 351 | RPL_VERSION | VERSION reply |
|
||
| 352 | RPL_WHOREPLY | User in WHO response |
|
||
| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) |
|
||
| 366 | RPL_ENDOFNAMES | End of NAMES list |
|
||
| 367 | RPL_BANLIST | One ban, on a ban list query |
|
||
| 368 | RPL_ENDOFBANLIST | End of a ban list |
|
||
| 371 | RPL_INFO | INFO line |
|
||
| 372 | RPL_MOTD | MOTD line |
|
||
| 374 | RPL_ENDOFINFO | End of INFO |
|
||
| 375 | RPL_MOTDSTART | Start of MOTD |
|
||
| 376 | RPL_ENDOFMOTD | End of MOTD |
|
||
| 381 | RPL_YOUREOPER | Successful OPER authentication |
|
||
| 391 | RPL_TIME | TIME reply |
|
||
|
||
### GET /api/v1/history — Message History
|
||
|
||
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` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. |
|
||
| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. |
|
||
|
||
`target` may also be your own nick, which returns the DMs sent to you. Errors:
|
||
`400` `target required`, `404` `channel not found`, `403`
|
||
`not a member of this channel`, and `403` `forbidden` for any other nick.
|
||
|
||
**Response:** `200 OK`
|
||
|
||
```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:**
|
||
|
||
```bash
|
||
# Latest 50 messages in #general
|
||
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
|
||
-b cookies.txt | jq .
|
||
|
||
# Older messages (pagination)
|
||
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
|
||
-b cookies.txt | jq .
|
||
```
|
||
|
||
### GET /api/v1/channels — List Channels
|
||
|
||
List all channels on the server.
|
||
|
||
**Response:** `200 OK`
|
||
|
||
```json
|
||
[
|
||
{ "id": 1, "name": "#general", "topic": "Welcome!" },
|
||
{ "id": 2, "name": "#dev", "topic": "Development discussion" }
|
||
]
|
||
```
|
||
|
||
### GET /api/v1/channels/{name}/members — Channel 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`
|
||
|
||
```json
|
||
[
|
||
{
|
||
"id": 1,
|
||
"nick": "alice",
|
||
"username": "alice",
|
||
"hostname": "host.example.com",
|
||
"isOperator": true,
|
||
"isVoiced": false,
|
||
"lastSeen": "2026-02-10T20:00:00Z"
|
||
}
|
||
]
|
||
```
|
||
|
||
An unknown channel gets `404` `channel not found`.
|
||
|
||
**curl example:**
|
||
|
||
```bash
|
||
curl -s http://localhost:8080/api/v1/channels/general/members \
|
||
-b cookies.txt | jq .
|
||
```
|
||
|
||
### POST /api/v1/logout — Logout
|
||
|
||
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.
|
||
|
||
**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`
|
||
|
||
```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.",
|
||
"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). |
|
||
|
||
### GET /.well-known/healthcheck.json — Health Check
|
||
|
||
Standard health check endpoint. No authentication required. Returns server
|
||
health status and runtime statistics.
|
||
|
||
**Response:** `200 OK`
|
||
|
||
```json
|
||
{
|
||
"status": "ok",
|
||
"now": "2024-01-15T12:00:00.123456789Z",
|
||
"uptimeSeconds": 3600,
|
||
"uptimeHuman": "1h0m0s",
|
||
"version": "0.1.0",
|
||
"appname": "neoirc",
|
||
"maintenanceMode": false,
|
||
"sessions": 42,
|
||
"clients": 85,
|
||
"queuedLines": 128,
|
||
"channels": 7,
|
||
"connectionsSinceBoot": 200,
|
||
"sessionsSinceBoot": 150,
|
||
"messagesSinceBoot": 5000
|
||
}
|
||
```
|
||
|
||
| 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 |
|
||
|
||
---
|
||
|
||
## Message Flow
|
||
|
||
### Channel Message Flow
|
||
|
||
```
|
||
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"}]} │
|
||
│<───────────────────────│───────────────────────>│
|
||
```
|
||
|
||
### DM Flow
|
||
|
||
```
|
||
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) │
|
||
```
|
||
|
||
### 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:
|
||
|
||
```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",
|
||
"ts": "2026-02-10T20:00:00.123456789Z",
|
||
"meta": {
|
||
"alg": "ed25519",
|
||
"sig": "base64url-encoded-64-byte-signature"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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 or authenticated HTTP API request (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 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) |
|
||
| `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 `(msg_to, id)` and `(created_at)`.
|
||
|
||
#### `spent_hashcash`
|
||
|
||
| Column | Type | Description |
|
||
| ------------ | -------- | ---------------------------------------------------- |
|
||
| `id` | INTEGER | Primary key (auto-increment) |
|
||
| `stamp_hash` | TEXT | Unique SHA-256 (hex) of a channel stamp already used |
|
||
| `created_at` | DATETIME | When the stamp was used |
|
||
|
||
Index on `(created_at)`. Only the stamps of `+H` channel messages are recorded
|
||
here, and rows older than a year are pruned; session creation stamps are tracked
|
||
in memory.
|
||
|
||
#### `client_queues`
|
||
|
||
| Column | Type | Description |
|
||
| ------------ | -------- | ------------------------------------------------------ |
|
||
| `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=<id>`. 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 — `handleQuit` and `cleanupUser` both call
|
||
`DeleteSession` unconditionally. Idle clients are automatically removed after
|
||
`SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP API
|
||
request — the server runs a background cleanup loop that, for a session left
|
||
with no clients, parts the user from all channels, broadcasts QUIT, and
|
||
releases the nick. A client on the IRC listener never makes such a request, so
|
||
it counts as idle from when it connected.
|
||
- **Clients**: Individual client auth cookies are invalidated on
|
||
`POST /api/v1/logout`. A session can have multiple clients; removing one
|
||
doesn't affect others. However, when the last client is removed (via logout),
|
||
the entire session is deleted — the user is parted from all channels, QUIT is
|
||
broadcast, and the nick is released.
|
||
|
||
The cleanup loop that does all of this pruning and expiry runs every half
|
||
`SESSION_IDLE_TIMEOUT`, 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.
|
||
|
||
| Variable | Type | Default | Description |
|
||
| ---------------------- | ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `PORT` | int | `8080` | HTTP listen port |
|
||
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. The SQLite driver ignores `_journal_mode`, so this does not turn on WAL mode. |
|
||
| `DEBUG` | bool | `false` | Switch logging to debug level |
|
||
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. |
|
||
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. |
|
||
| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. |
|
||
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` |
|
||
| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` |
|
||
| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. |
|
||
| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) |
|
||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
|
||
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
||
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
||
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
||
| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Only an empty value in `neoirc.yaml` disables it; an empty environment variable does not. |
|
||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||
|
||
### Example `.env` file
|
||
|
||
```bash
|
||
PORT=8080
|
||
SERVER_NAME=My NeoIRC Server
|
||
MOTD=Welcome! Be excellent to each other.
|
||
DEBUG=false
|
||
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||
SESSION_IDLE_TIMEOUT=720h
|
||
NEOIRC_HASHCASH_BITS=20
|
||
```
|
||
|
||
---
|
||
|
||
## IRC Protocol Listener
|
||
|
||
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.
|
||
|
||
### Configuration
|
||
|
||
The IRC listener is **enabled by default** on `:6667`. To disable it, set
|
||
`IRC_LISTEN_ADDR` to an empty string in `neoirc.yaml` (see
|
||
[Configuration](#configuration)); an empty environment variable counts as unset
|
||
and leaves it on `:6667`:
|
||
|
||
```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 (minimum 8
|
||
characters). Registering creates a session without a hashcash stamp; closing
|
||
the connection ends the session, as `QUIT` does.
|
||
- **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is
|
||
ignored, for compatibility with modern clients.
|
||
- **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if
|
||
omitted; other commands use the name as given.
|
||
- **First joiner**: The first user to join a channel is automatically granted
|
||
operator status (`@`).
|
||
- **Channel modes**: `+i` (invite-only), `+m` (moderated), `+n` (no external),
|
||
`+s` (secret), `+t` (topic lock), `+H` (hashcash), `+o` (operator), `+v`
|
||
(voice); see [MODE](#mode--query-and-change-modes). `+b`, `+k` and `+l` can be
|
||
set only over the HTTP API.
|
||
- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives
|
||
`WALLOPS`). `MODE` for any nick other than your own is rejected with
|
||
`ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison is
|
||
case-insensitive.
|
||
- **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, with these gaps today:
|
||
|
||
- An IRC listener user receives only the first line of a multi-line `body`.
|
||
- A `+k`, `+l` or `+b` change made over the HTTP API reaches IRC listener
|
||
members without its key, limit or mask.
|
||
- The IRC listener's `JOIN` accepts any channel name, while over the HTTP API a
|
||
name that is not `#` followed by 1–63 letters, digits, `_` or `-` gets
|
||
ERR_NOSUCHCHANNEL (403), so HTTP API users cannot join such a channel.
|
||
- The IRC listener does not pass on a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`,
|
||
`NICK` or `QUIT` whose sender's nick matches the user's own nick ignoring
|
||
case, so an IRC listener user `alice` never sees these from `Alice`, although
|
||
nicks are case-sensitive.
|
||
|
||
https://git.eeqj.de/sneak/neoirc/issues/127 tracks the first three and
|
||
https://git.eeqj.de/sneak/neoirc/issues/128 the last.
|
||
|
||
### 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 \
|
||
-v neoirc-data:/var/lib/neoirc \
|
||
-e SERVER_NAME="My Server" \
|
||
-e MOTD="Welcome!" \
|
||
neoirc
|
||
```
|
||
|
||
The Dockerfile is a five-stage build:
|
||
|
||
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
|
||
|
||
### Binary
|
||
|
||
```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:**
|
||
|
||
```
|
||
neoirc.example.com {
|
||
reverse_proxy localhost:8080
|
||
}
|
||
```
|
||
|
||
**nginx:**
|
||
|
||
```nginx
|
||
server {
|
||
listen 443 ssl;
|
||
server_name neoirc.example.com;
|
||
|
||
ssl_certificate /path/to/cert.pem;
|
||
ssl_certificate_key /path/to/key.pem;
|
||
|
||
location / {
|
||
proxy_pass http://127.0.0.1:8080;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $remote_addr;
|
||
proxy_read_timeout 60s; # Must be > long-poll timeout
|
||
}
|
||
}
|
||
```
|
||
|
||
**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 carries `?_journal_mode=WAL`,
|
||
but the SQLite driver ignores that parameter, so the database runs in SQLite's
|
||
default rollback-journal mode, not WAL.
|
||
- **Single writer**: the server uses one database connection, so reads and
|
||
writes take turns. For high-traffic servers, Postgres support is planned.
|
||
- **Backup**: The database is a single file. Back it up with
|
||
`sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`, or copy the file while
|
||
the server is stopped.
|
||
- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the
|
||
`DBURL` env var to place it elsewhere.
|
||
|
||
---
|
||
|
||
## 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` | `<nick> 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)
|
||
|
||
### 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 verifies a single hash and remembers the stamps already spent.
|
||
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
||
campuses, corporate networks, Tor exits). Every client computes their own
|
||
proof independently.
|
||
- **Cost falls on the requester**: The server's verification cost is constant
|
||
(one SHA-256 hash) regardless of difficulty. 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 <nick> +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
|
||
│ ├── 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.
|
||
|
||
---
|
||
|
||
## License
|
||
|
||
MIT
|
||
|
||
## Author
|
||
|
||
[@sneak](https://sneak.berlin)
|