659 lines
26 KiB
Markdown
659 lines
26 KiB
Markdown
# 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.**
|
||
|
||
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
|
||
|
||
IRC is in decline because session state is tied to the TCP connection. In a
|
||
mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer
|
||
or pay for IRCCloud.
|
||
|
||
This project builds a chat server that:
|
||
|
||
- Holds session state server-side (message queues, presence, channel membership)
|
||
- Exposes a minimal, clean HTTP+JSON API — easy to build clients against
|
||
- Supports multiple concurrent clients per user session
|
||
- Provides IRC-like semantics: channels, nicks, topics, modes
|
||
- Uses structured JSON messages with IRC command names and numeric reply codes
|
||
|
||
## 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
|
||
|
||
### 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)
|
||
|
||
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.
|
||
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`. 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/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
|
||
|
||
```
|
||
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 all server channels
|
||
GET /api/v1/channels/{name}/members — Channel member list
|
||
```
|
||
|
||
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, MOTD)
|
||
GET /.well-known/healthcheck.json — Health check
|
||
```
|
||
|
||
### Federation (Server-to-Server)
|
||
|
||
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)
|
||
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
|
||
|
||
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` |
|
||
|
||
### 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. **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.
|
||
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.
|
||
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
|
||
|
||
**Implementation in progress.** Core API is functional with SQLite storage and
|
||
embedded web client.
|
||
|
||
## License
|
||
|
||
MIT
|