docs: document register/login and dual authentication model (#77)
All checks were successful
check / check (push) Successful in 1m46s

closes #36

The README claimed "no accounts" and "no passwords" but the codebase has `POST /api/v1/register` and `POST /api/v1/login` endpoints with bcrypt password hashing. This PR updates the README to accurately describe the dual authentication model.

## Changes

### Identity & Sessions section
- Renamed from "No Accounts" to "Dual Authentication Model"
- Documented anonymous sessions (`POST /api/v1/session`) as the instant-access path
- Documented optional account registration (`POST /api/v1/register`) with password requirements
- Documented login (`POST /api/v1/login`) for returning to registered accounts
- Updated rationale to explain why both paths exist

### API Reference
- Added `POST /api/v1/register` endpoint documentation: request/response format, field constraints (min 8 char password), error codes, curl example
- Added `POST /api/v1/login` endpoint documentation: request/response format, channel state initialization behavior, error codes, curl example

### Security Model → Authentication
- Added password hashing details (bcrypt at default cost)
- Documented that anonymous sessions have empty `password_hash` and cannot use `/login`
- Distinguished between anonymous and registered auth paths

### Design Principles
- Changed principle #2 from "No accounts" to "Accounts optional" with updated description

### Schema section
- Updated from outdated `users` table to actual `sessions` table (with `password_hash`, `signing_key`, `away_message`, `uuid` columns)
- Added `clients` table documentation (session_id FK, token, uuid)

### Session Lifecycle
- Added "Registered Account" flow diagram showing register → use → login-from-new-device

### Multi-Client Model
- Updated MVP note to document that `POST /api/v1/login` is the working multi-client mechanism

### Client Development Guide
- Added register and login curl examples alongside anonymous session creation
- Updated error handling and reconnection guidance for registered accounts

### Data Lifecycle
- Documented that registered sessions persist across logouts (unlike anonymous)
- Added client lifecycle documentation

### Other
- Fixed token storage description (SHA-256 hash, not raw)
- Updated "What didn't change" section to reflect optional accounts

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #77
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #77.
This commit is contained in:
2026-03-17 12:44:48 +01:00
committed by Jeffrey Paul
parent 052674b4ee
commit e9d794764b

282
README.md
View File

@@ -113,8 +113,9 @@ mechanisms or stuffing data into CTCP.
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.
are unique per server. Identity starts with a key a nick is a display name.
Accounts are optional: you can create an anonymous session instantly, or
register with a password for multi-client access to a single session.
### On the resemblance to JSON-RPC
@@ -148,16 +149,45 @@ not arbitrary choices — each one follows from the project's core thesis that
IRC's command model is correct and only the transport and session management
need to change.
### Identity & Sessions — No Accounts
### Identity & Sessions — Dual Authentication Model
There are no accounts, no registration, no passwords. Identity is a signing
key; a nick is just a display name. The two are decoupled.
The server supports two authentication paths: **anonymous sessions** for
instant access, and **optional account registration** for multi-client access.
#### Anonymous Sessions (No Account Required)
The simplest entry point. No registration, no passwords.
- **Session creation**: client sends `POST /api/v1/session` with a desired
nick → server assigns an **auth token** (64 hex characters of
cryptographically random bytes) and returns the user ID, nick, and token.
- The auth token implicitly identifies the client. Clients present it via
`Authorization: Bearer <token>`.
- Anonymous sessions are ephemeral — when the session expires or the user
QUITs, the nick is released and there is no way to reclaim it.
#### Registered Accounts (Optional)
For users who want multi-client access (multiple devices sharing one session):
- **Registration**: client sends `POST /api/v1/register` with a nick and
password (minimum 8 characters) → server creates a session with the
password hashed via bcrypt, and returns the user ID, nick, and auth token.
- **Login**: client sends `POST /api/v1/login` with nick and password →
server verifies the password against the stored bcrypt hash and creates a
new client token for the existing session. This enables multi-client
access: logging in from a new device adds a client to the existing session
rather than creating a new one, so channel memberships and message queues
are shared. Note: login only works while the session still exists — if all
clients have logged out or the user has sent QUIT, the session is deleted
and the registration is lost.
- Registered accounts cannot be logged into via `POST /api/v1/session`
that endpoint is for anonymous sessions only.
- Anonymous sessions (created via `/session`) cannot be logged into via
`/login` because they have no password set.
#### Common Properties (Both Paths)
- Nicks are changeable via the `NICK` command; the server-assigned user ID is
the stable identity.
- Server-assigned IDs — clients do not choose their own IDs.
@@ -165,11 +195,17 @@ key; a nick is just a display name. The two are decoupled.
in the token, no client-side decode. The server is the sole authority on
token validity.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk. Adding
registration, email verification, or OAuth would solve a problem nobody asked
about and add complexity that drives away casual users. Identity verification
is handled at the message layer via cryptographic signatures (see
[Security Model](#security-model)), not at the session layer.
**Rationale:** IRC has no accounts. You connect, pick a nick, and talk.
Anonymous sessions preserve that simplicity — instant access, zero friction.
But some users want to access the same session from multiple devices without
a bouncer. Optional registration with password enables multi-client login
without adding friction for casual users: if you don't want an account,
don't create one. Note: in the current implementation, both anonymous and
registered sessions are deleted when the last client disconnects (QUIT or
logout); registration does not make a session survive all-client
removal. Identity verification at the message layer via cryptographic
signatures (see [Security Model](#security-model)) remains independent
of account registration.
### Nick Semantics
@@ -207,12 +243,12 @@ User Session
└── Client C (token_c, queue_c)
```
**Current MVP note:** The current implementation creates a new user (with new
nick) per `POST /api/v1/session` call. True multi-client (multiple tokens
sharing one nick/session) is supported by the schema (`client_queues` is keyed
by user_id, and multiple tokens can point to the same user) but the session
creation endpoint does not yet support "add a client to an existing session."
This will be added post-MVP.
**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new
client to an existing registered session, enabling true multi-client support
(multiple tokens sharing one nick/session with independent message queues).
Anonymous sessions created via `POST /api/v1/session` always create a new
user with a new nick. A future endpoint to "add a client to an existing
anonymous session" is planned but not yet implemented.
**Rationale:** The fundamental IRC mobile problem is that you can't have your
phone and laptop connected simultaneously without a bouncer. Server-side
@@ -327,8 +363,8 @@ needs to revoke a token, change the expiry model, or add/remove claims, JWT
clients may break or behave incorrectly.
Opaque tokens are simpler:
- Server generates 32 random bytes → hex-encodes → stores hash
- Client presents the token; server looks it up
- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash
- Client presents the raw token; server hashes and looks it up
- Revocation is a database delete
- No clock skew issues, no algorithm confusion, no "none" algorithm attacks
- Token format can change without breaking clients
@@ -355,6 +391,8 @@ The entire read/write loop for a client is two endpoints. Everything else
### Session Lifecycle
#### Anonymous Session
```
┌─ Client ──────────────────────────────────────────────────┐
│ │
@@ -385,6 +423,30 @@ The entire read/write loop for a client is two endpoints. Everything else
└────────────────────────────────────────────────────────────┘
```
#### Registered Account
```
┌─ Client ──────────────────────────────────────────────────┐
│ │
│ 1. POST /api/v1/register │
│ {"nick":"alice", "password":"s3cret!!"} │
│ → {"id":1, "nick":"alice", "token":"a1b2c3..."} │
│ (Session created with bcrypt-hashed password) │
│ │
│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │
│ │
│ (From another device, while session is still active) │
│ │
│ 2. POST /api/v1/login │
│ {"nick":"alice", "password":"s3cret!!"} │
│ → {"id":1, "nick":"alice", "token":"d4e5f6..."} │
│ (New client added to existing session — channels │
│ and message queues are preserved. If all clients │
│ have logged out, session no longer exists.) │
│ │
└────────────────────────────────────────────────────────────┘
```
### Queue Architecture
```
@@ -1034,6 +1096,105 @@ TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
echo $TOKEN
```
### POST /api/v1/register — Register Account
Create a new user session with a password. The password is hashed
with bcrypt and stored server-side. The password enables login from
additional clients via `POST /api/v1/login` while the session
remains active.
**Request Body:**
```json
{"nick": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | 132 characters, must be unique on the server |
| `password` | string | Yes | Minimum 8 characters |
**Response:** `201 Created`
```json
{
"id": 1,
"nick": "alice",
"token": "494ba9fc0f2242873fc5c285dd4a24fc3844ba5e67789a17e69b6fe5f8c132e3"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Server-assigned user ID |
| `nick` | string | Confirmed nick |
| `token` | string | 64-character hex auth token |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `invalid nick format` | Nick doesn't match allowed format |
| 400 | `password must be at least 8 characters` | Password too short |
| 409 | `nick already taken` | Another active session holds this nick |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
echo $TOKEN
```
### POST /api/v1/login — Login to Account
Authenticate with a previously registered nick and password. Creates a new
client token for the existing session, preserving channel memberships and
message queues. This is how multi-client access works for registered accounts:
each login adds a new client to the session.
On successful login, the server enqueues MOTD messages and synthetic channel
state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the
new client's queue, so the client can immediately restore its UI state.
**Request Body:**
```json
{"nick": "alice", "password": "mypassword"}
```
| Field | Type | Required | Constraints |
|------------|--------|----------|-------------|
| `nick` | string | Yes | Must match a registered account |
| `password` | string | Yes | Must match the account's password |
**Response:** `200 OK`
```json
{
"id": 1,
"nick": "alice",
"token": "7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f"
}
```
| Field | Type | Description |
|---------|---------|-------------|
| `id` | integer | Session ID (same as when registered) |
| `nick` | string | Current nick |
| `token` | string | New 64-character hex auth token for this client |
**Errors:**
| Status | Error | When |
|--------|-------|------|
| 400 | `nick and password required` | Missing nick or password |
| 401 | `invalid credentials` | Wrong password, nick not found, or account has no password |
**curl example:**
```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"alice","password":"mypassword"}' | jq -r .token)
echo $TOKEN
```
### GET /api/v1/state — Get Session State
Return the current user's session state.
@@ -1617,9 +1778,16 @@ authenticity.
### Authentication
- **Session auth**: Opaque bearer tokens (64 hex chars = 256 bits of entropy).
Tokens are stored in the database and validated on every request.
- **No passwords**: Session creation requires only a nick. The token is the
sole credential.
Tokens are hashed (SHA-256) before storage and validated on every request.
- **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No
password, instant access. The token is the sole credential.
- **Registered accounts**: `POST /api/v1/register` accepts a nick and password
(minimum 8 characters). The password is hashed with bcrypt at the default
cost factor and stored alongside the session. `POST /api/v1/login`
authenticates against the stored hash and issues a new client token.
- **Password security**: Passwords are never stored in plain text. bcrypt
handles salting and key stretching automatically. Anonymous sessions have
an empty `password_hash` and cannot be logged into via `/login`.
- **Token security**: Tokens should be treated like session cookies. Transmit
only over HTTPS in production. If a token is compromised, the attacker has
full access to the session until QUIT or expiry.
@@ -1767,13 +1935,26 @@ The database schema is managed via embedded SQL migration files in
**Current tables:**
#### `users`
#### `sessions`
| Column | Type | Description |
|----------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `uuid` | TEXT | Unique session UUID |
| `nick` | TEXT | Unique nick |
| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) |
| `signing_key` | TEXT | Public signing key (empty string if unset) |
| `away_message` | TEXT | Away message (empty string if not away) |
| `created_at` | DATETIME | Session creation time |
| `last_seen` | DATETIME | Last API request time |
#### `clients`
| Column | Type | Description |
|-------------|----------|-------------|
| `id` | INTEGER | Primary key (auto-increment) |
| `nick` | TEXT | Unique nick |
| `token` | TEXT | Unique auth token (64 hex chars) |
| `created_at`| DATETIME | Session creation time |
| `uuid` | TEXT | Unique client UUID |
| `session_id`| INTEGER | FK → sessions.id (cascade delete) |
| `token` | TEXT | Unique auth token (SHA-256 hash of 64 hex chars) |
| `created_at`| DATETIME | Client creation time |
| `last_seen` | DATETIME | Last API request time |
#### `channels`
@@ -1830,10 +2011,19 @@ skew issues) and simpler than UUIDs (integer comparison vs. string comparison).
- **Client output queue entries**: Pruned automatically when older than
`QUEUE_MAX_AGE` (default 30 days).
- **Channels**: Deleted when the last member leaves (ephemeral).
- **Users/sessions**: Deleted on `QUIT` or `POST /api/v1/logout`. Idle
sessions are automatically expired after `SESSION_IDLE_TIMEOUT` (default
30 days) — the server runs a background cleanup loop that parts idle users
from all channels, broadcasts QUIT, and releases their nicks.
- **Sessions**: Both anonymous and registered sessions are deleted on `QUIT`
or when the last client logs out (`POST /api/v1/logout` with no remaining
clients triggers session cleanup). There is no distinction between session
types in the cleanup path — `handleQuit` and `cleanupUser` both call
`DeleteSession` unconditionally. Idle sessions are automatically expired
after `SESSION_IDLE_TIMEOUT`
(default 30 days) — the server runs a background cleanup loop that parts
idle users from all channels, broadcasts QUIT, and releases their nicks.
- **Clients**: Individual client tokens are deleted on `POST /api/v1/logout`.
A session can have multiple clients; removing one doesn't affect others.
However, when the last client is removed (via logout), the entire session
is deleted — the user is parted from all channels, QUIT is broadcast, and
the nick is released.
---
@@ -1982,11 +2172,21 @@ A complete client needs only four HTTP calls:
### Step-by-Step with curl
```bash
# 1. Create a session
# 1a. Create an anonymous session (no account)
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser"}' | jq -r .token)
# 1b. Or register an account (multi-client support)
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/register \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
# 1c. Or login to an existing account
export TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/login \
-H 'Content-Type: application/json' \
-d '{"nick":"testuser","password":"mypassword"}' | jq -r .token)
# 2. Join a channel
curl -s -X POST http://localhost:8080/api/v1/messages \
-H "Authorization: Bearer $TOKEN" \
@@ -2119,9 +2319,11 @@ Clients should handle these message commands from the queue:
### Error Handling
- **HTTP 401**: Token expired or invalid. Re-create session.
- **HTTP 401**: Token expired or invalid. Re-create session (anonymous) or
re-login (registered account).
- **HTTP 404**: Channel or user not found.
- **HTTP 409**: Nick already taken (on session creation or NICK change).
- **HTTP 409**: Nick already taken (on session creation, registration, or
NICK change).
- **HTTP 400**: Malformed request. Check the `error` field in the response.
- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s).
@@ -2138,8 +2340,10 @@ Clients should handle these message commands from the queue:
4. **DM tab logic**: When you receive a PRIVMSG where `to` is not a channel
(no `#` prefix), the DM tab should be keyed by the **other** user's nick:
if `from` is you, use `to`; if `from` is someone else, use `from`.
5. **Reconnection**: If the poll loop fails with 401, the session is gone.
Create a new session. If it fails with a network error, retry with backoff.
5. **Reconnection**: If the poll loop fails with 401, the token is invalid.
For anonymous sessions, create a new session. For registered accounts,
log in again via `POST /api/v1/login` to get a fresh token on the same
session. If it fails with a network error, retry with backoff.
---
@@ -2412,9 +2616,13 @@ neoirc/
build a working IRC-style TUI client against this API in an afternoon, the
API is too complex.
2. **No accounts** — identity is a signing key, nick is a display name. No
registration, no passwords, no email verification. Session creation is
instant. The cost of entry is a hashcash proof, not bureaucracy.
2. **Accounts optional** — anonymous sessions are instant: pick a nick and
talk. No registration, no email verification. The cost of entry is a
hashcash proof, not bureaucracy. For users who want multi-client access
(multiple devices sharing one session), optional account registration
with password is available — but never required. Identity
verification at the message layer uses cryptographic signing,
independent of account status.
3. **IRC semantics over HTTP** — command names and numeric codes from
RFC 1459/2812. If you've built an IRC client or bot, you already know the