clawbot 647a7ce313 Revert: exclude chat-cli from final Docker image (server-only)
CLI is built during Docker build to verify compilation, but only chatd
is included in the final image. CLI distributed separately.
2026-02-10 18:10:45 -08:00
2026-02-09 12:36:55 -08:00

chat

IRC plus message metadata, a signing system using it, and server-based backlog queues for multiple connected clients on one nick. All via HTTP.

A 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

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 /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 chat, 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

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/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 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/messages with a to field. 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:

  1. Remove meta.sig from the message (the signature itself is not signed)
  2. Serialize using RFC 8785 JSON Canonicalization Scheme (JCS):
    • Object keys sorted lexicographically
    • No whitespace
    • Numbers in shortest form
    • UTF-8 encoding
  3. 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

  1. Client generates an Ed25519 keypair
  2. Client announces public key: {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}
  3. Server relays PUBKEY to channel members / stores for the session
  4. When sending a message, client: a. Constructs the message without meta.sig b. Canonicalizes per JCS c. Signs with private key d. Adds meta.sig (base64) and meta.alg
  5. Recipients verify by repeating steps ac 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:

  1. POST /api/v1/session — create a session, get a token
  2. GET /api/v1/state — see who you are and what channels you're in
  3. GET /api/v1/messages?timeout=15 — long-poll for all messages (channel, DM, system)
  4. 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 modes
  • channel_members — membership and user modes
  • messages — message history (rotated per MAX_HISTORY)
  • client_queues — per-client pending delivery queues
  • server_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

  1. 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.
  2. No accounts — identity is a signing key, nick is a display name. No registration, no passwords. Session creation is instant.
  3. IRC semantics over HTTP — command names and numeric codes from RFC 1459/2812. Familiar to anyone who's built IRC clients or bots.
  4. HTTP is the only transport — no WebSockets, no raw TCP, no protocol negotiation. HTTP is universal, proxy-friendly, and works everywhere.
  5. Server holds state — clients are stateless. Reconnect, switch devices, lose connectivity — your messages are waiting in your client queue.
  6. Structured messages — JSON with extensible metadata. Bodies are always objects or arrays for deterministic canonicalization (JCS) and signing.
  7. Immutable messages — no editing, no deletion. Fits naturally with cryptographic signatures.
  8. Simple deployment — single binary, SQLite default, zero mandatory external dependencies.
  9. No eternal logs — history rotates. Chat should be ephemeral by default. Channels disappear when empty.
  10. Federation optional — single server works standalone. Linking is manual and opt-in.
  11. Signable messages — optional Ed25519 signatures with TOFU key distribution. Servers relay signatures without verification.

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
  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 leading zeros
  4. Client submits the proof with the session request: POST /api/v1/session with {"nick": "...", "proof": {"nonce": "...", "counter": N}}
  5. Server verifies the proof before creating the session

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 Approx. Client CPU
Normal (< 100/min) 16 bits ~1ms
Elevated 20 bits ~15ms
High 24 bits ~250ms
Under attack 28+ bits ~4s+

Why hashcash and not rate limits?

  • No state to track (no IP tables, no token buckets)
  • Works through NATs and proxies — doesn't punish shared IPs
  • Cost falls on the requester, not the server
  • Fits the "no accounts" philosophy — proof-of-work is the cost of entry
  • Trivial for legitimate clients, expensive at scale for attackers

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

Status

Implementation in progress. Core API is functional with SQLite storage and embedded web client.

License

MIT

Description
No description provided
Readme 1.2 MiB
Languages
Go 86.1%
JavaScript 9.1%
CSS 2.8%
Dockerfile 0.7%
Makefile 0.7%
Other 0.6%