- Document all C2S commands with required/optional fields table - Remove separate join/part/nick/topic endpoint docs - Update /channels/all to /channels - Update /register to /session
22 KiB
chat
A modern IRC-inspired chat server written in Go. Decouples session state from transport connections, enabling mobile-friendly persistent sessions over 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.
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 chat 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
Design Decisions
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 connects → server assigns a session UUID (user identity for this server), a client UUID (this specific device), and an opaque auth token (random bytes, not JWT).
- The auth token implicitly identifies the client. Clients present it via
Authorization: Bearer <token>. - Nicks are changeable; the session UUID is the stable identity.
- Server-assigned UUIDs — clients do not choose their own IDs.
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/messagesdelivers 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 UUID)
├── Client A (client UUID, token, queue)
├── Client B (client UUID, token, queue)
└── Client C (client UUID, token, queue)
Message Immutability
Messages are immutable — no editing, no deletion by clients. This is a deliberate design choice that enables cryptographic signing: if a message could be modified after signing, signatures would be meaningless.
Message Delivery
- Long-poll timeout: 15 seconds
- Queue depth: server-configurable, default at least 48 hours worth of messages
- No delivery/read receipts except in DMs
- Bodies are structured objects or arrays (never raw strings) — enables deterministic canonicalization via RFC 8785 JCS for signing
Crypto & Signing
- Servers relay signatures verbatim — signatures are key/value metadata on
message objects (
meta.sig,meta.alg). Servers do not verify them. - Clients handle key authentication via TOFU (trust on first use).
- No key revocation mechanism — keep your keys safe.
- PUBKEY message type for distributing signing keys to channel members.
- E2E encryption for DMs is planned for 1.0.
Channels
- Any user can create channels — joining a nonexistent channel creates it, like IRC.
- Ephemeral — channels disappear when the last member leaves.
- No channel size limits.
- No channel-level encryption.
Federation
- Manual server linking only — no autodiscovery, no mesh. Operators explicitly configure server links.
- Servers relay messages (including signatures) verbatim.
Web Client
The SPA web client is a convenience UI. The primary interface is IRC-style client apps talking directly to the HTTP API.
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 polling: Clients long-poll
GET /api/v1/messages— server holds the connection for up to 15 seconds until messages arrive or timeout. One endpoint for everything. - Client sending:
POST /api/v1/messageswith atofield. That's it. - 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 is channel management and history.
Session Model
┌─────────────────────────────────┐
│ User Session (UUID) │
│ nick: "alice" │
│ signing key: ed25519:... │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Client A │ │ Client B │ ... │
│ │ UUID │ │ UUID │ │
│ │ token │ │ token │ │
│ │ queue │ │ queue │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────┘
- User session: server-assigned UUID. Represents a user on this server. Has a nick (changeable, unique per server at any point in time).
- Client: each device/connection gets its own UUID and opaque auth token. The token is the credential — present it to authenticate.
- Queue: each client has an independent S2C message queue. The server fans out messages to all active client queues for the session.
Sessions persist across disconnects. Messages queue until retrieved. Client queues expire independently after a configurable idle timeout.
Message Protocol
All messages use IRC command names and numeric reply codes from RFC 1459/2812.
The command field identifies the message type.
Message Envelope
Every message is a JSON object with these fields:
| Field | Type | Required | Description |
|---|---|---|---|
command |
string | ✓ | IRC command name or 3-digit numeric code |
from |
string | Sender nick or server name | |
to |
string | Destination: #channel or nick |
|
params |
array<string> | Additional IRC-style parameters | |
body |
array | object | Structured body (never a raw string — see below) | |
id |
string (uuid) | Server-assigned message UUID | |
ts |
string | Server-assigned ISO 8601 timestamp | |
meta |
object | Extensible metadata (signatures, hashes, etc.) |
Important: Message bodies are structured objects or arrays, never raw strings. This is a deliberate departure from IRC wire format that enables:
- Multiline messages — body is a list of lines, no escape sequences
- Deterministic canonicalization — for hashing and signing (see below)
- Structured data — commands like PUBKEY carry key material as objects
For text messages, body is an array of strings (one per line):
{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]}
{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["line one", "line two"]}
For numeric replies with text trailing parameters:
{"command": "001", "to": "nick", "body": ["Welcome to the network, nick"]}
{"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["@op1 alice bob"]}
For structured data (keys, etc.), body is an object:
{"command": "PUBKEY", "from": "nick", "body": {"alg": "ed25519", "key": "base64..."}}
IRC Command Mapping
Commands (C2S and S2C):
| Command | RFC | Description |
|---|---|---|
PRIVMSG |
1459 §4.4.1 | Message to channel or user |
NOTICE |
1459 §4.4.2 | Notice (must not trigger auto-reply) |
JOIN |
1459 §4.2.1 | Join a channel |
PART |
1459 §4.2.2 | Leave a channel |
QUIT |
1459 §4.1.6 | Disconnect from server |
NICK |
1459 §4.1.2 | Change nickname |
MODE |
1459 §4.2.3 | Set/query channel or user modes |
TOPIC |
1459 §4.2.4 | Set/query channel topic |
KICK |
1459 §4.2.8 | Kick user from channel |
PING |
1459 §4.6.2 | Keepalive |
PONG |
1459 §4.6.3 | Keepalive response |
PUBKEY |
(extension) | Announce/relay signing public key |
All C2S commands may be relayed S2C to other users (e.g. JOIN, PART, PRIVMSG).
Numeric Reply Codes (S2C):
| Code | Name | Description |
|---|---|---|
| 001 | RPL_WELCOME | Welcome after session creation |
| 002 | RPL_YOURHOST | Server host information |
| 003 | RPL_CREATED | Server creation date |
| 004 | RPL_MYINFO | Server info and modes |
| 322 | RPL_LIST | Channel list entry |
| 323 | RPL_LISTEND | End of channel list |
| 332 | RPL_TOPIC | Channel topic |
| 353 | RPL_NAMREPLY | Channel member list |
| 366 | RPL_ENDOFNAMES | End of NAMES list |
| 372 | RPL_MOTD | MOTD line |
| 375 | RPL_MOTDSTART | Start of MOTD |
| 376 | RPL_ENDOFMOTD | End of MOTD |
| 401 | ERR_NOSUCHNICK | No such nick/channel |
| 403 | ERR_NOSUCHCHANNEL | No such channel |
| 433 | ERR_NICKNAMEINUSE | Nickname already in use |
| 442 | ERR_NOTONCHANNEL | Not on that channel |
| 482 | ERR_CHANOPRIVSNEEDED | Not channel operator |
Server-to-Server (Federation):
Federated servers use the same IRC commands. After link establishment, servers exchange a burst of JOIN, NICK, TOPIC, and MODE commands to sync state. PING/PONG serve as inter-server keepalives.
Message Examples
{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]}
{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["line one", "line two"], "meta": {"sig": "base64...", "alg": "ed25519"}}
{"command": "PRIVMSG", "from": "alice", "to": "bob", "body": ["hey, DM"]}
{"command": "JOIN", "from": "bob", "to": "#general"}
{"command": "PART", "from": "bob", "to": "#general", "body": ["later"]}
{"command": "NICK", "from": "oldnick", "body": ["newnick"]}
{"command": "001", "to": "alice", "body": ["Welcome to the network, alice"]}
{"command": "353", "to": "alice", "params": ["=", "#general"], "body": ["@op1 alice bob +voiced1"]}
{"command": "433", "to": "*", "params": ["alice"], "body": ["Nickname is already in use"]}
{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64..."}}
JSON Schemas
Full JSON Schema (draft 2020-12) definitions for all message types are in
schema/. See schema/README.md for the
complete index.
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:
- Remove
meta.sigfrom the message (the signature itself is not signed) - Serialize using RFC 8785 JSON Canonicalization Scheme (JCS):
- Object keys sorted lexicographically
- No whitespace
- Numbers in shortest form
- UTF-8 encoding
- The resulting byte string is the signing input
This is why body must be an object or array — raw strings would be ambiguous
under canonicalization.
Signing Flow
- Client generates an Ed25519 keypair
- Client announces public key:
{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}} - Server relays PUBKEY to channel members / stores for the session
- When sending a message, client:
a. Constructs the message without
meta.sigb. Canonicalizes per JCS c. Signs with private key d. Addsmeta.sig(base64) andmeta.alg - Recipients verify by repeating steps a–c and checking the signature against the sender's announced public key
PUBKEY Message
{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}}
Servers relay PUBKEY messages to all channel members. Clients cache public keys
and use them to verify meta.sig on incoming messages. Key distribution is
trust-on-first-use (TOFU). There is no key revocation mechanism.
API Endpoints
All endpoints accept and return application/json. Authenticated endpoints
require Authorization: Bearer <token> header.
The API is the primary interface — designed for IRC-style clients. The entire client loop is:
POST /api/v1/session— create a session, get a tokenGET /api/v1/state— see who you are and what channels you're inGET /api/v1/messages?timeout=15— long-poll for all messages (channel, DM, system)POST /api/v1/messages— send to"#channel"or"nick"
That's the core. Everything else (join, part, history, members) is ancillary.
Quick example (curl)
# Create a session (get session UUID, client UUID, and auth token)
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-d '{"nick":"alice"}' | jq -r .token)
# Join a channel (creates it if it doesn't exist)
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-d '{"command":"JOIN","to":"#general"}'
# Send a message
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-d '{"command":"PRIVMSG","to":"#general","body":["hello world"]}'
# Poll for messages (long-poll, 15s timeout)
curl -s "http://localhost:8080/api/v1/messages?timeout=15" \
-H "Authorization: Bearer $TOKEN"
Session
POST /api/v1/session — Create session { "nick": "..." }
→ { id, nick, token }
Token is opaque (random), not JWT.
Token implicitly identifies the client.
State
GET /api/v1/state — User state: nick, session_id, client_id,
and list of joined channels
Messages (unified stream)
GET /api/v1/messages — Single message stream (long-poll, 15s timeout)
All message types: channel, DM, notices, events
Delivers from the calling client's queue
(identified by auth token)
Query params: ?after=<message-id>&timeout=15
POST /api/v1/messages — Send any C2S command (dispatched by "command" field)
All client-to-server commands use POST /api/v1/messages with a command
field. There are no separate endpoints for join, part, nick, topic, etc.
| Command | Required Fields | Optional Fields | Description |
|---|---|---|---|
PRIVMSG |
to, body |
meta |
Message to channel (#name) or user (nick) |
NOTICE |
to, body |
meta |
Notice (must not trigger auto-reply) |
JOIN |
to |
Join a channel (creates if nonexistent) | |
PART |
to |
body |
Leave a channel |
NICK |
body |
Change nickname — body: ["newnick"] |
|
TOPIC |
to, body |
Set channel topic | |
MODE |
to, params |
Set channel/user modes | |
KICK |
to, params |
body |
Kick user — params: ["nick"], body: ["reason"] |
PING |
Keepalive (server responds with PONG) | ||
PUBKEY |
body |
Announce signing key — body: {"alg":..., "key":...} |
Examples:
{"command": "PRIVMSG", "to": "#channel", "body": ["hello world"]}
{"command": "JOIN", "to": "#channel"}
{"command": "PART", "to": "#channel"}
{"command": "NICK", "body": ["newnick"]}
{"command": "TOPIC", "to": "#channel", "body": ["new topic text"]}
{"command": "PING"}
Messages are immutable — no edit or delete endpoints.
History
GET /api/v1/history — Fetch history for a target (channel or DM)
Query params: ?target=#channel&before=<id>&limit=50
For DMs: ?target=nick&before=<id>&limit=50
Channels
GET /api/v1/channels — List all server channels
GET /api/v1/channels/{name}/members — Channel member list
Join and part are handled via POST /api/v1/messages with JOIN and PART
commands (see Messages above).
Server Info
GET /api/v1/server — Server info (name, MOTD)
GET /.well-known/healthcheck.json — Health check
Federation (Server-to-Server)
Servers can link to form a network, similar to IRC server linking. Links are manually configured — there is no autodiscovery.
POST /api/v1/federation/link — Establish server link (mutual auth via shared key)
POST /api/v1/federation/relay — Relay messages between linked servers
GET /api/v1/federation/status — Link status
Federation uses the same HTTP+JSON transport. S2S messages use the RELAY, LINK, UNLINK, SYNC, PING, and PONG commands. Messages (including signatures) are relayed verbatim between servers so users on different servers can share channels.
Channel Modes
Inspired by IRC but simplified:
| Mode | Meaning |
|---|---|
+i |
Invite-only |
+m |
Moderated (only voiced users can send) |
+s |
Secret (hidden from channel list) |
+t |
Topic locked (only ops can change) |
+n |
No external messages |
User channel modes: +o (operator), +v (voice)
Configuration
Via environment variables (Viper), following gohttpserver conventions:
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Listen port |
DBURL |
"" |
SQLite/Postgres connection string |
DEBUG |
false |
Debug mode |
MAX_HISTORY |
10000 |
Max messages per channel history |
SESSION_TIMEOUT |
86400 |
Session idle timeout (seconds) |
QUEUE_MAX_AGE |
172800 |
Max client queue age in seconds (default 48h) |
MAX_MESSAGE_SIZE |
4096 |
Max message body size (bytes) |
LONG_POLL_TIMEOUT |
15 |
Long-poll timeout in seconds |
MOTD |
"" |
Message of the day |
SERVER_NAME |
hostname | Server display name |
FEDERATION_KEY |
"" |
Shared key for server linking |
Storage
SQLite by default (single-file, zero-config), with Postgres support for larger deployments. Tables:
sessions— user sessions (UUID, nick, created_at)clients— client records (UUID, session_id, token_hash, last_seen)channels— channel metadata and modeschannel_members— membership and user modesmessages— message history (rotated perMAX_HISTORY)client_queues— per-client pending delivery queuesserver_links— federation peer configuration
Project Structure
Following gohttpserver CONVENTIONS.md:
chat/
├── cmd/
│ └── chatd/
│ └── main.go
├── internal/
│ ├── config/
│ ├── database/
│ ├── globals/
│ ├── handlers/
│ ├── healthcheck/
│ ├── logger/
│ ├── middleware/
│ ├── models/
│ ├── queue/
│ └── server/
├── schema/
│ ├── message.schema.json
│ ├── c2s/
│ ├── s2c/
│ ├── s2s/
│ └── README.md
├── web/
├── go.mod
├── go.sum
├── Makefile
├── Dockerfile
├── CONVENTIONS.md
└── README.md
Required Libraries
Per gohttpserver conventions:
| 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 |
Design Principles
- API-first — the HTTP API is the product. Clients are thin. If you can't build a working IRC-style TUI client in an afternoon, the API is too complex.
- No accounts — identity is a signing key, nick is a display name. No registration, no passwords. Session creation is instant.
- IRC semantics over HTTP — command names and numeric codes from RFC 1459/2812. Familiar to anyone who's built IRC clients or bots.
- HTTP is the only transport — no WebSockets, no raw TCP, no protocol negotiation. HTTP is universal, proxy-friendly, and works everywhere.
- Server holds state — clients are stateless. Reconnect, switch devices, lose connectivity — your messages are waiting in your client queue.
- Structured messages — JSON with extensible metadata. Bodies are always objects or arrays for deterministic canonicalization (JCS) and signing.
- Immutable messages — no editing, no deletion. Fits naturally with cryptographic signatures.
- Simple deployment — single binary, SQLite default, zero mandatory external dependencies.
- No eternal logs — history rotates. Chat should be ephemeral by default. Channels disappear when empty.
- Federation optional — single server works standalone. Linking is manual and opt-in.
- Signable messages — optional Ed25519 signatures with TOFU key distribution. Servers relay signatures without verification.
Status
Implementation in progress. Core API is functional with SQLite storage and embedded web client.
License
MIT