refactor: model message schemas after IRC RFC 1459/2812

Replace c2s/s2c/s2s taxonomy with IRC-native structure:
- schema/commands/ — IRC command schemas (PRIVMSG, NOTICE, JOIN, PART,
  QUIT, NICK, TOPIC, MODE, KICK, PING, PONG)
- schema/numerics/ — IRC numeric reply codes (001-004, 322-323, 332,
  353, 366, 372-376, 401, 403, 433, 442, 482)
- schema/message.json — base envelope mapping IRC wire format to JSON

Messages use 'command' field with IRC command names or 3-digit numeric
codes. 'body' is a string (IRC trailing parameter), not object/array.
'from'/'to' map to IRC prefix and first parameter.

Federation uses the same IRC commands (no custom RELAY/LINK/SYNC).

Update README message format, command tables, and examples to match.
This commit is contained in:
clawbot
2026-02-10 10:31:26 -08:00
parent c8d88de8c5
commit dfb1636be5
72 changed files with 963 additions and 1149 deletions

227
README.md
View File

@@ -18,10 +18,87 @@ 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
- 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
## Design Decisions
### Identity & Sessions — No Accounts
There are no accounts, no registration, no passwords. Identity is a signing
key; a nick is just a display name. The two are decoupled.
- **Session creation**: client connects → server assigns a **session UUID**
(user identity for this server), a **client UUID** (this specific device),
and an **opaque auth token** (random bytes, not JWT).
- The auth token implicitly identifies the client. Clients present it via
`Authorization: Bearer <token>`.
- Nicks are changeable; the session UUID is the stable identity.
- Server-assigned UUIDs — clients do not choose their own IDs.
### Multi-Client Model
A single user session can have multiple clients (phone, laptop, terminal).
- Each client gets a **separate server-to-client (S2C) message queue**.
- The server fans out all S2C messages to every active client queue for that
user session.
- `GET /api/v1/messages` delivers from the calling client's specific queue,
identified by the auth token.
- Client queues have **independent expiry/pruning** — one client going offline
doesn't affect others.
```
User (session UUID)
├── Client A (client UUID, token, queue)
├── Client B (client UUID, token, queue)
└── Client C (client UUID, token, queue)
```
### Message Immutability
Messages are **immutable** — no editing, no deletion by clients. This is a
deliberate design choice that enables cryptographic signing: if a message could
be modified after signing, signatures would be meaningless.
### Message Delivery
- **Long-poll timeout**: 15 seconds
- **Queue depth**: server-configurable, default at least 48 hours worth of
messages
- **No delivery/read receipts** except in DMs
- **Bodies are structured** objects or arrays (never raw strings) — enables
deterministic canonicalization via RFC 8785 JCS for signing
### Crypto & Signing
- Servers **relay signatures verbatim** — signatures are key/value metadata on
message objects (`meta.sig`, `meta.alg`). Servers do not verify them.
- Clients handle key authentication via **TOFU** (trust on first use).
- **No key revocation mechanism** — keep your keys safe.
- **PUBKEY** message type for distributing signing keys to channel members.
- **E2E encryption for DMs** is planned for 1.0.
### Channels
- **Any user can create channels** — joining a nonexistent channel creates it,
like IRC.
- **Ephemeral** — channels disappear when the last member leaves.
- No channel size limits.
- No channel-level encryption.
### Federation
- **Manual server linking only** — no autodiscovery, no mesh. Operators
explicitly configure server links.
- Servers relay messages (including signatures) verbatim.
### Web Client
The SPA web client is a **convenience UI**. The primary interface is IRC-style
client apps talking directly to the HTTP API.
## Architecture
### Transport: HTTP only
@@ -30,7 +107,8 @@ 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.
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)
@@ -38,6 +116,33 @@ request/response bodies. No WebSockets, no raw TCP, no gRPC — just plain HTTP.
The entire read/write loop for a client is two endpoints. Everything else is
channel management and history.
### Session Model
```
┌─────────────────────────────────┐
│ User Session (UUID) │
│ nick: "alice" │
│ signing key: ed25519:... │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Client A │ │ Client B │ ... │
│ │ UUID │ │ UUID │ │
│ │ token │ │ token │ │
│ │ queue │ │ queue │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────┘
```
- **User session**: server-assigned UUID. Represents a user on this server.
Has a nick (changeable, unique per server at any point in time).
- **Client**: each device/connection gets its own UUID and opaque auth token.
The token is the credential — present it to authenticate.
- **Queue**: each client has an independent S2C message queue. The server fans
out messages to all active client queues for the session.
Sessions persist across disconnects. Messages queue until retrieved. Client
queues expire independently after a configurable idle timeout.
### Message Protocol
All messages use **IRC command names and numeric reply codes** from RFC 1459/2812.
@@ -72,7 +177,7 @@ This enables:
|----------|-------------|
| PRIVMSG | Send message to channel or user |
| NOTICE | Send notice (no auto-reply expected) |
| JOIN | Join a channel |
| JOIN | Join a channel (creates it if nonexistent) |
| PART | Leave a channel |
| QUIT | Disconnect from server |
| NICK | Change nickname |
@@ -96,7 +201,7 @@ All C2S commands may be echoed back as S2C (relayed to other users), plus:
| Code | Name | Description |
|------|-------------------|-------------|
| 001 | RPL_WELCOME | Welcome after registration |
| 001 | RPL_WELCOME | Welcome after session creation |
| 002 | RPL_YOURHOST | Server host information |
| 322 | RPL_LIST | Channel list entry |
| 353 | RPL_NAMREPLY | Names list for a channel |
@@ -144,6 +249,8 @@ complete index.
### Canonicalization and Signing
Messages support optional cryptographic signatures for integrity verification.
Servers relay signatures verbatim without verifying them — verification is
purely a client-side concern.
#### Canonicalization (RFC 8785 JCS)
@@ -179,34 +286,9 @@ under canonicalization.
{"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
Servers relay PUBKEY messages to all channel members. Clients cache public keys
and use them to verify `meta.sig` on incoming messages. Key distribution is
trust-on-first-use (TOFU). There is no key revocation mechanism.
### API Endpoints
@@ -216,9 +298,9 @@ 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
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?after=0` — long-poll for all messages (channel, DM, system)
3. `GET /api/v1/messages?timeout=15` — long-poll for all messages (channel, DM, system)
4. `POST /api/v1/messages` — send to `"#channel"` or `"nick"`
That's the core. Everything else (join, part, history, members) is ancillary.
@@ -226,11 +308,11 @@ 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 \
# Create a session (get session UUID, client UUID, and auth token)
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-d '{"nick":"alice"}' | jq -r .token)
# Join a channel
# Join a channel (creates it if it doesn't exist)
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
-d '{"command":"JOIN","to":"#general"}'
@@ -240,34 +322,41 @@ 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 \
# Poll for messages (long-poll, 15s timeout)
curl -s "http://localhost:8080/api/v1/messages?timeout=15" \
-H "Authorization: Bearer $TOKEN"
```
#### Registration
#### Session
```
POST /api/v1/register — Create account { "nick": "..." } → { id, nick, token }
POST /api/v1/session — Create session { "nick": "..." }
→ { session_id, client_id, nick, token }
Token is opaque (random), not JWT.
Token implicitly identifies the client.
```
#### State
```
GET /api/v1/state — User state: nick, id, and list of joined channels
Replaces separate /me and /channels endpoints
GET /api/v1/state — User state: nick, session_id, client_id,
and list of joined channels
```
#### Messages (unified stream)
```
GET /api/v1/messages — Single message stream (long-poll supported)
GET /api/v1/messages — Single message stream (long-poll, 15s timeout)
All message types: channel, DM, notices, events
Query params: ?after=<message-id>&timeout=30
Delivers from the calling client's queue
(identified by auth token)
Query params: ?after=<message-id>&timeout=15
POST /api/v1/messages — Send a message (IRC command in body)
Body: { "command": "PRIVMSG", "to": "#channel", "body": ["..."] }
```
Messages are immutable — no edit or delete endpoints.
#### History
```
@@ -281,7 +370,9 @@ GET /api/v1/history — Fetch history for a target (channel or DM)
```
GET /api/v1/channels/all — List all server channels
POST /api/v1/channels/join — Join a channel { "channel": "#name" }
Creates the channel if it doesn't exist.
DELETE /api/v1/channels/{name} — Part (leave) a channel
Channel is destroyed when last member leaves.
GET /api/v1/channels/{name}/members — Channel member list
```
@@ -294,7 +385,8 @@ GET /.well-known/healthcheck.json — Health check
### Federation (Server-to-Server)
Servers can link to form a network, similar to IRC server linking:
Servers can link to form a network, similar to IRC server linking. Links are
**manually configured** — there is no autodiscovery.
```
POST /api/v1/federation/link — Establish server link (mutual auth via shared key)
@@ -303,8 +395,8 @@ 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.
UNLINK, SYNC, PING, and PONG commands. Messages (including signatures) are
relayed verbatim between servers so users on different servers can share channels.
### Channel Modes
@@ -331,7 +423,9 @@ Via environment variables (Viper), following gohttpserver conventions:
| `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 |
@@ -341,11 +435,12 @@ Via environment variables (Viper), following gohttpserver conventions:
SQLite by default (single-file, zero-config), with Postgres support for
larger deployments. Tables:
- `users`accounts and auth tokens
- `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`)
- `message_queue` — per-user pending delivery queue
- `client_queues` — per-client pending delivery queues
- `server_links` — federation peer configuration
### Project Structure
@@ -398,30 +493,30 @@ Per gohttpserver conventions:
| 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.
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.
3. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol
4. **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
5. **Server holds state** — clients are stateless. Reconnect, switch devices,
lose connectivity — your messages are waiting in your client queue.
6. **Structured messages** — JSON with extensible metadata. Bodies are always
objects or arrays for deterministic canonicalization (JCS) and signing.
6. **Simple deployment** — single binary, SQLite default, zero mandatory
7. **Immutable messages** — no editing, no deletion. Fits naturally with
cryptographic signatures.
8. **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.
9. **No eternal logs** — history rotates. Chat should be ephemeral by default.
Channels disappear when empty.
10. **Federation optional** — single server works standalone. Linking is manual
and opt-in.
11. **Signable messages** — optional Ed25519 signatures with TOFU key
distribution. Servers relay signatures without verification.
## Status