This commit was merged in pull request #8.
This commit is contained in:
624
README.md
624
README.md
@@ -1,7 +1,15 @@
|
||||
# chat
|
||||
|
||||
A modern IRC-inspired chat server written in Go. Decouples session state from
|
||||
transport connections, enabling mobile-friendly persistent sessions over HTTP.
|
||||
**IRC plus message metadata, a signing system using it, and server-based
|
||||
backlog queues for multiple connected clients on one nick. All via HTTP.**
|
||||
|
||||
A 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
|
||||
|
||||
@@ -12,10 +20,166 @@ or pay for IRCCloud.
|
||||
This project builds a chat server that:
|
||||
|
||||
- Holds session state server-side (message queues, presence, channel membership)
|
||||
- Delivers messages over HTTP (JSON-RPC style)
|
||||
- Supports multiple concurrent connections per user session
|
||||
- Exposes a minimal, clean HTTP+JSON API — easy to build clients against
|
||||
- Supports multiple concurrent clients per user session
|
||||
- Provides IRC-like semantics: channels, nicks, topics, modes
|
||||
- Uses structured JSON messages with arbitrary extensibility
|
||||
- Uses structured JSON messages with IRC command names and numeric reply codes
|
||||
|
||||
## Why Not Just Use IRC / XMPP / Matrix?
|
||||
|
||||
This isn't a new protocol that borrows IRC terminology for familiarity. This
|
||||
**is** IRC — the same command model, the same semantics, the same numeric
|
||||
reply codes from RFC 1459/2812 — carried over HTTP+JSON instead of raw TCP.
|
||||
|
||||
The question isn't "why build something new?" It's "what's the minimum set of
|
||||
changes to make IRC work on modern devices?" The answer turned out to be four
|
||||
things:
|
||||
|
||||
### 1. HTTP transport instead of persistent TCP
|
||||
|
||||
IRC requires a persistent TCP connection. That's fine on a desktop. On a phone,
|
||||
the OS kills your background socket, you lose your session, you miss messages.
|
||||
Bouncers exist but add complexity and a second point of failure.
|
||||
|
||||
HTTP solves this cleanly: clients poll when they're awake, messages queue when
|
||||
they're not. Works through firewalls, proxies, CDNs. Every language has an HTTP
|
||||
client. No custom protocol parsers, no connection state machines.
|
||||
|
||||
### 2. Server-held session state
|
||||
|
||||
In IRC, the TCP connection *is* the session. Disconnect and you're gone — your
|
||||
nick is released, you leave all channels, messages sent while you're offline
|
||||
are lost forever. This is IRC's fundamental mobile problem.
|
||||
|
||||
Here, sessions persist independently of connections. Your nick, channel
|
||||
memberships, and message queue survive disconnects. Multiple devices can share
|
||||
a session simultaneously, each with its own delivery queue.
|
||||
|
||||
### 3. Structured message bodies
|
||||
|
||||
IRC messages are single lines of text. That's a protocol constraint from 1988,
|
||||
not a deliberate design choice. It forces multiline content through ugly
|
||||
workarounds (multiple PRIVMSG commands, paste flood).
|
||||
|
||||
Message bodies here are JSON arrays (one string per line) or objects (for
|
||||
structured data like key material). This also enables deterministic
|
||||
canonicalization via RFC 8785 JCS — you can't reliably sign something if the
|
||||
wire representation is ambiguous.
|
||||
|
||||
### 4. Key/value metadata on messages
|
||||
|
||||
The `meta` field on every message envelope carries extensible attributes —
|
||||
cryptographic signatures, content hashes, whatever clients want to attach.
|
||||
IRC has no equivalent; bolting signatures onto IRC requires out-of-band
|
||||
mechanisms or stuffing data into CTCP.
|
||||
|
||||
### What didn't change
|
||||
|
||||
Everything else is IRC. `PRIVMSG`, `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`,
|
||||
`KICK`, `353`, `433` — same commands, same semantics. Channels start with `#`.
|
||||
Joining a nonexistent channel creates it. Channels disappear when empty. Nicks
|
||||
are unique per server. There are no accounts — identity is a key, a nick is a
|
||||
display name.
|
||||
|
||||
### On the resemblance to JSON-RPC
|
||||
|
||||
All C2S commands go through `POST /messages` with a `command` field that
|
||||
dispatches the action. This looks like JSON-RPC, but the resemblance is
|
||||
incidental. It's IRC's command model — `PRIVMSG #channel :hello` becomes
|
||||
`{"command": "PRIVMSG", "to": "#channel", "body": ["hello"]}` — encoded as
|
||||
JSON rather than space-delimited text. The command vocabulary is IRC's, not
|
||||
an invention.
|
||||
|
||||
The message envelope is deliberately identical for C2S and S2C. A `PRIVMSG` is
|
||||
a `PRIVMSG` regardless of direction. A `JOIN` from a client is the same shape
|
||||
as the `JOIN` relayed to channel members. This keeps the protocol simple and
|
||||
makes signing consistent — you sign the same structure you send.
|
||||
|
||||
### Why not XMPP or Matrix?
|
||||
|
||||
XMPP is XML-based, overengineered for chat, and the ecosystem is fragmented
|
||||
across incompatible extensions (XEPs). Matrix is a federated append-only event
|
||||
graph with a spec that runs to hundreds of pages. Both are fine protocols, but
|
||||
they're solving different problems at different scales.
|
||||
|
||||
This project wants IRC's simplicity with four specific fixes. That's it.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 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
|
||||
|
||||
@@ -24,117 +188,335 @@ This project builds a chat server that:
|
||||
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 poll for new messages via `GET` with long-polling
|
||||
support (server holds the connection open until messages arrive or timeout)
|
||||
- **Client sending**: Clients send messages/commands via `POST`
|
||||
- **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)
|
||||
|
||||
### Core Concepts
|
||||
The entire read/write loop for a client is two endpoints. Everything else is
|
||||
channel management and history.
|
||||
|
||||
#### Users
|
||||
### Session Model
|
||||
|
||||
- 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
|
||||
|
||||
#### Messages
|
||||
|
||||
Every message is a structured JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"ts": "2026-02-09T20:00:00.000Z",
|
||||
"from": "nick",
|
||||
"to": "#channel",
|
||||
"type": "message",
|
||||
"body": "Hello, world!",
|
||||
"meta": {}
|
||||
}
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ User Session (UUID) │
|
||||
│ nick: "alice" │
|
||||
│ signing key: ed25519:... │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Client A │ │ Client B │ ... │
|
||||
│ │ UUID │ │ UUID │ │
|
||||
│ │ token │ │ token │ │
|
||||
│ │ queue │ │ queue │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `id` — Server-assigned UUID, globally unique
|
||||
- `ts` — Server-assigned timestamp (ISO 8601)
|
||||
- `from` — Sender nick
|
||||
- `to` — Destination: channel name (`#foo`) or nick (for DMs)
|
||||
- `type` — Message type: `message`, `action`, `notice`, `join`, `part`, `quit`,
|
||||
`topic`, `mode`, `nick`, `system`
|
||||
- `body` — Message content (UTF-8 text)
|
||||
- `meta` — Arbitrary extensible metadata (JSON object). Can carry:
|
||||
- Cryptographic signatures
|
||||
- Rich content hints (URLs, embeds)
|
||||
- Client-specific extensions
|
||||
- Reactions, edits, threading references
|
||||
- **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.
|
||||
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 — see below) |
|
||||
| `id` | string (uuid) | | Server-assigned message UUID |
|
||||
| `ts` | string | | Server-assigned ISO 8601 timestamp |
|
||||
| `meta` | object | | Extensible metadata (signatures, hashes, etc.) |
|
||||
|
||||
**Important:** Message bodies are **structured objects or arrays**, never raw
|
||||
strings. This is a deliberate departure from IRC wire format that enables:
|
||||
|
||||
- **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):
|
||||
|
||||
```json
|
||||
{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]}
|
||||
{"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["line one", "line two"]}
|
||||
```
|
||||
|
||||
For numeric replies with text trailing parameters:
|
||||
|
||||
```json
|
||||
{"command": "001", "to": "nick", "body": ["Welcome to the network, nick"]}
|
||||
{"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["@op1 alice bob"]}
|
||||
```
|
||||
|
||||
For structured data (keys, etc.), `body` is an object:
|
||||
|
||||
```json
|
||||
{"command": "PUBKEY", "from": "nick", "body": {"alg": "ed25519", "key": "base64..."}}
|
||||
```
|
||||
|
||||
#### IRC Command Mapping
|
||||
|
||||
**Commands (C2S and S2C):**
|
||||
|
||||
| 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 |
|
||||
|
||||
All C2S commands may be relayed S2C to other users (e.g. JOIN, PART, PRIVMSG).
|
||||
|
||||
**Numeric Reply Codes (S2C):**
|
||||
|
||||
| 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 |
|
||||
|
||||
**Server-to-Server (Federation):**
|
||||
|
||||
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.
|
||||
|
||||
#### Message Examples
|
||||
|
||||
```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..."}}
|
||||
```
|
||||
|
||||
#### 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.
|
||||
Servers relay signatures verbatim without verifying them — verification is
|
||||
purely a client-side concern.
|
||||
|
||||
#### Canonicalization (RFC 8785 JCS)
|
||||
|
||||
To produce a deterministic byte representation of a message for signing:
|
||||
|
||||
1. 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 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
|
||||
|
||||
All endpoints accept and return `application/json`.
|
||||
All endpoints accept and return `application/json`. Authenticated endpoints
|
||||
require `Authorization: Bearer <token>` header.
|
||||
|
||||
#### Authentication
|
||||
The API is the primary interface — designed for IRC-style clients. The entire
|
||||
client loop is:
|
||||
|
||||
```
|
||||
POST /api/v1/register — Create account (nick, password) → token
|
||||
POST /api/v1/login — Authenticate → token
|
||||
POST /api/v1/logout — Invalidate 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?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.
|
||||
|
||||
#### Quick example (curl)
|
||||
|
||||
```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)
|
||||
|
||||
# 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"}'
|
||||
|
||||
# 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, 15s timeout)
|
||||
curl -s "http://localhost:8080/api/v1/messages?timeout=15" \
|
||||
-H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
#### Session & Messages
|
||||
#### Session
|
||||
|
||||
```
|
||||
GET /api/v1/messages — Retrieve queued messages (long-poll supported)
|
||||
Query params: ?after=<message-id>&timeout=30
|
||||
POST /api/v1/messages — Send a message or command
|
||||
GET /api/v1/history — Retrieve channel/DM history
|
||||
Query params: ?target=#channel&before=<id>&limit=50
|
||||
POST /api/v1/session — Create session { "nick": "..." }
|
||||
→ { id, nick, token }
|
||||
Token is opaque (random), not JWT.
|
||||
Token implicitly identifies the client.
|
||||
```
|
||||
|
||||
#### State
|
||||
|
||||
```
|
||||
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, 15s timeout)
|
||||
All message types: channel, DM, notices, events
|
||||
Delivers from the calling client's queue
|
||||
(identified by auth token)
|
||||
Query params: ?after=<message-id>&timeout=15
|
||||
POST /api/v1/messages — Send any C2S command (dispatched by "command" field)
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```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"}
|
||||
```
|
||||
|
||||
Messages are immutable — no edit or delete endpoints.
|
||||
|
||||
#### 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 — List joined channels
|
||||
POST /api/v1/channels/join — Join a channel
|
||||
POST /api/v1/channels/part — Leave a channel
|
||||
GET /api/v1/channels/{name} — Channel info (topic, members, modes)
|
||||
POST /api/v1/channels/{name}/topic — Set channel topic
|
||||
GET /api/v1/channels — List all server channels
|
||||
GET /api/v1/channels/{name}/members — Channel member list
|
||||
```
|
||||
|
||||
#### Users
|
||||
|
||||
```
|
||||
GET /api/v1/users/me — Current user info
|
||||
POST /api/v1/users/nick — Change nick
|
||||
GET /api/v1/users/{nick} — User info (online status, idle time)
|
||||
```
|
||||
Join and part are handled via `POST /api/v1/messages` with `JOIN` and `PART`
|
||||
commands (see Messages above).
|
||||
|
||||
#### Server Info
|
||||
|
||||
```
|
||||
GET /api/v1/server — Server info (name, version, MOTD, user count)
|
||||
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:
|
||||
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)
|
||||
@@ -142,8 +524,9 @@ POST /api/v1/federation/relay — Relay messages between linked servers
|
||||
GET /api/v1/federation/status — Link status
|
||||
```
|
||||
|
||||
Federation uses the same HTTP+JSON transport. Messages are relayed between
|
||||
servers so users on different servers can share channels.
|
||||
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
|
||||
|
||||
@@ -170,7 +553,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 |
|
||||
@@ -180,11 +565,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
|
||||
@@ -194,44 +580,31 @@ Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/s
|
||||
```
|
||||
chat/
|
||||
├── cmd/
|
||||
│ └── chat/
|
||||
│ └── chatd/
|
||||
│ └── main.go
|
||||
├── internal/
|
||||
│ ├── config/
|
||||
│ │ └── config.go
|
||||
│ ├── database/
|
||||
│ │ └── database.go
|
||||
│ ├── globals/
|
||||
│ │ └── globals.go
|
||||
│ ├── handlers/
|
||||
│ │ ├── handlers.go
|
||||
│ │ ├── auth.go
|
||||
│ │ ├── channels.go
|
||||
│ │ ├── federation.go
|
||||
│ │ ├── healthcheck.go
|
||||
│ │ ├── messages.go
|
||||
│ │ └── users.go
|
||||
│ ├── healthcheck/
|
||||
│ │ └── healthcheck.go
|
||||
│ ├── logger/
|
||||
│ │ └── logger.go
|
||||
│ ├── middleware/
|
||||
│ │ └── middleware.go
|
||||
│ ├── models/
|
||||
│ │ ├── channel.go
|
||||
│ │ ├── message.go
|
||||
│ │ └── user.go
|
||||
│ ├── queue/
|
||||
│ │ └── queue.go
|
||||
│ └── server/
|
||||
│ ├── server.go
|
||||
│ ├── http.go
|
||||
│ └── routes.go
|
||||
├── schema/
|
||||
│ ├── message.schema.json
|
||||
│ ├── c2s/
|
||||
│ ├── s2c/
|
||||
│ ├── s2s/
|
||||
│ └── README.md
|
||||
├── web/
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── Makefile
|
||||
├── Dockerfile
|
||||
├── CONVENTIONS.md → (copy from gohttpserver)
|
||||
├── CONVENTIONS.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
@@ -252,20 +625,33 @@ Per gohttpserver conventions:
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol
|
||||
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. **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.
|
||||
4. **HTTP is the only transport** — no WebSockets, no raw TCP, no protocol
|
||||
negotiation. HTTP is universal, proxy-friendly, and works everywhere.
|
||||
2. **Server holds state** — clients are stateless. Reconnect, switch devices,
|
||||
lose connectivity — your messages are waiting.
|
||||
3. **Structured messages** — JSON with extensible metadata. Enables signatures,
|
||||
rich content, client extensions without protocol changes.
|
||||
4. **Simple deployment** — single binary, SQLite default, zero mandatory
|
||||
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.
|
||||
7. **Immutable messages** — no editing, no deletion. Fits naturally with
|
||||
cryptographic signatures.
|
||||
8. **Simple deployment** — single binary, SQLite default, zero mandatory
|
||||
external dependencies.
|
||||
5. **No eternal logs** — history rotates. Chat should be ephemeral by default.
|
||||
6. **Federation optional** — single server works standalone. Linking is opt-in.
|
||||
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
|
||||
|
||||
**Design phase.** This README is the spec. Implementation has not started.
|
||||
**Implementation in progress.** Core API is functional with SQLite storage and
|
||||
embedded web client.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
Reference in New Issue
Block a user