Files
chat/README.md
clawbot 4538d59b1d
All checks were successful
check / check (push) Successful in 2m22s
fix: remove PruneOrphanedMessages to preserve history within MAX_HISTORY
PruneOrphanedMessages deleted messages that lost their client_queues
references after PruneOldQueueEntries ran, even when those messages
were within the MAX_HISTORY limit. This made MAX_HISTORY meaningless
for low-traffic channels.

RotateChannelMessages already caps messages per target. Queue pruning
handles client_queues growth. Orphan cleanup is redundant.

Closes #40
2026-03-10 03:28:41 -07:00

91 KiB
Raw Blame History

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

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) or objects (for structured data like key material). This also enables deterministic canonicalization via RFC 8785 JCS — you can't reliably sign something if the wire representation is ambiguous.

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. There are no accounts — identity is a key, a nick is a display name.

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. A JOIN from a client is the same shape as the JOIN relayed to channel members. 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 — No Accounts

There are no accounts, no registration, no passwords. Identity is a signing key; a nick is just a display name. The two are decoupled.

  • Session creation: client sends POST /api/v1/session with a desired nick → server assigns an auth token (64 hex characters of cryptographically random bytes) and returns the user ID, nick, and token.
  • The auth token implicitly identifies the client. Clients present it via Authorization: Bearer <token>.
  • 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.
  • Tokens are opaque random bytes, not JWTs. No claims, no expiry encoded in the token, no client-side decode. The server is the sole authority on token validity.

Rationale: IRC has no accounts. You connect, pick a nick, and talk. Adding registration, email verification, or OAuth would solve a problem nobody asked about and add complexity that drives away casual users. Identity verification is handled at the message layer via cryptographic signatures (see Security Model), not at the session layer.

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: 132 characters. No further character restrictions in the current implementation.
  • 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 all S2C messages to every active client queue for that user session.
  • GET /api/v1/messages delivers from the calling client's specific queue, identified by the auth token.
  • Client queues have independent expiry/pruning — one client going offline doesn't affect others.
User Session
├── Client A (token_a, queue_a)
├── Client B (token_b, queue_b)
└── Client C (token_c, queue_c)

Current MVP note: The current implementation creates a new user (with new nick) per POST /api/v1/session call. True multi-client (multiple tokens sharing one nick/session) is supported by the schema (client_queues is keyed by user_id, and multiple tokens can point to the same user) but the session creation endpoint does not yet support "add a client to an existing session." This will be added post-MVP.

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.
  • Queue depth: Server-configurable via QUEUE_MAX_AGE. Default is 48 hours. 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-user notification channels. When a message is enqueued for a user, the broker closes all waiting channels for that user, waking up any blocked long-poll handlers. 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 in the current implementation.
  • Channel names must start with #. If a client sends a JOIN without the # prefix, the server adds it.
  • No channel-level encryption — encryption is per-message via the meta field.

Direct Messages (DMs)

  • DMs are addressed by nick at send time — the server resolves the nick to a user ID internally.
  • DMs are fan-out to both sender and recipient — the sender sees their own DM echoed back in their message queue, enabling multi-client consistency (your laptop sees DMs you sent from your phone).
  • DM history is stored in the messages table with the recipient nick as the msg_to field. This means DM history is queryable per-nick, but if a user changes their nick, old DMs are associated with the old nick.
  • DMs are not stored long-term by default — they follow the same rotation policy as channel messages.

JSON, Not Binary

All messages are JSON. No CBOR, no protobuf, no MessagePack, no custom binary framing.

Rationale: JSON is human-readable, universally supported, and debuggable with curl | jq. Binary formats save bandwidth at the cost of debuggability and ecosystem compatibility. Chat messages are small — the overhead of JSON over binary is measured in bytes per message, not meaningful bandwidth. The canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which matters for signing.

Why Opaque Tokens 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 tokens are simpler:

  • Server generates 32 random bytes → hex-encodes → stores hash
  • Client presents the token; server looks it up
  • Revocation is a database delete
  • No clock skew issues, no algorithm confusion, no "none" algorithm attacks
  • Token format can change without breaking clients

Architecture

Transport: HTTP Only

All client↔server and server↔server communication uses HTTP/1.1+ with JSON request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP.

  • Client reading: Long-poll GET /api/v1/messages — server holds the connection for up to 15s until messages arrive or timeout. One endpoint for everything — channel messages, DMs, system events, numeric replies.
  • Client writing: POST /api/v1/messages with a command field. One endpoint for everything — PRIVMSG, JOIN, PART, NICK, TOPIC, etc.
  • Server federation: Servers exchange messages via HTTP to enable multi-server networks (like IRC server linking).

The entire read/write loop for a client is two endpoints. Everything else (state, history, channels, members, server info) is ancillary.

Session Lifecycle

┌─ Client ──────────────────────────────────────────────────┐
│                                                            │
│  1. POST /api/v1/session {"nick":"alice"}                  │
│     → {"id":1, "nick":"alice", "token":"a1b2c3..."}       │
│                                                            │
│  2. POST /api/v1/messages {"command":"JOIN","to":"#gen"}   │
│     → {"status":"joined","channel":"#general"}             │
│     (Server fans out JOIN event to all #general members)   │
│                                                            │
│  3. POST /api/v1/messages {"command":"PRIVMSG",            │
│       "to":"#general","body":["hello"]}                    │
│     → {"id":"uuid-...","status":"sent"}                    │
│     (Server fans out to all #general 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)                       │
│                                                            │
└────────────────────────────────────────────────────────────┘

Queue Architecture

                    ┌─────────────────┐
                    │  messages table  │  (one row per message, shared)
                    │  id | uuid | cmd│
                    │  from | to | ..  │
                    └────────┬────────┘
                             │
              ┌──────────────┼──────────────┐
              │              │              │
    ┌─────────▼──┐  ┌───────▼────┐  ┌──────▼─────┐
    │client_queue│  │client_queue│  │client_queue│
    │ user_id=1  │  │ user_id=2  │  │ user_id=3  │
    │ msg_id=N   │  │ msg_id=N   │  │ msg_id=N   │
    └────────────┘  └────────────┘  └────────────┘
         alice           bob             carol

Each message is stored ONCE. One queue entry per recipient.

The client_queues table contains (user_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 user, 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 user_id → []chan struct{}. When a message is enqueued for a user:

  1. The handler calls broker.Notify(userID)
  2. The broker closes all waiting channels for that user
  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:

{
  "id":      "string (uuid)",
  "command": "string",
  "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 3-digit numeric reply code (001, 433, etc.). Case-insensitive on input; server normalizes to uppercase.
from string Ignored Usually Sender's nick (for user messages) or server name (for server messages). Server always overwrites this field — clients cannot spoof the sender.
to string Usually Usually Destination: #channel for channel targets, bare nick for DMs/user targets.
params array of strings Sometimes Sometimes Additional IRC-style positional parameters. Used by commands like MODE, KICK, and numeric replies like 353 (NAMES).
body array or object Usually Usually Structured message body. For text messages: array of strings (one per line). For structured data (e.g., PUBKEY): JSON object. Never a raw string.
ts string (ISO 8601) Ignored Always Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: "2026-02-10T20:00:00.000000000Z". Always UTC.
meta object Optional If present Extensible metadata. Used for cryptographic signatures (meta.sig, meta.alg), content hashes, or any client-defined key/value pairs. Server relays meta verbatim — it does not interpret or validate it.

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, validated, or interpreted by the server.

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:

{"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):

{
  "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
  "command": "PRIVMSG",
  "from": "alice",
  "to": "#general",
  "body": ["hello world"],
  "ts": "2026-02-10T20:00:00.000000000Z",
  "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.
  • If the channel doesn't exist, the server returns HTTP 404.
  • If the DM target nick doesn't exist, the server returns HTTP 404.

Response: 201 Created

{"id": "uuid-string", "status": "sent"}

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:

{"command": "NOTICE", "to": "#general", "body": ["server maintenance in 5 min"]}

Behavior: Same as PRIVMSG in all respects, except clients receiving a NOTICE must not send an automatic reply.

IRC reference: RFC 1459 §4.4.2

JOIN — Join Channel

Join a channel. If the channel doesn't exist, it is created.

C2S:

{"command": "JOIN", "to": "#general"}
{"command": "JOIN", "to": "general"}

If the # prefix is omitted, the server adds it.

S2C (broadcast to all channel members, including the joiner):

{
  "id": "...",
  "command": "JOIN",
  "from": "alice",
  "to": "#general",
  "body": [],
  "ts": "2026-02-10T20:00:00.000000000Z",
  "meta": {}
}

Behavior:

  • If the channel doesn't exist, it is created with no topic and no modes.
  • If the user is already in the channel, the JOIN is a no-op (no error, no duplicate broadcast).
  • The JOIN event is broadcast to all channel members, including the user who joined. This lets the client confirm the join succeeded and lets other members update their member lists.
  • The first user to join a channel becomes its implicit operator (not yet enforced in current implementation).

Response: 200 OK

{"status": "joined", "channel": "#general"}

IRC reference: RFC 1459 §4.2.1

PART — Leave Channel

Leave a channel.

C2S:

{"command": "PART", "to": "#general"}
{"command": "PART", "to": "#general", "body": ["goodbye"]}

S2C (broadcast to all channel members, including the leaver):

{
  "id": "...",
  "command": "PART",
  "from": "alice",
  "to": "#general",
  "body": ["goodbye"],
  "ts": "...",
  "meta": {}
}

Behavior:

  • The PART event is broadcast before the member is removed, so the departing user receives their own PART event.
  • 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

{"status": "parted", "channel": "#general"}

IRC reference: RFC 1459 §4.2.2

NICK — Change Nickname

Change the user's nickname.

C2S:

{"command": "NICK", "body": ["newnick"]}

S2C (broadcast to all users sharing a channel with the changer):

{
  "id": "...",
  "command": "NICK",
  "from": "oldnick",
  "to": "",
  "body": ["newnick"],
  "ts": "...",
  "meta": {}
}

Behavior:

  • body[0] is the new nick. Must be 132 characters.
  • The from field in the broadcast contains the old nick.
  • The body[0] in the broadcast contains the new nick.
  • The NICK event is broadcast to the user themselves and to all users who share at least one channel with the changer. Each recipient receives the event exactly once, even if they share multiple channels.
  • If the new nick is already taken, the server returns HTTP 409 Conflict.

Response: 200 OK

{"status": "ok", "nick": "newnick"}

Error (nick taken): 409 Conflict

{"error": "nick already in use"}

IRC reference: RFC 1459 §4.1.2

TOPIC — Set Channel Topic

Set or change a channel's topic.

C2S:

{"command": "TOPIC", "to": "#general", "body": ["Welcome to #general"]}

S2C (broadcast to all channel members):

{
  "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.
  • If the channel doesn't exist, the server returns an error.
  • If the channel has mode +t (topic lock), only operators can change the topic (not yet enforced).

Response: 200 OK

{"status": "ok", "topic": "Welcome to #general"}

IRC reference: RFC 1459 §4.2.4

QUIT — Disconnect

Destroy the session and disconnect from the server.

C2S:

{"command": "QUIT"}
{"command": "QUIT", "body": ["leaving"]}

S2C (broadcast to all users sharing channels with the quitter):

{
  "id": "...",
  "command": "QUIT",
  "from": "alice",
  "to": "",
  "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 token is invalidated, the nick is released.
  • Subsequent requests with the old token return HTTP 401.

Response: 200 OK

{"status": "quit"}

IRC reference: RFC 1459 §4.1.6

PING — Keepalive

Client keepalive. Server responds synchronously with PONG.

C2S:

{"command": "PING"}

Response (synchronous, not via the queue): 200 OK

{"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 Modes

Query channel or user modes. Returns the current mode string and, for channels, the creation timestamp.

C2S:

{"command": "MODE", "to": "#general"}
{"command": "MODE", "to": "alice"}

S2C (via message queue):

For channels, the server sends RPL_CHANNELMODEIS (324) and RPL_CREATIONTIME (329):

{"command": "324", "to": "alice", "params": ["#general", "+n"]}
{"command": "329", "to": "alice", "params": ["#general", "1709251200"]}

For users, the server sends RPL_UMODEIS (221):

{"command": "221", "to": "alice", "body": ["+"]}

Note: Mode changes (setting/unsetting modes) are not yet implemented. Currently only query is supported.

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:

{"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:

{"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_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318).

C2S:

{"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:

{"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:

{"command": "LUSERS"}

LUSERS replies are also sent automatically during connection registration.

IRC reference: RFC 1459 §4.3.2

KICK — Kick User (Planned)

Remove a user from a channel.

C2S:

{"command": "KICK", "to": "#general", "params": ["bob"], "body": ["misbehaving"]}

Status: Not yet implemented.

IRC reference: RFC 1459 §4.2.8

PUBKEY — Announce Signing Key

Distribute a public signing key to channel members.

C2S:

{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}}

S2C (relayed to channel members):

{
  "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 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) and use 3-digit string codes in the command field.

Code Name When Sent Example
001 RPL_WELCOME After session creation {"command":"001","to":"alice","body":["Welcome to the network, alice"]}
002 RPL_YOURHOST After session creation {"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}
003 RPL_CREATED After session creation {"command":"003","to":"alice","body":["This server was created 2026-02-10"]}
004 RPL_MYINFO After session creation {"command":"004","to":"alice","params":["neoirc","0.1","","imnst"]}
005 RPL_ISUPPORT After session creation {"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","NETWORK=neoirc"],"body":["are supported by this server"]}
221 RPL_UMODEIS In response to user MODE query {"command":"221","to":"alice","body":["+"]}
251 RPL_LUSERCLIENT On connect or LUSERS command {"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}
252 RPL_LUSEROP On connect or LUSERS command {"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}
254 RPL_LUSERCHANNELS On connect or LUSERS command {"command":"254","to":"alice","params":["3"],"body":["channels formed"]}
255 RPL_LUSERME On connect or LUSERS command {"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}
311 RPL_WHOISUSER In response to WHOIS {"command":"311","to":"alice","params":["bob","bob","neoirc","*"],"body":["bob"]}
312 RPL_WHOISSERVER In response to WHOIS {"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}
315 RPL_ENDOFWHO End of WHO response {"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}
318 RPL_ENDOFWHOIS End of WHOIS response {"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}
319 RPL_WHOISCHANNELS In response to WHOIS {"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}
322 RPL_LIST In response to LIST {"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}
323 RPL_LISTEND End of LIST response {"command":"323","to":"alice","body":["End of /LIST"]}
324 RPL_CHANNELMODEIS In response to channel MODE query {"command":"324","to":"alice","params":["#general","+n"]}
329 RPL_CREATIONTIME After channel MODE query {"command":"329","to":"alice","params":["#general","1709251200"]}
331 RPL_NOTOPIC Channel has no topic (on JOIN) {"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}
332 RPL_TOPIC On JOIN or TOPIC query {"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}
352 RPL_WHOREPLY In response to WHO {"command":"352","to":"alice","params":["#general","bob","neoirc","neoirc","bob","H"],"body":["0 bob"]}
353 RPL_NAMREPLY On JOIN or NAMES query {"command":"353","to":"alice","params":["=","#general"],"body":["@op1 alice bob +voiced1"]}
366 RPL_ENDOFNAMES End of NAMES response {"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}
372 RPL_MOTD MOTD line {"command":"372","to":"alice","body":["Welcome to the server"]}
375 RPL_MOTDSTART Start of MOTD {"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}
376 RPL_ENDOFMOTD End of MOTD {"command":"376","to":"alice","body":["End of /MOTD command"]}
401 ERR_NOSUCHNICK DM to nonexistent nick {"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}
403 ERR_NOSUCHCHANNEL Action on nonexistent channel {"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}
421 ERR_UNKNOWNCOMMAND Unrecognized command {"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}
432 ERR_ERRONEUSNICKNAME Invalid nick format {"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}
433 ERR_NICKNAMEINUSE NICK to taken nick {"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}
442 ERR_NOTONCHANNEL Action on unjoined channel {"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}
461 ERR_NEEDMOREPARAMS Missing required fields {"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}
482 ERR_CHANOPRIVSNEEDED Non-op tries op action {"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}

Note: Numeric replies are now implemented. All IRC command responses (success and error) are delivered as numeric replies through the message queue. HTTP error codes are reserved for transport-level issues (auth failures, malformed requests, server errors). The params field in the message envelope carries IRC-style parameters (e.g., channel name, target nick).

Channel Modes

Inspired by IRC, simplified:

Mode Name Meaning
+i Invite-only Only invited users can join
+m Moderated Only voiced (+v) users and operators (+o) can send
+s Secret Channel hidden from LIST response
+t Topic lock Only operators can change the topic
+n No external Only channel members can send messages to the channel

User channel modes (set per-user per-channel):

Mode Meaning Display prefix
+o Operator @ in NAMES reply
+v Voice + in NAMES reply

Status: Channel modes are defined but not yet enforced. The modes column exists in the channels table but the server does not check modes on actions.


API Reference

All endpoints accept and return application/json. Authenticated endpoints require Authorization: Bearer <token> header. The token is obtained from POST /api/v1/session.

All API responses include appropriate HTTP status codes. Error responses have the format:

{"error": "human-readable error message"}

POST /api/v1/session — Create Session

Create a new user session. This is the entry point for all clients.

Request:

{"nick": "alice"}
Field Type Required Constraints
nick string Yes 132 characters, must be unique on the server

Response: 201 Created

{
  "id": 1,
  "nick": "alice",
  "token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
}
Field Type Description
id integer Server-assigned user ID
nick string Confirmed nick (always matches request on success)
token string 64-character hex auth token. Store this — it's the only credential.

Errors:

Status Error When
400 nick must be 1-32 characters Empty or too-long nick
409 nick already taken Another active session holds this nick

curl example:

TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
  -H 'Content-Type: application/json' \
  -d '{"nick":"alice"}' | jq -r .token)
echo $TOKEN

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

{
  "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:

curl -s http://localhost:8080/api/v1/state \
  -H "Authorization: Bearer $TOKEN" | jq .

Reconnect with channel state initialization:

curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
  -H "Authorization: Bearer $TOKEN" | 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

{
  "messages": [
    {
      "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
      "command": "JOIN",
      "from": "bob",
      "to": "#general",
      "body": [],
      "ts": "2026-02-10T20:00:00.000000000Z",
      "meta": {}
    },
    {
      "id": "b7c8210f-849c-4b90-9ee8-d99c8889358e",
      "command": "PRIVMSG",
      "from": "alice",
      "to": "#general",
      "body": ["hello world"],
      "ts": "2026-02-10T20:00:01.000000000Z",
      "meta": {}
    }
  ],
  "last_id": 42
}
Field Type Description
messages array Array of IRC message envelopes (see Protocol Specification). 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):

curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
  -H "Authorization: Bearer $TOKEN" | jq .

curl example (long-poll, 15s):

curl -s "http://localhost:8080/api/v1/messages?after=42&timeout=15" \
  -H "Authorization: Bearer $TOKEN" | 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:

{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]}

See 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 200 OK
PART to body 200 OK
NICK body 200 OK
TOPIC to, body 200 OK
MODE to 200 OK
NAMES to 200 OK
LIST 200 OK
WHOIS to or body 200 OK
WHO to 200 OK
LUSERS 200 OK
QUIT body 200 OK
PING 200 OK

All IRC commands return HTTP 200 OK. IRC-level success and error responses are delivered as numeric replies through the message queue (see Numeric Replies below). HTTP error codes (4xx/5xx) are reserved for transport-level problems: malformed JSON (400), missing/invalid auth tokens (401), and server errors (500).

HTTP errors (transport-level only):

Status Error When
400 invalid request Malformed JSON or empty command
401 unauthorized Missing or invalid auth token
500 internal error Server-side failure

IRC numeric error replies (delivered via message queue):

Numeric Name When
401 ERR_NOSUCHNICK DM target nick doesn't exist
403 ERR_NOSUCHCHANNEL Target channel doesn't exist or invalid name
421 ERR_UNKNOWNCOMMAND Unrecognized command
432 ERR_ERRONEUSNICKNAME Invalid nickname format
433 ERR_NICKNAMEINUSE NICK target is taken
442 ERR_NOTONCHANNEL Not a member of the target channel
461 ERR_NEEDMOREPARAMS Missing required fields (to, body)

IRC numeric success replies (delivered via message queue):

Numeric Name When
001 RPL_WELCOME Sent on session creation/login
002 RPL_YOURHOST Sent on session creation/login
003 RPL_CREATED Sent on session creation/login
004 RPL_MYINFO Sent on session creation/login
005 RPL_ISUPPORT Sent on session creation/login
221 RPL_UMODEIS In response to user MODE query
251 RPL_LUSERCLIENT On connect or LUSERS command
252 RPL_LUSEROP On connect or LUSERS command
254 RPL_LUSERCHANNELS On connect or LUSERS command
255 RPL_LUSERME On connect or LUSERS command
311 RPL_WHOISUSER WHOIS user info
312 RPL_WHOISSERVER WHOIS server info
315 RPL_ENDOFWHO End of WHO list
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 response
329 RPL_CREATIONTIME Channel creation timestamp
331 RPL_NOTOPIC Channel has no topic (on JOIN)
332 RPL_TOPIC Channel topic (on JOIN, TOPIC set)
352 RPL_WHOREPLY User in WHO response
353 RPL_NAMREPLY Channel member list (on JOIN, NAMES)
366 RPL_ENDOFNAMES End of NAMES list
375 RPL_MOTDSTART Start of MOTD
372 RPL_MOTD MOTD line
376 RPL_ENDOFMOTD End of MOTD

GET /api/v1/history — Message History

Fetch historical messages for a channel. Returns messages in chronological order (oldest first).

Query Parameters:

Param Type Default Description
target string (required) Channel name (e.g., #general)
before integer 0 Return only messages with DB ID < this value (for pagination). 0 means latest.
limit integer 50 Maximum messages to return.

Response: 200 OK

[
  {
    "id": "uuid-1",
    "command": "PRIVMSG",
    "from": "alice",
    "to": "#general",
    "body": ["first message"],
    "ts": "2026-02-10T19:00:00.000000000Z",
    "meta": {}
  },
  {
    "id": "uuid-2",
    "command": "PRIVMSG",
    "from": "bob",
    "to": "#general",
    "body": ["second message"],
    "ts": "2026-02-10T19:01:00.000000000Z",
    "meta": {}
  }
]

Note: History currently returns only PRIVMSG messages (not JOIN/PART/etc. events). Event messages are delivered via the live queue only.

curl example:

# Latest 50 messages in #general
curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \
  -H "Authorization: Bearer $TOKEN" | jq .

# Older messages (pagination)
curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit=50" \
  -H "Authorization: Bearer $TOKEN" | jq .

GET /api/v1/channels — List Channels

List all channels on the server.

Response: 200 OK

[
  {"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

[
  {"id": 1, "nick": "alice", "lastSeen": "2026-02-10T20:00:00Z"},
  {"id": 2, "nick": "bob", "lastSeen": "2026-02-10T19:55:00Z"}
]

curl example:

curl -s http://localhost:8080/api/v1/channels/general/members \
  -H "Authorization: Bearer $TOKEN" | jq .

POST /api/v1/logout — Logout

Destroy the current client's auth token. 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.

Request: No body. Requires auth.

Response: 200 OK

{"status": "ok"}

Errors:

Status Error When
401 unauthorized Missing or invalid auth token

curl example:

curl -s -X POST http://localhost:8080/api/v1/logout \
  -H "Authorization: Bearer $TOKEN" | 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

{
  "id": 1,
  "nick": "alice",
  "channels": [
    {"id": 1, "name": "#general", "topic": "Welcome!"}
  ]
}

curl example:

curl -s http://localhost:8080/api/v1/users/me \
  -H "Authorization: Bearer $TOKEN" | jq .

GET /api/v1/server — Server Info

Return server metadata. No authentication required.

Response: 200 OK

{
  "name": "My NeoIRC Server",
  "version": "0.1.0",
  "motd": "Welcome! Be nice.",
  "users": 42
}
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

GET /.well-known/healthcheck.json — Health Check

Standard health check endpoint. No authentication required.

Response: 200 OK

{"status": "ok"}

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   │
  │  201 {"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         │
  │  201 {"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.

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):
    • 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:

{
  "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:
    {"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

{"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

{
  "command": "PRIVMSG",
  "from": "alice",
  "to": "#general",
  "body": ["this message is signed"],
  "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
  "ts": "2026-02-10T20:00:00.000000000Z",
  "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

  • Session auth: Opaque bearer tokens (64 hex chars = 256 bits of entropy). Tokens are stored in the database and validated on every request.
  • No passwords: Session creation requires only a nick. The token is the sole credential.
  • Token security: Tokens should be treated like session cookies. Transmit only over HTTPS in production. If a token 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).
  • DMs are stored in the messages table, subject to the same rotation policy as channel messages.

Transport Security

  • HTTPS is strongly recommended for production deployments. The server itself serves plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS termination.
  • CORS: The server allows all origins by default (Access-Control-Allow-Origin: *). Restrict this 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.

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, 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.

Current tables:

users

Column Type Description
id INTEGER Primary key (auto-increment)
nick TEXT Unique nick
token TEXT Unique auth token (64 hex chars)
created_at DATETIME Session creation time
last_seen DATETIME Last API request time

channels

Column Type Description
id INTEGER Primary key (auto-increment)
name TEXT Unique channel name (e.g., #general)
topic TEXT Channel topic (default empty)
created_at DATETIME Channel creation time
updated_at DATETIME Last modification time

channel_members

Column Type Description
id INTEGER Primary key (auto-increment)
channel_id INTEGER FK → channels.id
user_id INTEGER FK → users.id
joined_at DATETIME When the user joined

Unique constraint on (channel_id, user_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)
body TEXT JSON-encoded body (array or object)
meta TEXT JSON-encoded metadata
created_at DATETIME Server timestamp

Indexes on (msg_to, id) and (created_at).

client_queues

Column Type Description
id INTEGER Primary key (auto-increment). Used as the poll cursor.
user_id INTEGER FK → users.id
message_id INTEGER FK → messages.id
created_at DATETIME When the entry was queued

Unique constraint on (user_id, message_id). Index on (user_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: Rotated per MAX_HISTORY — oldest messages beyond the limit are pruned periodically per target (channel or DM).
  • Queue entries: Pruned automatically when older than QUEUE_MAX_AGE (default 48h).
  • Channels: Deleted when the last member leaves (ephemeral).
  • Users/sessions: Deleted on QUIT or POST /api/v1/logout. Idle sessions are automatically expired after SESSION_IDLE_TIMEOUT (default 24h) — the server runs a background cleanup loop that parts idle users from all channels, broadcasts QUIT, and releases their nicks.

Configuration

All configuration is via environment variables, read by Viper. A .env file in the working directory is also loaded automatically via godotenv.

Variable Type Default Description
PORT int 8080 HTTP listen port
DBURL string file:///var/lib/neoirc/state.db?_journal_mode=WAL SQLite connection string. For file-based: file:///path/to/db.db?_journal_mode=WAL. For in-memory (testing): file::memory:?cache=shared.
DEBUG bool false Enable debug logging (verbose request/response logging)
MAX_HISTORY int 10000 Maximum messages retained per target (channel or DM) before rotation
SESSION_IDLE_TIMEOUT string 24h Session idle timeout as a Go duration string (e.g. 24h, 30m). Sessions with no activity for this long are expired and the nick is released.
QUEUE_MAX_AGE int 172800 Maximum age of client queue entries in seconds (48h). Entries older than this are pruned.
MAX_MESSAGE_SIZE int 4096 Maximum message body size in bytes (planned enforcement)
LONG_POLL_TIMEOUT int 15 Default long-poll timeout in seconds (client can override via query param, server caps at 30)
MOTD string "" Message of the day, shown to clients via GET /api/v1/server
SERVER_NAME string "" Server display name. Defaults to hostname if empty.
FEDERATION_KEY string "" Shared key for server federation linking (planned)
SENTRY_DSN string "" Sentry error tracking DSN (optional)
METRICS_USERNAME string "" Basic auth username for /metrics endpoint. If empty, metrics endpoint is disabled.
METRICS_PASSWORD string "" Basic auth password for /metrics endpoint
MAINTENANCE_MODE bool false Maintenance mode flag (reserved)

Example .env file

PORT=8080
SERVER_NAME=My NeoIRC Server
MOTD=Welcome! Be excellent to each other.
DEBUG=false
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
SESSION_IDLE_TIMEOUT=24h

Deployment

The Docker image contains a single static binary (neoircd) and nothing else.

# Build
docker build -t neoirc .

# 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 four-stage build:

  1. web-builder: Installs Node dependencies and compiles the SPA (JSX → bundled JS via esbuild) into web/dist/
  2. lint: Runs formatting checks and golangci-lint against the Go source (uses empty placeholder files for web/dist/ so it runs independently of web-builder for fast feedback)
  3. builder: Runs tests and 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)
  4. final: Minimal Alpine image with only the neoircd binary

Binary

# Build from source
make build
# Binary at ./bin/neoircd

# Run
./bin/neoircd
# Listens on :8080, 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:

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_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

  • WAL mode is enabled by default (?_journal_mode=WAL in the connection string). This allows concurrent reads during writes.
  • Single writer: SQLite allows only one writer at a time. For high-traffic servers, Postgres support is planned.
  • Backup: The database is a single file. Back it up with sqlite3 /var/lib/neoirc/state.db ".backup backup.db" or just copy the file (safe with WAL mode).
  • 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 token
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

# 1. Create a session
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
  -H 'Content-Type: application/json' \
  -d '{"nick":"testuser"}' | jq -r .token)

# 2. Join a channel
curl -s -X POST http://localhost:8080/api/v1/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"command":"JOIN","to":"#general"}'

# 3. Send a message
curl -s -X POST http://localhost:8080/api/v1/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}'

# 4. Poll for messages (one-shot)
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
  -H "Authorization: Bearer $TOKEN" | jq .

# 5. Long-poll (blocks up to 15s waiting for messages)
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=15" \
  -H "Authorization: Bearer $TOKEN" | jq .

# 6. Send a DM
curl -s -X POST http://localhost:8080/api/v1/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}'

# 7. Change nick
curl -s -X POST http://localhost:8080/api/v1/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"command":"NICK","body":["newnick"]}'

# 8. Set channel topic
curl -s -X POST http://localhost:8080/api/v1/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"command":"TOPIC","to":"#general","body":["New topic!"]}'

# 9. Leave a channel
curl -s -X POST http://localhost:8080/api/v1/messages \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"command":"PART","to":"#general","body":["goodbye"]}'

# 10. Disconnect
curl -s -X POST http://localhost:8080/api/v1/messages \
  -H "Authorization: Bearer $TOKEN" \
  -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 example
import requests, json

BASE = "http://localhost:8080/api/v1"
token = None
last_id = 0

# Create session
resp = requests.post(f"{BASE}/session", json={"nick": "pybot"})
token = resp.json()["token"]
headers = {"Authorization": f"Bearer {token}"}

# Join channel
requests.post(f"{BASE}/messages", headers=headers,
              json={"command": "JOIN", "to": "#general"})

# Poll loop
while True:
    try:
        resp = requests.get(f"{BASE}/messages",
                          headers=headers,
                          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/browser example
async function pollLoop(token) {
  let lastId = 0;
  while (true) {
    try {
      const resp = await fetch(
        `/api/v1/messages?after=${lastId}&timeout=15`,
        {headers: {'Authorization': `Bearer ${token}`}}
      );
      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: Token expired or invalid. Re-create session.
  • HTTP 404: Channel or user not found.
  • HTTP 409: Nick already taken (on session creation or NICK change).
  • 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 session is gone. Create a new session. If it fails with a network error, retry with backoff.

Rate Limiting & Abuse Prevention

Session creation (POST /api/v1/session) will require a hashcash-style proof-of-work token. This is the primary defense against resource exhaustion — no CAPTCHAs, no account registration, no IP-based rate limits that punish shared networks.

How It Works

  1. Client requests a challenge: GET /api/v1/challenge
     {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"}
    
  2. Server returns a nonce and a required difficulty (number of leading zero bits in the SHA-256 hash)
  3. Client finds a counter value such that SHA-256(nonce || ":" || counter) has the required number of leading zero bits:
    SHA-256("a1b2c3:0")     = 0xf3a1...  (0 leading zeros — no good)
    SHA-256("a1b2c3:1")     = 0x8c72...  (0 leading zeros — no good)
    ...
    SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!)
    
  4. Client submits the proof with the session request:
    POST /api/v1/session
    {"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}}
    
  5. Server verifies:
    • Nonce was issued by this server and hasn't expired
    • Nonce hasn't been used before (prevent replay)
    • SHA-256(nonce || ":" || counter) has the required leading zeros
    • If valid, create the session normally

Adaptive Difficulty

The required difficulty scales with server load. Under normal conditions, the cost is negligible (a few milliseconds of CPU). As concurrent sessions or session creation rate increases, difficulty rises — making bulk session creation exponentially more expensive for attackers while remaining cheap for legitimate single-user connections.

Server Load Difficulty (bits) Approx. Client CPU
Normal (< 100/min) 16 ~1ms
Elevated 20 ~15ms
High 24 ~250ms
Under attack 28+ ~4s+

Each additional bit of difficulty doubles the expected work. An attacker creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate user creating one session needs ~4 seconds once and never again for the duration of their session.

Why Hashcash and Not Rate Limits?

  • No state to track: No IP tables, no token buckets, no sliding windows. The server only needs to verify a hash.
  • 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.
  • Trivial for legitimate clients: A single-user client pays ~1ms of CPU once. A botnet trying to create thousands of sessions pays exponentially more.
  • Language-agnostic: SHA-256 is available in every programming language. The proof computation is trivially implementable in any client.

Challenge Endpoint (Planned)

GET /api/v1/challenge

Response: 200 OK

{
  "nonce": "a1b2c3d4e5f6...",
  "difficulty": 20,
  "algorithm": "sha256",
  "expires": "2026-02-10T20:01:00Z"
}
Field Type Description
nonce string Server-generated random hex string (32+ chars)
difficulty integer Required number of leading zero bits in the hash
algorithm string Hash algorithm (always sha256 for now)
expires string ISO 8601 expiry time for this challenge

Status: Not yet implemented. Tracked for post-MVP.


Roadmap

Implemented (MVP)

  • Session creation with nick claim
  • All core commands: PRIVMSG, JOIN, PART, NICK, TOPIC, QUIT, PING
  • IRC message envelope format (command, from, to, body, ts, meta)
  • Per-client delivery queues with fan-out
  • Long-polling with in-memory broker
  • Channel messages and DMs
  • Ephemeral channels (deleted when empty)
  • NICK change with broadcast
  • QUIT with broadcast and cleanup
  • Embedded web SPA client
  • CLI client (neoirc-cli)
  • SQLite storage with WAL mode
  • Docker deployment
  • Prometheus metrics endpoint
  • Health check endpoint
  • Session expiry — auto-expire idle sessions, release nicks
  • Logout endpoint (POST /api/v1/logout)
  • Current user endpoint (GET /api/v1/users/me)
  • User count in server info (GET /api/v1/server)

Post-MVP (Planned)

  • Hashcash proof-of-work for session creation (abuse prevention)
  • Queue pruning — delete old queue entries per QUEUE_MAX_AGE
  • Message rotation — enforce MAX_HISTORY per target
  • Channel modes — enforce +i, +m, +s, +t, +n
  • User channel modes+o (operator), +v (voice)
  • MODE command — query channel and user modes (set not yet implemented)
  • NAMES command — query channel member list
  • LIST command — list all channels with member counts
  • WHOIS command — query user information and channel membership
  • WHO command — query channel user list
  • LUSERS command — query server statistics
  • Connection registration numerics — 001-005 sent on session creation
  • LUSERS numerics — 251/252/254/255 sent on connect and via /LUSERS
  • KICK command — remove users from channels
  • Numeric replies — send IRC numeric codes via the message queue (001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE, 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-461 errors)
  • Max message size enforcement — reject oversized messages
  • NOTICE command — distinct from PRIVMSG (no auto-reply flag)
  • Multi-client sessions — add client to existing session (share nick across devices)

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
  • User info command — WHOIS for querying user info and channels
  • Connection flood protection — per-IP connection limits as a complement to hashcash
  • Invite systemINVITE command for +i channels
  • Ban system — channel-level bans by nick pattern

Project Structure

Following gohttpserver CONVENTIONS.md:

neoirc/
├── cmd/
│   ├── neoircd/            # Server binary entry point
│   │   └── main.go
│   └── neoirc-cli/         # TUI client
│       ├── main.go         # Command handling, poll loop
│       ├── ui.go           # tview-based terminal UI
│       └── api/
│           ├── client.go   # HTTP API client library
│           └── types.go    # Request/response types
├── internal/
│   ├── broker/             # In-memory pub/sub for long-poll notifications
│   │   └── broker.go
│   ├── config/             # Viper-based configuration
│   │   └── config.go
│   ├── db/                 # Database access and migrations
│   │   ├── db.go           # Connection, migration runner
│   │   ├── queries.go      # All SQL queries and data types
│   │   └── schema/
│   │       └── 001_initial.sql
│   ├── globals/            # Application-wide metadata
│   │   └── globals.go
│   ├── handlers/           # HTTP request handlers
│   │   ├── handlers.go     # Deps, JSON response helper
│   │   ├── api.go          # All API endpoint handlers
│   │   └── healthcheck.go  # Health check handler
│   ├── healthcheck/        # Health check logic
│   │   └── healthcheck.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)
│   ├── package-lock.json
│   ├── src/                # SPA source files (JSX + HTML + CSS)
│   │   ├── app.jsx
│   │   ├── index.html
│   │   └── style.css
│   └── dist/               # Generated at Docker build time (not committed)
├── schema/                 # JSON Schema definitions (planned)
├── go.mod
├── go.sum
├── Makefile
├── Dockerfile
├── CONVENTIONS.md
└── README.md

Required Libraries

Purpose Library
DI go.uber.org/fx
Router github.com/go-chi/chi
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. No accounts — identity is a signing key, nick is a display name. No registration, no passwords, no email verification. Session creation is instant. The cost of entry is a hashcash proof, not bureaucracy.

  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 raw TCP, no protocol negotiation. HTTP is universal, proxy-friendly, CDN-friendly, and works on every device and network. Long-polling provides real-time delivery without any of the complexity of persistent connections.

  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 with WAL mode
  • 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 for what's next.


License

MIT

Author

@sneak