- Add IRC command/numeric mapping tables (C2S, S2C, S2S) - Document structured message bodies (array/object, never raw strings) - Document RFC 8785 JCS canonicalization for deterministic hashing - Document Ed25519 signing/verification flow with TOFU key distribution - Document PUBKEY message type for public key announcement - Update message examples to use IRC command format - Update curl examples to use command-based messages - Note web client as convenience UI; primary interface is IRC-style clients - Add schema/ to project structure
434 lines
15 KiB
Markdown
434 lines
15 KiB
Markdown
# chat
|
||
|
||
A modern IRC-inspired chat server written in Go. Decouples session state from
|
||
transport connections, enabling mobile-friendly persistent sessions over HTTP.
|
||
|
||
The **HTTP API is the primary interface**. It's designed to be simple enough
|
||
that writing a terminal IRC-style client against it is straightforward — just
|
||
`curl` and `jq` get you surprisingly far. The server also ships an embedded
|
||
web client as a convenience/reference implementation, but the API comes first.
|
||
|
||
## Motivation
|
||
|
||
IRC is in decline because session state is tied to the TCP connection. In a
|
||
mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer
|
||
or pay for IRCCloud.
|
||
|
||
This project builds a chat server that:
|
||
|
||
- Holds session state server-side (message queues, presence, channel membership)
|
||
- Exposes a minimal, clean HTTP+JSON API — easy to build clients against
|
||
- Supports multiple concurrent connections per user session
|
||
- Provides IRC-like semantics: channels, nicks, topics, modes
|
||
- Uses structured JSON messages with IRC command names and numeric reply codes
|
||
|
||
## Architecture
|
||
|
||
### Transport: HTTP only
|
||
|
||
All client↔server and server↔server communication uses HTTP/1.1+ with JSON
|
||
request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP.
|
||
|
||
- **Client polling**: Clients long-poll `GET /api/v1/messages` — server holds
|
||
the connection until messages arrive or timeout. One endpoint for everything.
|
||
- **Client sending**: `POST /api/v1/messages` with a `to` field. That's it.
|
||
- **Server federation**: Servers exchange messages via HTTP to enable multi-server
|
||
networks (like IRC server linking)
|
||
|
||
The entire read/write loop for a client is two endpoints. Everything else is
|
||
channel management and history.
|
||
|
||
### Message Protocol
|
||
|
||
All messages use **IRC command names and numeric reply codes** from RFC 1459/2812.
|
||
The `command` field identifies the message type.
|
||
|
||
#### Message Envelope
|
||
|
||
Every message is a JSON object with these fields:
|
||
|
||
| Field | Type | Required | Description |
|
||
|-----------|-----------------|----------|-------------|
|
||
| `command` | string | ✓ | IRC command name or 3-digit numeric code |
|
||
| `from` | string | | Sender nick or server name |
|
||
| `to` | string | | Destination: `#channel` or nick |
|
||
| `params` | array\<string\> | | Additional IRC-style parameters |
|
||
| `body` | array \| object | | Structured body (never a raw string) |
|
||
| `meta` | object | | Extensible metadata (signatures, etc.) |
|
||
| `id` | string (uuid) | | Server-assigned message ID |
|
||
| `ts` | string | | Server-assigned ISO 8601 timestamp |
|
||
|
||
**Important:** Message bodies MUST be objects or arrays, never raw strings.
|
||
This enables:
|
||
- Multiline messages (array of lines)
|
||
- Deterministic canonicalization for hashing/signing (RFC 8785 JCS)
|
||
- Structured data where needed (e.g. PUBKEY)
|
||
|
||
#### IRC Command Mapping
|
||
|
||
**Client-to-Server (C2S):**
|
||
|
||
| Command | Description |
|
||
|----------|-------------|
|
||
| PRIVMSG | Send message to channel or user |
|
||
| NOTICE | Send notice (no auto-reply expected) |
|
||
| JOIN | Join a channel |
|
||
| PART | Leave a channel |
|
||
| QUIT | Disconnect from server |
|
||
| NICK | Change nickname |
|
||
| MODE | Set/query channel or user modes |
|
||
| TOPIC | Set/query channel topic |
|
||
| KICK | Kick a user from a channel |
|
||
| PING | Client keepalive |
|
||
| PUBKEY | Announce public signing key |
|
||
|
||
**Server-to-Client (S2C):**
|
||
|
||
All C2S commands may be echoed back as S2C (relayed to other users), plus:
|
||
|
||
| Command | Description |
|
||
|----------|-------------|
|
||
| PONG | Server keepalive response |
|
||
| PUBKEY | Relayed public key from another user |
|
||
| ERROR | Server error message |
|
||
|
||
**Numeric Reply Codes (S2C):**
|
||
|
||
| Code | Name | Description |
|
||
|------|-------------------|-------------|
|
||
| 001 | RPL_WELCOME | Welcome after registration |
|
||
| 002 | RPL_YOURHOST | Server host information |
|
||
| 322 | RPL_LIST | Channel list entry |
|
||
| 353 | RPL_NAMREPLY | Names list for a channel |
|
||
| 366 | RPL_ENDOFNAMES | End of names list |
|
||
| 372 | RPL_MOTD | Message of the day line |
|
||
| 375 | RPL_MOTDSTART | Start of MOTD |
|
||
| 376 | RPL_ENDOFMOTD | End of MOTD |
|
||
| 401 | ERR_NOSUCHNICK | No such nick or channel |
|
||
| 403 | ERR_NOSUCHCHANNEL | No such channel |
|
||
| 433 | ERR_NICKNAMEINUSE | Nickname already in use |
|
||
|
||
**Server-to-Server (S2S):**
|
||
|
||
| Command | Description |
|
||
|---------|-------------|
|
||
| RELAY | Relay message to linked server |
|
||
| LINK | Establish server link |
|
||
| UNLINK | Tear down server link |
|
||
| SYNC | Synchronize state between servers |
|
||
| PING | Server-to-server keepalive |
|
||
| PONG | Server-to-server keepalive response |
|
||
|
||
#### Message Examples
|
||
|
||
```json
|
||
{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"], "meta": {"sig": "base64...", "alg": "ed25519"}}
|
||
|
||
{"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["line one", "line two"]}
|
||
|
||
{"command": "001", "to": "alice", "body": ["Welcome to the network, alice"]}
|
||
|
||
{"command": "353", "to": "alice", "params": ["=", "#general"], "body": ["alice", "bob", "@charlie"]}
|
||
|
||
{"command": "JOIN", "from": "bob", "to": "#general", "body": []}
|
||
|
||
{"command": "ERROR", "body": ["Closing link: connection timeout"]}
|
||
```
|
||
|
||
#### JSON Schemas
|
||
|
||
Full JSON Schema (draft 2020-12) definitions for all message types are in
|
||
[`schema/`](schema/). See [`schema/README.md`](schema/README.md) for the
|
||
complete index.
|
||
|
||
### Canonicalization and Signing
|
||
|
||
Messages support optional cryptographic signatures for integrity verification.
|
||
|
||
#### Canonicalization (RFC 8785 JCS)
|
||
|
||
To produce a deterministic byte representation of a message for signing:
|
||
|
||
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
|
||
|
||
This is why `body` must be an object or array — raw strings would be ambiguous
|
||
under canonicalization.
|
||
|
||
#### Signing Flow
|
||
|
||
1. Client generates an Ed25519 keypair
|
||
2. Client announces public key: `{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}}`
|
||
3. Server relays PUBKEY to channel members / stores for the session
|
||
4. When sending a message, client:
|
||
a. Constructs the message without `meta.sig`
|
||
b. Canonicalizes per JCS
|
||
c. Signs with private key
|
||
d. Adds `meta.sig` (base64) and `meta.alg`
|
||
5. Recipients verify by repeating steps a–c and checking the signature
|
||
against the sender's announced public key
|
||
|
||
#### PUBKEY Message
|
||
|
||
```json
|
||
{"command": "PUBKEY", "from": "alice", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}}
|
||
```
|
||
|
||
Servers SHOULD relay PUBKEY messages to all channel members. Clients SHOULD
|
||
cache public keys and use them to verify `meta.sig` on incoming messages.
|
||
Key distribution is trust-on-first-use (TOFU) by default.
|
||
|
||
### Core Concepts
|
||
|
||
#### Users
|
||
|
||
- Identified by a unique user ID (UUID)
|
||
- Authenticate via token (issued at registration or login)
|
||
- Have a nick (changeable, unique per server at any point in time)
|
||
- Maintain a persistent message queue on the server
|
||
|
||
#### Sessions
|
||
|
||
- A session represents an authenticated user's connection context
|
||
- Session state is **server-held**, not connection-bound
|
||
- Multiple devices can share a session (messages delivered to all)
|
||
- Sessions persist across disconnects — messages queue until retrieved
|
||
- Sessions expire after a configurable idle timeout (default 24h)
|
||
|
||
#### Channels
|
||
|
||
- Named with `#` prefix (e.g. `#general`)
|
||
- Have a topic, mode flags, and member list
|
||
- Messages to a channel are queued for all members
|
||
- Channel history is stored server-side (configurable depth)
|
||
- No eternal logging by default — history rotates
|
||
|
||
### API Endpoints
|
||
|
||
All endpoints accept and return `application/json`. Authenticated endpoints
|
||
require `Authorization: Bearer <token>` header.
|
||
|
||
The API is the primary interface — designed for IRC-style clients. The entire
|
||
client loop is:
|
||
|
||
1. `POST /api/v1/register` — get a token
|
||
2. `GET /api/v1/state` — see who you are and what channels you're in
|
||
3. `GET /api/v1/messages?after=0` — long-poll for all messages (channel, DM, system)
|
||
4. `POST /api/v1/messages` — send to `"#channel"` or `"nick"`
|
||
|
||
That's the core. Everything else (join, part, history, members) is ancillary.
|
||
|
||
#### Quick example (curl)
|
||
|
||
```bash
|
||
# Register
|
||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
|
||
-d '{"nick":"alice"}' | jq -r .token)
|
||
|
||
# Join a channel
|
||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||
-H "Authorization: Bearer $TOKEN" \
|
||
-d '{"command":"JOIN","to":"#general"}'
|
||
|
||
# Send a message
|
||
curl -s -X POST http://localhost:8080/api/v1/messages \
|
||
-H "Authorization: Bearer $TOKEN" \
|
||
-d '{"command":"PRIVMSG","to":"#general","body":["hello world"]}'
|
||
|
||
# Poll for messages (long-poll)
|
||
curl -s http://localhost:8080/api/v1/messages?after=0 \
|
||
-H "Authorization: Bearer $TOKEN"
|
||
```
|
||
|
||
#### Registration
|
||
|
||
```
|
||
POST /api/v1/register — Create account { "nick": "..." } → { id, nick, token }
|
||
```
|
||
|
||
#### State
|
||
|
||
```
|
||
GET /api/v1/state — User state: nick, id, and list of joined channels
|
||
Replaces separate /me and /channels endpoints
|
||
```
|
||
|
||
#### Messages (unified stream)
|
||
|
||
```
|
||
GET /api/v1/messages — Single message stream (long-poll supported)
|
||
All message types: channel, DM, notices, events
|
||
Query params: ?after=<message-id>&timeout=30
|
||
POST /api/v1/messages — Send a message (IRC command in body)
|
||
Body: { "command": "PRIVMSG", "to": "#channel", "body": ["..."] }
|
||
```
|
||
|
||
#### History
|
||
|
||
```
|
||
GET /api/v1/history — Fetch history for a target (channel or DM)
|
||
Query params: ?target=#channel&before=<id>&limit=50
|
||
For DMs: ?target=nick&before=<id>&limit=50
|
||
```
|
||
|
||
#### Channels
|
||
|
||
```
|
||
GET /api/v1/channels/all — List all server channels
|
||
POST /api/v1/channels/join — Join a channel { "channel": "#name" }
|
||
DELETE /api/v1/channels/{name} — Part (leave) a channel
|
||
GET /api/v1/channels/{name}/members — Channel member list
|
||
```
|
||
|
||
#### Server Info
|
||
|
||
```
|
||
GET /api/v1/server — Server info (name, MOTD)
|
||
GET /.well-known/healthcheck.json — Health check
|
||
```
|
||
|
||
### Federation (Server-to-Server)
|
||
|
||
Servers can link to form a network, similar to IRC server linking:
|
||
|
||
```
|
||
POST /api/v1/federation/link — Establish server link (mutual auth via shared key)
|
||
POST /api/v1/federation/relay — Relay messages between linked servers
|
||
GET /api/v1/federation/status — Link status
|
||
```
|
||
|
||
Federation uses the same HTTP+JSON transport. S2S messages use the RELAY, LINK,
|
||
UNLINK, SYNC, PING, and PONG commands. Messages are relayed between servers so
|
||
users on different servers can share channels.
|
||
|
||
### Channel Modes
|
||
|
||
Inspired by IRC but simplified:
|
||
|
||
| Mode | Meaning |
|
||
|------|---------|
|
||
| `+i` | Invite-only |
|
||
| `+m` | Moderated (only voiced users can send) |
|
||
| `+s` | Secret (hidden from channel list) |
|
||
| `+t` | Topic locked (only ops can change) |
|
||
| `+n` | No external messages |
|
||
|
||
User channel modes: `+o` (operator), `+v` (voice)
|
||
|
||
### Configuration
|
||
|
||
Via environment variables (Viper), following gohttpserver conventions:
|
||
|
||
| Variable | Default | Description |
|
||
|----------|---------|-------------|
|
||
| `PORT` | `8080` | Listen port |
|
||
| `DBURL` | `""` | SQLite/Postgres connection string |
|
||
| `DEBUG` | `false` | Debug mode |
|
||
| `MAX_HISTORY` | `10000` | Max messages per channel history |
|
||
| `SESSION_TIMEOUT` | `86400` | Session idle timeout (seconds) |
|
||
| `MAX_MESSAGE_SIZE` | `4096` | Max message body size (bytes) |
|
||
| `MOTD` | `""` | Message of the day |
|
||
| `SERVER_NAME` | hostname | Server display name |
|
||
| `FEDERATION_KEY` | `""` | Shared key for server linking |
|
||
|
||
### Storage
|
||
|
||
SQLite by default (single-file, zero-config), with Postgres support for
|
||
larger deployments. Tables:
|
||
|
||
- `users` — accounts and auth tokens
|
||
- `channels` — channel metadata and modes
|
||
- `channel_members` — membership and user modes
|
||
- `messages` — message history (rotated per `MAX_HISTORY`)
|
||
- `message_queue` — per-user pending delivery queue
|
||
- `server_links` — federation peer configuration
|
||
|
||
### Project Structure
|
||
|
||
Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md):
|
||
|
||
```
|
||
chat/
|
||
├── cmd/
|
||
│ └── chatd/
|
||
│ └── main.go
|
||
├── internal/
|
||
│ ├── config/
|
||
│ ├── database/
|
||
│ ├── globals/
|
||
│ ├── handlers/
|
||
│ ├── healthcheck/
|
||
│ ├── logger/
|
||
│ ├── middleware/
|
||
│ ├── models/
|
||
│ ├── queue/
|
||
│ └── server/
|
||
├── schema/
|
||
│ ├── message.schema.json
|
||
│ ├── c2s/
|
||
│ ├── s2c/
|
||
│ ├── s2s/
|
||
│ └── README.md
|
||
├── web/
|
||
├── go.mod
|
||
├── go.sum
|
||
├── Makefile
|
||
├── Dockerfile
|
||
├── CONVENTIONS.md
|
||
└── README.md
|
||
```
|
||
|
||
### Required Libraries
|
||
|
||
Per gohttpserver conventions:
|
||
|
||
| Purpose | Library |
|
||
|---------|---------|
|
||
| DI | `go.uber.org/fx` |
|
||
| Router | `github.com/go-chi/chi` |
|
||
| Logging | `log/slog` (stdlib) |
|
||
| Config | `github.com/spf13/viper` |
|
||
| Env | `github.com/joho/godotenv/autoload` |
|
||
| CORS | `github.com/go-chi/cors` |
|
||
| Metrics | `github.com/prometheus/client_golang` |
|
||
| DB | `modernc.org/sqlite` + `database/sql` |
|
||
|
||
### Web Client
|
||
|
||
The server embeds a single-page web client (Preact) served at `/`. This is a
|
||
**convenience/reference implementation** — not the primary interface. The
|
||
primary intended clients are IRC-style terminal applications, bots, and custom
|
||
clients talking directly to the HTTP API.
|
||
|
||
### Design Principles
|
||
|
||
1. **API-first** — the HTTP API is the product. Clients are thin. If you can't
|
||
build a working IRC-style TUI client in an afternoon, the API is too complex.
|
||
2. **IRC semantics over HTTP** — command names and numeric codes from RFC 1459/2812.
|
||
Familiar to anyone who's built IRC clients or bots.
|
||
3. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol
|
||
negotiation. HTTP is universal, proxy-friendly, and works everywhere.
|
||
4. **Server holds state** — clients are stateless. Reconnect, switch devices,
|
||
lose connectivity — your messages are waiting.
|
||
5. **Structured messages** — JSON with extensible metadata. Bodies are always
|
||
objects or arrays for deterministic canonicalization (JCS) and signing.
|
||
6. **Simple deployment** — single binary, SQLite default, zero mandatory
|
||
external dependencies.
|
||
7. **No eternal logs** — history rotates. Chat should be ephemeral by default.
|
||
8. **Federation optional** — single server works standalone. Linking is opt-in.
|
||
9. **Signable messages** — optional Ed25519 signatures with TOFU key distribution.
|
||
|
||
## Status
|
||
|
||
**Implementation in progress.** Core API is functional with SQLite storage and
|
||
embedded web client.
|
||
|
||
## License
|
||
|
||
MIT
|