Expanded from ~700 lines to ~2200 lines covering: - Complete protocol specification (every command, field, behavior) - Full API reference with request/response examples for all endpoints - Architecture deep-dive (session model, queue system, broker, message flow) - Sequence diagrams for channel messages, DMs, and JOIN flows - All design decisions with rationale (no accounts, JSON, opaque tokens, etc.) - Canonicalization and signing spec (JCS, Ed25519, TOFU) - Security model (threat model, authentication, key management) - Federation design (link establishment, relay, state sync, S2S commands) - Storage schema with all tables and columns documented - Configuration reference with all environment variables - Deployment guide (Docker, binary, reverse proxy, SQLite considerations) - Client development guide with curl examples and Python/JS code - Hashcash proof-of-work spec (challenge/response flow, adaptive difficulty) - Detailed roadmap (MVP, post-MVP, future) - Project structure with every directory explained
2207 lines
81 KiB
Markdown
2207 lines
81 KiB
Markdown
# 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: 1–32 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 1–32 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 | 1–32 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 .
|
||
```
|
||
|
||
### GET /api/v1/server — Server Info
|
||
|
||
Return server metadata. No authentication required.
|
||
|
||
**Response:** `200 OK`
|
||
```json
|
||
{
|
||
"name": "My Chat Server",
|
||
"motd": "Welcome! Be nice."
|
||
}
|
||
```
|
||
|
||
### 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`. Session expiry by `SESSION_TIMEOUT`
|
||
is planned.
|
||
|
||
---
|
||
|
||
## 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_TIMEOUT` | int | `86400` | Session idle timeout in seconds (planned). 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_TIMEOUT=86400
|
||
```
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
### Post-MVP (Planned)
|
||
|
||
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||
- [ ] **Session expiry** — auto-expire idle sessions, release nicks
|
||
- [ ] **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)
|
||
|