chat/README.md
clawbot f5cc098b7b
All checks were successful
check / check (push) Successful in 1m24s
docs: update README for new endpoints, fix config name, remove dead field
- Document POST /api/v1/logout endpoint
- Document GET /api/v1/users/me endpoint
- Add 'users' field to GET /api/v1/server response docs
- Fix config: SESSION_TIMEOUT -> SESSION_IDLE_TIMEOUT
- Update storage section: session expiry is implemented
- Update roadmap: move session expiry to implemented
- Remove dead SessionTimeout config field from Go code
2026-03-01 06:41:10 -08:00

2268 lines
82 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# chat
**IRC semantics, structured message metadata, cryptographic signing, and
server-held session state with per-client delivery queues. All over HTTP+JSON.**
A chat server written in Go that decouples session state from transport
connections, enabling mobile-friendly persistent sessions over plain HTTP.
The **HTTP API is the primary interface**. It's designed to be simple enough
that writing a terminal IRC-style client against it is straightforward — just
`curl` and `jq` get you surprisingly far. The server also ships an embedded
web client as a convenience/reference implementation, but the API comes first.
---
## Table of Contents
- [Motivation](#motivation)
- [Why Not IRC / XMPP / Matrix?](#why-not-just-use-irc--xmpp--matrix)
- [Design Decisions](#design-decisions)
- [Architecture](#architecture)
- [Protocol Specification](#protocol-specification)
- [API Reference](#api-reference)
- [Message Flow](#message-flow)
- [Canonicalization & Signing](#canonicalization-and-signing)
- [Security Model](#security-model)
- [Federation](#federation-server-to-server)
- [Storage](#storage)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [Client Development Guide](#client-development-guide)
- [Rate Limiting & Abuse Prevention](#rate-limiting--abuse-prevention)
- [Roadmap](#roadmap)
- [Project Structure](#project-structure)
- [Design Principles](#design-principles)
- [Status](#status)
- [License](#license)
---
## Motivation
IRC is in decline because session state is tied to the TCP connection. In a
mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer
or pay for IRCCloud.
This project builds a 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
- 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 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
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](#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:
```json
{
"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:**
```json
{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]}
{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]}
{"command": "PRIVMSG", "to": "bob", "body": ["hey, DM"]}
{"command": "PRIVMSG", "to": "#general", "body": ["signed message"],
"meta": {"sig": "base64...", "alg": "ed25519"}}
```
**S2C (as delivered to recipients):**
```json
{
"id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["hello world"],
"ts": "2026-02-10T20:00:00.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`
```json
{"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:**
```json
{"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:**
```json
{"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):**
```json
{
"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`
```json
{"status": "joined", "channel": "#general"}
```
**IRC reference:** RFC 1459 §4.2.1
#### PART — Leave Channel
Leave a channel.
**C2S:**
```json
{"command": "PART", "to": "#general"}
{"command": "PART", "to": "#general", "body": ["goodbye"]}
```
**S2C (broadcast to all channel members, including the leaver):**
```json
{
"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`
```json
{"status": "parted", "channel": "#general"}
```
**IRC reference:** RFC 1459 §4.2.2
#### NICK — Change Nickname
Change the user's nickname.
**C2S:**
```json
{"command": "NICK", "body": ["newnick"]}
```
**S2C (broadcast to all users sharing a channel with the changer):**
```json
{
"id": "...",
"command": "NICK",
"from": "oldnick",
"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`
```json
{"status": "ok", "nick": "newnick"}
```
**Error (nick taken):** `409 Conflict`
```json
{"error": "nick already in use"}
```
**IRC reference:** RFC 1459 §4.1.2
#### TOPIC — Set Channel Topic
Set or change a channel's topic.
**C2S:**
```json
{"command": "TOPIC", "to": "#general", "body": ["Welcome to #general"]}
```
**S2C (broadcast to all channel members):**
```json
{
"id": "...",
"command": "TOPIC",
"from": "alice",
"to": "#general",
"body": ["Welcome to #general"],
"ts": "...",
"meta": {}
}
```
**Behavior:**
- Updates the channel's topic in the database.
- The TOPIC event is broadcast to all channel members.
- 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`
```json
{"status": "ok", "topic": "Welcome to #general"}
```
**IRC reference:** RFC 1459 §4.2.4
#### QUIT — Disconnect
Destroy the session and disconnect from the server.
**C2S:**
```json
{"command": "QUIT"}
{"command": "QUIT", "body": ["leaving"]}
```
**S2C (broadcast to all users sharing channels with the quitter):**
```json
{
"id": "...",
"command": "QUIT",
"from": "alice",
"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`
```json
{"status": "quit"}
```
**IRC reference:** RFC 1459 §4.1.6
#### PING — Keepalive
Client keepalive. Server responds synchronously with PONG.
**C2S:**
```json
{"command": "PING"}
```
**Response (synchronous, not via the queue):** `200 OK`
```json
{"command": "PONG", "from": "servername"}
```
**Note:** PING/PONG is synchronous — the PONG is the HTTP response body, not
a queued message. This is deliberate: keepalives should be low-latency and
not pollute the message queue.
**IRC reference:** RFC 1459 §4.6.2, §4.6.3
#### MODE — Set/Query Modes (Planned)
Set channel or user modes.
**C2S:**
```json
{"command": "MODE", "to": "#general", "params": ["+m"]}
{"command": "MODE", "to": "#general", "params": ["+o", "alice"]}
```
**Status:** Not yet implemented. See [Channel Modes](#channel-modes) for the
planned mode set.
**IRC reference:** RFC 1459 §4.2.3
#### KICK — Kick User (Planned)
Remove a user from a channel.
**C2S:**
```json
{"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:**
```json
{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}}
```
**S2C (relayed to channel members):**
```json
{
"id": "...",
"command": "PUBKEY",
"from": "alice",
"body": {"alg": "ed25519", "key": "base64-encoded-pubkey"},
"ts": "...",
"meta": {}
}
```
**Behavior:** The server relays PUBKEY messages verbatim. It does not verify,
store, or interpret the key material. See [Security Model](#security-model)
for the full key distribution protocol.
**Status:** Not yet implemented.
### Numeric Reply Codes (S2C Only)
Numeric replies follow IRC conventions from RFC 1459/2812. They are sent from
the server to the client (never C2S) 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 chatserver, 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":["chatserver","0.1","","imnst"]}` |
| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General chat"]}` |
| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` |
| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` |
| `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":["- chatserver 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"]}` |
| `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"]}` |
| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` |
**Note:** Numeric replies are planned for full implementation. The current MVP
returns standard HTTP error responses (4xx/5xx with JSON error bodies) instead
of numeric replies for error conditions. Numeric replies in the message queue
will be added post-MVP.
### 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:
```json
{"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:**
```json
{"nick": "alice"}
```
| Field | Type | Required | Constraints |
|--------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
**Response:** `201 Created`
```json
{
"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:**
```bash
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.
**Response:** `200 OK`
```json
{
"id": 1,
"nick": "alice",
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": ""}
]
}
```
| Field | Type | Description |
|------------|--------|-------------|
| `id` | integer | User ID |
| `nick` | string | Current nick |
| `channels` | array | Channels the user is a member of |
Each channel object:
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Channel ID |
| `name` | string | Channel name (e.g., `#general`) |
| `topic` | string | Channel topic (empty string if unset) |
**curl example:**
```bash
curl -s http://localhost:8080/api/v1/state \
-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`
```json
{
"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](#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):**
```bash
curl -s "http://localhost:8080/api/v1/messages?after=0&timeout=0" \
-H "Authorization: Bearer $TOKEN" | jq .
```
**curl example (long-poll, 15s):**
```bash
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:
```json
{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]}
```
See [Commands (C2S and S2C)](#commands-c2s-and-s2c) for the full command
reference with all required and optional fields.
**Command dispatch table:**
| Command | Required Fields | Optional | Response Status |
|-----------|---------------------|---------------|-----------------|
| `PRIVMSG` | `to`, `body` | `meta` | 201 Created |
| `NOTICE` | `to`, `body` | `meta` | 201 Created |
| `JOIN` | `to` | | 200 OK |
| `PART` | `to` | `body` | 200 OK |
| `NICK` | `body` | | 200 OK |
| `TOPIC` | `to`, `body` | | 200 OK |
| `QUIT` | | `body` | 200 OK |
| `PING` | | | 200 OK |
**Errors (all commands):**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid request` | Malformed JSON |
| 400 | `to field required` | Missing `to` for commands that need it |
| 400 | `body required` | Missing `body` for commands that need it |
| 400 | `unknown command: X` | Unrecognized command |
| 401 | `unauthorized` | Missing or invalid auth token |
| 404 | `channel not found` | Target channel doesn't exist |
| 404 | `user not found` | DM target nick doesn't exist |
| 409 | `nick already in use` | NICK target is taken |
### 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`
```json
[
{
"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:**
```bash
# 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`
```json
[
{"id": 1, "name": "#general", "topic": "Welcome!"},
{"id": 2, "name": "#dev", "topic": "Development discussion"}
]
```
### GET /api/v1/channels/{name}/members — Channel Members
List members of a channel. The `{name}` parameter is the channel name
**without** the `#` prefix (it's added by the server).
**Response:** `200 OK`
```json
[
{"id": 1, "nick": "alice", "lastSeen": "2026-02-10T20:00:00Z"},
{"id": 2, "nick": "bob", "lastSeen": "2026-02-10T19:55:00Z"}
]
```
**curl example:**
```bash
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`
```json
{"status": "ok"}
```
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 401 | `unauthorized` | Missing or invalid auth token |
**curl example:**
```bash
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`
```json
{
"id": 1,
"nick": "alice",
"channels": [
{"id": 1, "name": "#general", "topic": "Welcome!"}
]
}
```
**curl example:**
```bash
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`
```json
{
"name": "My Chat Server",
"motd": "Welcome! Be nice.",
"users": 42
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `name` | string | Server display name |
| `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`
```json
{"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)](https://www.rfc-editor.org/rfc/rfc8785):
- Object keys sorted lexicographically (Unicode code point order)
- No insignificant whitespace
- Numbers serialized in shortest form (no trailing zeros)
- Strings escaped per JSON spec (no unnecessary escapes)
- UTF-8 encoding throughout
4. The resulting byte string is the signing input
**Example:**
Given this message:
```json
{
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["hello"],
"id": "abc-123",
"ts": "2026-02-10T20:00:00Z",
"meta": {"alg": "ed25519"}
}
```
The JCS canonical form is:
```
{"body":["hello"],"command":"PRIVMSG","from":"alice","id":"abc-123","meta":{"alg":"ed25519"},"to":"#general","ts":"2026-02-10T20:00:00Z"}
```
This is why `body` must be an object or array raw strings would be ambiguous
under canonicalization (a bare string `hello` is not valid JSON, and
`"hello"` has different canonical forms depending on escaping rules).
### Signing Flow
1. Client generates an Ed25519 keypair (32-byte seed 64-byte secret key,
32-byte public key)
2. Client announces public key via PUBKEY command:
```json
{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64url-encoded-pubkey"}}
```
3. Server relays PUBKEY to channel members and/or stores for the session
4. When sending a message, client:
a. Constructs the complete message envelope **without** `meta.sig`
b. Canonicalizes per JCS (step above)
c. Signs the canonical bytes with the Ed25519 private key
d. Adds `meta.sig` (base64url-encoded signature) and `meta.alg` ("ed25519")
5. Server stores and relays the message including `meta` verbatim
6. Recipients verify by:
a. Extracting and removing `meta.sig` from the received message
b. Canonicalizing the remaining message per JCS
c. Verifying the Ed25519 signature against the sender's announced public key
### PUBKEY Distribution
```json
{"command": "PUBKEY", "from": "alice",
"body": {"alg": "ed25519", "key": "base64url-encoded-32-byte-pubkey"}}
```
- Servers relay PUBKEY messages to all channel members
- Clients cache public keys locally, indexed by (server, nick)
- Key distribution uses **TOFU** (trust on first use): the first key seen for
a nick is trusted; subsequent different keys trigger a warning
- **There is no key revocation mechanism** if a key is compromised, the user
must change their nick or wait for the old key's TOFU cache to expire
### Signed Message Example
```json
{
"command": "PRIVMSG",
"from": "alice",
"to": "#general",
"body": ["this message is signed"],
"id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5",
"ts": "2026-02-10T20:00:00.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](#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.
---
## Federation (Server-to-Server)
Federation allows multiple chat servers to link together, forming a network
where users on different servers can share channels similar to IRC server
linking.
**Status:** Not yet implemented. This section documents the design.
### Link Establishment
Server links are **manually configured** by operators. There is no
autodiscovery, no mesh networking, no DNS-based lookup. Operators on both
servers must agree to link and configure shared authentication credentials.
```
POST /api/v1/federation/link
{
"server_name": "peer.example.com",
"shared_key": "pre-shared-secret"
}
```
Both servers must configure the link. Authentication uses a pre-shared key
(hashed, never transmitted in plain text after initial setup).
### Message Relay
Once linked, servers relay messages using the same IRC envelope format:
```
POST /api/v1/federation/relay
{
"command": "PRIVMSG",
"from": "alice@server1.example.com",
"to": "#shared-channel",
"body": ["hello from server1"],
"meta": {"sig": "base64...", "alg": "ed25519"}
}
```
Key properties:
- **Signatures are relayed verbatim** federated servers do not strip,
modify, or re-sign messages. A signature from a user on server1 can be
verified by a user on server2.
- **Nick namespacing**: In federated mode, nicks include a server suffix
(`nick@server`) to prevent collisions. Within a single server, bare nicks
are used.
### State Synchronization
After link establishment, servers exchange a **burst** of state:
1. `NICK` commands for all connected users
2. `JOIN` commands for all shared channel memberships
3. `TOPIC` commands for all channel topics
4. `MODE` commands for all channel modes
This mirrors IRC's server burst protocol.
### S2S Commands
| Command | Description |
|----------|-------------|
| `RELAY` | Relay a message from a remote user |
| `LINK` | Establish server link |
| `UNLINK` | Tear down server link |
| `SYNC` | Request full state synchronization |
| `PING` | Inter-server keepalive |
| `PONG` | Inter-server keepalive response |
### Federation Endpoints
```
POST /api/v1/federation/link — Establish server link
POST /api/v1/federation/relay — Relay messages between linked servers
GET /api/v1/federation/status — Link status and peer list
POST /api/v1/federation/unlink — Tear down a server link
```
---
## Storage
### Database
SQLite by default (single-file, zero-config). The server uses
[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite), a pure-Go SQLite
implementation no CGO required, cross-compiles cleanly.
Postgres support is planned for larger deployments but not yet implemented.
### Schema
The database schema is managed via embedded SQL migration files in
`internal/db/schema/`. Migrations run automatically on server start.
**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**: Stored indefinitely in the current implementation. Rotation
per `MAX_HISTORY` is planned.
- **Queue entries**: Stored until pruned. Pruning by `QUEUE_MAX_AGE` is
planned.
- **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](https://github.com/spf13/viper). A `.env` file in the working
directory is also loaded automatically via
[godotenv](https://github.com/joho/godotenv).
| Variable | Type | Default | Description |
|--------------------|---------|--------------------------------------|-------------|
| `PORT` | int | `8080` | HTTP listen port |
| `DBURL` | string | `file:./data.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:./path.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 channel before rotation (planned) |
| `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 (planned). |
| `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
```bash
PORT=8080
SERVER_NAME=My Chat Server
MOTD=Welcome! Be excellent to each other.
DEBUG=false
DBURL=file:./data.db?_journal_mode=WAL
SESSION_IDLE_TIMEOUT=24h
```
---
## Deployment
### Docker (Recommended)
The Docker image contains a single static binary (`chatd`) and nothing else.
```bash
# Build
docker build -t chat .
# Run
docker run -p 8080:8080 \
-v chat-data:/data \
-e DBURL="file:/data/chat.db?_journal_mode=WAL" \
-e SERVER_NAME="My Server" \
-e MOTD="Welcome!" \
chat
```
The Dockerfile is a multi-stage build:
1. **Build stage**: Compiles `chatd` and `chat-cli` (CLI built to verify
compilation, not included in final image)
2. **Final stage**: Alpine Linux + `chatd` binary only
```dockerfile
FROM golang:1.24-alpine AS builder
WORKDIR /src
RUN apk add --no-cache make
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /chatd ./cmd/chatd/
RUN go build -o /chat-cli ./cmd/chat-cli/
FROM alpine:latest
COPY --from=builder /chatd /usr/local/bin/chatd
EXPOSE 8080
CMD ["chatd"]
```
### Binary
```bash
# Build from source
make build
# Binary at ./bin/chatd
# Run
./bin/chatd
# Listens on :8080, creates ./data.db
```
### Reverse Proxy (Production)
For production, run behind a TLS-terminating reverse proxy.
**Caddy:**
```
chat.example.com {
reverse_proxy localhost:8080
}
```
**nginx:**
```nginx
server {
listen 443 ssl;
server_name chat.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 data.db ".backup backup.db"` or just copy the file (safe with WAL mode).
- **Location**: By default, `data.db` is created in the working directory.
Use the `DBURL` env var to place it elsewhere.
---
## Client Development Guide
This section explains how to write a client against the chat 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
```bash
# 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
# 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
// 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](https://en.wikipedia.org/wiki/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`
```json
{"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:
```json
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`
```json
{
"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)
- [x] Session creation with nick claim
- [x] All core commands: PRIVMSG, JOIN, PART, NICK, TOPIC, QUIT, PING
- [x] IRC message envelope format (command, from, to, body, ts, meta)
- [x] Per-client delivery queues with fan-out
- [x] Long-polling with in-memory broker
- [x] Channel messages and DMs
- [x] Ephemeral channels (deleted when empty)
- [x] NICK change with broadcast
- [x] QUIT with broadcast and cleanup
- [x] Embedded web SPA client
- [x] CLI client (chat-cli)
- [x] SQLite storage with WAL mode
- [x] Docker deployment
- [x] Prometheus metrics endpoint
- [x] Health check endpoint
- [x] Session expiry auto-expire idle sessions, release nicks
- [x] Logout endpoint (`POST /api/v1/logout`)
- [x] Current user endpoint (`GET /api/v1/users/me`)
- [x] User count in server info (`GET /api/v1/server`)
### Post-MVP (Planned)
- [ ] **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 channel
- [ ] **Channel modes** enforce `+i`, `+m`, `+s`, `+t`, `+n`
- [ ] **User channel modes** `+o` (operator), `+v` (voice)
- [ ] **MODE command** set/query channel and user modes
- [ ] **KICK command** remove users from channels
- [ ] **Numeric replies** send IRC numeric codes via the message queue
(001 welcome, 353 NAMES, 332 TOPIC, etc.)
- [ ] **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-equivalent for querying user metadata
- [ ] **Connection flood protection** per-IP connection limits as a
complement to hashcash
- [ ] **Invite system** `INVITE` command for `+i` channels
- [ ] **Ban system** channel-level bans by nick pattern
---
## Project Structure
Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md):
```
chat/
├── cmd/
│ ├── chatd/ # Server binary entry point
│ │ └── main.go
│ └── chat-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
│ └── dist/ # Built SPA (vanilla JS, no build step)
│ ├── index.html
│ ├── style.css
│ └── app.js
├── 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 (chat-cli)
- Docker image
- Prometheus metrics
See [Roadmap](#roadmap) for what's next.
---
## License
MIT
## Author
[@sneak](https://sneak.berlin)