check / check (push) Waiting to run
bcrypt cannot hash a password longer than 72 bytes. PASS over the HTTP API answered one with a 500, and the IRC listener confirmed it without storing it. Both transports now refuse a password outside 8 to 72 bytes with the reply a short one already got: 461 over the HTTP API, a NOTICE over the IRC listener. setPassword returns its error, and "Password set" is sent only when the password was stored. A PASS given before registration is checked after the welcome burst and gets the same NOTICE. Model: opus-5-5
3483 lines
169 KiB
Markdown
3483 lines
169 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 8 to 72 bytes.
|
||
- **Login from another client**: `POST /api/v1/login` with nick and password →
|
||
server verifies the password, creates a new client for the existing session,
|
||
and sets an auth cookie. Channel memberships are shared; the new client gets
|
||
its own message queue. Login only works while the session still exists — if
|
||
all clients have logged out or the user has sent QUIT, the session is deleted
|
||
and the password is lost.
|
||
|
||
#### Common Properties
|
||
|
||
- Nicks are changeable via the `NICK` command; the server-assigned user ID is
|
||
the stable identity.
|
||
- Server-assigned IDs — clients do not choose their own IDs.
|
||
- Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry
|
||
encoded in the cookie value, no client-side decode. The server is the sole
|
||
authority on cookie validity.
|
||
|
||
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
|
||
Anonymous sessions preserve that simplicity — instant access, zero friction. But
|
||
some users want to access the same session from multiple devices without a
|
||
bouncer. The PASS command enables multi-client login without adding friction for
|
||
casual users: if you don't need multi-client, just create a session and go.
|
||
Cookie-based auth simplifies credential management — browsers handle cookies
|
||
automatically, and CLI clients just need a cookie jar (e.g., curl's `-c`/`-b`
|
||
flags). Note: both anonymous and password-protected sessions are deleted when
|
||
any client sends QUIT, or when the last client logs out. Identity verification
|
||
at the message layer via cryptographic signatures (see
|
||
[Security Model](#security-model)) remains independent of password status.
|
||
|
||
### Hostmask (nick!user@host)
|
||
|
||
Each session has an IRC-style hostmask composed of three parts:
|
||
|
||
- **nick** — the user's current nick (changes with `NICK` command)
|
||
- **username** — an ident-like identifier set at session creation (optional
|
||
`username` field in the session request; defaults to the nick)
|
||
- **hostname** — the reverse DNS name of the creating client's IP address,
|
||
looked up at session creation time, or the IP address itself when the lookup
|
||
gives no name
|
||
- **ip** — the real IP address of the session creator, extracted from
|
||
`X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr`
|
||
|
||
Each **client connection** (created at session creation or login) also stores
|
||
its own **ip** and **hostname**, found the same way. The session's hostname is
|
||
the hostname of the client that created it, and every user can see it: in WHOIS
|
||
(311), WHO (352), USERHOST (302), NAMES (353) over the HTTP API, and
|
||
`GET /api/v1/channels/{name}/members`. Only `RPL_WHOISACTUALLY` (338) is for
|
||
**server operators** (o-line) alone: it gives the IP address and hostname of the
|
||
target's newest client.
|
||
|
||
The hostmask appears in:
|
||
|
||
- **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains
|
||
`[nick, username, hostname, "*"]`
|
||
- **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a server
|
||
operator, includes the IP address and hostname of the target's newest client
|
||
(HTTP API only; the IRC listener's WHOIS does not send it)
|
||
- **WHO** (`352 RPL_WHOREPLY`) — `params` contains
|
||
`[channel, username, hostname, server, nick, flags]`
|
||
|
||
The hostmask (`nick!user@host`) is built from these fields when needed, and is
|
||
what channel bans (`+b` mode) match against.
|
||
|
||
### Nick Semantics
|
||
|
||
- Nicks are **unique per server at any point in time** — two sessions cannot
|
||
hold the same nick simultaneously.
|
||
- Nicks are **case-sensitive** (unlike traditional IRC). `Alice` and `alice` are
|
||
different nicks.
|
||
- Nick length: 1–32 characters. A nick starts with a letter or `_`, followed by
|
||
letters, digits and `` _-[]\^{}|` ``. The HTTP API and the IRC listener both
|
||
apply this rule. On the IRC listener, a `NICK` with any other nick, before or
|
||
after registration, gets ERR_ERRONEUSNICKNAME (432) and leaves the nick as it
|
||
was.
|
||
- Nicks are **released when a session is destroyed** (via `QUIT` command or
|
||
session expiry). There is no nick registration or reservation system.
|
||
- Nick changes are broadcast to all users sharing a channel with the changer, as
|
||
a `NICK` event message.
|
||
|
||
**Rationale:** IRC nick semantics, simplified. Case-insensitive nick comparison
|
||
is a perpetual source of IRC bugs (different servers use different case-folding
|
||
rules). Case-sensitive comparison is unambiguous.
|
||
|
||
### Multi-Client Model
|
||
|
||
A single user session can have multiple clients (phone, laptop, terminal).
|
||
|
||
- Each client gets a **separate server-to-client (S2C) message queue**.
|
||
- The server fans out messages and events from other users, 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's session as
|
||
the `to_session_id` field. A user's DM history therefore follows them across
|
||
nick changes, and a user who later takes a released nick sees none of the DMs
|
||
sent to its previous holder.
|
||
- DMs are **not stored long-term** by default — they follow the same rotation
|
||
policy as channel messages.
|
||
|
||
### JSON, Not Binary
|
||
|
||
All messages are JSON. No CBOR, no protobuf, no MessagePack, no custom binary
|
||
framing.
|
||
|
||
**Rationale:** JSON is human-readable, universally supported, and debuggable
|
||
with `curl | jq`. Binary formats save bandwidth at the cost of debuggability and
|
||
ecosystem compatibility. Chat messages are small — the overhead of JSON over
|
||
binary is measured in bytes per message, not meaningful bandwidth. The
|
||
canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which
|
||
matters for signing.
|
||
|
||
### Why Opaque Cookies Instead of JWTs
|
||
|
||
JWTs encode claims that clients can decode and potentially rely on. This creates
|
||
a coupling between token format and client behavior. If the server needs to
|
||
revoke a token, change the expiry model, or add/remove claims, JWT clients may
|
||
break or behave incorrectly.
|
||
|
||
Opaque auth cookies are simpler:
|
||
|
||
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash → sets
|
||
raw hex as an HttpOnly cookie
|
||
- On each request, server hashes the cookie value and looks it up
|
||
- Revocation is a database delete (cookie becomes invalid immediately)
|
||
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
|
||
- Cookie format can change without breaking clients
|
||
- Browsers and HTTP cookie jars manage cookies automatically; CLI clients must
|
||
explicitly save and resend cookies (e.g., curl `-c`/`-b` flags)
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
### Transport: HTTP Only
|
||
|
||
All client↔server and server↔server communication uses HTTP/1.1+ with JSON
|
||
request/response bodies. No WebSockets, no gRPC — just plain HTTP. The one
|
||
exception is the [IRC Protocol Listener](#irc-protocol-listener), a raw TCP
|
||
listener for standard IRC clients, which is on by default.
|
||
|
||
- **Client reading**: Long-poll `GET /api/v1/messages` — server holds the
|
||
connection for up to the `timeout` the client asks for (at most 30s) until
|
||
messages arrive. One endpoint for everything — channel messages, DMs, system
|
||
events, numeric replies.
|
||
- **Client writing**: `POST /api/v1/messages` with a `command` field. One
|
||
endpoint for everything — PRIVMSG, JOIN, PART, NICK, TOPIC, etc.
|
||
- **Server federation** (planned, not yet implemented): Servers exchange
|
||
messages via HTTP to enable multi-server networks (like IRC server linking).
|
||
|
||
The entire read/write loop for a client is two endpoints. Everything else
|
||
(state, history, channels, members, server info) is ancillary.
|
||
|
||
### Session Lifecycle
|
||
|
||
#### Session Creation
|
||
|
||
```
|
||
┌─ Client ──────────────────────────────────────────────────┐
|
||
│ │
|
||
│ 1. POST /api/v1/session │
|
||
│ {"nick":"alice","pow_token":"<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 8 to 72 bytes; bcrypt cannot hash a longer
|
||
one.
|
||
- On success, the server responds with `{"status": "ok"}`.
|
||
- If the password is too short, too long or missing, the server sends
|
||
ERR_NEEDMOREPARAMS (461) via the message queue.
|
||
- Calling PASS again overwrites the previous password.
|
||
- Once a password is set, `POST /api/v1/login` can be used with the nick and
|
||
password to create additional clients on the same session.
|
||
|
||
**Response:** `200 OK`
|
||
|
||
```json
|
||
{ "status": "ok" }
|
||
```
|
||
|
||
**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for session
|
||
password management.
|
||
|
||
#### TOPIC — Set Channel Topic
|
||
|
||
Set or change a channel's topic.
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{ "command": "TOPIC", "to": "#general", "body": ["Welcome to #general"] }
|
||
```
|
||
|
||
**S2C (broadcast to all channel members):**
|
||
|
||
```json
|
||
{
|
||
"id": "...",
|
||
"command": "TOPIC",
|
||
"from": "alice",
|
||
"to": "#general",
|
||
"body": ["Welcome to #general"],
|
||
"ts": "...",
|
||
"meta": {}
|
||
}
|
||
```
|
||
|
||
**Behavior:**
|
||
|
||
- Updates the channel's topic in the database.
|
||
- The TOPIC event is broadcast to all channel members, and the setter's client
|
||
also gets RPL_TOPIC (332) and RPL_TOPICWHOTIME (333).
|
||
- `body` is required: there is no topic query over the HTTP API, and a TOPIC
|
||
without one gets ERR_NEEDMOREPARAMS (461).
|
||
- If the channel doesn't exist, the server returns an error.
|
||
- If the channel has mode `+t` (topic lock, default: ON for new channels), only
|
||
operators (`+o`) can change the topic. Non-operators receive
|
||
`ERR_CHANOPRIVSNEEDED` (482).
|
||
|
||
**Response:** `200 OK`
|
||
|
||
```json
|
||
{ "status": "ok", "topic": "Welcome to #general" }
|
||
```
|
||
|
||
**IRC reference:** RFC 1459 §4.2.4
|
||
|
||
#### QUIT — Disconnect
|
||
|
||
Destroy the session and disconnect from the server.
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{"command": "QUIT"}
|
||
{"command": "QUIT", "body": ["leaving"]}
|
||
```
|
||
|
||
**S2C (broadcast to all users sharing channels with the quitter):**
|
||
|
||
```json
|
||
{
|
||
"id": "...",
|
||
"command": "QUIT",
|
||
"from": "alice",
|
||
"body": ["leaving"],
|
||
"ts": "...",
|
||
"meta": {}
|
||
}
|
||
```
|
||
|
||
**Behavior:**
|
||
|
||
- The QUIT event is broadcast to all users who share a channel with the quitting
|
||
user. The quitting user does **not** receive their own QUIT.
|
||
- The user is removed from all channels.
|
||
- Empty channels are deleted (ephemeral).
|
||
- The user's session is destroyed — the auth cookies of all its clients are
|
||
invalidated, the nick is released.
|
||
- Subsequent requests with the old auth cookie return HTTP 401.
|
||
|
||
**Response:** `200 OK`
|
||
|
||
```json
|
||
{ "status": "quit" }
|
||
```
|
||
|
||
**IRC reference:** RFC 1459 §4.1.6
|
||
|
||
#### PING — Keepalive
|
||
|
||
Client keepalive. Server responds synchronously with PONG.
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{ "command": "PING" }
|
||
```
|
||
|
||
**Response (synchronous, not via the queue):** `200 OK`
|
||
|
||
```json
|
||
{ "command": "PONG", "from": "servername" }
|
||
```
|
||
|
||
**Note:** PING/PONG is synchronous — the PONG is the HTTP response body, not a
|
||
queued message. This is deliberate: keepalives should be low-latency and not
|
||
pollute the message queue.
|
||
|
||
**IRC reference:** RFC 1459 §4.6.2, §4.6.3
|
||
|
||
#### MODE — Query and Change Modes
|
||
|
||
Query or change a channel's modes, or query or change your own user modes. A
|
||
`to` that starts with `#` is a channel; any other `to` is a nick.
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{"command": "MODE", "to": "#general"}
|
||
{"command": "MODE", "to": "#general", "body": ["+m"]}
|
||
{"command": "MODE", "to": "#general", "body": ["+o", "bob"]}
|
||
{"command": "MODE", "to": "alice"}
|
||
{"command": "MODE", "to": "alice", "body": ["+w"]}
|
||
```
|
||
|
||
**Channel query:** with no `body`, the server sends RPL_CHANNELMODEIS (324) and
|
||
RPL_CREATIONTIME (329). Anyone may query, member or not. The mode string lists
|
||
the set flags out of `nimst`, then `k`, `l` and `H` with their values (key,
|
||
member limit, bits) after it, e.g. `+ntkl secretpass 50`. A new channel has
|
||
`+nt`:
|
||
|
||
```json
|
||
{"command": "RPL_CHANNELMODEIS", "code": 324, "to": "alice", "params": ["#general", "+nt"]}
|
||
{"command": "RPL_CREATIONTIME", "code": 329, "to": "alice", "params": ["#general", "1709251200"]}
|
||
```
|
||
|
||
**Channel changes:** only channel operators can change channel modes. Over the
|
||
HTTP API, `body[0]` is one change, a sign and one letter, and `body[1]` is its
|
||
value where it takes one; a string of several letters such as `+mt` is not
|
||
accepted. The letters:
|
||
|
||
| Change | Value | Effect |
|
||
| ----------- | --------------------------- | ----------------------------------------------------------------- |
|
||
| `+o` / `-o` | nick | Give or take channel operator |
|
||
| `+v` / `-v` | nick | Give or take voice |
|
||
| `+i` / `-i` | | Invite-only on or off |
|
||
| `+m` / `-m` | | Moderated on or off |
|
||
| `+s` / `-s` | | Secret on or off |
|
||
| `+t` / `-t` | | Topic lock on or off |
|
||
| `+k` / `-k` | key, for `+k` | Set or clear the channel key |
|
||
| `+l` / `-l` | positive integer, for `+l` | Set or clear the member limit |
|
||
| `+b` / `-b` | `nick!user@host` mask | Add or remove a ban; `+b` with no mask lists the bans, for anyone |
|
||
| `+H` / `-H` | bits from 1 to 40, for `+H` | Require hashcash on `PRIVMSG`, or stop requiring it |
|
||
|
||
A change made over the HTTP API is sent to every channel member, the operator
|
||
included, as a `MODE` message from the operator to the channel. Its `body` is
|
||
the change as one string for `o`, `v`, `i`, `m`, `s` and `t` (`["+o bob"]`,
|
||
`["+m"]`), and as the change and its value for `k`, `l` and `b`
|
||
(`["+k", "secretpass"]`, `["-k", "*"]`, `["+l", "50"]`, `["-l"]`,
|
||
`["+b", "*!*@*.example.com"]`). A `+H` or `-H` change is not sent to the
|
||
members; the operator gets RPL_CHANNELMODEIS (324) with the new mode string.
|
||
Listing bans sends RPL_BANLIST (367) for each ban, with the mask, who set it and
|
||
when, then RPL_ENDOFBANLIST (368).
|
||
|
||
On the IRC listener, `MODE #channel <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 gets ERR_USERSDONTMATCH (502). With no mode string the server sends
|
||
RPL_UMODEIS (221) with your modes: `+`, `+o`, `+w` or `+ow`. A change is a mode
|
||
string, `body[0]` over the HTTP API or the parameter after the nick on the IRC
|
||
listener, that starts with `+` or `-`: `w` (receive `WALLOPS`) can be set or
|
||
unset, and `o` (server operator) only unset, since only `OPER` sets it. Any
|
||
other letter, or a string that names no mode, gets ERR_UMODEUNKNOWNFLAG (501)
|
||
and changes nothing. A change that is accepted is answered with 221 and the new
|
||
modes; it is not sent to anyone else.
|
||
|
||
```json
|
||
{ "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] }
|
||
```
|
||
|
||
**Response:** `200 OK` with `{"status": "ok"}`, or `{"status": "error"}` when
|
||
the reply is an error numeric.
|
||
|
||
**IRC reference:** RFC 1459 §4.2.3
|
||
|
||
#### NAMES — Channel Member List
|
||
|
||
Request the member list for a channel. Returns RPL_NAMREPLY (353) and
|
||
RPL_ENDOFNAMES (366).
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{ "command": "NAMES", "to": "#general" }
|
||
```
|
||
|
||
**IRC reference:** RFC 1459 §4.2.5
|
||
|
||
#### LIST — List Channels
|
||
|
||
Request a list of all channels with member counts. Returns RPL_LIST (322) for
|
||
each channel followed by RPL_LISTEND (323).
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{ "command": "LIST" }
|
||
```
|
||
|
||
**IRC reference:** RFC 1459 §4.2.6
|
||
|
||
#### WHOIS — User Information
|
||
|
||
Query information about a user. Returns RPL_WHOISUSER (311), RPL_WHOISSERVER
|
||
(312), RPL_WHOISOPERATOR (313, if target is oper), RPL_WHOISIDLE (317),
|
||
RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).
|
||
|
||
If the querying user is a **server operator** (authenticated via `OPER`), the
|
||
response over the HTTP API additionally includes RPL_WHOISACTUALLY (338) with
|
||
the IP address and hostname of the target's newest client. The IRC listener's
|
||
WHOIS does not send it.
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{ "command": "WHOIS", "to": "alice" }
|
||
```
|
||
|
||
**IRC reference:** RFC 1459 §4.5.2
|
||
|
||
#### WHO — Channel User List
|
||
|
||
Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed by
|
||
RPL_ENDOFWHO (315).
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{ "command": "WHO", "to": "#general" }
|
||
```
|
||
|
||
**IRC reference:** RFC 1459 §4.5.1
|
||
|
||
#### LUSERS — Server Statistics
|
||
|
||
Request server user/channel statistics. Returns RPL_LUSERCLIENT (251),
|
||
RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and RPL_LUSERME (255).
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{ "command": "LUSERS" }
|
||
```
|
||
|
||
LUSERS replies are also sent automatically during connection registration.
|
||
|
||
**IRC reference:** RFC 1459 §4.3.2
|
||
|
||
#### OPER — Gain Server Operator Status
|
||
|
||
Authenticate as a server operator (o-line). On success, the session gains oper
|
||
privileges: it can use `KILL` and `WALLOPS`, and over the HTTP API its WHOIS
|
||
responses include more (the IP address and hostname of the target user's newest
|
||
client).
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{ "command": "OPER", "body": ["opername", "operpassword"] }
|
||
```
|
||
|
||
**S2C (via message queue on success):**
|
||
|
||
```json
|
||
{
|
||
"command": "RPL_YOUREOPER",
|
||
"code": 381,
|
||
"to": "alice",
|
||
"body": ["You are now an IRC operator"]
|
||
}
|
||
```
|
||
|
||
**Behavior:**
|
||
|
||
- `body[0]` is the operator name, `body[1]` is the operator password.
|
||
- The server checks against the configured `NEOIRC_OPER_NAME` and
|
||
`NEOIRC_OPER_PASSWORD` environment variables.
|
||
- On success, the session's `is_oper` flag is set and `381 RPL_YOUREOPER` is
|
||
returned.
|
||
- On failure (wrong credentials or no o-line configured), `491 ERR_NOOPERHOST`
|
||
is returned.
|
||
- Oper status lasts until the session ends or the user drops it with user mode
|
||
`-o` (see [MODE](#mode--query-and-change-modes)).
|
||
|
||
**IRC reference:** RFC 1459 §4.1.5
|
||
|
||
#### KICK — Kick User
|
||
|
||
Remove a user from a channel. Only channel operators (`+o`) can use this
|
||
command. The kicked user and all channel members receive the KICK message.
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{ "command": "KICK", "to": "#general", "body": ["bob", "misbehaving"] }
|
||
```
|
||
|
||
The first element of `body` is the target nick, the second (optional) is the
|
||
reason. If no reason is provided, the kicker's nick is used as the default; the
|
||
IRC listener uses the kicked user's nick instead.
|
||
|
||
**Errors:**
|
||
|
||
- `482` (ERR_CHANOPRIVSNEEDED) — kicker is not a channel operator
|
||
- `401` (ERR_NOSUCHNICK) — target nick does not exist
|
||
- `441` (ERR_USERNOTINCHANNEL) — target is not in the channel
|
||
- `403` (ERR_NOSUCHCHANNEL) — channel does not exist
|
||
|
||
**IRC reference:** RFC 1459 §4.2.8
|
||
|
||
#### PUBKEY — Announce Signing Key
|
||
|
||
Distribute a public signing key to channel members.
|
||
|
||
**C2S:**
|
||
|
||
```json
|
||
{
|
||
"command": "PUBKEY",
|
||
"body": { "alg": "ed25519", "key": "base64-encoded-pubkey" }
|
||
}
|
||
```
|
||
|
||
**S2C (relayed to channel members):**
|
||
|
||
```json
|
||
{
|
||
"id": "...",
|
||
"command": "PUBKEY",
|
||
"from": "alice",
|
||
"body": { "alg": "ed25519", "key": "base64-encoded-pubkey" },
|
||
"ts": "...",
|
||
"meta": {}
|
||
}
|
||
```
|
||
|
||
**Behavior:** The server relays PUBKEY messages verbatim. It does not verify,
|
||
store, or interpret the key material. See [Security Model](#security-model) for
|
||
the full key distribution protocol.
|
||
|
||
**Status:** Not yet implemented.
|
||
|
||
### Numeric Reply Codes (S2C Only)
|
||
|
||
Numeric replies follow IRC conventions from RFC 1459/2812. They are sent from
|
||
the server to the client (never C2S). Over the HTTP API, which the table below
|
||
describes, the `command` field holds the reply's name and `code` its number; the
|
||
IRC listener sends the usual 3-digit code. Over the HTTP API, 005 arrives named
|
||
`RPL_BOUNCE`, its name in RFC 2812.
|
||
|
||
| Code | Name | When Sent | Example |
|
||
| ----- | -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||
| `001` | RPL_WELCOME | After session creation or login, on MOTD | `{"command":"RPL_WELCOME","code":1,"to":"alice","body":["Welcome to the network, alice"]}` |
|
||
| `002` | RPL_YOURHOST | After session creation or login, on MOTD | `{"command":"RPL_YOURHOST","code":2,"to":"alice","body":["Your host is neoirc, running version 0.1"]}` |
|
||
| `003` | RPL_CREATED | After session creation or login, on MOTD | `{"command":"RPL_CREATED","code":3,"to":"alice","body":["This server was created 2026-02-10"]}` |
|
||
| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","","ikmnostl"]}` |
|
||
| `005` | RPL_ISUPPORT | After session creation or login, on MOTD | `{"command":"RPL_BOUNCE","code":5,"to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc","CASEMAPPING=ascii"],"body":["are supported by this server"]}` |
|
||
| `221` | RPL_UMODEIS | In response to user MODE | `{"command":"RPL_UMODEIS","code":221,"to":"alice","body":["+"]}` |
|
||
| `251` | RPL_LUSERCLIENT | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCLIENT","code":251,"to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` |
|
||
| `252` | RPL_LUSEROP | On connect, MOTD or LUSERS | `{"command":"RPL_LUSEROP","code":252,"to":"alice","params":["0"],"body":["operator(s) online"]}` |
|
||
| `254` | RPL_LUSERCHANNELS | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCHANNELS","code":254,"to":"alice","params":["3"],"body":["channels formed"]}` |
|
||
| `255` | RPL_LUSERME | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERME","code":255,"to":"alice","body":["I have 5 clients and 1 servers"]}` |
|
||
| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"RPL_WHOISUSER","code":311,"to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` |
|
||
| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"RPL_WHOISSERVER","code":312,"to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` |
|
||
| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"RPL_WHOISOPERATOR","code":313,"to":"alice","params":["bob"],"body":["is an IRC operator"]}` |
|
||
| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"RPL_ENDOFWHO","code":315,"to":"alice","params":["#general"],"body":["End of /WHO list"]}` |
|
||
| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"RPL_ENDOFWHOIS","code":318,"to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` |
|
||
| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"RPL_WHOISCHANNELS","code":319,"to":"alice","params":["bob"],"body":["#general #dev"]}` |
|
||
| `322` | RPL_LIST | In response to LIST | `{"command":"RPL_LIST","code":322,"to":"alice","params":["#general","5"],"body":["General discussion"]}` |
|
||
| `323` | RPL_LISTEND | End of LIST response | `{"command":"RPL_LISTEND","code":323,"to":"alice","body":["End of /LIST"]}` |
|
||
| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"RPL_CHANNELMODEIS","code":324,"to":"alice","params":["#general","+nt"]}` |
|
||
| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"RPL_CREATIONTIME","code":329,"to":"alice","params":["#general","1709251200"]}` |
|
||
| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"RPL_NOTOPIC","code":331,"to":"alice","params":["#general"],"body":["No topic is set"]}` |
|
||
| `332` | RPL_TOPIC | On JOIN or TOPIC change | `{"command":"RPL_TOPIC","code":332,"to":"alice","params":["#general"],"body":["Welcome!"]}` |
|
||
| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"RPL_WHOISACTUALLY","code":338,"to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` |
|
||
| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"RPL_WHOREPLY","code":352,"to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` |
|
||
| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"RPL_NAMREPLY","code":353,"to":"alice","params":["=","#general"],"body":["@op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` |
|
||
| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"RPL_ENDOFNAMES","code":366,"to":"alice","params":["#general"],"body":["End of /NAMES list"]}` |
|
||
| `367` | RPL_BANLIST | One ban, on a ban list query | `{"command":"RPL_BANLIST","code":367,"to":"alice","params":["#general","*!*@*.example.com","alice","1709251200"]}` |
|
||
| `368` | RPL_ENDOFBANLIST | End of a ban list | `{"command":"RPL_ENDOFBANLIST","code":368,"to":"alice","params":["#general"],"body":["End of channel ban list"]}` |
|
||
| `372` | RPL_MOTD | MOTD line | `{"command":"RPL_MOTD","code":372,"to":"alice","body":["- Welcome to the server"]}` |
|
||
| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"RPL_MOTDSTART","code":375,"to":"alice","body":["- neoirc Message of the Day -"]}` |
|
||
| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"RPL_ENDOFMOTD","code":376,"to":"alice","body":["End of /MOTD command."]}` |
|
||
| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"RPL_YOUREOPER","code":381,"to":"alice","body":["You are now an IRC operator"]}` |
|
||
| `401` | ERR_NOSUCHNICK | Nick does not exist | `{"command":"ERR_NOSUCHNICK","code":401,"to":"alice","params":["bob"],"body":["No such nick"]}` |
|
||
| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"ERR_NOSUCHCHANNEL","code":403,"to":"alice","params":["#nope"],"body":["No such channel"]}` |
|
||
| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"ERR_UNKNOWNCOMMAND","code":421,"to":"alice","params":["FOO"],"body":["Unknown command"]}` |
|
||
| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"ERR_ERRONEUSNICKNAME","code":432,"to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` |
|
||
| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"ERR_NICKNAMEINUSE","code":433,"to":"alice","params":["bob"],"body":["Nickname is already in use"]}` |
|
||
| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"ERR_NOTONCHANNEL","code":442,"to":"alice","params":["#general"],"body":["You're not on that channel"]}` |
|
||
| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"ERR_NEEDMOREPARAMS","code":461,"to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` |
|
||
| `472` | ERR_UNKNOWNMODE | Channel mode not accepted | `{"command":"ERR_UNKNOWNMODE","code":472,"to":"alice","params":["+q"],"body":["is unknown mode char to me"]}` |
|
||
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"ERR_CHANOPRIVSNEEDED","code":482,"to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
|
||
| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"ERR_NOOPERHOST","code":491,"to":"alice","body":["No O-lines for your host"]}` |
|
||
| `501` | ERR_UMODEUNKNOWNFLAG | User mode not accepted | `{"command":"ERR_UMODEUNKNOWNFLAG","code":501,"to":"alice","body":["Unknown MODE flag"]}` |
|
||
| `502` | ERR_USERSDONTMATCH | User MODE for another nick | `{"command":"ERR_USERSDONTMATCH","code":502,"to":"alice","body":["Can't change mode for other users"]}` |
|
||
|
||
### Channel Modes
|
||
|
||
Inspired by IRC, simplified. See [MODE](#mode--query-and-change-modes) for how
|
||
to set them; `b`, `k` and `l` can be set only over the HTTP API, and `n` only on
|
||
the IRC listener.
|
||
|
||
| Mode | Name | Meaning | Status |
|
||
| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||
| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** |
|
||
| `+i` | Invite-only | Only invited users can join; use `INVITE` to invite | **Enforced** |
|
||
| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** |
|
||
| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** |
|
||
| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** |
|
||
| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set |
|
||
| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS`; not by `GET /api/v1/channels` |
|
||
| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** |
|
||
| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced**; the IRC listener refuses every PRIVMSG to the channel, as it cannot carry a stamp |
|
||
|
||
**User channel modes (set per-user per-channel):**
|
||
|
||
| Mode | Meaning | Display prefix | Status |
|
||
| ---- | -------- | ------------------ | ------------ |
|
||
| `+o` | Operator | `@` in NAMES reply | **Enforced** |
|
||
| `+v` | Voice | `+` in NAMES reply | **Enforced** |
|
||
|
||
**Channel creator auto-op:** The first user to JOIN a channel (creating it)
|
||
automatically receives `+o` operator status.
|
||
|
||
**Ban system (+b):** Operators can ban users by hostmask pattern with wildcard
|
||
matching (`*` and `?`, ignoring letter case). `+b` with no mask lists current
|
||
bans. Bans prevent both joining and sending messages.
|
||
|
||
```json
|
||
{"command": "MODE", "to": "#channel", "body": ["+b", "*!*@*.example.com"]}
|
||
{"command": "MODE", "to": "#channel", "body": ["-b", "*!*@*.example.com"]}
|
||
{"command": "MODE", "to": "#channel", "body": ["+b"]}
|
||
```
|
||
|
||
These ban all users from example.com, remove that ban, and list all bans
|
||
(RPL_BANLIST 367, RPL_ENDOFBANLIST 368).
|
||
|
||
**Invite-only (+i):** When set, users must be invited before joining. `INVITE`
|
||
records an invite that is consumed on JOIN; any member can invite, and on a `+i`
|
||
channel only an operator can.
|
||
|
||
```json
|
||
{"command": "MODE", "to": "#channel", "body": ["+i"]}
|
||
{"command": "INVITE", "body": ["nick", "#channel"]}
|
||
```
|
||
|
||
**Channel key (+k):** Requires a password to join the channel. Over the HTTP API
|
||
the key is `body[0]` of the `JOIN`. On the IRC listener it follows the channel
|
||
name, as in `JOIN #channel secretpass`; `JOIN #a,#b keya,keyb` gives each
|
||
channel its own key.
|
||
|
||
```json
|
||
{"command": "MODE", "to": "#channel", "body": ["+k", "secretpass"]}
|
||
{"command": "MODE", "to": "#channel", "body": ["-k"]}
|
||
{"command": "JOIN", "to": "#channel", "body": ["secretpass"]}
|
||
```
|
||
|
||
**User limit (+l):** Caps the number of members in the channel.
|
||
|
||
```json
|
||
{"command": "MODE", "to": "#channel", "body": ["+l", "50"]}
|
||
{"command": "MODE", "to": "#channel", "body": ["-l"]}
|
||
```
|
||
|
||
**Secret (+s):** `LIST` hides the channel from non-members, and `WHOIS` leaves
|
||
it out of the channel list when the querier is not in the same channel.
|
||
|
||
**Join errors:** a user who is banned gets ERR_BANNEDFROMCHAN (474), one not
|
||
invited to a `+i` channel ERR_INVITEONLYCHAN (473), one with a missing or wrong
|
||
key ERR_BADCHANNELKEY (475), and one joining a full channel ERR_CHANNELISFULL
|
||
(471). None of these apply to the user who creates the channel.
|
||
|
||
**KICK command:** Channel operators can remove users with
|
||
`KICK #channel nick [:reason]`. The kicked user and all channel members receive
|
||
the KICK message.
|
||
|
||
**NOTICE:** A NOTICE gets no hashcash check on `+H` channels, but it gets the
|
||
same error numerics as PRIVMSG (411, 412, 401, 403, 404). Over the HTTP API it
|
||
gets no RPL_AWAY; the IRC listener sends RPL_AWAY for a NOTICE to a user who is
|
||
away, as it does for PRIVMSG.
|
||
|
||
**ISUPPORT:** The server advertises `PREFIX=(ov)@+` in RPL_ISUPPORT (005), with
|
||
`CHANMODES=b,k,Hl,imnst` over the HTTP API and `CHANMODES=,,H,imnst` on the IRC
|
||
listener.
|
||
|
||
### Per-Channel Hashcash (Anti-Spam)
|
||
|
||
Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an
|
||
anti-spam mechanism: channel operators set a difficulty level, and clients must
|
||
compute a proof-of-work stamp bound to the specific channel and message before
|
||
sending. An IRC listener client has no way to attach a stamp, so its `PRIVMSG`
|
||
to such a channel always gets ERR_CANNOTSENDTOCHAN (404).
|
||
|
||
**Setting the requirement:**
|
||
|
||
```
|
||
MODE #channel +H <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` | string | (none) | The `id` of a message in this history; return only messages older than it. Omit for the latest. |
|
||
| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. |
|
||
|
||
To page back, pass the `id` of the first (oldest) message of a page as `before`
|
||
for the next; an empty page means there is nothing older.
|
||
|
||
`target` may also be your own nick, which returns the DMs sent to your session,
|
||
including those sent under its earlier nicks. A channel created again after its
|
||
last member left starts with no history, and a user who takes a released nick
|
||
gets none of the DMs sent to its previous holder. Errors: `400`
|
||
`target required`, `400` `unknown before id` when no message with that `id` is
|
||
in this history, `404` `channel not found`, `403`
|
||
`not a member of this channel`, and `403` `forbidden` for any other nick.
|
||
|
||
**Response:** `200 OK`
|
||
|
||
```json
|
||
[
|
||
{
|
||
"id": "uuid-1",
|
||
"command": "PRIVMSG",
|
||
"from": "alice",
|
||
"to": "#general",
|
||
"body": ["first message"],
|
||
"ts": "2026-02-10T19:00:00.123456789Z",
|
||
"meta": {}
|
||
},
|
||
{
|
||
"id": "uuid-2",
|
||
"command": "PRIVMSG",
|
||
"from": "bob",
|
||
"to": "#general",
|
||
"body": ["second message"],
|
||
"ts": "2026-02-10T19:01:00.234567891Z",
|
||
"meta": {}
|
||
}
|
||
]
|
||
```
|
||
|
||
**Note:** History currently returns only PRIVMSG messages (not JOIN/PART/etc.
|
||
events). Event messages are delivered via the live queue only.
|
||
|
||
**curl example:**
|
||
|
||
```bash
|
||
# Latest 50 messages in #general
|
||
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
|
||
-b cookies.txt | jq .
|
||
|
||
# The 50 messages before uuid-1 (pagination)
|
||
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=uuid-1&limit=50" \
|
||
-b cookies.txt | jq .
|
||
```
|
||
|
||
### 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, authenticated HTTP API request or IRC listener command (or creation) |
|
||
|
||
Index on `(uuid)`.
|
||
|
||
#### `clients`
|
||
|
||
| Column | Type | Description |
|
||
| ------------ | -------- | ------------------------------------------------------------------------- |
|
||
| `id` | INTEGER | Primary key (auto-increment) |
|
||
| `uuid` | TEXT | Unique client UUID |
|
||
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
||
| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) |
|
||
| `ip` | TEXT | Real IP address of this client connection |
|
||
| `hostname` | TEXT | Reverse DNS name of this client's IP, or the IP itself when it has none |
|
||
| `created_at` | DATETIME | Client creation time |
|
||
| `last_seen` | DATETIME | Last authenticated HTTP API request or IRC listener command (or creation) |
|
||
|
||
Indexes on `(token)` and `(session_id)`.
|
||
|
||
#### `channels`
|
||
|
||
| Column | Type | Description |
|
||
| ----------------- | -------- | --------------------------------------------------------- |
|
||
| `id` | INTEGER | Primary key (auto-increment) |
|
||
| `name` | TEXT | Unique channel name (e.g., `#general`) |
|
||
| `topic` | TEXT | Channel topic (default empty) |
|
||
| `topic_set_by` | TEXT | Nick of the user who set the topic (default empty) |
|
||
| `topic_set_at` | DATETIME | When the topic was last set (null until then) |
|
||
| `hashcash_bits` | INTEGER | Mode `+H`: bits a `PRIVMSG` stamp must have (0 = off) |
|
||
| `is_moderated` | INTEGER | Mode `+m` (default 0) |
|
||
| `is_topic_locked` | INTEGER | Mode `+t` (default 1) |
|
||
| `is_invite_only` | INTEGER | Mode `+i` (default 0) |
|
||
| `is_secret` | INTEGER | Mode `+s` (default 0) |
|
||
| `is_no_external` | INTEGER | Mode `+n` (default 1) |
|
||
| `channel_key` | TEXT | Mode `+k`: the key needed to join (empty string = no key) |
|
||
| `user_limit` | INTEGER | Mode `+l`: maximum number of members (0 = no limit) |
|
||
| `created_at` | DATETIME | Channel creation time |
|
||
| `updated_at` | DATETIME | Last change to the topic or to a mode stored in this row |
|
||
|
||
#### `channel_bans`
|
||
|
||
| Column | Type | Description |
|
||
| ------------ | -------- | --------------------------------------------------- |
|
||
| `id` | INTEGER | Primary key (auto-increment) |
|
||
| `channel_id` | INTEGER | FK → channels.id (cascade delete) |
|
||
| `mask` | TEXT | Banned `nick!user@host` pattern (`*` and `?` match) |
|
||
| `set_by` | TEXT | Nick of the operator who set the ban |
|
||
| `created_at` | DATETIME | When the ban was set |
|
||
|
||
Unique constraint on `(channel_id, mask)`. Index on `(channel_id)`.
|
||
|
||
#### `channel_invites`
|
||
|
||
| Column | Type | Description |
|
||
| ------------ | -------- | --------------------------------- |
|
||
| `id` | INTEGER | Primary key (auto-increment) |
|
||
| `channel_id` | INTEGER | FK → channels.id (cascade delete) |
|
||
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
||
| `invited_by` | TEXT | Nick of the user who invited |
|
||
| `created_at` | DATETIME | When the invite was made |
|
||
|
||
Unique constraint on `(channel_id, session_id)`. Index on `(channel_id)`. An
|
||
invite is deleted when the invited user joins the channel.
|
||
|
||
#### `channel_members`
|
||
|
||
| Column | Type | Description |
|
||
| ------------- | -------- | --------------------------------------------- |
|
||
| `id` | INTEGER | Primary key (auto-increment) |
|
||
| `channel_id` | INTEGER | FK → channels.id (cascade delete) |
|
||
| `session_id` | INTEGER | FK → sessions.id (cascade delete) |
|
||
| `is_operator` | INTEGER | Channel operator, mode `+o` (0 = no, 1 = yes) |
|
||
| `is_voiced` | INTEGER | Voice, mode `+v` (0 = no, 1 = yes) |
|
||
| `joined_at` | DATETIME | When the user joined |
|
||
|
||
Unique constraint on `(channel_id, session_id)`.
|
||
|
||
#### `messages`
|
||
|
||
| Column | Type | Description |
|
||
| --------------- | -------- | ----------------------------------------------------------------------------------- |
|
||
| `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. |
|
||
| `uuid` | TEXT | UUID v4, exposed to clients as the message `id` |
|
||
| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) |
|
||
| `msg_from` | TEXT | Sender nick |
|
||
| `msg_to` | TEXT | Target (`#channel` or nick) |
|
||
| `to_session_id` | INTEGER | FK → sessions.id (set to null on delete): the session a DM or `INVITE` was sent to |
|
||
| `to_channel_id` | INTEGER | FK → channels.id (set to null on delete): the channel a channel message was sent to |
|
||
| `params` | TEXT | JSON-encoded IRC-style positional parameters |
|
||
| `body` | TEXT | JSON-encoded body (array or object) |
|
||
| `meta` | TEXT | JSON-encoded metadata |
|
||
| `created_at` | DATETIME | Server timestamp |
|
||
|
||
Indexes on `(to_session_id, id)`, `(to_channel_id, id)` and `(created_at)`.
|
||
`to_session_id` and `to_channel_id` are null on a message sent to neither, such
|
||
as `QUIT`. `GET /api/v1/history` selects by one of these two columns rather than
|
||
by `msg_to`, because nicks and channel names are reused once released, and
|
||
session and channel ids are not.
|
||
|
||
#### `spent_hashcash`
|
||
|
||
| Column | Type | Description |
|
||
| ------------ | -------- | ---------------------------------------------------- |
|
||
| `id` | INTEGER | Primary key (auto-increment) |
|
||
| `stamp_hash` | TEXT | Unique SHA-256 (hex) of a channel stamp already used |
|
||
| `created_at` | DATETIME | When the stamp was used |
|
||
|
||
Index on `(created_at)`. Only the stamps of `+H` channel messages are recorded
|
||
here, and rows older than a year are pruned; session creation stamps are tracked
|
||
in memory.
|
||
|
||
#### `client_queues`
|
||
|
||
| Column | Type | Description |
|
||
| ------------ | -------- | ------------------------------------------------------ |
|
||
| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. |
|
||
| `client_id` | INTEGER | FK → clients.id (cascade delete) |
|
||
| `message_id` | INTEGER | FK → messages.id (cascade delete) |
|
||
| `created_at` | DATETIME | When the entry was queued |
|
||
|
||
Unique constraint on `(client_id, message_id)`. Index on `(client_id, id)`.
|
||
|
||
The `client_queues.id` is the monotonically increasing cursor used by
|
||
`GET /messages?after=<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: the session is parted from its channels,
|
||
deleted, and its QUIT queued to its channel peers in one database transaction,
|
||
so when several QUITs or logouts end one session at once only the one that
|
||
deletes it broadcasts QUIT, and whatever a new holder of the nick sends
|
||
reaches those peers after the QUIT. Idle clients are automatically removed
|
||
after `SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP
|
||
API request or, on the IRC listener, a command — the server runs a background
|
||
cleanup loop that, for a session left with no clients, parts the user from all
|
||
channels, broadcasts QUIT, and releases the nick.
|
||
- **Clients**: Individual client auth cookies are invalidated on
|
||
`POST /api/v1/logout`. A session can have multiple clients; removing one
|
||
doesn't affect others. However, when the last client is removed (via logout),
|
||
the entire session is deleted — the user is parted from all channels, QUIT is
|
||
broadcast, and the nick is released.
|
||
|
||
The cleanup loop that does all of this pruning and expiry runs every half
|
||
`SESSION_IDLE_TIMEOUT`, but at least a minute apart (every 15 days by default),
|
||
the first time that long after the server starts, so rows can outlive their
|
||
maximum age by up to that interval.
|
||
|
||
---
|
||
|
||
## Configuration
|
||
|
||
Configuration comes from environment variables, read by
|
||
[Viper](https://github.com/spf13/viper). A `.env` file in the working directory
|
||
is also loaded automatically via [godotenv](https://github.com/joho/godotenv),
|
||
and Viper also reads a YAML file `neoirc.yaml` from `/etc/neoirc/` or
|
||
`$HOME/.config/neoirc/` if one exists, with the same names as keys; an
|
||
environment variable wins over the file. An environment variable set to the
|
||
empty string counts as unset, so the file's value or the default applies; the
|
||
one exception is `IRC_LISTEN_ADDR`, where the empty string disables the IRC
|
||
listener.
|
||
|
||
| Variable | Type | Default | Description |
|
||
| ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `PORT` | int | `8080` | HTTP listen port |
|
||
| `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. See [SQLite Considerations](#sqlite-considerations). |
|
||
| `DEBUG` | bool | `false` | Switch logging to debug level |
|
||
| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. |
|
||
| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. |
|
||
| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. |
|
||
| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` |
|
||
| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` |
|
||
| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. |
|
||
| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) |
|
||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||
| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. |
|
||
| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. |
|
||
| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. |
|
||
| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. |
|
||
| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set it to the empty string, in the environment or in `neoirc.yaml`, to disable the listener. |
|
||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||
|
||
### Example `.env` file
|
||
|
||
```bash
|
||
PORT=8080
|
||
SERVER_NAME=My NeoIRC Server
|
||
MOTD=Welcome! Be excellent to each other.
|
||
DEBUG=false
|
||
DBURL=file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)
|
||
SESSION_IDLE_TIMEOUT=720h
|
||
NEOIRC_HASHCASH_BITS=20
|
||
```
|
||
|
||
---
|
||
|
||
## 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 (see [Configuration](#configuration)),
|
||
either in the environment:
|
||
|
||
```bash
|
||
IRC_LISTEN_ADDR=
|
||
```
|
||
|
||
or in `neoirc.yaml`:
|
||
|
||
```yaml
|
||
IRC_LISTEN_ADDR: ""
|
||
```
|
||
|
||
### Supported Commands
|
||
|
||
| Category | Commands |
|
||
| ---------- | ---------------------------------------------------------------------------------------- |
|
||
| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` |
|
||
| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` |
|
||
| Messaging | `PRIVMSG`, `NOTICE` |
|
||
| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` |
|
||
| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) |
|
||
|
||
### Protocol Details
|
||
|
||
- **Wire format**: CR-LF delimited lines, max 512 bytes per line
|
||
- **Connection registration**: Clients must send `NICK` and `USER` to register.
|
||
An optional `PASS` before registration sets the session password (8 to 72
|
||
bytes); one outside that range is refused with a `NOTICE` once registration
|
||
completes, as `PASS` after registration is. Registering creates a session
|
||
without a hashcash stamp; closing the connection ends the session, as `QUIT`
|
||
does.
|
||
- **PING**: a connection that sends nothing for 90 seconds is sent a `PING`, and
|
||
is closed if it sends nothing in the 30 seconds after that; any line, such as
|
||
the client's `PONG`, starts both waits again.
|
||
- **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is
|
||
ignored, for compatibility with modern clients.
|
||
- **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if
|
||
omitted; other commands use the name as given. As over the HTTP API, `JOIN`
|
||
answers a name that is not `#` followed by 1–63 letters, digits, `_` or `-`
|
||
with ERR_NOSUCHCHANNEL (403).
|
||
- **First joiner**: The first user to join a channel is automatically granted
|
||
operator status (`@`).
|
||
- **Channel modes**: `+i` (invite-only), `+m` (moderated), `+n` (no external),
|
||
`+s` (secret), `+t` (topic lock), `+H` (hashcash), `+o` (operator), `+v`
|
||
(voice); see [MODE](#mode--query-and-change-modes). `+b`, `+k` and `+l` can be
|
||
set only over the HTTP API.
|
||
- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives
|
||
`WALLOPS`). `MODE` for any nick other than your own is rejected with
|
||
`ERR_USERSDONTMATCH` (502), for both queries and changes.
|
||
- **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel
|
||
peers, deletes its session, and then sends the victim a `KILL` and
|
||
`ERROR :Closing Link` before closing its socket. This applies to victims on
|
||
the IRC listener regardless of whether the `KILL` arrived over IRC or the HTTP
|
||
API.
|
||
|
||
### Bridge to HTTP API
|
||
|
||
Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients
|
||
and vice versa. The IRC listener and HTTP API share the same database, broker,
|
||
and session infrastructure, so a user connected via IRC and a user connected via
|
||
the HTTP API can talk in the same channels.
|
||
|
||
Each line of a multi-line `body` reaches an IRC listener user as its own
|
||
`PRIVMSG` or `NOTICE` line. A CR, LF or NUL inside a line is removed before it
|
||
is sent, so it cannot start a line of its own. A mode change made over the HTTP
|
||
API reaches IRC listener members as an IRC `MODE` line with its value as a
|
||
separate parameter, e.g. `MODE #general +k secretpass` or
|
||
`MODE #general +o bob`.
|
||
|
||
### 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 runs the database in WAL mode
|
||
with a 5000 ms busy timeout, through the SQLite driver's
|
||
`_pragma=journal_mode(WAL)` and `_pragma=busy_timeout(5000)` parameters. A
|
||
`DBURL` of your own should keep both.
|
||
- **Single writer**: the server uses one database connection, so reads and
|
||
writes take turns. For high-traffic servers, Postgres support is planned.
|
||
- **Backup**: Back the database up with
|
||
`sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`, or copy `state.db`
|
||
while the server is stopped, together with `state.db-wal` if one is left
|
||
beside it: in WAL mode that file holds recent writes not yet folded into
|
||
`state.db`.
|
||
- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the
|
||
`DBURL` env var to place it elsewhere.
|
||
|
||
---
|
||
|
||
## Client Development Guide
|
||
|
||
This section explains how to write a client against the neoirc API. The API is
|
||
designed to be simple enough that a basic client can be written in any language
|
||
with an HTTP client library.
|
||
|
||
### Minimal Client Loop
|
||
|
||
A complete client needs only four HTTP calls:
|
||
|
||
```
|
||
1. POST /api/v1/session → get auth cookie
|
||
2. POST /api/v1/messages (JOIN) → join channels
|
||
3. GET /api/v1/messages (loop) → receive messages
|
||
4. POST /api/v1/messages → send messages
|
||
```
|
||
|
||
### Step-by-Step with curl
|
||
|
||
These examples, and the Python one below, assume a server run with
|
||
`NEOIRC_HASHCASH_BITS=0`. With the default of 20, session creation also needs a
|
||
`pow_token` (see [Hashcash Proof-of-Work](#hashcash-proof-of-work)).
|
||
|
||
```bash
|
||
# 1a. Create a session (cookie saved automatically with -c)
|
||
curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"nick":"testuser"}'
|
||
|
||
# 1b. Optionally set a password for multi-client access
|
||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"command":"PASS","body":["mypassword"]}'
|
||
|
||
# 1c. Login from another device (saves new cookie)
|
||
curl -s -c cookies2.txt -X POST http://localhost:8080/api/v1/login \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"nick":"testuser","password":"mypassword"}'
|
||
|
||
# 2. Join a channel
|
||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"command":"JOIN","to":"#general"}'
|
||
|
||
# 3. Send a message
|
||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'
|
||
|
||
# 4. Poll for messages (one-shot)
|
||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq .
|
||
|
||
# 5. Long-poll (blocks up to 15s waiting for messages)
|
||
curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq .
|
||
|
||
# 6. Send a DM
|
||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}'
|
||
|
||
# 7. Change nick
|
||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"command":"NICK","body":["newnick"]}'
|
||
|
||
# 8. Set channel topic
|
||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"command":"TOPIC","to":"#general","body":["New topic!"]}'
|
||
|
||
# 9. Leave a channel
|
||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"command":"PART","to":"#general","body":["goodbye"]}'
|
||
|
||
# 10. Disconnect
|
||
curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"command":"QUIT","body":["leaving"]}'
|
||
```
|
||
|
||
### Implementing Long-Poll in Code
|
||
|
||
The key to real-time messaging is the poll loop. Here's the pattern:
|
||
|
||
```python
|
||
# Python example — using requests.Session for cookie handling
|
||
import requests, json, time
|
||
|
||
BASE = "http://localhost:8080/api/v1"
|
||
session = requests.Session()
|
||
last_id = 0
|
||
|
||
# Create session (the server sets the auth cookie via Set-Cookie)
|
||
resp = session.post(f"{BASE}/session", json={"nick": "pybot"})
|
||
print(f"Session: {resp.json()}")
|
||
# The auth cookie is Secure, and requests sends it only over HTTPS.
|
||
# For a plain-HTTP server on localhost, send it on every request:
|
||
session.headers["Cookie"] = f"neoirc_auth={resp.cookies['neoirc_auth']}"
|
||
|
||
# Join channel
|
||
session.post(f"{BASE}/messages",
|
||
json={"command": "JOIN", "to": "#general"})
|
||
|
||
# Poll loop
|
||
while True:
|
||
try:
|
||
resp = session.get(f"{BASE}/messages",
|
||
params={"after": last_id, "timeout": 15},
|
||
timeout=20) # HTTP timeout > long-poll timeout
|
||
data = resp.json()
|
||
if data.get("last_id"):
|
||
last_id = data["last_id"]
|
||
for msg in data.get("messages", []):
|
||
print(f"[{msg['command']}] <{msg.get('from','')}> "
|
||
f"{' '.join(msg.get('body', []))}")
|
||
except requests.exceptions.Timeout:
|
||
continue # Normal — just re-poll
|
||
except Exception as e:
|
||
print(f"Error: {e}")
|
||
time.sleep(2) # Back off on errors
|
||
```
|
||
|
||
```javascript
|
||
// JavaScript/browser example — cookies sent automatically
|
||
async function pollLoop() {
|
||
let lastId = 0;
|
||
while (true) {
|
||
try {
|
||
const resp = await fetch(
|
||
`/api/v1/messages?after=${lastId}&timeout=15`,
|
||
{ credentials: "same-origin" }, // Include cookies
|
||
);
|
||
if (resp.status === 401) {
|
||
/* session expired */ break;
|
||
}
|
||
const data = await resp.json();
|
||
if (data.last_id) lastId = data.last_id;
|
||
for (const msg of data.messages || []) {
|
||
handleMessage(msg);
|
||
}
|
||
} catch (e) {
|
||
await new Promise((r) => setTimeout(r, 2000)); // back off
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### Handling Message Types
|
||
|
||
Clients should handle these message commands from the queue:
|
||
|
||
| Command | Display As |
|
||
| --------- | ---------------------------------------------------------- |
|
||
| `PRIVMSG` | `<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)
|