From 9c8e40d014fc7805caf8a6d9c4f49ced404930d7 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 10 Feb 2026 18:18:29 -0800 Subject: [PATCH] Comprehensive README: full protocol spec, API reference, architecture, security model 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 --- README.md | 2332 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 1916 insertions(+), 416 deletions(-) diff --git a/README.md b/README.md index 5268eee..1c9cbfa 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,43 @@ # chat -**IRC plus message metadata, a signing system using it, and server-based -backlog queues for multiple connected clients on one nick. All via HTTP.** +**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. Decouples session state from transport -connections, enabling mobile-friendly persistent sessions over HTTP. +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 @@ -24,6 +51,14 @@ This project builds a chat server that: - 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? @@ -83,7 +118,7 @@ display name. ### On the resemblance to JSON-RPC -All C2S commands go through `POST /messages` with a `command` field that +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 @@ -104,20 +139,54 @@ 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 connects → server assigns a **session UUID** - (user identity for this server), a **client UUID** (this specific device), - and an **opaque auth token** (random bytes, not JWT). +- **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 `. -- Nicks are changeable; the session UUID is the stable identity. -- Server-assigned UUIDs — clients do not choose their own IDs. +- 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 @@ -132,474 +201,1901 @@ A single user session can have multiple clients (phone, laptop, terminal). doesn't affect others. ``` -User (session UUID) -├── Client A (client UUID, token, queue) -├── Client B (client UUID, token, queue) -└── Client C (client UUID, token, queue) +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. This is a -deliberate design choice that enables cryptographic signing: if a message could -be modified after signing, signatures would be meaningless. +Messages are **immutable** — no editing, no deletion by clients. There are no +edit or delete API endpoints and there never will be. -### Message Delivery +**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. -- **Long-poll timeout**: 15 seconds -- **Queue depth**: server-configurable, default at least 48 hours worth of - messages -- **No delivery/read receipts** except in DMs -- **Bodies are structured** objects or arrays (never raw strings) — enables - deterministic canonicalization via RFC 8785 JCS for signing +### Message Delivery Model -### Crypto & Signing +The server uses a **fan-out queue** model: -- Servers **relay signatures verbatim** — signatures are key/value metadata on - message objects (`meta.sig`, `meta.alg`). Servers do not verify them. -- Clients handle key authentication via **TOFU** (trust on first use). -- **No key revocation mechanism** — keep your keys safe. -- **PUBKEY** message type for distributing signing keys to channel members. -- **E2E encryption for DMs** is planned for 1.0. +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=&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, - like IRC. -- **Ephemeral** — channels disappear when the last member leaves. -- No channel size limits. -- No channel-level encryption. + 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. -### Federation +### Direct Messages (DMs) -- **Manual server linking only** — no autodiscovery, no mesh. Operators - explicitly configure server links. -- Servers relay messages (including signatures) verbatim. +- 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. -### Web Client +### JSON, Not Binary -The SPA web client is a **convenience UI**. The primary interface is IRC-style -client apps talking directly to the HTTP API. +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 +### Transport: HTTP Only All client↔server and server↔server communication uses HTTP/1.1+ with JSON request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP. -- **Client polling**: Clients long-poll `GET /api/v1/messages` — server holds - the connection for up to 15 seconds until messages arrive or timeout. - One endpoint for everything. -- **Client sending**: `POST /api/v1/messages` with a `to` field. That's it. -- **Server federation**: Servers exchange messages via HTTP to enable multi-server - networks (like IRC server linking) +- **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 is -channel management and history. +The entire read/write loop for a client is two endpoints. Everything else +(state, history, channels, members, server info) is ancillary. -### Session Model +### Session Lifecycle ``` -┌─────────────────────────────────┐ -│ User Session (UUID) │ -│ nick: "alice" │ -│ signing key: ed25519:... │ -│ │ -│ ┌──────────┐ ┌──────────┐ │ -│ │ Client A │ │ Client B │ ... │ -│ │ UUID │ │ UUID │ │ -│ │ token │ │ token │ │ -│ │ queue │ │ queue │ │ -│ └──────────┘ └──────────┘ │ -└─────────────────────────────────┘ +┌─ 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) │ +│ │ +└────────────────────────────────────────────────────────────┘ ``` -- **User session**: server-assigned UUID. Represents a user on this server. - Has a nick (changeable, unique per server at any point in time). -- **Client**: each device/connection gets its own UUID and opaque auth token. - The token is the credential — present it to authenticate. -- **Queue**: each client has an independent S2C message queue. The server fans - out messages to all active client queues for the session. +### Queue Architecture -Sessions persist across disconnects. Messages queue until retrieved. Client -queues expire independently after a configurable idle timeout. +``` + ┌─────────────────┐ + │ 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 -### Message Protocol +Each message is stored ONCE. One queue entry per recipient. +``` -All messages use **IRC command names and numeric reply codes** from RFC 1459/2812. -The `command` field identifies the message type. +The `client_queues` table contains `(user_id, message_id)` pairs. When a +client polls with `GET /messages?after=`, 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. -#### Message Envelope +### In-Memory Broker -Every message is a JSON object with these fields: +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: -| Field | Type | Required | Description | -|-----------|-----------------|----------|-------------| -| `command` | string | ✓ | IRC command name or 3-digit numeric code | -| `from` | string | | Sender nick or server name | -| `to` | string | | Destination: `#channel` or nick | -| `params` | array\ | | Additional IRC-style parameters | -| `body` | array \| object | | Structured body (never a raw string — see below) | -| `id` | string (uuid) | | Server-assigned message UUID | -| `ts` | string | | Server-assigned ISO 8601 timestamp | -| `meta` | object | | Extensible metadata (signatures, hashes, etc.) | +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 -**Important:** Message bodies are **structured objects or arrays**, never raw -strings. This is a deliberate departure from IRC wire format that enables: +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. -- **Multiline messages** — body is a list of lines, no escape sequences -- **Deterministic canonicalization** — for hashing and signing (see below) -- **Structured data** — commands like PUBKEY carry key material as objects +--- -For text messages, `body` is an array of strings (one per line): +## Protocol Specification + +### Message Envelope + +Every message — client-to-server, server-to-client, and server-to-server — uses +the same JSON envelope: ```json -{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]} -{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["line one", "line two"]} +{ + "id": "string (uuid)", + "command": "string", + "from": "string", + "to": "string", + "params": ["string", ...], + "body": ["string", ...] | {...}, + "ts": "string (ISO 8601)", + "meta": {...} +} ``` -For numeric replies with text trailing parameters: +#### 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 ` header. The token is obtained from +`POST /api/v1/session`. + +All API responses include appropriate HTTP status codes. Error responses have +the format: ```json -{"command": "001", "to": "nick", "body": ["Welcome to the network, nick"]} -{"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["@op1 alice bob"]} +{"error": "human-readable error message"} ``` -For structured data (keys, etc.), `body` is an object: +### 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": }`) + - 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": "PUBKEY", "from": "nick", "body": {"alg": "ed25519", "key": "base64..."}} +{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]} ``` -#### IRC Command Mapping +See [Commands (C2S and S2C)](#commands-c2s-and-s2c) for the full command +reference with all required and optional fields. -**Commands (C2S and S2C):** +**Command dispatch table:** -| Command | RFC | Description | -|-----------|--------------|--------------------------------------| -| `PRIVMSG` | 1459 §4.4.1 | Message to channel or user | -| `NOTICE` | 1459 §4.4.2 | Notice (must not trigger auto-reply) | -| `JOIN` | 1459 §4.2.1 | Join a channel | -| `PART` | 1459 §4.2.2 | Leave a channel | -| `QUIT` | 1459 §4.1.6 | Disconnect from server | -| `NICK` | 1459 §4.1.2 | Change nickname | -| `MODE` | 1459 §4.2.3 | Set/query channel or user modes | -| `TOPIC` | 1459 §4.2.4 | Set/query channel topic | -| `KICK` | 1459 §4.2.8 | Kick user from channel | -| `PING` | 1459 §4.6.2 | Keepalive | -| `PONG` | 1459 §4.6.3 | Keepalive response | -| `PUBKEY` | (extension) | Announce/relay signing public key | +| 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 | -All C2S commands may be relayed S2C to other users (e.g. JOIN, PART, PRIVMSG). +**Errors (all commands):** -**Numeric Reply Codes (S2C):** +| 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 | -| Code | Name | Description | -|------|----------------------|-------------| -| 001 | RPL_WELCOME | Welcome after session creation | -| 002 | RPL_YOURHOST | Server host information | -| 003 | RPL_CREATED | Server creation date | -| 004 | RPL_MYINFO | Server info and modes | -| 322 | RPL_LIST | Channel list entry | -| 323 | RPL_LISTEND | End of channel list | -| 332 | RPL_TOPIC | Channel topic | -| 353 | RPL_NAMREPLY | Channel member list | -| 366 | RPL_ENDOFNAMES | End of NAMES list | -| 372 | RPL_MOTD | MOTD line | -| 375 | RPL_MOTDSTART | Start of MOTD | -| 376 | RPL_ENDOFMOTD | End of MOTD | -| 401 | ERR_NOSUCHNICK | No such nick/channel | -| 403 | ERR_NOSUCHCHANNEL | No such channel | -| 433 | ERR_NICKNAMEINUSE | Nickname already in use | -| 442 | ERR_NOTONCHANNEL | Not on that channel | -| 482 | ERR_CHANOPRIVSNEEDED | Not channel operator | +### GET /api/v1/history — Message History -**Server-to-Server (Federation):** +Fetch historical messages for a channel. Returns messages in chronological +order (oldest first). -Federated servers use the same IRC commands. After link establishment, servers -exchange a burst of JOIN, NICK, TOPIC, and MODE commands to sync state. -PING/PONG serve as inter-server keepalives. +**Query Parameters:** -#### Message Examples +| 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 -{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]} - -{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["line one", "line two"], "meta": {"sig": "base64...", "alg": "ed25519"}} - -{"command": "PRIVMSG", "from": "alice", "to": "bob", "body": ["hey, DM"]} - -{"command": "JOIN", "from": "bob", "to": "#general"} - -{"command": "PART", "from": "bob", "to": "#general", "body": ["later"]} - -{"command": "NICK", "from": "oldnick", "body": ["newnick"]} - -{"command": "001", "to": "alice", "body": ["Welcome to the network, alice"]} - -{"command": "353", "to": "alice", "params": ["=", "#general"], "body": ["@op1 alice bob +voiced1"]} - -{"command": "433", "to": "*", "params": ["alice"], "body": ["Nickname is already in use"]} - -{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64..."}} +[ + { + "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": {} + } +] ``` -#### JSON Schemas +**Note:** History currently returns only PRIVMSG messages (not JOIN/PART/etc. +events). Event messages are delivered via the live queue only. -Full JSON Schema (draft 2020-12) definitions for all message types are in -[`schema/`](schema/). See [`schema/README.md`](schema/README.md) for the -complete index. +**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 . -### Canonicalization and Signing +# 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) +### Canonicalization (RFC 8785 JCS) To produce a deterministic byte representation of a message for signing: -1. Remove `meta.sig` from the message (the signature itself is not signed) -2. Serialize using [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785): - - Object keys sorted lexicographically - - No whitespace - - Numbers in shortest form - - UTF-8 encoding -3. The resulting byte string is the signing input +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. +under canonicalization (a bare string `hello` is not valid JSON, and +`"hello"` has different canonical forms depending on escaping rules). -#### Signing Flow +### Signing Flow -1. Client generates an Ed25519 keypair -2. Client announces public key: `{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}` -3. Server relays PUBKEY to channel members / stores for the session +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 message without `meta.sig` - b. Canonicalizes per JCS - c. Signs with private key - d. Adds `meta.sig` (base64) and `meta.alg` -5. Recipients verify by repeating steps a–c and checking the signature - against the sender's announced public key + 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 Message +### PUBKEY Distribution ```json -{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}} +{"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 -and use them to verify `meta.sig` on incoming messages. Key distribution is -trust-on-first-use (TOFU). There is no key revocation mechanism. +- 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 -### API Endpoints +### Signed Message Example -All endpoints accept and return `application/json`. Authenticated endpoints -require `Authorization: Bearer ` header. +```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" + } +} +``` -The API is the primary interface — designed for IRC-style clients. The entire -client loop is: +--- -1. `POST /api/v1/session` — create a session, get a token -2. `GET /api/v1/state` — see who you are and what channels you're in -3. `GET /api/v1/messages?timeout=15` — long-poll for all messages (channel, DM, system) -4. `POST /api/v1/messages` — send to `"#channel"` or `"nick"` +## Security Model -That's the core. Everything else (join, part, history, members) is ancillary. +### Threat Model -#### Quick example (curl) +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=`. 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 -# Create a session (get session UUID, client UUID, and auth token) -TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ - -d '{"nick":"alice"}' | jq -r .token) +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 +``` -# Join a channel (creates it if it doesn't exist) +--- + +## 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"}' -# Send a message +# 3. Send a message curl -s -X POST http://localhost:8080/api/v1/messages \ -H "Authorization: Bearer $TOKEN" \ - -d '{"command":"PRIVMSG","to":"#general","body":["hello world"]}' + -H 'Content-Type: application/json' \ + -d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}' -# Poll for messages (long-poll, 15s timeout) -curl -s "http://localhost:8080/api/v1/messages?timeout=15" \ - -H "Authorization: Bearer $TOKEN" +# 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"]}' ``` -#### Session +### Implementing Long-Poll in Code -``` -POST /api/v1/session — Create session { "nick": "..." } - → { id, nick, token } - Token is opaque (random), not JWT. - Token implicitly identifies the client. +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 ``` -#### State - -``` -GET /api/v1/state — User state: nick, session_id, client_id, - and list of joined channels +```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 + } + } +} ``` -#### Messages (unified stream) +### Handling Message Types + +Clients should handle these message commands from the queue: + +| Command | Display As | +|-----------|------------| +| `PRIVMSG` | ` 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/messages — Single message stream (long-poll, 15s timeout) - All message types: channel, DM, notices, events - Delivers from the calling client's queue - (identified by auth token) - Query params: ?after=&timeout=15 -POST /api/v1/messages — Send any C2S command (dispatched by "command" field) +GET /api/v1/challenge ``` -All client-to-server commands use `POST /api/v1/messages` with a `command` -field. There are no separate endpoints for join, part, nick, topic, etc. - -| Command | Required Fields | Optional Fields | Description | -|-----------|---------------------|-----------------|-------------| -| `PRIVMSG` | `to`, `body` | `meta` | Message to channel (`#name`) or user (nick) | -| `NOTICE` | `to`, `body` | `meta` | Notice (must not trigger auto-reply) | -| `JOIN` | `to` | | Join a channel (creates if nonexistent) | -| `PART` | `to` | `body` | Leave a channel | -| `NICK` | `body` | | Change nickname — `body: ["newnick"]` | -| `TOPIC` | `to`, `body` | | Set channel topic | -| `MODE` | `to`, `params` | | Set channel/user modes | -| `KICK` | `to`, `params` | `body` | Kick user — `params: ["nick"]`, `body: ["reason"]` | -| `PING` | | | Keepalive (server responds with PONG) | -| `PUBKEY` | `body` | | Announce signing key — `body: {"alg":..., "key":...}` | - -Examples: - +**Response:** `200 OK` ```json -{"command": "PRIVMSG", "to": "#channel", "body": ["hello world"]} -{"command": "JOIN", "to": "#channel"} -{"command": "PART", "to": "#channel"} -{"command": "NICK", "body": ["newnick"]} -{"command": "TOPIC", "to": "#channel", "body": ["new topic text"]} -{"command": "PING"} +{ + "nonce": "a1b2c3d4e5f6...", + "difficulty": 20, + "algorithm": "sha256", + "expires": "2026-02-10T20:01:00Z" +} ``` -Messages are immutable — no edit or delete endpoints. +| 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 | -#### History +**Status:** Not yet implemented. Tracked for post-MVP. -``` -GET /api/v1/history — Fetch history for a target (channel or DM) - Query params: ?target=#channel&before=&limit=50 - For DMs: ?target=nick&before=&limit=50 -``` +--- -#### Channels +## Roadmap -``` -GET /api/v1/channels — List all server channels -GET /api/v1/channels/{name}/members — Channel member list -``` +### Implemented (MVP) -Join and part are handled via `POST /api/v1/messages` with `JOIN` and `PART` -commands (see Messages above). +- [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 -#### Server Info +### Post-MVP (Planned) -``` -GET /api/v1/server — Server info (name, MOTD) -GET /.well-known/healthcheck.json — Health check -``` +- [ ] **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) -### Federation (Server-to-Server) +### Future (1.0+) -Servers can link to form a network, similar to IRC server linking. Links are -**manually configured** — there is no autodiscovery. +- [ ] **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 -``` -POST /api/v1/federation/link — Establish server link (mutual auth via shared key) -POST /api/v1/federation/relay — Relay messages between linked servers -GET /api/v1/federation/status — Link status -``` +--- -Federation uses the same HTTP+JSON transport. S2S messages use the RELAY, LINK, -UNLINK, SYNC, PING, and PONG commands. Messages (including signatures) are -relayed verbatim between servers so users on different servers can share channels. - -### Channel Modes - -Inspired by IRC but simplified: - -| Mode | Meaning | -|------|---------| -| `+i` | Invite-only | -| `+m` | Moderated (only voiced users can send) | -| `+s` | Secret (hidden from channel list) | -| `+t` | Topic locked (only ops can change) | -| `+n` | No external messages | - -User channel modes: `+o` (operator), `+v` (voice) - -### Configuration - -Via environment variables (Viper), following gohttpserver conventions: - -| Variable | Default | Description | -|----------|---------|-------------| -| `PORT` | `8080` | Listen port | -| `DBURL` | `""` | SQLite/Postgres connection string | -| `DEBUG` | `false` | Debug mode | -| `MAX_HISTORY` | `10000` | Max messages per channel history | -| `SESSION_TIMEOUT` | `86400` | Session idle timeout (seconds) | -| `QUEUE_MAX_AGE` | `172800` | Max client queue age in seconds (default 48h) | -| `MAX_MESSAGE_SIZE` | `4096` | Max message body size (bytes) | -| `LONG_POLL_TIMEOUT` | `15` | Long-poll timeout in seconds | -| `MOTD` | `""` | Message of the day | -| `SERVER_NAME` | hostname | Server display name | -| `FEDERATION_KEY` | `""` | Shared key for server linking | - -### Storage - -SQLite by default (single-file, zero-config), with Postgres support for -larger deployments. Tables: - -- `sessions` — user sessions (UUID, nick, created_at) -- `clients` — client records (UUID, session_id, token_hash, last_seen) -- `channels` — channel metadata and modes -- `channel_members` — membership and user modes -- `messages` — message history (rotated per `MAX_HISTORY`) -- `client_queues` — per-client pending delivery queues -- `server_links` — federation peer configuration - -### Project Structure +## Project Structure Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md): ``` chat/ ├── cmd/ -│ └── chatd/ -│ └── main.go +│ ├── 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/ -│ ├── config/ -│ ├── database/ -│ ├── globals/ -│ ├── handlers/ -│ ├── healthcheck/ -│ ├── logger/ -│ ├── middleware/ -│ ├── models/ -│ ├── queue/ -│ └── server/ -├── schema/ -│ ├── message.schema.json -│ ├── c2s/ -│ ├── s2c/ -│ ├── s2s/ -│ └── README.md +│ ├── 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 @@ -610,91 +2106,95 @@ chat/ ### Required Libraries -Per gohttpserver conventions: +| Purpose | Library | +|------------|---------| +| DI | `go.uber.org/fx` | +| Router | `github.com/go-chi/chi` | +| Logging | `log/slog` (stdlib) | +| Config | `github.com/spf13/viper` | +| Env | `github.com/joho/godotenv/autoload` | +| CORS | `github.com/go-chi/cors` | +| Metrics | `github.com/prometheus/client_golang` | +| DB | `modernc.org/sqlite` + `database/sql` | +| UUIDs | `github.com/google/uuid` | +| Errors | `github.com/getsentry/sentry-go` (optional) | +| TUI Client | `github.com/rivo/tview` + `github.com/gdamore/tcell/v2` | -| Purpose | Library | -|---------|---------| -| DI | `go.uber.org/fx` | -| Router | `github.com/go-chi/chi` | -| Logging | `log/slog` (stdlib) | -| Config | `github.com/spf13/viper` | -| Env | `github.com/joho/godotenv/autoload` | -| CORS | `github.com/go-chi/cors` | -| Metrics | `github.com/prometheus/client_golang` | -| DB | `modernc.org/sqlite` + `database/sql` | +--- -### Design Principles +## Design Principles 1. **API-first** — the HTTP API is the product. Clients are thin. If you can't - build a working IRC-style TUI client in an afternoon, the API is too complex. + 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. Session creation is instant. -3. **IRC semantics over HTTP** — command names and numeric codes from RFC 1459/2812. - Familiar to anyone who's built IRC clients or bots. + 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, and works everywhere. + 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 — your messages are waiting in your client queue. + 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 for deterministic canonicalization (JCS) and signing. -7. **Immutable messages** — no editing, no deletion. Fits naturally with - cryptographic signatures. + 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. + 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. -10. **Federation optional** — single server works standalone. Linking is manual - and opt-in. + 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. + distribution. Servers relay signatures without verification. Trust + decisions are made by clients, not servers. -### Rate Limiting & Abuse Prevention +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. -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` -2. Server returns a nonce and a required difficulty (number of leading zero - bits in the SHA-256 hash) -3. Client finds a counter value such that `SHA-256(nonce || counter)` has the - required leading zeros -4. Client submits the proof with the session request: - `POST /api/v1/session` with `{"nick": "...", "proof": {"nonce": "...", "counter": N}}` -5. Server verifies the proof before creating the session - -**Adaptive difficulty:** - -The required difficulty scales with server load. Under normal conditions, the -cost is negligible (a few milliseconds of CPU). As concurrent sessions or -session creation rate increases, difficulty rises — making bulk session creation -exponentially more expensive for attackers while remaining cheap for legitimate -single-user connections. - -| Server Load | Difficulty | Approx. Client CPU | -|--------------------|------------|--------------------| -| Normal (< 100/min) | 16 bits | ~1ms | -| Elevated | 20 bits | ~15ms | -| High | 24 bits | ~250ms | -| Under attack | 28+ bits | ~4s+ | - -**Why hashcash and not rate limits?** - -- No state to track (no IP tables, no token buckets) -- Works through NATs and proxies — doesn't punish shared IPs -- Cost falls on the requester, not the server -- Fits the "no accounts" philosophy — proof-of-work is the cost of entry -- Trivial for legitimate clients, expensive at scale for attackers - -**Status:** Not yet implemented. Tracked for post-MVP. +--- ## Status -**Implementation in progress.** Core API is functional with SQLite storage and -embedded web client. +**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