- Add IRC command/numeric mapping tables (C2S, S2C, S2S) - Document structured message bodies (array/object, never raw strings) - Document RFC 8785 JCS canonicalization for deterministic hashing - Document Ed25519 signing/verification flow with TOFU key distribution - Document PUBKEY message type for public key announcement - Update message examples to use IRC command format - Update curl examples to use command-based messages - Note web client as convenience UI; primary interface is IRC-style clients - Add schema/ to project structure
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 connections per user session
- Provides IRC-like semantics: channels, nicks, topics, modes
- Uses structured JSON messages with IRC command names and numeric reply codes
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 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.
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) | |
meta |
object | Extensible metadata (signatures, etc.) | |
id |
string (uuid) | Server-assigned message ID | |
ts |
string | Server-assigned ISO 8601 timestamp |
Important: Message bodies MUST be objects or arrays, never raw strings. This enables:
- Multiline messages (array of lines)
- Deterministic canonicalization for hashing/signing (RFC 8785 JCS)
- Structured data where needed (e.g. PUBKEY)
IRC Command Mapping
Client-to-Server (C2S):
| Command | Description |
|---|---|
| PRIVMSG | Send message to channel or user |
| NOTICE | Send notice (no auto-reply expected) |
| JOIN | Join a channel |
| PART | Leave a channel |
| QUIT | Disconnect from server |
| NICK | Change nickname |
| MODE | Set/query channel or user modes |
| TOPIC | Set/query channel topic |
| KICK | Kick a user from a channel |
| PING | Client keepalive |
| PUBKEY | Announce public signing key |
Server-to-Client (S2C):
All C2S commands may be echoed back as S2C (relayed to other users), plus:
| Command | Description |
|---|---|
| PONG | Server keepalive response |
| PUBKEY | Relayed public key from another user |
| ERROR | Server error message |
Numeric Reply Codes (S2C):
| Code | Name | Description |
|---|---|---|
| 001 | RPL_WELCOME | Welcome after registration |
| 002 | RPL_YOURHOST | Server host information |
| 322 | RPL_LIST | Channel list entry |
| 353 | RPL_NAMREPLY | Names list for a channel |
| 366 | RPL_ENDOFNAMES | End of names list |
| 372 | RPL_MOTD | Message of the day line |
| 375 | RPL_MOTDSTART | Start of MOTD |
| 376 | RPL_ENDOFMOTD | End of MOTD |
| 401 | ERR_NOSUCHNICK | No such nick or channel |
| 403 | ERR_NOSUCHCHANNEL | No such channel |
| 433 | ERR_NICKNAMEINUSE | Nickname already in use |
Server-to-Server (S2S):
| Command | Description |
|---|---|
| RELAY | Relay message to linked server |
| LINK | Establish server link |
| UNLINK | Tear down server link |
| SYNC | Synchronize state between servers |
| PING | Server-to-server keepalive |
| PONG | Server-to-server keepalive response |
Message Examples
{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"], "meta": {"sig": "base64...", "alg": "ed25519"}}
{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["line one", "line two"]}
{"command": "001", "to": "alice", "body": ["Welcome to the network, alice"]}
{"command": "353", "to": "alice", "params": ["=", "#general"], "body": ["alice", "bob", "@charlie"]}
{"command": "JOIN", "from": "bob", "to": "#general", "body": []}
{"command": "ERROR", "body": ["Closing link: connection timeout"]}
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.
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 SHOULD relay PUBKEY messages to all channel members. Clients SHOULD
cache public keys and use them to verify meta.sig on incoming messages.
Key distribution is trust-on-first-use (TOFU) by default.
Core Concepts
Users
- Identified by a unique user ID (UUID)
- Authenticate via token (issued at registration or login)
- Have a nick (changeable, unique per server at any point in time)
- Maintain a persistent message queue on the server
Sessions
- A session represents an authenticated user's connection context
- Session state is server-held, not connection-bound
- Multiple devices can share a session (messages delivered to all)
- Sessions persist across disconnects — messages queue until retrieved
- Sessions expire after a configurable idle timeout (default 24h)
Channels
- Named with
#prefix (e.g.#general) - Have a topic, mode flags, and member list
- Messages to a channel are queued for all members
- Channel history is stored server-side (configurable depth)
- No eternal logging by default — history rotates
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/register— get a tokenGET /api/v1/state— see who you are and what channels you're inGET /api/v1/messages?after=0— 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)
# Register
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
-d '{"nick":"alice"}' | jq -r .token)
# Join a channel
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)
curl -s http://localhost:8080/api/v1/messages?after=0 \
-H "Authorization: Bearer $TOKEN"
Registration
POST /api/v1/register — Create account { "nick": "..." } → { id, nick, token }
State
GET /api/v1/state — User state: nick, id, and list of joined channels
Replaces separate /me and /channels endpoints
Messages (unified stream)
GET /api/v1/messages — Single message stream (long-poll supported)
All message types: channel, DM, notices, events
Query params: ?after=<message-id>&timeout=30
POST /api/v1/messages — Send a message (IRC command in body)
Body: { "command": "PRIVMSG", "to": "#channel", "body": ["..."] }
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/all — List all server channels
POST /api/v1/channels/join — Join a channel { "channel": "#name" }
DELETE /api/v1/channels/{name} — Part (leave) a channel
GET /api/v1/channels/{name}/members — Channel member list
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:
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 are relayed 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) |
MAX_MESSAGE_SIZE |
4096 |
Max message body size (bytes) |
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:
users— accounts and auth tokenschannels— channel metadata and modeschannel_members— membership and user modesmessages— message history (rotated perMAX_HISTORY)message_queue— per-user pending delivery queueserver_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 |
Web Client
The server embeds a single-page web client (Preact) served at /. This is a
convenience/reference implementation — not the primary interface. The
primary intended clients are IRC-style terminal applications, bots, and custom
clients talking directly to the HTTP API.
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.
- 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.
- Structured messages — JSON with extensible metadata. Bodies are always objects or arrays for deterministic canonicalization (JCS) and signing.
- Simple deployment — single binary, SQLite default, zero mandatory external dependencies.
- No eternal logs — history rotates. Chat should be ephemeral by default.
- Federation optional — single server works standalone. Linking is opt-in.
- Signable messages — optional Ed25519 signatures with TOFU key distribution.
Status
Implementation in progress. Core API is functional with SQLite storage and embedded web client.
License
MIT