diff --git a/.golangci.yml b/.golangci.yml index 1b73eb9..945ef89 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -61,6 +61,10 @@ linters: desc: >- Test-support code belongs in test files and in packages whose directory name ends in test, not in the shipped binary. + - pkg: sneak.berlin/go/neoirc/internal/dbtest + desc: >- + Test-support code belongs in test files and in packages whose + directory name ends in test, not in the shipped binary. # Only decisions already recorded in the Go package defaults are # listed here. Every entry matches the module path exactly. gomodguard_v2: diff --git a/AGENTS.md b/AGENTS.md index d189eb2..b1a56ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,55 +1,73 @@ -# AGENTS.md — Rules for AI Agents +# Rules for coding agents -## Before Every Commit +This file holds the rules for coding agents working in this repository. +`CONVENTIONS.md` defines the architecture. `REPO_POLICIES.md` defines the +repository standards. Both are copies of shared documents. Do not edit them +here. -1. **Format**: `make fmt` -2. **Check**: `make check` — the tests and the linter, which run in Docker, and - the format check: all passing, zero issues +## Before every commit -Never run `go test`, `golangci-lint` or `gofmt` directly; use the `make` -targets. +1. Run `make fmt`. +2. Run `make check`. It runs the tests and the linter in Docker, then the format + check. All three must pass with no findings. -No commit lands on main with lint errors, test failures, or formatting issues. +Never run `go test`, `golangci-lint` or `gofmt` directly. Use the `make` targets +instead, such as `make test` and `make lint`. -## Code Style +## Branches and pull requests -- Follow CONVENTIONS.md exactly — it defines the architecture -- All exported types, functions, methods, and vars must have doc comments -- All packages must have a package doc comment -- JSON tags use camelCase (not snake_case) -- No magic numbers — use named constants -- No inline error handling (`if err := ...; err != nil`) — use plain assignment -- Blank line before every `return` statement -- Blank line separating embedded fields from regular fields in structs -- Blank imports must have a justification comment +- Never commit to `main` or `next` directly. Every change is a pull request from + a feature branch into `next`. +- Only the owner merges `next` into `main`. +- A pull request that closes an issue has the title `Description (closes #N)`. +- One logical change per commit. +- Commit messages are in the imperative mood and short. -## Git Workflow +## Code style -- **Never commit directly to main** — all changes go on feature branches -- Merge to main only when ready and passing all checks -- PR titles: `Description (closes #N)` when closing an issue -- One logical change per commit -- Commit messages: imperative mood, concise +- Follow `CONVENTIONS.md`. +- Every package has a package doc comment. Test files need none. +- Every exported type, function and variable has a doc comment. Test files need + none. +- JSON tags are camelCase. The HTTP API's `last_id`, `pow_token` and + `hashcash_bits` stay as they are, each with a `//nolint:tagliatelle` comment. +- Prefer named constants to magic numbers. The `mnd` linter checks for them + outside test files. +- No inline error handling such as `if err := f(); err != nil`. Assign the error + on its own line, then test it. +- A `return` that follows another statement in its block has a blank line before + it. +- A blank line between the embedded fields of a struct and its other fields. +- Every blank import has a comment saying why it is there. Test files need none. ## Architecture -- Dependency injection via Uber fx — all components are fx providers -- `internal/` for all application packages — one package per concern -- Handlers return `http.HandlerFunc` using the closure pattern -- Request/response structs defined inside handler closures -- Models embed `models.Base` for database access to relation methods -- Database migrations in `internal/db/schema/` — embedded via `go:embed` -- SQLite by default (pure Go driver: modernc.org/sqlite) +- The server uses `go.uber.org/fx` for dependency injection. + `cmd/neoircd/main.go` lists its `fx` providers, such as `db.New` and + `handlers.New`. +- Application packages live under `internal/`, one package per concern. +- `pkg/irc` holds the IRC command names and numeric reply codes. +- The `web` package embeds the built web client. +- Each exported `Handle…` method of `Handlers` returns an `http.HandlerFunc`. +- A request struct is defined inside the method that decodes it. +- The server uses the database only through the methods of `db.Database`. +- The database migrations live in `internal/db/schema/` and are embedded with + `go:embed`. +- The database is SQLite, through the pure Go driver `modernc.org/sqlite`. ## Testing -- Table-driven tests preferred -- Use `testing.T` helpers, not assertion libraries -- Test files live next to the code they test -- `_test.go` suffix, same package for white-box testing +- Prefer table-driven tests. +- Use `testing.T` helpers, not assertion libraries. +- A test file lives next to the code it tests and ends in `_test.go`. +- The linter requires each test file to be in the package's external test + package, such as `handlers_test`. It allows `export_test.go` and files ending + in `_internal_test.go` in the package itself. +- An `export_test.go` file gives the external tests access to the package's + unexported code. ## Dependencies -- Required libraries are listed in CONVENTIONS.md §1 -- No new dependencies without justification -- Pure Go preferred (no CGO) +- The required libraries are listed in `CONVENTIONS.md` §1. +- No new dependency without a reason given in the pull request. +- Prefer dependencies written in pure Go, which build without cgo. diff --git a/Dockerfile b/Dockerfile index d8fae5c..0e22ff2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,14 +33,18 @@ RUN golangci-lint run --config .golangci.yml ./... # golang:1.24.13-bookworm, 2026-10-06 FROM golang@sha256:1a6d4452c65dea36aac2e2d606b01b4a029ec90cc1ae53890540ce6173ea77ac AS test WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download COPY . . RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js # -p 4 because test runs on a shared build host cap their parallelism. -RUN go test -p 4 -timeout 90s -race -cover ./... || \ +# The modules download in this RUN rather than a layer of their own, because +# exporting a layer that holds them is slow. Nothing reads them or the build +# cache afterwards, so go clean removes both. +RUN set -e; \ + go mod download; \ + go test -p 4 -timeout 90s -race -cover ./... || \ { echo "--- Rerunning with -v for details ---"; \ - go test -p 4 -timeout 90s -race -v ./...; exit 1; } + go test -p 4 -timeout 90s -race -v ./...; exit 1; }; \ + go clean -cache -modcache # Build stage. Nothing is wanted from either phase above; the copies # are what make BuildKit build them first, so this stage cannot run diff --git a/README.md b/README.md index 7acab63..d05ce68 100644 --- a/README.md +++ b/README.md @@ -1,1662 +1,322 @@ # neoirc -**IRC semantics, structured message metadata, cryptographic signing, and -server-held session state with per-client delivery queues. All over HTTP+JSON.** +neoirc is an MIT-licensed chat server written in Go by +[@sneak](https://sneak.berlin). It carries IRC commands and replies as JSON over +HTTP, and keeps each user's session on the server. -An IRC-inspired server written in Go that decouples session state from transport -connections, enabling mobile-friendly persistent sessions over plain 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. - ---- - -## Table of Contents - -- [Motivation](#motivation) -- [Why Not IRC / XMPP / Matrix?](#why-not-just-use-irc--xmpp--matrix) -- [Design Decisions](#design-decisions) -- [Architecture](#architecture) -- [Protocol Specification](#protocol-specification) -- [API Reference](#api-reference) -- [Message Flow](#message-flow) -- [Canonicalization & Signing](#canonicalization-and-signing) -- [Security Model](#security-model) -- [Federation](#federation-server-to-server) -- [Storage](#storage) -- [Configuration](#configuration) -- [IRC Protocol Listener](#irc-protocol-listener) -- [Entrypoints](#entrypoints) -- [Deployment](#deployment) -- [Client Development Guide](#client-development-guide) -- [Rate Limiting & Abuse Prevention](#rate-limiting--abuse-prevention) -- [Roadmap](#roadmap) -- [Project Structure](#project-structure) -- [Design Principles](#design-principles) -- [Status](#status) +- [What neoirc is](#what-neoirc-is) +- [Why it exists](#why-it-exists) +- [Getting started](#getting-started) +- [How it works](#how-it-works) +- [Using the HTTP API](#using-the-http-api) +- [The IRC listener](#the-irc-listener) +- [The web client](#the-web-client) +- [neoirc-cli](#neoirc-cli) +- [Running the server](#running-the-server) +- [Development](#development) +- [TODO](#todo) - [License](#license) +- [Author](#author) ---- +## What neoirc is -## Motivation +neoirc is IRC with a different transport. The commands are IRC's: `PRIVMSG`, +`JOIN`, `PART`, `NICK`, `TOPIC`, `MODE`, `KICK`. The replies are IRC's numeric +replies, such as `353` and `433`. Channels start with `#`. The first user to +join a channel creates it, and a channel disappears when its last member leaves. -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. +The main differences from IRC: -This project builds a server that: +- A client talks to the server over HTTP. It sends commands with one `POST` + request and reads its messages with one `GET` request. +- The server holds the session. A session keeps its nick, its channels and its + queued messages while no client is connected. +- One session can have several clients at once, such as a phone and a laptop. + Each client has its own message queue. +- A message body is a JSON array of lines, not one line of text. -- 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 -- Enables optional cryptographic message signing with deterministic - canonicalization +The server also has a plain IRC listener, on port 6667 by default. Standard IRC +clients connect to it and share channels with HTTP clients. -The entire client read/write loop is two HTTP endpoints. If a developer can't -build a working IRC-style TUI client against this API in an afternoon, the API -is too complex. +The repository ships three programs: ---- +- `neoircd`, the server. +- A web client, which the server serves at `/`. +- `neoirc-cli`, a terminal client. -## Why Not Just Use IRC / XMPP / Matrix? +## Why it exists -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. +IRC ties a session to one TCP connection. When a phone drops the connection, the +user loses the nick, the channels and every message sent in between. neoirc +keeps the session on the server. A client can disconnect and come back to its +nick, its channels and the messages it missed. -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: +## Getting started -### 1. HTTP transport instead of persistent TCP +Clone the repository, build the Docker image and run it: -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), with objects planned -for structured data like key material (see `PUBKEY`). 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. Identity starts with a key — a nick is a display name. -Accounts are optional: you can create an anonymous session instantly, or set a -password via the PASS command for multi-client access to a single session. - -### On the resemblance to JSON-RPC - -All C2S commands go through `POST /api/v1/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. 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 messaging, 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 - -This section documents every major design decision and its rationale. These are -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 — Cookie-Based Authentication - -The server uses **HTTP cookies** for all authentication. There is no separate -registration step — sessions start anonymous and can optionally set a password -for multi-client access. - -#### Session Creation - -- **Session creation**: client sends `POST /api/v1/session` with a desired nick - and, unless the server has hashcash turned off, a proof-of-work stamp (see - [Hashcash Proof-of-Work](#hashcash-proof-of-work)) → server sets an **HttpOnly - auth cookie** (`neoirc_auth`) containing a cryptographically random value (64 - hex characters) and returns the user ID and nick in the JSON response body. No - auth credential appears in the JSON body. -- The auth cookie is HttpOnly, SameSite=Strict, and Secure: clients send it only - over HTTPS, which the TLS-terminating reverse proxy provides, or to a server - on `localhost` (see [Transport Security](#transport-security)). Browsers - handle cookies automatically. **CLI clients (curl, custom HTTP clients) must - explicitly save and send cookies** — e.g., using curl's `-c`/`-b` flags or an - HTTP cookie jar in their language's HTTP library. -- Sessions start anonymous — no password required. When the session expires or - the user QUITs, the nick is released. - -#### Setting a Password (Optional, for Multi-Client Access) - -For users who want to access the same session from multiple devices: - -- **Set password via IRC PASS command**: the authenticated client sends - `POST /api/v1/messages` with `{"command":"PASS","body":["mypassword"]}`. The - server hashes the password with bcrypt and stores it on the session. Password - must be 8 to 72 bytes. -- **Login from another client**: `POST /api/v1/login` with nick and password → - server verifies the password, creates a new client for the existing session, - and sets an auth cookie. Channel memberships are shared; the new client gets - its own message queue. 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 password is lost. - -#### Common Properties - -- 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. -- Auth cookies contain opaque random bytes, **not JWTs**. No claims, no expiry - encoded in the cookie value, no client-side decode. The server is the sole - authority on cookie validity. - -**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. The PASS command enables multi-client login without adding friction for -casual users: if you don't need multi-client, just create a session and go. -Cookie-based auth simplifies credential management — browsers handle cookies -automatically, and CLI clients just need a cookie jar (e.g., curl's `-c`/`-b` -flags). Note: both anonymous and password-protected sessions are deleted when -any client sends QUIT, or when the last client logs out. Identity verification -at the message layer via cryptographic signatures (see -[Security Model](#security-model)) remains independent of password status. - -### Hostmask (nick!user@host) - -Each session has an IRC-style hostmask composed of three parts: - -- **nick** — the user's current nick (changes with `NICK` command) -- **username** — an ident-like identifier set at session creation (optional - `username` field in the session request; defaults to the nick) -- **hostname** — the reverse DNS name of the creating client's IP address, - looked up at session creation time, or the IP address itself when the lookup - gives no name -- **ip** — the real IP address of the session creator, extracted from - `X-Forwarded-For`, `X-Real-IP`, or `RemoteAddr` - -Each **client connection** (created at session creation or login) also stores -its own **ip** and **hostname**, found the same way. The session's hostname is -the hostname of the client that created it, and every user can see it: in WHOIS -(311), WHO (352), USERHOST (302), NAMES (353) over the HTTP API, and -`GET /api/v1/channels/{name}/members`. Only `RPL_WHOISACTUALLY` (338) is for -**server operators** (o-line) alone: it gives the IP address and hostname of the -target's newest client. - -The hostmask appears in: - -- **WHOIS** (`311 RPL_WHOISUSER`) — `params` contains - `[nick, username, hostname, "*"]` -- **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a server - operator, includes the IP address and hostname of the target's newest client - (HTTP API only; the IRC listener's WHOIS does not send it) -- **WHO** (`352 RPL_WHOREPLY`) — `params` contains - `[channel, username, hostname, server, nick, flags]` - -The hostmask (`nick!user@host`) is built from these fields when needed, and is -what channel bans (`+b` mode) match against. - -### Nick Semantics - -- Nicks are **unique per server at any point in time** — two sessions cannot - hold the same nick simultaneously. -- Nicks are **case-sensitive** (unlike traditional IRC). `Alice` and `alice` are - different nicks. -- Nick length: 1–32 characters. A nick starts with a letter or `_`, followed by - letters, digits and `` _-[]\^{}|` ``. The HTTP API and the IRC listener both - apply this rule. On the IRC listener, a `NICK` with any other nick, before or - after registration, gets ERR_ERRONEUSNICKNAME (432) and leaves the nick as it - was. -- Nicks are **released when a session is destroyed** (via `QUIT` command or - session expiry). There is no nick registration or reservation system. -- Nick changes are broadcast to all users sharing a channel with the changer, as - a `NICK` event message. - -**Rationale:** IRC nick semantics, simplified. Case-insensitive nick comparison -is a perpetual source of IRC bugs (different servers use different case-folding -rules). Case-sensitive comparison is unambiguous. - -### 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 messages and events from other users to every client queue - for that user session. -- When the user sends a `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, `NICK`, `TOPIC`, - `MODE` or `KICK` from one of its clients, the resulting event goes to every - client queue of the user's own session, as well as to the other users it is - meant for. The one exception is an IRC listener connection that sent the - command, which gets nothing queued for it: it has already sent its user the - echo, or, for `PRIVMSG` and `NOTICE`, IRC sends none. -- Replies to a command (numerics) go only to the client that sent it. -- `GET /api/v1/messages` delivers from the calling client's specific queue, - identified by the auth cookie. -- Client queues have **independent expiry/pruning** — one client going offline - doesn't affect others. - -``` -User Session -├── Client A (cookie_a, queue_a) -├── Client B (cookie_b, queue_b) -└── Client C (cookie_c, queue_c) +```bash +make docker +docker run -d --name neoirc \ + -p 8080:8080 -p 6667:6667 \ + -v neoirc-data:/var/lib/neoirc \ + -e NEOIRC_HASHCASH_BITS=0 \ + neoirc ``` -**Multi-client via login:** The `POST /api/v1/login` endpoint adds a new client -to an existing session (one that has a password set via PASS command), enabling -true multi-client support (multiple cookies sharing one nick/session with -independent message queues). Sessions without a password cannot be logged into. +The server listens for HTTP on port 8080 and for IRC on port 6667. It keeps its +data in `/var/lib/neoirc/state.db`. `NEOIRC_HASHCASH_BITS=0` turns off the proof +of work that session creation otherwise needs (see [Hashcash](#hashcash)). -**Rationale:** The fundamental IRC mobile problem is that you can't have your -phone and laptop connected simultaneously without a bouncer. Server-side -per-client queues solve this cleanly. +Open `http://localhost:8080/` for the web client, or talk to the API with +`curl`: -### Message Immutability +```bash +# Create a session. -c saves the auth cookie, -b sends it. +curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ + -H 'Content-Type: application/json' \ + -d '{"nick":"alice"}' -Messages are **immutable** — no editing, no deletion by clients. There are no -edit or delete API endpoints and there never will be. +# Join a channel. +curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ + -H 'Content-Type: application/json' \ + -d '{"command":"JOIN","to":"#general"}' -**Rationale:** Cryptographic signing requires immutability. If a message could -be modified after signing, signatures would be meaningless. This is a feature, -not a limitation. Chat platforms that allow editing signed messages have -fundamentally broken their trust model. If you said something wrong, send a -correction — that's what IRC's culture has always been. +# Say something. +curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ + -H 'Content-Type: application/json' \ + -d '{"command":"PRIVMSG","to":"#general","body":["hello"]}' -### Message Delivery Model - -The server uses a **fan-out queue** model: - -1. Client sends a command (e.g., `PRIVMSG` to `#general`) -2. Server determines all recipients (all members of `#general`) -3. Server stores the message once in the `messages` table -4. Server creates one entry per recipient in the `client_queues` table -5. Server notifies all waiting long-poll connections for those recipients -6. Each recipient's next `GET /messages` poll returns the queued message - -Key properties: - -- **At-least-once delivery**: Messages are queued until the client polls for - them. The client advances its cursor (`after` parameter) to acknowledge - receipt. Messages are not deleted from the queue on read — the cursor-based - model means clients can re-read by providing an earlier `after` value. -- **Ordered**: Queue entries have monotonically increasing IDs. Messages are - always delivered in order within a client's queue. -- **No delivery/read receipts** for channel messages. DM receipts are planned. -- **Client output queue depth**: Server-configurable via `QUEUE_MAX_AGE`. - Default is 30 days. Entries older than this are pruned. - -### Long-Polling - -The server implements HTTP long-polling for real-time message delivery: - -1. Client sends `GET /api/v1/messages?after=&timeout=15` -2. If messages are immediately available, server responds instantly -3. If no messages are available, server holds the connection open -4. Server responds when either: - - A message arrives for this client (via the in-memory broker) - - The timeout expires (returns empty array) - - The client disconnects (connection closed, no response needed) - -**Implementation detail:** The server maintains an in-memory broker with -per-session notification channels. When a message is enqueued for a session, the -broker signals every waiting channel for that session, waking up any blocked -long-poll handlers of its clients. This is O(1) notification — no polling loops, -no database scanning. - -**Timeout limits:** The server caps the `timeout` parameter at 30 seconds. -Clients should use 15 seconds as the default. The HTTP write timeout is set to -60 seconds to accommodate long-poll connections. - -**Rationale:** Long-polling over HTTP is the simplest real-time transport that -works everywhere. WebSockets add connection state, require different proxy -configuration, break in some corporate firewalls, and don't work with standard -HTTP middleware. SSE (Server-Sent Events) is one-directional and poorly -supported by some HTTP client libraries. Long-polling is just regular HTTP -requests that sometimes take longer to respond. Every HTTP client, proxy, load -balancer, and CDN handles it correctly. - -### Channels - -- **Any user can create channels** — joining a nonexistent channel creates it, - exactly like IRC. -- **Ephemeral** — channels disappear when the last member leaves. There is no - persistent channel registration. -- **No channel size limits** unless an operator sets one with mode `+l`. -- **Channel names** must start with `#`. If a client sends a `JOIN` without the - `#` prefix, the server adds it. -- **No channel-level encryption** — encryption is per-message via the `meta` - field. - -### Direct Messages (DMs) - -- DMs are addressed by **nick at send time** — the server resolves the nick to a - user ID internally. -- DMs are **fan-out to both sender and recipient** — the sender sees their own - DM echoed back in their message queue, enabling multi-client consistency (your - laptop sees DMs you sent from your phone). -- DM history is stored in the `messages` table with the recipient's session as - the `to_session_id` field. A user's DM history therefore follows them across - nick changes, and a user who later takes a released nick sees none of the DMs - sent to its previous holder. -- DMs are **not stored long-term** by default — they follow the same rotation - policy as channel messages. - -### JSON, Not Binary - -All messages are JSON. No CBOR, no protobuf, no MessagePack, no custom binary -framing. - -**Rationale:** JSON is human-readable, universally supported, and debuggable -with `curl | jq`. Binary formats save bandwidth at the cost of debuggability and -ecosystem compatibility. Chat messages are small — the overhead of JSON over -binary is measured in bytes per message, not meaningful bandwidth. The -canonicalization story (RFC 8785 JCS) is also well-defined for JSON, which -matters for signing. - -### Why Opaque Cookies Instead of JWTs - -JWTs encode claims that clients can decode and potentially rely on. This creates -a coupling between token format and client behavior. If the server needs to -revoke a token, change the expiry model, or add/remove claims, JWT clients may -break or behave incorrectly. - -Opaque auth cookies are simpler: - -- Server generates 32 random bytes → hex-encodes → stores SHA-256 hash → sets - raw hex as an HttpOnly cookie -- On each request, server hashes the cookie value and looks it up -- Revocation is a database delete (cookie becomes invalid immediately) -- No clock skew issues, no algorithm confusion, no "none" algorithm attacks -- Cookie format can change without breaking clients -- Browsers and HTTP cookie jars manage cookies automatically; CLI clients must - explicitly save and resend cookies (e.g., curl `-c`/`-b` flags) - ---- - -## Architecture - -### Transport: HTTP Only - -All client↔server and server↔server communication uses HTTP/1.1+ with JSON -request/response bodies. No WebSockets, no gRPC — just plain HTTP. The one -exception is the [IRC Protocol Listener](#irc-protocol-listener), a raw TCP -listener for standard IRC clients, which is on by default. - -- **Client reading**: Long-poll `GET /api/v1/messages` — server holds the - connection for up to the `timeout` the client asks for (at most 30s) until - messages arrive. One endpoint for everything — channel messages, DMs, system - events, numeric replies. -- **Client writing**: `POST /api/v1/messages` with a `command` field. One - endpoint for everything — PRIVMSG, JOIN, PART, NICK, TOPIC, etc. -- **Server federation** (planned, not yet implemented): 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 -(state, history, channels, members, server info) is ancillary. - -### Session Lifecycle - -#### Session Creation - -``` -┌─ Client ──────────────────────────────────────────────────┐ -│ │ -│ 1. POST /api/v1/session │ -│ {"nick":"alice","pow_token":""} │ -│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ -│ → {"id":1, "nick":"alice"} │ -│ │ -│ 2. POST /api/v1/messages {"command":"JOIN","to":"#gen"} │ -│ → {"status":"joined","channel":"#gen"} │ -│ (Cookie must be sent on all subsequent requests) │ -│ │ -│ 3. POST /api/v1/messages {"command":"PRIVMSG", │ -│ "to":"#gen","body":["hello"]} │ -│ → {"id":"uuid-...","status":"sent"} │ -│ (Server fans out to all #gen members' queues) │ -│ │ -│ 4. GET /api/v1/messages?after=0&timeout=15 │ -│ ← (held open up to 15s until messages arrive) │ -│ → {"messages":[...], "last_id": 42} │ -│ │ -│ 5. GET /api/v1/messages?after=42&timeout=15 │ -│ ← (recursive long-poll, using last_id as cursor) │ -│ │ -│ 6. POST /api/v1/messages {"command":"QUIT"} │ -│ → {"status":"quit"} │ -│ (Server broadcasts QUIT, removes from channels, │ -│ deletes session, releases nick, clears cookie) │ -│ │ -└────────────────────────────────────────────────────────────┘ +# Read your queue. The request waits up to 15 seconds for messages. +curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" ``` -#### Multi-Client via Password +Or connect a standard IRC client to `localhost` port 6667. + +In production, run the server behind a reverse proxy that terminates TLS (see +[Running the server](#running-the-server)). The auth cookie is marked `Secure`, +so browsers send it only over HTTPS, or to `localhost`. + +## How it works + +This section defines the terms the rest of this document uses. + +A **session** is one user on the server. It has a nick, a username, a hostname, +the channels it has joined and, optionally, a password. The server creates a +session when a client sends `POST /api/v1/session`, or when an IRC client +registers on the IRC listener. + +A **client** is one connection to a session: a browser, a `curl` cookie jar, an +IRC connection. Each client has its own **queue** of messages waiting to be +read. An HTTP client also has its own auth cookie. A session starts with one +client. `POST /api/v1/login` adds another client to a session that has a +password. + +A **message** is one JSON object (see +[The message envelope](#the-message-envelope)). The server stores each message +once. For every client that should receive it, the server adds one entry to that +client's queue. An HTTP client reads its queue with `GET /api/v1/messages`, +which can wait for new messages; the listener does this for an IRC connection. +Queue entries have increasing numbers. The client passes the last number it has +seen as `after`, so it reads each entry once. + +Three kinds of message reach a queue: + +- **Chat messages**: `PRIVMSG` and `NOTICE`. The server echoes a session's own + chat messages to every client of the session, except the IRC connection that + sent one. +- **Events**: `JOIN`, `PART`, `NICK`, `QUIT`, `TOPIC`, `MODE`, `KICK`, `INVITE` + and `WALLOPS`, telling the client what other users did. When a client's own + `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE` or `KICK` causes an event, every HTTP + client of its session receives it too. The IRC connection that sent the + command gets it as an IRC line instead. +- **Numeric replies**: the server's answers to commands, such as `RPL_TOPIC` or + `ERR_NOSUCHNICK`. A numeric reply goes only to the client that sent the + command. + +A **channel** is a named room. Its name starts with `#`. The first user to join +creates it and becomes its **channel operator**. A channel operator can set the +channel's modes, change its topic, kick members and invite users. The channel is +deleted when its last member leaves. + +A **server operator** is a user who has sent `OPER` with the name and password +from the server's configuration. A server operator can use `KILL` and `WALLOPS`. +Over the HTTP API, `WHOIS` also shows a server operator the IP address of other +users. + +Nicks and channel names compare by exact case. `Alice` and `alice` are two +nicks. `#Upper` and `#upper` are two channels. The HTTP API's `005` reply +advertises `CASEMAPPING=ascii`; IRC has no value for names that compare by exact +case. + +Messages are never edited or deleted by clients. There is no endpoint for +either. + +## Using the HTTP API + +### Conventions + +- Request and response bodies are JSON. +- Endpoints marked "needs auth" need the `neoirc_auth` cookie. + `POST /api/v1/session` and `POST /api/v1/login` set it. A request without a + valid cookie gets `401` with `{"error": "not registered", "numeric": 451}`. +- An endpoint in this section answers an error at the HTTP level with a `4xx` or + `5xx` status and `{"error": ""}`. +- An error in a command is not an HTTP error. The request gets `200` with + `{"status": "error"}`, and the reason arrives as a numeric reply in the + client's queue. +- Request bodies for `POST /api/v1/session`, `POST /api/v1/login` and + `POST /api/v1/messages` are limited to `MAX_MESSAGE_SIZE` bytes (default + `4096`). A larger body gets `400` `invalid request body`. +- The server allows cross-origin requests from any origin, with credentials. The + cookie is `SameSite=Strict`, so a browser sends it only from the same site. + +### The auth cookie + +`POST /api/v1/session` and `POST /api/v1/login` answer with a `Set-Cookie` +header: ``` -┌─ Client A ────────────────────────────────────────────────┐ -│ │ -│ 1. POST /api/v1/session │ -│ {"nick":"alice","pow_token":""} │ -│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ -│ → {"id":1, "nick":"alice"} │ -│ │ -│ 2. POST /api/v1/messages │ -│ {"command":"PASS","body":["s3cret!!"]} │ -│ → {"status":"ok"} │ -│ (Password set via IRC PASS command) │ -│ │ -│ ... use the API normally (JOIN, PRIVMSG, poll, etc.) ... │ -│ │ -└────────────────────────────────────────────────────────────┘ - -┌─ Client B (another device, while session is still active) ┐ -│ │ -│ 3. POST /api/v1/login │ -│ {"nick":"alice", "password":"s3cret!!"} │ -│ → Set-Cookie: neoirc_auth=; HttpOnly; ... │ -│ → {"id":1, "nick":"alice"} │ -│ (New client added to existing session — channels │ -│ are kept; the new client gets its own queue.) │ -│ │ -└────────────────────────────────────────────────────────────┘ +Set-Cookie: neoirc_auth=<64 hex characters>; Path=/; HttpOnly; Secure; SameSite=Strict ``` -### Queue Architecture +The value is 32 random bytes in hex. The server stores only its SHA-256 hash and +compares that on every request. The cookie is the only credential; the JSON body +of the response does not repeat it. A browser sends the cookie by itself. Any +other client must save it and send it back, as `curl -c` and `curl -b` do. -``` - ┌─────────────────┐ - │ messages table │ (one row per message, shared) - │ id | uuid | cmd│ - │ from | to | .. │ - └────────┬────────┘ - │ - ┌──────────────┼──────────────┐ - │ │ │ - ┌─────────▼──┐ ┌───────▼────┐ ┌──────▼─────┐ - │client_queue│ │client_queue│ │client_queue│ - │ client_id=1│ │ client_id=2│ │ client_id=3│ - │ msg_id=N │ │ msg_id=N │ │ msg_id=N │ - └────────────┘ └────────────┘ └────────────┘ - alice bob carol +The cookie is `Secure`. Chrome, Firefox, `curl` and `neoirc-cli` send it to a +server on `localhost` over plain HTTP too; Python's `requests` does not. To a +remote host they send it only over HTTPS, which a reverse proxy provides. -Each message is stored ONCE. One queue entry per recipient client. -``` +`QUIT`, `POST /api/v1/logout`, `KILL` and the idle cleanup invalidate the +cookie. After that, every request with it gets `401`. -The `client_queues` table contains `(client_id, message_id)` pairs. When a -client polls with `GET /messages?after=`, the server queries for queue -entries with `id > after` for that client, joins against the messages table, and -returns the results. The `queue_id` (auto-incrementing primary key of -`client_queues`) serves as a monotonically increasing cursor. +### Endpoints -### In-Memory Broker +#### `POST /api/v1/session`: create a session -The server maintains an in-memory notification broker to avoid database polling. -The broker is a map of `session_id → []chan struct{}`. When a message is -enqueued for a session: - -1. The handler calls `broker.Notify(sessionID)` -2. The broker sends a signal on each waiting channel for that session and - forgets them -3. Any goroutines blocked in `select` on those channels wake up -4. The woken handler queries the database for new queue entries -5. Messages are returned to the client - -If the server restarts, the broker is empty — but this is fine because clients -that reconnect will poll immediately and get any queued messages from the -database. The broker is purely an optimization to avoid polling latency. - ---- - -## Protocol Specification - -### Message Envelope - -Every message — client-to-server, server-to-client, and server-to-server — uses -the same JSON envelope: - -```json -{ - "id": "string (uuid)", - "command": "string", - "code": integer, - "from": "string", - "to": "string", - "params": ["string", ...], - "body": ["string", ...] | {...}, - "ts": "string (ISO 8601)", - "meta": {...} -} -``` - -#### Field Reference - -| Field | Type | C2S | S2C | Description | -| --------- | ----------------- | -------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `id` | string (UUID v4) | Ignored | Always | Server-assigned unique message identifier. | -| `command` | string | Required | Always | IRC command name (`PRIVMSG`, `JOIN`, etc.), or for a numeric reply its name (`RPL_WELCOME`, `ERR_NICKNAMEINUSE`, etc.). Case-insensitive on input; server normalizes to uppercase. | -| `code` | integer | Ignored | Numerics | The number of a numeric reply (`1` for `RPL_WELCOME`, `433` for `ERR_NICKNAMEINUSE`); absent on other messages. | -| `from` | string | Ignored | Usually | Sender's nick (for user messages) or server name (for server messages). Server always overwrites this field — clients cannot spoof the sender. | -| `to` | string | Usually | Usually | Destination: `#channel` for channel targets, bare nick for DMs/user targets. | -| `params` | array of strings | Ignored | Sometimes | Additional IRC-style positional parameters, used by `KICK` and numeric replies like `353` (NAMES). Commands take their arguments from `to` and `body`. | -| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). Objects, for structured data such as `PUBKEY`, are planned; no command accepts one yet. **Never a raw string.** | -| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with up to nanosecond precision, trailing zeros dropped. Example: `"2026-02-10T20:00:00.123456789Z"`. Always UTC. | -| `meta` | object | Optional | If present | Extensible metadata. Used for cryptographic signatures (`meta.sig`, `meta.alg`), hashcash proof-of-work (`meta.hashcash`), content hashes, or any client-defined key/value pairs. Server relays `meta` verbatim except for `hashcash` which is validated on channels with `+H` mode. | - -**Important invariants:** - -- `body` is **always** an array or object, **never** a raw string. This enables - deterministic canonicalization via RFC 8785 JCS. -- `from` is **always set by the server** on S2C messages. Clients may include - `from` on C2S messages, but it is ignored and overwritten. -- `id` and `ts` are **always set by the server**. Client-supplied values are - ignored. -- `meta` is **relayed verbatim**. The server stores it as-is and includes it in - S2C messages. It is never modified, and the server reads nothing in it except - `meta.hashcash` on a `PRIVMSG` to a `+H` channel. - -### Commands (C2S and S2C) - -All commands use the same envelope format regardless of direction. A `PRIVMSG` -from a client to the server has the same shape as the `PRIVMSG` relayed from the -server to other clients. The only differences are which fields the server fills -in (`id`, `ts`, `from`). - -#### PRIVMSG — Send Message - -Send a message to a channel or user. This is the primary messaging command. - -**C2S:** - -```json -{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]} -{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]} -{"command": "PRIVMSG", "to": "bob", "body": ["hey, DM"]} -{"command": "PRIVMSG", "to": "#general", "body": ["signed message"], - "meta": {"sig": "base64...", "alg": "ed25519"}} -``` - -**S2C (as delivered to recipients):** - -```json -{ - "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["hello world"], - "ts": "2026-02-10T20:00:00.123456789Z", - "meta": {} -} -``` - -**Behavior:** - -- If `to` starts with `#`, the message is sent to a channel. The server fans out - to all channel members (including the sender — the sender sees their own - message echoed back via the queue). -- If `to` is a bare nick, the message is a DM. The server fans out to the - recipient and the sender (so all of the sender's clients see the DM). -- `body` must be a non-empty array of strings; otherwise the server sends - ERR_NOTEXTTOSEND (412). A missing `to` gets ERR_NORECIPIENT (411). -- Only channel members can send to a channel. A non-member, a banned user, and - on a `+m` channel a user with neither `+o` nor `+v`, get ERR_CANNOTSENDTOCHAN - (404). -- If the channel doesn't exist, the server sends ERR_NOSUCHCHANNEL (403). -- If the DM target nick doesn't exist, the server sends ERR_NOSUCHNICK (401). -- The sender of a DM to a user who is away gets RPL_AWAY (301) with their away - message. - -**Response:** `200 OK` - -```json -{ "id": "uuid-string", "status": "sent" } -``` - -When the server sends an error numeric instead, the response is -`{"status": "error"}`. - -**IRC reference:** RFC 1459 §4.4.1 - -#### NOTICE — Send Notice - -Identical to PRIVMSG but **must not trigger auto-replies** from bots or clients. -This prevents infinite loops between automated systems. - -**C2S:** - -```json -{ - "command": "NOTICE", - "to": "#general", - "body": ["server maintenance in 5 min"] -} -``` - -**Behavior:** Same as PRIVMSG, except that clients receiving a NOTICE must not -send an automatic reply, and over the HTTP API the server sends no RPL_AWAY for -it and does not check hashcash on `+H` channels. - -**IRC reference:** RFC 1459 §4.4.2 - -#### JOIN — Join Channel - -Join a channel. If the channel doesn't exist, it is created. - -**C2S:** - -```json -{"command": "JOIN", "to": "#general"} -{"command": "JOIN", "to": "general"} -{"command": "JOIN", "to": "#general", "body": ["secretpass"]} -``` - -If the `#` prefix is omitted, the server adds it. The name must then be `#` -followed by 1–63 letters, digits, `_` or `-`; anything else gets -ERR_NOSUCHCHANNEL (403). `body[0]`, if present, is the channel key for a `+k` -channel. - -**S2C (broadcast to all channel members, including the joiner):** - -```json -{ - "id": "...", - "command": "JOIN", - "from": "alice", - "to": "#general", - "body": ["#general"], - "ts": "2026-02-10T20:00:00.123456789Z", - "meta": {} -} -``` - -**Behavior:** - -- If the channel doesn't exist, it is created with no topic and modes `+nt`. -- The joining client then gets the topic (RPL_TOPIC 332 with RPL_TOPICWHOTIME - 333, or RPL_NOTOPIC 331) and the member list (RPL_NAMREPLY 353, RPL_ENDOFNAMES - 366). -- A JOIN by a user already in the channel goes through the same checks and is - broadcast and answered again; the membership does not change. -- The JOIN event is broadcast to **all** channel members, including the user who - joined. This lets the client confirm the join succeeded and lets other members - update their member lists. -- The first user to join a channel becomes its operator (`+o`). Bans, `+i`, `+k` - and `+l` can refuse anyone else (see [Channel Modes](#channel-modes)). - -**Response:** `200 OK` - -```json -{ "status": "joined", "channel": "#general" } -``` - -**IRC reference:** RFC 1459 §4.2.1 - -#### PART — Leave Channel - -Leave a channel. - -**C2S:** - -```json -{"command": "PART", "to": "#general"} -{"command": "PART", "to": "#general", "body": ["goodbye"]} -``` - -**S2C (broadcast to all channel members, including the departing user):** - -```json -{ - "id": "...", - "command": "PART", - "from": "alice", - "to": "#general", - "body": ["goodbye"], - "ts": "...", - "meta": {} -} -``` - -**Behavior:** - -- The PART event goes to every channel member, the departing user's own clients - included. (An IRC listener connection that sends a PART echoes it itself.) -- If the channel is empty after the user leaves, the channel is **deleted** - (ephemeral channels). -- If the user is not in the channel, the server returns an error. -- The `body` field is optional and contains a part message (reason). - -**Response:** `200 OK` - -```json -{ "status": "parted", "channel": "#general" } -``` - -**IRC reference:** RFC 1459 §4.2.2 - -#### NICK — Change Nickname - -Change the user's nickname. - -**C2S:** - -```json -{ "command": "NICK", "body": ["newnick"] } -``` - -**S2C (broadcast to all users sharing a channel with the changer):** - -```json -{ - "id": "...", - "command": "NICK", - "from": "oldnick", - "body": ["newnick"], - "ts": "...", - "meta": {} -} -``` - -**Behavior:** - -- `body[0]` is the new nick. It must be a valid nick (see - [Nick Semantics](#nick-semantics)); otherwise the server sends - ERR_ERRONEUSNICKNAME (432). -- The `from` field in the broadcast contains the **old** nick. -- The `body[0]` in the broadcast contains the **new** nick. -- The NICK event is broadcast to the user themselves and to all users who share - at least one channel with the changer. Each recipient receives the event - exactly once, even if they share multiple channels. -- If the new nick is already taken, the server sends ERR_NICKNAMEINUSE (433). - -**Response:** `200 OK` - -```json -{ "status": "ok", "nick": "newnick" } -``` - -When the server sends an error numeric instead, the response is -`{"status": "error"}`. - -**IRC reference:** RFC 1459 §4.1.2 - -#### PASS — Set Session Password - -Set a password on the current session, enabling multi-client login via -`POST /api/v1/login`. The password is hashed with bcrypt and stored server-side. - -**C2S:** - -```json -{ "command": "PASS", "body": ["mypassword"] } -``` - -**Behavior:** - -- `body[0]` is the password. Must be 8 to 72 bytes; bcrypt cannot hash a longer - one. -- On success, the server responds with `{"status": "ok"}`. -- If the password is too short, too long or missing, the server sends - ERR_NEEDMOREPARAMS (461) via the message queue. -- Calling PASS again overwrites the previous password. -- Once a password is set, `POST /api/v1/login` can be used with the nick and - password to create additional clients on the same session. - -**Response:** `200 OK` - -```json -{ "status": "ok" } -``` - -**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for session -password management. - -#### TOPIC — Set Channel Topic - -Set or change a channel's topic. - -**C2S:** - -```json -{ "command": "TOPIC", "to": "#general", "body": ["Welcome to #general"] } -``` - -**S2C (broadcast to all channel members):** - -```json -{ - "id": "...", - "command": "TOPIC", - "from": "alice", - "to": "#general", - "body": ["Welcome to #general"], - "ts": "...", - "meta": {} -} -``` - -**Behavior:** - -- Updates the channel's topic in the database. -- The TOPIC event is broadcast to all channel members, and the setter's client - also gets RPL_TOPIC (332) and RPL_TOPICWHOTIME (333). -- `body` is required: there is no topic query over the HTTP API, and a TOPIC - without one gets ERR_NEEDMOREPARAMS (461). -- If the channel doesn't exist, the server returns an error. -- If the channel has mode `+t` (topic lock, default: ON for new channels), only - operators (`+o`) can change the topic. Non-operators receive - `ERR_CHANOPRIVSNEEDED` (482). - -**Response:** `200 OK` - -```json -{ "status": "ok", "topic": "Welcome to #general" } -``` - -**IRC reference:** RFC 1459 §4.2.4 - -#### QUIT — Disconnect - -Destroy the session and disconnect from the server. - -**C2S:** - -```json -{"command": "QUIT"} -{"command": "QUIT", "body": ["leaving"]} -``` - -**S2C (broadcast to all users sharing channels with the quitter):** - -```json -{ - "id": "...", - "command": "QUIT", - "from": "alice", - "body": ["leaving"], - "ts": "...", - "meta": {} -} -``` - -**Behavior:** - -- The QUIT event is broadcast to all users who share a channel with the quitting - user. The quitting user does **not** receive their own QUIT. -- The user is removed from all channels. -- Empty channels are deleted (ephemeral). -- The user's session is destroyed — the auth cookies of all its clients are - invalidated, the nick is released. -- Subsequent requests with the old auth cookie return HTTP 401. - -**Response:** `200 OK` - -```json -{ "status": "quit" } -``` - -**IRC reference:** RFC 1459 §4.1.6 - -#### PING — Keepalive - -Client keepalive. Server responds synchronously with PONG. - -**C2S:** - -```json -{ "command": "PING" } -``` - -**Response (synchronous, not via the queue):** `200 OK` - -```json -{ "command": "PONG", "from": "servername" } -``` - -**Note:** PING/PONG is synchronous — the PONG is the HTTP response body, not a -queued message. This is deliberate: keepalives should be low-latency and not -pollute the message queue. - -**IRC reference:** RFC 1459 §4.6.2, §4.6.3 - -#### MODE — Query and Change Modes - -Query or change a channel's modes, or query or change your own user modes. A -`to` that starts with `#` is a channel; any other `to` is a nick. - -**C2S:** - -```json -{"command": "MODE", "to": "#general"} -{"command": "MODE", "to": "#general", "body": ["+m"]} -{"command": "MODE", "to": "#general", "body": ["+o", "bob"]} -{"command": "MODE", "to": "alice"} -{"command": "MODE", "to": "alice", "body": ["+w"]} -``` - -**Channel query:** with no `body`, the server sends RPL_CHANNELMODEIS (324) and -RPL_CREATIONTIME (329). Anyone may query, member or not. The mode string lists -the set flags out of `nimst`, then `k`, `l` and `H` with their values (key, -member limit, bits) after it, e.g. `+ntkl secretpass 50`. A new channel has -`+nt`: - -```json -{"command": "RPL_CHANNELMODEIS", "code": 324, "to": "alice", "params": ["#general", "+nt"]} -{"command": "RPL_CREATIONTIME", "code": 329, "to": "alice", "params": ["#general", "1709251200"]} -``` - -**Channel changes:** only channel operators can change channel modes. Over the -HTTP API, `body[0]` is one change, a sign and one letter, and `body[1]` is its -value where it takes one; a string of several letters such as `+mt` is not -accepted. The letters: - -| Change | Value | Effect | -| ----------- | --------------------------- | ----------------------------------------------------------------- | -| `+o` / `-o` | nick | Give or take channel operator | -| `+v` / `-v` | nick | Give or take voice | -| `+i` / `-i` | | Invite-only on or off | -| `+m` / `-m` | | Moderated on or off | -| `+s` / `-s` | | Secret on or off | -| `+t` / `-t` | | Topic lock on or off | -| `+k` / `-k` | key, for `+k` | Set or clear the channel key | -| `+l` / `-l` | positive integer, for `+l` | Set or clear the member limit | -| `+b` / `-b` | `nick!user@host` mask | Add or remove a ban; `+b` with no mask lists the bans, for anyone | -| `+H` / `-H` | bits from 1 to 40, for `+H` | Require hashcash on `PRIVMSG`, or stop requiring it | - -A change made over the HTTP API is sent to every channel member, the operator -included, as a `MODE` message from the operator to the channel. Its `body` is -the change as one string for `o`, `v`, `i`, `m`, `s` and `t` (`["+o bob"]`, -`["+m"]`), and as the change and its value for `k`, `l` and `b` -(`["+k", "secretpass"]`, `["-k", "*"]`, `["+l", "50"]`, `["-l"]`, -`["+b", "*!*@*.example.com"]`). A `+H` or `-H` change is not sent to the -members; the operator gets RPL_CHANNELMODEIS (324) with the new mode string. -Listing bans sends RPL_BANLIST (367) for each ban, with the mask, who set it and -when, then RPL_ENDOFBANLIST (368). - -On the IRC listener, `MODE #channel [values...]` takes several letters -in one string, e.g. `MODE #general +mt-i` or `MODE #general +ov alice bob`. It -accepts `i`, `m`, `n`, `s`, `t`, `o` and `v` (with a nick) and `H` (with bits -from 1 to 40 when setting), but not `b`, `k` or `l`. A letter whose value is -missing is skipped. The changes made are sent to every channel member as one -`MODE` line, e.g. `+o-v alice bob`; the operator gets that line once. - -**Channel errors:** - -| Numeric | Name | When | -| ------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `403` | ERR_NOSUCHCHANNEL | The channel does not exist | -| `482` | ERR_CHANOPRIVSNEEDED | A change by someone who is not a channel operator; over the HTTP API, a letter that is not accepted gets 472 instead | -| `461` | ERR_NEEDMOREPARAMS | No `to`; over the HTTP API, also `+o`, `-o`, `+v`, `-v`, `+k`, `+l`, `-b` or `+H` without its value | -| `472` | ERR_UNKNOWNMODE | A letter that is not accepted, `+H` bits that are not a whole number from 1 to 40, or over the HTTP API a `+l` value that is not a positive whole number | -| `401` | ERR_NOSUCHNICK | The `+o`/`+v` nick does not exist | -| `441` | ERR_USERNOTINCHANNEL | The `+o`/`+v` nick is not in the channel | - -**User modes:** you can query and change only your own; a nick other than your -own gets ERR_USERSDONTMATCH (502). With no mode string the server sends -RPL_UMODEIS (221) with your modes: `+`, `+o`, `+w` or `+ow`. A change is a mode -string, `body[0]` over the HTTP API or the parameter after the nick on the IRC -listener, that starts with `+` or `-`: `w` (receive `WALLOPS`) can be set or -unset, and `o` (server operator) only unset, since only `OPER` sets it. Any -other letter, or a string that names no mode, gets ERR_UMODEUNKNOWNFLAG (501) -and changes nothing. A change that is accepted is answered with 221 and the new -modes; it is not sent to anyone else. - -```json -{ "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } -``` - -**Response:** `200 OK` with `{"status": "ok"}`, or `{"status": "error"}` when -the reply is an error numeric. - -**IRC reference:** RFC 1459 §4.2.3 - -#### NAMES — Channel Member List - -Request the member list for a channel. Returns RPL_NAMREPLY (353) and -RPL_ENDOFNAMES (366). - -**C2S:** - -```json -{ "command": "NAMES", "to": "#general" } -``` - -**IRC reference:** RFC 1459 §4.2.5 - -#### LIST — List Channels - -Request a list of all channels with member counts. Returns RPL_LIST (322) for -each channel followed by RPL_LISTEND (323). - -**C2S:** - -```json -{ "command": "LIST" } -``` - -**IRC reference:** RFC 1459 §4.2.6 - -#### WHOIS — User Information - -Query information about a user. Returns RPL_WHOISUSER (311), RPL_WHOISSERVER -(312), RPL_WHOISOPERATOR (313, if target is oper), RPL_WHOISIDLE (317), -RPL_WHOISCHANNELS (319), and RPL_ENDOFWHOIS (318). - -If the querying user is a **server operator** (authenticated via `OPER`), the -response over the HTTP API additionally includes RPL_WHOISACTUALLY (338) with -the IP address and hostname of the target's newest client. The IRC listener's -WHOIS does not send it. - -**C2S:** - -```json -{ "command": "WHOIS", "to": "alice" } -``` - -**IRC reference:** RFC 1459 §4.5.2 - -#### WHO — Channel User List - -Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed by -RPL_ENDOFWHO (315). - -**C2S:** - -```json -{ "command": "WHO", "to": "#general" } -``` - -**IRC reference:** RFC 1459 §4.5.1 - -#### LUSERS — Server Statistics - -Request server user/channel statistics. Returns RPL_LUSERCLIENT (251), -RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and RPL_LUSERME (255). - -**C2S:** - -```json -{ "command": "LUSERS" } -``` - -LUSERS replies are also sent automatically during connection registration. - -**IRC reference:** RFC 1459 §4.3.2 - -#### OPER — Gain Server Operator Status - -Authenticate as a server operator (o-line). On success, the session gains oper -privileges: it can use `KILL` and `WALLOPS`, and over the HTTP API its WHOIS -responses include more (the IP address and hostname of the target user's newest -client). - -**C2S:** - -```json -{ "command": "OPER", "body": ["opername", "operpassword"] } -``` - -**S2C (via message queue on success):** - -```json -{ - "command": "RPL_YOUREOPER", - "code": 381, - "to": "alice", - "body": ["You are now an IRC operator"] -} -``` - -**Behavior:** - -- `body[0]` is the operator name, `body[1]` is the operator password. -- The server checks against the configured `NEOIRC_OPER_NAME` and - `NEOIRC_OPER_PASSWORD` environment variables. -- On success, the session's `is_oper` flag is set and `381 RPL_YOUREOPER` is - returned. -- On failure (wrong credentials or no o-line configured), `491 ERR_NOOPERHOST` - is returned. -- Oper status lasts until the session ends or the user drops it with user mode - `-o` (see [MODE](#mode--query-and-change-modes)). - -**IRC reference:** RFC 1459 §4.1.5 - -#### KICK — Kick User - -Remove a user from a channel. Only channel operators (`+o`) can use this -command. The kicked user and all channel members receive the KICK message. - -**C2S:** - -```json -{ "command": "KICK", "to": "#general", "body": ["bob", "misbehaving"] } -``` - -The first element of `body` is the target nick, the second (optional) is the -reason. If no reason is provided, the kicker's nick is used as the default; the -IRC listener uses the kicked user's nick instead. - -**Errors:** - -- `482` (ERR_CHANOPRIVSNEEDED) — kicker is not a channel operator -- `401` (ERR_NOSUCHNICK) — target nick does not exist -- `441` (ERR_USERNOTINCHANNEL) — target is not in the channel -- `403` (ERR_NOSUCHCHANNEL) — channel does not exist - -**IRC reference:** RFC 1459 §4.2.8 - -#### PUBKEY — Announce Signing Key - -Distribute a public signing key to channel members. - -**C2S:** - -```json -{ - "command": "PUBKEY", - "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } -} -``` - -**S2C (relayed to channel members):** - -```json -{ - "id": "...", - "command": "PUBKEY", - "from": "alice", - "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" }, - "ts": "...", - "meta": {} -} -``` - -**Behavior:** The server relays PUBKEY messages verbatim. It does not verify, -store, or interpret the key material. See [Security Model](#security-model) for -the full key distribution protocol. - -**Status:** Not yet implemented. - -### Numeric Reply Codes (S2C Only) - -Numeric replies follow IRC conventions from RFC 1459/2812. They are sent from -the server to the client (never C2S). Over the HTTP API, which the table below -describes, the `command` field holds the reply's name and `code` its number; the -IRC listener sends the usual 3-digit code. Over the HTTP API, 005 arrives named -`RPL_BOUNCE`, its name in RFC 2812. - -| Code | Name | When Sent | Example | -| ----- | -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `001` | RPL_WELCOME | After session creation or login, on MOTD | `{"command":"RPL_WELCOME","code":1,"to":"alice","body":["Welcome to the network, alice"]}` | -| `002` | RPL_YOURHOST | After session creation or login, on MOTD | `{"command":"RPL_YOURHOST","code":2,"to":"alice","body":["Your host is neoirc, running version 0.1"]}` | -| `003` | RPL_CREATED | After session creation or login, on MOTD | `{"command":"RPL_CREATED","code":3,"to":"alice","body":["This server was created 2026-02-10"]}` | -| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","","ikmnostl"]}` | -| `005` | RPL_ISUPPORT | After session creation or login, on MOTD | `{"command":"RPL_BOUNCE","code":5,"to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc","CASEMAPPING=ascii"],"body":["are supported by this server"]}` | -| `221` | RPL_UMODEIS | In response to user MODE | `{"command":"RPL_UMODEIS","code":221,"to":"alice","body":["+"]}` | -| `251` | RPL_LUSERCLIENT | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCLIENT","code":251,"to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | -| `252` | RPL_LUSEROP | On connect, MOTD or LUSERS | `{"command":"RPL_LUSEROP","code":252,"to":"alice","params":["0"],"body":["operator(s) online"]}` | -| `254` | RPL_LUSERCHANNELS | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERCHANNELS","code":254,"to":"alice","params":["3"],"body":["channels formed"]}` | -| `255` | RPL_LUSERME | On connect, MOTD or LUSERS | `{"command":"RPL_LUSERME","code":255,"to":"alice","body":["I have 5 clients and 1 servers"]}` | -| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"RPL_WHOISUSER","code":311,"to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` | -| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"RPL_WHOISSERVER","code":312,"to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | -| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"RPL_WHOISOPERATOR","code":313,"to":"alice","params":["bob"],"body":["is an IRC operator"]}` | -| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"RPL_ENDOFWHO","code":315,"to":"alice","params":["#general"],"body":["End of /WHO list"]}` | -| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"RPL_ENDOFWHOIS","code":318,"to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | -| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"RPL_WHOISCHANNELS","code":319,"to":"alice","params":["bob"],"body":["#general #dev"]}` | -| `322` | RPL_LIST | In response to LIST | `{"command":"RPL_LIST","code":322,"to":"alice","params":["#general","5"],"body":["General discussion"]}` | -| `323` | RPL_LISTEND | End of LIST response | `{"command":"RPL_LISTEND","code":323,"to":"alice","body":["End of /LIST"]}` | -| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"RPL_CHANNELMODEIS","code":324,"to":"alice","params":["#general","+nt"]}` | -| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"RPL_CREATIONTIME","code":329,"to":"alice","params":["#general","1709251200"]}` | -| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"RPL_NOTOPIC","code":331,"to":"alice","params":["#general"],"body":["No topic is set"]}` | -| `332` | RPL_TOPIC | On JOIN or TOPIC change | `{"command":"RPL_TOPIC","code":332,"to":"alice","params":["#general"],"body":["Welcome!"]}` | -| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"RPL_WHOISACTUALLY","code":338,"to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` | -| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"RPL_WHOREPLY","code":352,"to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | -| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"RPL_NAMREPLY","code":353,"to":"alice","params":["=","#general"],"body":["@op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | -| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"RPL_ENDOFNAMES","code":366,"to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | -| `367` | RPL_BANLIST | One ban, on a ban list query | `{"command":"RPL_BANLIST","code":367,"to":"alice","params":["#general","*!*@*.example.com","alice","1709251200"]}` | -| `368` | RPL_ENDOFBANLIST | End of a ban list | `{"command":"RPL_ENDOFBANLIST","code":368,"to":"alice","params":["#general"],"body":["End of channel ban list"]}` | -| `372` | RPL_MOTD | MOTD line | `{"command":"RPL_MOTD","code":372,"to":"alice","body":["- Welcome to the server"]}` | -| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"RPL_MOTDSTART","code":375,"to":"alice","body":["- neoirc Message of the Day -"]}` | -| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"RPL_ENDOFMOTD","code":376,"to":"alice","body":["End of /MOTD command."]}` | -| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"RPL_YOUREOPER","code":381,"to":"alice","body":["You are now an IRC operator"]}` | -| `401` | ERR_NOSUCHNICK | Nick does not exist | `{"command":"ERR_NOSUCHNICK","code":401,"to":"alice","params":["bob"],"body":["No such nick"]}` | -| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"ERR_NOSUCHCHANNEL","code":403,"to":"alice","params":["#nope"],"body":["No such channel"]}` | -| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"ERR_UNKNOWNCOMMAND","code":421,"to":"alice","params":["FOO"],"body":["Unknown command"]}` | -| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"ERR_ERRONEUSNICKNAME","code":432,"to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` | -| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"ERR_NICKNAMEINUSE","code":433,"to":"alice","params":["bob"],"body":["Nickname is already in use"]}` | -| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"ERR_NOTONCHANNEL","code":442,"to":"alice","params":["#general"],"body":["You're not on that channel"]}` | -| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"ERR_NEEDMOREPARAMS","code":461,"to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | -| `472` | ERR_UNKNOWNMODE | Channel mode not accepted | `{"command":"ERR_UNKNOWNMODE","code":472,"to":"alice","params":["+q"],"body":["is unknown mode char to me"]}` | -| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"ERR_CHANOPRIVSNEEDED","code":482,"to":"alice","params":["#general"],"body":["You're not channel operator"]}` | -| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"ERR_NOOPERHOST","code":491,"to":"alice","body":["No O-lines for your host"]}` | -| `501` | ERR_UMODEUNKNOWNFLAG | User mode not accepted | `{"command":"ERR_UMODEUNKNOWNFLAG","code":501,"to":"alice","body":["Unknown MODE flag"]}` | -| `502` | ERR_USERSDONTMATCH | User MODE for another nick | `{"command":"ERR_USERSDONTMATCH","code":502,"to":"alice","body":["Can't change mode for other users"]}` | - -### Channel Modes - -Inspired by IRC, simplified. See [MODE](#mode--query-and-change-modes) for how -to set them; `b`, `k` and `l` can be set only over the HTTP API, and `n` only on -the IRC listener. - -| Mode | Name | Meaning | Status | -| ---- | ----------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `+b` | Ban | Prevents matching hostmasks from joining or sending (parameter: `nick!user@host` mask with wildcards) | **Enforced** | -| `+i` | Invite-only | Only invited users can join; use `INVITE` to invite | **Enforced** | -| `+k` | Channel key | Requires a password to join (parameter: key string) | **Enforced** | -| `+l` | User limit | Maximum number of members allowed in the channel (parameter: integer) | **Enforced** | -| `+m` | Moderated | Only voiced (`+v`) users and operators (`+o`) can send | **Enforced** | -| `+n` | No external | Only channel members can send messages to the channel (default: ON) | Shown only: non-members can never send to a channel, whether or not `+n` is set | -| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** by `LIST` and `WHOIS`; not by `GET /api/v1/channels` | -| `+t` | Topic lock | Only operators can change the topic (default: ON) | **Enforced** | -| `+H` | Hashcash | Requires proof-of-work for PRIVMSG (parameter: bits, e.g. `+H 20`) | **Enforced**; the IRC listener refuses every PRIVMSG to the channel, as it cannot carry a stamp | - -**User channel modes (set per-user per-channel):** - -| Mode | Meaning | Display prefix | Status | -| ---- | -------- | ------------------ | ------------ | -| `+o` | Operator | `@` in NAMES reply | **Enforced** | -| `+v` | Voice | `+` in NAMES reply | **Enforced** | - -**Channel creator auto-op:** The first user to JOIN a channel (creating it) -automatically receives `+o` operator status. - -**Ban system (+b):** Operators can ban users by hostmask pattern with wildcard -matching (`*` and `?`, ignoring letter case). `+b` with no mask lists current -bans. Bans prevent both joining and sending messages. - -```json -{"command": "MODE", "to": "#channel", "body": ["+b", "*!*@*.example.com"]} -{"command": "MODE", "to": "#channel", "body": ["-b", "*!*@*.example.com"]} -{"command": "MODE", "to": "#channel", "body": ["+b"]} -``` - -These ban all users from example.com, remove that ban, and list all bans -(RPL_BANLIST 367, RPL_ENDOFBANLIST 368). - -**Invite-only (+i):** When set, users must be invited before joining. `INVITE` -records an invite that is consumed on JOIN; any member can invite, and on a `+i` -channel only an operator can. - -```json -{"command": "MODE", "to": "#channel", "body": ["+i"]} -{"command": "INVITE", "body": ["nick", "#channel"]} -``` - -**Channel key (+k):** Requires a password to join the channel. Over the HTTP API -the key is `body[0]` of the `JOIN`. On the IRC listener it follows the channel -name, as in `JOIN #channel secretpass`; `JOIN #a,#b keya,keyb` gives each -channel its own key. - -```json -{"command": "MODE", "to": "#channel", "body": ["+k", "secretpass"]} -{"command": "MODE", "to": "#channel", "body": ["-k"]} -{"command": "JOIN", "to": "#channel", "body": ["secretpass"]} -``` - -**User limit (+l):** Caps the number of members in the channel. - -```json -{"command": "MODE", "to": "#channel", "body": ["+l", "50"]} -{"command": "MODE", "to": "#channel", "body": ["-l"]} -``` - -**Secret (+s):** `LIST` hides the channel from non-members, and `WHOIS` leaves -it out of the channel list when the querier is not in the same channel. - -**Join errors:** a user who is banned gets ERR_BANNEDFROMCHAN (474), one not -invited to a `+i` channel ERR_INVITEONLYCHAN (473), one with a missing or wrong -key ERR_BADCHANNELKEY (475), and one joining a full channel ERR_CHANNELISFULL -(471). None of these apply to the user who creates the channel. - -**KICK command:** Channel operators can remove users with -`KICK #channel nick [:reason]`. The kicked user and all channel members receive -the KICK message. - -**NOTICE:** A NOTICE gets no hashcash check on `+H` channels, but it gets the -same error numerics as PRIVMSG (411, 412, 401, 403, 404). Over the HTTP API it -gets no RPL_AWAY; the IRC listener sends RPL_AWAY for a NOTICE to a user who is -away, as it does for PRIVMSG. - -**ISUPPORT:** The server advertises `PREFIX=(ov)@+` in RPL_ISUPPORT (005), with -`CHANMODES=b,k,Hl,imnst` over the HTTP API and `CHANMODES=,,H,imnst` on the IRC -listener. - -### Per-Channel Hashcash (Anti-Spam) - -Channels can require hashcash proof-of-work for every `PRIVMSG`. This is an -anti-spam mechanism: channel operators set a difficulty level, and clients must -compute a proof-of-work stamp bound to the specific channel and message before -sending. An IRC listener client has no way to attach a stamp, so its `PRIVMSG` -to such a channel always gets ERR_CANNOTSENDTOCHAN (404). - -**Setting the requirement:** - -``` -MODE #channel +H — require leading zero bits (1-40) -MODE #channel -H — disable hashcash requirement -``` - -**Stamp format:** `1:bits:YYMMDD:channel:bodyhash:counter` - -- `bits` — difficulty (leading zero bits in SHA-256 hash of the stamp) -- `YYMMDD` — current date (prevents old token reuse) -- `channel` — channel name (prevents cross-channel reuse) -- `bodyhash` — hex-encoded SHA-256 of the message body (binds stamp to message) -- `counter` — hex nonce - -**Sending a message to a hashcash-protected channel:** - -Include the hashcash stamp in the `meta` field: - -```json -{ - "command": "PRIVMSG", - "to": "#general", - "body": ["hello world"], - "meta": { - "hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a" - } -} -``` - -**Server validation:** The server checks that the stamp is well-formed, meets -the required difficulty, is bound to the correct channel and message body, has a -recent date, and has not been previously used. Spent stamps are cached for 1 -year to prevent replay attacks. The stamp is checked after membership, bans and -`+m`, so a message refused for one of those does not spend it. - -**Error responses:** If the channel requires hashcash and the stamp is missing, -invalid, or replayed, the server returns `ERR_CANNOTSENDTOCHAN (404)` with a -descriptive reason. - -**Client minting:** The CLI provides `MintChannelHashcash(bits, channel, body)` -to compute stamps. Higher bit counts take exponentially longer to compute. - ---- - -## API Reference - -All endpoints accept and return `application/json`. Authenticated endpoints -require the `neoirc_auth` cookie, which is set automatically by -`POST /api/v1/session` and `POST /api/v1/login`. - -All API responses include appropriate HTTP status codes. Error responses have -the format: - -```json -{ "error": "human-readable error message" } -``` - -A request to an authenticated endpoint without a valid cookie gets `401` with -`{"error": "not registered", "numeric": 451}`. - -### POST /api/v1/session — Create Session - -Create a new user session. This is the entry point for all clients. - -If the server requires hashcash proof-of-work (see -[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a -valid stamp in the `pow_token` field of the JSON request body. The required -difficulty is advertised via `GET /api/v1/server` in the `hashcash_bits` field. - -**Request Body:** +No auth. Request body: ```json { "nick": "alice", "username": "alice", - "pow_token": "1:20:260310:neoirc::3a2f1" + "pow_token": "1:20:261027:neoirc::5971e" } ``` -| Field | Type | Required | Constraints | -| ----------- | ------ | ----------- | -------------------------------------------------------------------------- | -| `nick` | string | Yes | A valid nick (see [Nick Semantics](#nick-semantics)), unique on the server | -| `username` | string | No | 1–32 characters, IRC ident-style. Defaults to nick if omitted. | -| `pow_token` | string | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) | +- `nick` (required): the nick. A nick is 1 to 32 characters: a letter or `_`, + then letters, digits and `` _-[]\^{}|` ``. It must not be in use. +- `username` (optional): the user part of the hostmask (see + [Hostmasks](#hostmasks)). 1 to 32 characters out of letters, digits and + `` _-[]\^{}|` ``. Default: the nick. +- `pow_token`: a hashcash stamp (see [Hashcash](#hashcash)). Required unless the + server runs with `NEOIRC_HASHCASH_BITS=0`. -The `username` field sets the user portion of the IRC hostmask -(`nick!user@host`). The hostname is the reverse DNS name of the connecting -client's IP address, looked up at session creation time, or the IP address -itself when the lookup gives no name; every user can see it (see -[Hostmask](#hostmask-nickuserhost)). Together these form the hostmask used in -WHOIS, WHO, NAMES, and ban matching (`+b`). - -**Response:** `201 Created` - -The response sets an `neoirc_auth` HttpOnly cookie containing an opaque auth -value. The JSON body does **not** include the auth credential. - -``` -Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; Secure; SameSite=Strict -``` +Response `201`, with the cookie: ```json -{ - "id": 1, - "nick": "alice" -} +{ "id": 1, "nick": "alice" } ``` -| Field | Type | Description | -| ------ | ------- | -------------------------------------------------- | -| `id` | integer | Server-assigned user ID | -| `nick` | string | Confirmed nick (always matches request on success) | +`id` is the session's number. The new client's queue already holds the welcome +replies `001` to `005` and the `LUSERS` replies (see +[Numeric replies](#numeric-replies)). It also holds the message of the day, +unless that is empty (see the `MOTD` setting under +[Configuration](#configuration)). -**Cookie properties:** +Errors: -| Property | Value | -| ---------- | --------------------------------------- | -| `Name` | `neoirc_auth` | -| `HttpOnly` | `true` (not accessible from JavaScript) | -| `SameSite` | `Strict` (prevents CSRF) | -| `Secure` | `true` (sent only over HTTPS) | -| `Path` | `/` | +- `400` `invalid request body`: not JSON, or too large. +- `400` `invalid nick format`. +- `400` `invalid username format`. +- `402` `hashcash proof-of-work required`: no `pow_token`, and the server + requires one. +- `402` `invalid hashcash stamp: `. +- `409` `nick already taken`. +- `500` `internal error`. -**Errors:** +The server checks the stamp before the nick, and spends it only when the session +is created. A request refused with `400`, `409` or `500` leaves the stamp +unspent. -| Status | Error | When | -| ------ | --------------------------------- | ------------------------------------------------------------------ | -| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | -| 400 | `invalid nick format` | Nick is not a valid nick | -| 400 | `invalid username format` | Username doesn't match allowed format | -| 402 | `hashcash proof-of-work required` | Missing `pow_token` field in request body when hashcash is enabled | -| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | -| 409 | `nick already taken` | Another active session holds this nick | +#### `POST /api/v1/login`: add a client to a session -**curl example:** - -```bash -# Use -c to save cookies, -b to send them -curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ - -H 'Content-Type: application/json' \ - -d '{"nick":"alice","pow_token":"1:20:260310:neoirc::3a2f1"}' -``` - -### POST /api/v1/login — Login to Account - -Authenticate with a nick and password (set via the PASS IRC command). Creates a -new client for the existing session, preserving channel memberships. This is how -multi-client access works: each login adds a new client to the session with its -own auth cookie and message delivery queue. - -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:** +No auth. The session must have a password, set with the `PASS` command. Request +body: ```json { "nick": "alice", "password": "mypassword" } ``` -| Field | Type | Required | Constraints | -| ---------- | ------ | -------- | ------------------------------------------------ | -| `nick` | string | Yes | Must match an active session with a password set | -| `password` | string | Yes | Must match the session's password | - -**Response:** `200 OK` - -The response sets an `neoirc_auth` HttpOnly cookie for the new client. +Response `200`, with a new cookie for the new client: ```json -{ - "id": 1, - "nick": "alice" -} +{ "id": 1, "nick": "alice" } ``` -| Field | Type | Description | -| ------ | ------- | ------------ | -| `id` | integer | Session ID | -| `nick` | string | Current nick | +The new client's queue holds the welcome replies and the `LUSERS` replies, then +the message of the day unless that is empty. For each channel of the session it +then holds: -**Errors:** +- a `JOIN` event from the session's own nick with an empty `body`; +- the topic replies; +- the member list. -| Status | Error | When | -| ------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- | -| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | -| 400 | `nick and password required` | Missing nick or password | -| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set | -| 429 | `too many login attempts, try again later` | Per-IP rate limit exceeded (see [Login Rate Limiting](#login-rate-limiting)); comes with `Retry-After: 1` | +A client can rebuild its channel windows from them. -**curl example:** +Errors: -```bash -curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \ - -H 'Content-Type: application/json' \ - -d '{"nick":"alice","password":"mypassword"}' -``` +- `429` `too many login attempts, try again later`, with `Retry-After: 1`. See + [Login rate limit](#login-rate-limit). +- `400` `invalid request body`. +- `400` `nick and password required`. +- `401` `invalid credentials`: wrong password, unknown nick, or a session + without a password. -### GET /api/v1/state — Get Session State +#### `GET /api/v1/state`: the session's state -Return the current user's session state. - -**Request:** No body. Requires auth. - -**Query Parameters:** - -| Parameter | Type | Default | Description | -| ------------------ | ------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. | - -**Response:** `200 OK` +Needs auth. Response `200`: ```json { "id": 1, "nick": "alice", "channels": [ - { "id": 1, "name": "#general", "topic": "Welcome!" }, - { "id": 2, "name": "#dev", "topic": "" } + { "id": 2, "name": "#dev", "topic": "" }, + { "id": 1, "name": "#general", "topic": "Welcome!" } ] } ``` -| Field | Type | Description | -| ---------- | ------- | -------------------------------- | -| `id` | integer | User ID | -| `nick` | string | Current nick | -| `channels` | array | Channels the user is a member of | +`channels` lists the channels the session has joined, sorted by name. With +`?initChannelState=1`, the server also queues, for each channel, the `JOIN` +event, the topic replies and the member list, as `POST /api/v1/login` does. The +web client uses this when it loads, to restore its channel tabs. -Each channel object: +`GET /api/v1/users/me` is the same endpoint under another path. -| Field | Type | Description | -| ------- | ------- | ------------------------------------- | -| `id` | integer | Channel ID | -| `name` | string | Channel name (e.g., `#general`) | -| `topic` | string | Channel topic (empty string if unset) | +#### `GET /api/v1/messages`: read the queue -**curl example:** +Needs auth. Query parameters: -```bash -curl -s http://localhost:8080/api/v1/state \ - -b cookies.txt | jq . -``` +- `after` (default `0`): return only queue entries with a number greater than + this. Pass the `last_id` of the previous response. A value that is not a whole + number counts as `0`. +- `timeout` (default `0`): how many seconds to wait for a message when the queue + has none. `0` answers at once. The server caps it at `30`. A negative value, + or one that is not a whole number, counts as `0`. -**Reconnect with channel state initialization:** - -```bash -curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \ - -b cookies.txt | jq . -``` - -### GET /api/v1/messages — Poll Messages (Long-Poll) - -Retrieve messages from the client's delivery queue. This is the primary -real-time endpoint — clients call it in a loop. - -**Query Parameters:** - -| Param | Type | Default | Description | -| --------- | ------- | ------- | ----------------------------------------------------------------------------------------- | -| `after` | integer | `0` | Return only queue entries with ID > this value. Use `last_id` from the previous response. | -| `timeout` | integer | `0` | Long-poll timeout in seconds. `0` = return immediately. Max `30`. Recommended: `15`. | - -**Response:** `200 OK` +Response `200`: ```json { "messages": [ { "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "command": "JOIN", - "from": "bob", - "to": "#general", - "body": ["#general"], - "ts": "2026-02-10T20:00:00.123456789Z", - "meta": {} - }, - { - "id": "b7c8210f-849c-4b90-9ee8-d99c8889358e", "command": "PRIVMSG", - "from": "alice", + "from": "bob", "to": "#general", "body": ["hello world"], "ts": "2026-02-10T20:00:01.234567891Z", @@ -1667,260 +327,98 @@ real-time endpoint — clients call it in a loop. } ``` -| Field | Type | Description | -| ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `messages` | array | Array of IRC message envelopes (see [Protocol Specification](#protocol-specification)), at most 100 per response. Empty array if no messages. | -| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. | +`messages` holds at most 100 messages, oldest first. `last_id` is the number of +the last entry returned, or the `after` value when there was none. -**Long-poll behavior:** +When the queue has nothing after `after` and `timeout` is above `0`, the server +holds the request. This wait is a long poll. It ends when a message arrives for +any client of the session, or when the time is up. The answer then holds the +messages queued for this client. When there are none, `messages` is an empty +array. That happens when the message was for another client of the session, or +when the time ran out. A client that closes the connection first gets no answer. -1. If messages are immediately available (queue entries with ID > `after`), the - server responds instantly. -2. If no messages are available and `timeout` > 0, the server holds the - connection open. -3. The server responds when: - - A message arrives for this user (instantly via in-memory broker) - - The timeout expires (returns `{"messages":[], "last_id": }`) - - The client disconnects (no response) +A client loop is: call with `after=0`, then call again with `after` set to each +response's `last_id`. Passing an earlier `after` reads the entries again. Queue +entries are deleted after `QUEUE_MAX_AGE` (default 30 days), not when read. -**curl example (immediate):** +#### `POST /api/v1/messages`: send a command -```bash -curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq . -``` - -**curl example (long-poll, 15s):** - -```bash -curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=42&timeout=15" | jq . -``` - -### POST /api/v1/messages — Send Command - -Send any client-to-server command. The `command` field determines the action. -This is the unified write endpoint — there are no separate endpoints for join, -part, nick, etc. - -**Request body:** An IRC message envelope with `command` and relevant fields: +Needs auth. The body is a message envelope with at least `command`: ```json { "command": "PRIVMSG", "to": "#general", "body": ["hello world"] } ``` -See [Commands (C2S and S2C)](#commands-c2s-and-s2c) for the full command -reference with all required and optional fields. +The server reads `command`, `to`, `body` and `meta` and ignores the rest. +`command` is case-insensitive. [Commands](#commands) describes each command, its +fields, its replies and its HTTP response. A command the server does not know +gets `ERR_UNKNOWNCOMMAND` (`421`) in the queue and `{"status": "error"}`. -**Command dispatch table:** +Errors at the HTTP level: -| Command | Required Fields | Optional | Response Status | -| ---------- | --------------- | -------- | --------------- | -| `PRIVMSG` | `to`, `body` | `meta` | 200 OK | -| `NOTICE` | `to`, `body` | `meta` | 200 OK | -| `JOIN` | `to` | `body` | 200 OK | -| `PART` | `to` | `body` | 200 OK | -| `NICK` | `body` | | 200 OK | -| `PASS` | `body` | | 200 OK | -| `TOPIC` | `to`, `body` | | 200 OK | -| `MODE` | `to` | `body` | 200 OK | -| `INVITE` | `body` | | 200 OK | -| `KICK` | `to`, `body` | | 200 OK | -| `AWAY` | | `body` | 200 OK | -| `MOTD` | | | 200 OK | -| `NAMES` | `to` | | 200 OK | -| `LIST` | | | 200 OK | -| `WHOIS` | `to` or `body` | | 200 OK | -| `WHO` | `to` | | 200 OK | -| `LUSERS` | | | 200 OK | -| `USERHOST` | `body` | | 200 OK | -| `VERSION` | | | 200 OK | -| `ADMIN` | | | 200 OK | -| `INFO` | | | 200 OK | -| `TIME` | | | 200 OK | -| `OPER` | `body` | | 200 OK | -| `KILL` | `body` | | 200 OK | -| `WALLOPS` | `body` | | 200 OK | -| `QUIT` | | `body` | 200 OK | -| `PING` | | | 200 OK | +- `400` `invalid request body`: not JSON, or too large. A `PRIVMSG` or `NOTICE` + also gets it when `body` is not an array of strings or `meta` is not an + object. +- `400` `command required`. +- `401` `not registered`. +- `500` `internal error`. -All IRC commands return HTTP 200 OK, usually with `{"status": "error"}` when the -reply is an error numeric (a WHOIS for an unknown nick still gets -`{"status": "ok"}`), and otherwise a status of the command's own (see each -command). **Numeric replies**, for success and for errors, are delivered through -the message queue (see [Numeric Reply Codes](#numeric-reply-codes-s2c-only)); a -command that has none, such as a successful `PRIVMSG`, is answered by the HTTP -response alone, and `PING`'s `PONG` is the HTTP response body. HTTP error codes -(4xx/5xx) are reserved for transport-level problems: malformed JSON (400), -missing/invalid auth cookies (401), and server errors (500). +#### `GET /api/v1/history`: past messages -**HTTP errors (transport-level only):** +Needs auth. Query parameters: -| Status | Error | When | -| ------ | ---------------------- | ------------------------------------------------------- | -| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | -| 400 | `command required` | Empty command | -| 401 | `not registered` | Missing or invalid auth cookie | -| 500 | `internal error` | Server-side failure | +- `target` (required): a channel name, or the caller's own nick. +- `before` (optional): the `id` of a message in this history. Only older + messages are returned. +- `limit` (default `50`): at most this many messages, from 1 to 500. Any other + value means 50. -**IRC numeric error replies (delivered via message queue):** +Response `200`: an array of `PRIVMSG` messages, oldest first. History holds only +`PRIVMSG`; events and `NOTICE` are left out. -| Numeric | Name | When | -| ------- | -------------------- | ----------------------------------------------------------------- | -| 401 | ERR_NOSUCHNICK | Target nick doesn't exist | -| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name | -| 404 | ERR_CANNOTSENDTOCHAN | Message to a channel not allowed (not a member, `+b`, `+m`, `+H`) | -| 411 | ERR_NORECIPIENT | PRIVMSG or NOTICE without `to` | -| 412 | ERR_NOTEXTTOSEND | PRIVMSG or NOTICE without `body` | -| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command | -| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format | -| 433 | ERR_NICKNAMEINUSE | NICK target is taken | -| 441 | ERR_USERNOTINCHANNEL | KICK or MODE `+o`/`+v` target is not in the channel | -| 442 | ERR_NOTONCHANNEL | Not a member of the target channel | -| 443 | ERR_USERONCHANNEL | INVITE of a user already in the channel | -| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | -| 471 | ERR_CHANNELISFULL | JOIN to a full `+l` channel | -| 472 | ERR_UNKNOWNMODE | Channel MODE not accepted | -| 473 | ERR_INVITEONLYCHAN | JOIN to a `+i` channel without an invite | -| 474 | ERR_BANNEDFROMCHAN | JOIN by a banned user | -| 475 | ERR_BADCHANNELKEY | JOIN to a `+k` channel with a missing or wrong key | -| 481 | ERR_NOPRIVILEGES | KILL or WALLOPS by a non-operator | -| 482 | ERR_CHANOPRIVSNEEDED | Operator-only action by a non-operator | -| 483 | ERR_CANTKILLSERVER | KILL of yourself | -| 491 | ERR_NOOPERHOST | Failed OPER authentication | -| 501 | ERR_UMODEUNKNOWNFLAG | User MODE string not accepted | -| 502 | ERR_USERSDONTMATCH | User MODE for a nick other than your own | +Without `before`, the answer holds the newest `limit` messages. With `before`, +it holds the newest `limit` messages older than that one. -**IRC numeric success replies (delivered via message queue):** +For a channel, the caller must be a member. For a nick, only the caller's own +nick is allowed. The result is then the direct messages sent to the caller's +session, under any nick it has held. It does not hold the direct messages the +caller sent. A session that takes a released nick sees none of the messages sent +to the nick's earlier holder. A channel created again after it was deleted +starts with no history. -| Numeric | Name | When | -| ------- | ----------------- | --------------------------------------- | -| 001 | RPL_WELCOME | Sent on session creation/login and MOTD | -| 002 | RPL_YOURHOST | Sent on session creation/login and MOTD | -| 003 | RPL_CREATED | Sent on session creation/login and MOTD | -| 004 | RPL_MYINFO | Sent on session creation/login and MOTD | -| 005 | RPL_ISUPPORT | Sent on session creation/login and MOTD | -| 221 | RPL_UMODEIS | In response to user MODE | -| 251 | RPL_LUSERCLIENT | On connect, MOTD or LUSERS command | -| 252 | RPL_LUSEROP | On connect, MOTD or LUSERS command | -| 254 | RPL_LUSERCHANNELS | On connect, MOTD or LUSERS command | -| 255 | RPL_LUSERME | On connect, MOTD or LUSERS command | -| 256–259 | RPL_ADMINME etc. | ADMIN info | -| 301 | RPL_AWAY | PRIVMSG to a user who is away | -| 302 | RPL_USERHOST | USERHOST reply | -| 305 | RPL_UNAWAY | AWAY with no message | -| 306 | RPL_NOWAWAY | AWAY with a message | -| 311 | RPL_WHOISUSER | WHOIS user info | -| 312 | RPL_WHOISSERVER | WHOIS server info | -| 313 | RPL_WHOISOPERATOR | WHOIS target is oper | -| 315 | RPL_ENDOFWHO | End of WHO list | -| 317 | RPL_WHOISIDLE | WHOIS idle time and signon time | -| 318 | RPL_ENDOFWHOIS | End of WHOIS list | -| 319 | RPL_WHOISCHANNELS | WHOIS channels list | -| 322 | RPL_LIST | Channel in LIST response | -| 323 | RPL_LISTEND | End of LIST | -| 324 | RPL_CHANNELMODEIS | Channel mode query, `+H`/`-H` change | -| 329 | RPL_CREATIONTIME | Channel creation timestamp | -| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) | -| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) | -| 333 | RPL_TOPICWHOTIME | Who set the topic, and when | -| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | -| 341 | RPL_INVITING | INVITE sent | -| 351 | RPL_VERSION | VERSION reply | -| 352 | RPL_WHOREPLY | User in WHO response | -| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) | -| 366 | RPL_ENDOFNAMES | End of NAMES list | -| 367 | RPL_BANLIST | One ban, on a ban list query | -| 368 | RPL_ENDOFBANLIST | End of a ban list | -| 371 | RPL_INFO | INFO line | -| 372 | RPL_MOTD | MOTD line | -| 374 | RPL_ENDOFINFO | End of INFO | -| 375 | RPL_MOTDSTART | Start of MOTD | -| 376 | RPL_ENDOFMOTD | End of MOTD | -| 381 | RPL_YOUREOPER | Successful OPER authentication | -| 391 | RPL_TIME | TIME reply | +To page back, pass the `id` of the oldest message of a page as `before`. An +empty array means there is nothing older. -### GET /api/v1/history — Message History +Errors: -Fetch historical messages for a channel. Returns messages in chronological order -(oldest first). +- `400` `target required`. +- `400` `unknown before id`: no `PRIVMSG` with that `id` in this history. +- `403` `not a member of this channel`. +- `403` `forbidden`: `target` is another user's nick. +- `404` `channel not found`. -**Query Parameters:** - -| Param | Type | Default | Description | -| -------- | ------- | ---------- | ----------------------------------------------------------------------------------------------- | -| `target` | string | (required) | Channel name (e.g., `#general`) | -| `before` | string | (none) | The `id` of a message in this history; return only messages older than it. Omit for the latest. | -| `limit` | integer | `50` | Maximum messages to return, 1–500; any other value gives 50. | - -To page back, pass the `id` of the first (oldest) message of a page as `before` -for the next; an empty page means there is nothing older. - -`target` may also be your own nick, which returns the DMs sent to your session, -including those sent under its earlier nicks. A channel created again after its -last member left starts with no history, and a user who takes a released nick -gets none of the DMs sent to its previous holder. Errors: `400` -`target required`, `400` `unknown before id` when no message with that `id` is -in this history, `404` `channel not found`, `403` -`not a member of this channel`, and `403` `forbidden` for any other nick. - -**Response:** `200 OK` - -```json -[ - { - "id": "uuid-1", - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["first message"], - "ts": "2026-02-10T19:00:00.123456789Z", - "meta": {} - }, - { - "id": "uuid-2", - "command": "PRIVMSG", - "from": "bob", - "to": "#general", - "body": ["second message"], - "ts": "2026-02-10T19:01:00.234567891Z", - "meta": {} - } -] -``` - -**Note:** History currently returns only PRIVMSG messages (not JOIN/PART/etc. -events). Event messages are delivered via the live queue only. - -**curl example:** +Example: ```bash -# Latest 50 messages in #general -curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \ - -b cookies.txt | jq . - -# The 50 messages before uuid-1 (pagination) -curl -s "http://localhost:8080/api/v1/history?target=%23general&before=uuid-1&limit=50" \ - -b cookies.txt | jq . +curl -s -b cookies.txt "http://localhost:8080/api/v1/history?target=%23general&limit=50" +curl -s -b cookies.txt "http://localhost:8080/api/v1/history?target=%23general&before=&limit=50" ``` -### GET /api/v1/channels — List Channels +#### `GET /api/v1/channels`: all channels -List all channels on the server. - -**Response:** `200 OK` +Needs auth. Response `200`: every channel on the server, sorted by name, secret +(`+s`) channels included: ```json [ - { "id": 1, "name": "#general", "topic": "Welcome!" }, - { "id": 2, "name": "#dev", "topic": "Development discussion" } + { "id": 2, "name": "#dev", "topic": "" }, + { "id": 1, "name": "#general", "topic": "Welcome!" } ] ``` -### GET /api/v1/channels/{name}/members — Channel Members +#### `GET /api/v1/channels/{name}/members`: a channel's members -List members of a channel. The `{name}` parameter is the channel name -**without** the `#` prefix (it's added by the server). - -**Response:** `200 OK` +Needs auth. `{name}` is the channel name without the `#`. Response `200`: ```json [ @@ -1936,106 +434,52 @@ List members of a channel. The `{name}` parameter is the channel name ] ``` -An unknown channel gets `404` `channel not found`. +The members are sorted by nick. `id` is the member's session number. `lastSeen` +is the time of the session's last request. An unknown channel gets `404` +`channel not found`. -**curl example:** +#### `POST /api/v1/logout`: remove this client -```bash -curl -s http://localhost:8080/api/v1/channels/general/members \ - -b cookies.txt | jq . -``` +Needs auth. Deletes the calling client and clears its cookie. Response `200` +`{"status": "ok"}`. -### POST /api/v1/logout — Logout +When the session has no other client, the session ends as it does on +[`QUIT`](#quit-end-the-session). Its channel peers see the reason +`Connection closed`. -Destroy the current client's session cookie and server-side client record. If no -other clients remain on the session, the user is fully cleaned up: parted from -all channels (with QUIT broadcast to members), session deleted, nick released. -The auth cookie is cleared in the response. +#### `GET /api/v1/server`: server information -**Request:** No body. Requires auth cookie. - -**Response:** `200 OK` - -The response clears the `neoirc_auth` cookie. - -```json -{ "status": "ok" } -``` - -**Errors:** - -| Status | Error | When | -| ------ | ---------------- | ------------------------------ | -| 401 | `not registered` | Missing or invalid auth cookie | - -**curl example:** - -```bash -curl -s -b cookies.txt -c cookies.txt -X POST http://localhost:8080/api/v1/logout | jq . -``` - -### GET /api/v1/users/me — Current User Info - -Return the current user's session state. This is an alias for -`GET /api/v1/state`. - -**Request:** No body. Requires auth. - -**Response:** `200 OK` +No auth. Response `200`: ```json { - "id": 1, - "nick": "alice", - "channels": [{ "id": 1, "name": "#general", "topic": "Welcome!" }] -} -``` - -**curl example:** - -```bash -curl -s http://localhost:8080/api/v1/users/me \ - -b cookies.txt | jq . -``` - -### GET /api/v1/server — Server Info - -Return server metadata. No authentication required. - -**Response:** `200 OK` - -```json -{ - "name": "My NeoIRC Server", - "version": "0.1.0", - "motd": "Welcome! Be nice.", + "name": "My Server", + "version": "", + "motd": "Welcome!", "users": 42, "hashcash_bits": 20 } ``` -| Field | Type | Description | -| --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `name` | string | Server display name | -| `version` | string | Server version | -| `motd` | string | Message of the day | -| `users` | integer | Number of currently active user sessions | -| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). | +- `name`: `SERVER_NAME`. An empty string when it is not set. +- `version`: the version compiled into the binary. An empty string in a binary + built without one. +- `motd`: the message of the day. +- `users`: the number of sessions. +- `hashcash_bits`: the difficulty a session stamp needs. Absent when the server + requires no stamp. -### GET /.well-known/healthcheck.json — Health Check +#### `GET /.well-known/healthcheck.json`: health check -Standard health check endpoint. No authentication required. Returns server -health status and runtime statistics. - -**Response:** `200 OK` +No auth. Response `200`: ```json { "status": "ok", - "now": "2024-01-15T12:00:00.123456789Z", + "now": "2026-02-10T12:00:00.123456789Z", "uptimeSeconds": 3600, "uptimeHuman": "1h0m0s", - "version": "0.1.0", + "version": "", "appname": "neoirc", "maintenanceMode": false, "sessions": 42, @@ -2048,780 +492,1327 @@ health status and runtime statistics. } ``` -| Field | Description | -| ---------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `sessions` | Current number of active sessions | -| `clients` | Current number of connected clients | -| `queuedLines` | Total entries in client output queues | -| `channels` | Current number of channels | -| `connectionsSinceBoot` | Session creations and logins over the HTTP API since server start | -| `sessionsSinceBoot` | Sessions created over the HTTP API since server start | -| `messagesSinceBoot` | PRIVMSG/NOTICE commands with a target and body received over the HTTP API since server start, whether delivered or refused | +- `sessions`, `clients`, `channels`: current counts. +- `queuedLines`: the number of entries in all client queues. +- `connectionsSinceBoot`: sessions created and logins over the HTTP API since + the server started. +- `sessionsSinceBoot`: sessions created over the HTTP API since the server + started. +- `messagesSinceBoot`: `PRIVMSG` and `NOTICE` commands with a target and a body + received over the HTTP API since the server started, delivered or refused. +- `maintenanceMode`: the value of `MAINTENANCE_MODE`. ---- +The Docker image's `HEALTHCHECK` calls this endpoint every 30 seconds. -## Message Flow +#### `GET /metrics`: Prometheus metrics -### Channel Message Flow +Served only when `METRICS_USERNAME` is set, behind HTTP basic auth with +`METRICS_USERNAME` and `METRICS_PASSWORD`. The server records a request metric +for every HTTP request when metrics are on. -``` -Alice Server Bob - │ │ │ - │ POST /messages │ │ - │ {PRIVMSG, #gen, "hi"} │ │ - │───────────────────────>│ │ - │ │ 1. Store in messages │ - │ │ 2. Query #gen members │ - │ │ → [alice, bob] │ - │ │ 3. Enqueue for alice │ - │ │ 4. Enqueue for bob │ - │ │ 5. Notify alice broker │ - │ │ 6. Notify bob broker │ - │ 200 {"status":"sent"} │ │ - │<───────────────────────│ │ - │ │ │ - │ GET /messages?after=N │ GET /messages?after=M │ - │ (long-poll wakes up) │ (long-poll wakes up) │ - │───────────────────────>│<───────────────────────│ - │ │ │ - │ {messages: [{PRIVMSG, │ {messages: [{PRIVMSG, │ - │ from:alice, "hi"}]} │ from:alice, "hi"}]} │ - │<───────────────────────│───────────────────────>│ +#### Other paths + +Every other `GET` path serves the web client. A path that names one of its files +gets that file; any other path gets its `index.html`. An unknown path under +`/api/v1/` gets a plain-text `404`. A request whose method the path does not +take gets `405` with no body. + +### The message envelope + +Every message in a queue, and every command a client sends, is one JSON object +with these fields: + +- `id`: the message's UUID. The server assigns it and ignores one sent by a + client. +- `command`: the command name, such as `PRIVMSG`, or the name of a numeric + reply, such as `RPL_WELCOME`. The server upper-cases what a client sends. +- `code`: the number of a numeric reply, such as `1` for `RPL_WELCOME` or `433` + for `ERR_NICKNAMEINUSE`. Absent on other messages. +- `from`: the sender's nick, or the server name on numeric replies. The server + sets it and ignores one sent by a client. +- `to`: the target, a channel name or a nick; `*` on a `WALLOPS` event. Absent + on `NICK` and `QUIT` events. +- `params`: an array of strings with further parameters. `KICK` events and many + numeric replies have it. The server ignores one sent by a client. +- `body`: an array of strings. For chat it is the lines of text, one string per + line. A `body` is never a bare string. On a command other than `PRIVMSG` and + `NOTICE`, a `body` that is not an array of strings counts as missing. Every + numeric reply has a `body`; a few, such as `RPL_CHANNELMODEIS`, carry one + empty string. +- `ts`: when the server stored the message: UTC, RFC 3339 with fractional + seconds, such as `2026-02-10T20:00:00.123456789Z`; trailing zeros are dropped. + The server assigns it. +- `meta`: an object. A client may put anything in it. On a `PRIVMSG` or `NOTICE` + the server stores and relays it unchanged. When the client sent none, the + message carries `{}`. On every other command the server drops it, and the + event carries `{}`. The server reads one key: `meta.hashcash` on a `PRIVMSG` + to a channel with mode `+H`. + +### Commands + +A client sends a command as the body of `POST /api/v1/messages`. For each +command this section gives: + +- the fields it takes; +- what the server does; +- the event other users receive; +- the numeric replies the sender receives; +- the HTTP response. + +Every numeric reply goes into the sender's own queue only. Unless stated +otherwise, an error numeric comes with the HTTP response `{"status": "error"}`. + +#### `PRIVMSG`: send a message + +```json +{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]} +{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]} +{"command": "PRIVMSG", "to": "bob", "body": ["hey"]} ``` -### DM Flow +- `to` (required): a channel name, or a nick for a direct message. The server + does not add a missing `#` here: a `to` without one is a nick. +- `body` (required): one or more lines. +- `meta` (optional): relayed unchanged. A `PRIVMSG` to a `+H` channel carries + its stamp in `meta.hashcash`. -``` -Alice Server Bob - │ │ │ - │ POST /messages │ │ - │ {PRIVMSG, "bob", "yo"} │ │ - │───────────────────────>│ │ - │ │ 1. Resolve nick "bob" │ - │ │ 2. Store in messages │ - │ │ 3. Enqueue for bob │ - │ │ 4. Enqueue for alice │ - │ │ (echo to sender) │ - │ │ 5. Notify both │ - │ 200 {"status":"sent"} │ │ - │<───────────────────────│ │ - │ │ │ - │ (alice sees her own DM │ (bob sees DM from │ - │ on all her clients) │ alice) │ -``` +A channel message goes to every member of the channel, the sender included: +every client of the sender's session sees it in its queue. The sender must be a +member. A direct message goes to the target's session and to the sender's own +session, so every client of both sees it. -### JOIN Flow - -``` -Alice Server Bob (already in #gen) - │ │ │ - │ POST /messages │ │ - │ {JOIN, "#general"} │ │ - │───────────────────────>│ │ - │ │ 1. Get/create #general │ - │ │ 2. Add alice to members│ - │ │ 3. Store JOIN message │ - │ │ 4. Fan out to all │ - │ │ members (alice, bob) │ - │ 200 {"joined"} │ │ - │<───────────────────────│ │ - │ │ │ - │ (alice's queue gets │ (bob's queue gets │ - │ JOIN from alice) │ JOIN from alice) │ -``` - ---- - -## 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. - -**Status:** this section documents the design. `PUBKEY` and message signing are -not yet implemented (see [Roadmap](#roadmap)); today the server relays `meta`, -and any signature in it, verbatim. - -### Canonicalization (RFC 8785 JCS) - -To produce a deterministic byte representation of a message for signing: - -1. Start with the full message envelope (including `id`, `ts`, `from`, etc.) -2. Remove `meta.sig` from the message (the signature itself is not signed) -3. Serialize using - [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785): - - Object keys sorted lexicographically (Unicode code point order) - - No insignificant whitespace - - Numbers serialized in shortest form (no trailing zeros) - - Strings escaped per JSON spec (no unnecessary escapes) - - UTF-8 encoding throughout -4. The resulting byte string is the signing input - -**Example:** - -Given this message: +The message as the recipients receive it: ```json { - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["hello"], - "id": "abc-123", - "ts": "2026-02-10T20:00:00Z", - "meta": { "alg": "ed25519" } -} -``` - -The JCS canonical form is: - -``` -{"body":["hello"],"command":"PRIVMSG","from":"alice","id":"abc-123","meta":{"alg":"ed25519"},"to":"#general","ts":"2026-02-10T20:00:00Z"} -``` - -This is why `body` must be an object or array — raw strings would be ambiguous -under canonicalization (a bare string `hello` is not valid JSON, and `"hello"` -has different canonical forms depending on escaping rules). - -### Signing Flow - -1. Client generates an Ed25519 keypair (32-byte seed → 64-byte secret key, - 32-byte public key) -2. Client announces public key via PUBKEY command: - ```json - { - "command": "PUBKEY", - "body": { "alg": "ed25519", "key": "base64url-encoded-pubkey" } - } - ``` -3. Server relays PUBKEY to channel members and/or stores for the session -4. When sending a message, client: a. Constructs the complete message envelope - **without** `meta.sig` b. Canonicalizes per JCS (step above) c. Signs the - canonical bytes with the Ed25519 private key d. Adds `meta.sig` - (base64url-encoded signature) and `meta.alg` ("ed25519") -5. Server stores and relays the message including `meta` verbatim -6. Recipients verify by: a. Extracting and removing `meta.sig` from the received - message b. Canonicalizing the remaining message per JCS c. Verifying the - Ed25519 signature against the sender's announced public key - -### PUBKEY Distribution - -```json -{ - "command": "PUBKEY", - "from": "alice", - "body": { "alg": "ed25519", "key": "base64url-encoded-32-byte-pubkey" } -} -``` - -- Servers relay PUBKEY messages to all channel members -- Clients cache public keys locally, indexed by (server, nick) -- Key distribution uses **TOFU** (trust on first use): the first key seen for a - nick is trusted; subsequent different keys trigger a warning -- **There is no key revocation mechanism** — if a key is compromised, the user - must change their nick or wait for the old key's TOFU cache to expire - -### Signed Message Example - -```json -{ - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["this message is signed"], "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello world"], "ts": "2026-02-10T20:00:00.123456789Z", + "meta": {} +} +``` + +Replies and errors: + +- `ERR_NORECIPIENT` (`411`): no `to`. +- `ERR_NOTEXTTOSEND` (`412`): no `body`, or an empty one. +- `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist. +- `ERR_CANNOTSENDTOCHAN` (`404`): the message is not allowed: + - the sender is not a member; + - the sender is banned (`+b`); + - the channel is moderated (`+m`) and the sender has neither `+o` nor `+v`; + - the channel is `+H` and the message has no valid hashcash stamp. + + The `body` names the reason in the last three cases. + +- `ERR_NOSUCHNICK` (`401`): no session has the target nick. +- `RPL_AWAY` (`301`): the target of a direct message is away. The message is + still delivered. `params` holds the nick and `body` the away message. + +HTTP response: `{"id": "", "status": "sent"}`. + +A `body` or `meta` of the wrong type gets `400` `invalid request body`, and +nothing is relayed. + +#### `NOTICE`: send a notice + +The same as `PRIVMSG`, with three differences: + +- The server sends no `RPL_AWAY` for a notice. +- A notice to a `+H` channel needs no hashcash stamp. +- A notice does not appear in history (see + [`GET /api/v1/history`](#get-apiv1history-past-messages)). + +A client that receives a `NOTICE` must not answer it automatically. That is the +IRC rule that keeps two bots from answering each other forever. + +#### `JOIN`: join a channel + +```json +{"command": "JOIN", "to": "#general"} +{"command": "JOIN", "to": "general"} +{"command": "JOIN", "to": "#private", "body": ["secretkey"]} +``` + +- `to` (required): the channel. The server adds a missing `#`. The name must + then be `#` followed by 1 to 63 letters, digits, `_` or `-`. +- `body[0]` (optional): the key of a `+k` channel. + +A channel that does not exist is created with modes `+nt` and no topic. The +joining user becomes its channel operator. Every member, the joiner included, +receives the event: + +```json +{ "command": "JOIN", "from": "alice", "to": "#general", "body": ["#general"] } +``` + +The joiner then receives the topic, as `RPL_TOPIC` (`332`) with +`RPL_TOPICWHOTIME` (`333`) or as `RPL_NOTOPIC` (`331`), and the member list, as +`RPL_NAMREPLY` (`353`) and `RPL_ENDOFNAMES` (`366`). A `JOIN` to a channel the +user is already in is checked, broadcast and answered again; the membership does +not change. + +Errors: + +- `ERR_NEEDMOREPARAMS` (`461`): no `to`. +- `ERR_NOSUCHCHANNEL` (`403`): the name is not allowed. +- `ERR_BANNEDFROMCHAN` (`474`): a ban matches the user's hostmask. +- `ERR_INVITEONLYCHAN` (`473`): the channel is `+i` and the user has no invite. +- `ERR_BADCHANNELKEY` (`475`): the channel is `+k` and the key is missing or + wrong. +- `ERR_CHANNELISFULL` (`471`): the channel has its `+l` number of members. + +None of the last four applies to the user who creates the channel. + +HTTP response: `{"status": "joined", "channel": "#general"}`. + +#### `PART`: leave a channel + +```json +{"command": "PART", "to": "#general"} +{"command": "PART", "to": "#general", "body": ["goodbye"]} +``` + +- `to` (required): the channel. The server adds a missing `#`. +- `body[0]` (optional): the reason. + +Every member, the leaver included, receives the event. Its `body` holds the +reason, or one empty string: + +```json +{ "command": "PART", "from": "alice", "to": "#general", "body": ["goodbye"] } +``` + +The channel is deleted when it has no members left. + +Errors: + +- `ERR_NEEDMOREPARAMS` (`461`): no `to`. +- `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist. +- `ERR_NOTONCHANNEL` (`442`): the user is not a member. + +HTTP response: `{"status": "parted", "channel": "#general"}`. + +#### `NICK`: change nick + +```json +{ "command": "NICK", "body": ["newnick"] } +``` + +`body[0]` is the new nick. Every user who shares a channel with the user, and +the user's own session, receives one event. `from` is the old nick and `body[0]` +the new one; the event has no `to`: + +```json +{ "command": "NICK", "from": "oldnick", "body": ["newnick"] } +``` + +A `NICK` to the nick the session already has changes nothing and sends no event. + +Errors: + +- `ERR_NEEDMOREPARAMS` (`461`): no `body`. +- `ERR_ERRONEUSNICKNAME` (`432`): the nick is not allowed. +- `ERR_NICKNAMEINUSE` (`433`): the nick is in use. + +HTTP response: `{"status": "ok", "nick": "newnick"}`. + +#### `PASS`: set the session password + +```json +{ "command": "PASS", "body": ["mypassword"] } +``` + +`body[0]` is the password, 8 to 72 bytes. The server stores a bcrypt hash of it. +From then on `POST /api/v1/login` with the nick and the password adds a client +to the session. A second `PASS` replaces the password. The password disappears +with the session. + +Error: `ERR_NEEDMOREPARAMS` (`461`). Its `body` is `["Not enough parameters"]` +for a missing password and `["Password must be 8 to 72 bytes"]` for a wrong +length. + +HTTP response: `{"status": "ok"}`. + +#### `TOPIC`: set a channel's topic + +```json +{ "command": "TOPIC", "to": "#general", "body": ["Welcome to #general"] } +``` + +- `to` (required): the channel. The server adds a missing `#`. +- `body` (required): the topic. Several strings are joined with spaces. + +There is no topic query over the HTTP API; `GET /api/v1/state` and the `JOIN` +replies carry the topic. Every member receives the event: + +```json +{ + "command": "TOPIC", + "from": "alice", + "to": "#general", + "body": ["Welcome to #general"] +} +``` + +The setter also receives `RPL_TOPIC` (`332`) and `RPL_TOPICWHOTIME` (`333`). + +Errors: + +- `ERR_NEEDMOREPARAMS` (`461`): no `to`, or no `body`. +- `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist. +- `ERR_NOTONCHANNEL` (`442`): the user is not a member. +- `ERR_CHANOPRIVSNEEDED` (`482`): the channel is `+t` and the user is not a + channel operator. New channels are `+t`. + +HTTP response: `{"status": "ok", "topic": "Welcome to #general"}`. + +#### `QUIT`: end the session + +```json +{"command": "QUIT"} +{"command": "QUIT", "body": ["leaving"]} +``` + +The session ends: + +- The user leaves every channel. A channel left empty is deleted. +- The nick is released. +- The cookie of every client of the session stops working. +- The password is gone. + +Every user who shared a channel receives the event; the quitting user does not. +`body[0]` is the reason, `Client quit` when none was given: + +```json +{ "command": "QUIT", "from": "alice", "body": ["leaving"] } +``` + +HTTP response: `{"status": "quit"}`, and the cookie is cleared. + +#### `PING`: keepalive + +```json +{ "command": "PING" } +``` + +The answer is the HTTP response itself, not a queued message: + +```json +{ "command": "PONG", "from": "neoirc" } +``` + +`from` is the server name. + +#### `MODE`: query or change modes + +A `to` that starts with `#` names a channel; any other `to` is a nick. +[Channel modes](#channel-modes) and [User modes](#user-modes) explain each mode. + +```json +{"command": "MODE", "to": "#general"} +{"command": "MODE", "to": "#general", "body": ["+m"]} +{"command": "MODE", "to": "#general", "body": ["+o", "bob"]} +{"command": "MODE", "to": "#general", "body": ["+k", "secretkey"]} +{"command": "MODE", "to": "alice"} +{"command": "MODE", "to": "alice", "body": ["+w"]} +``` + +**Channel query.** With no `body`, anyone, member or not, receives +`RPL_CHANNELMODEIS` (`324`) and `RPL_CREATIONTIME` (`329`). The mode string +lists the set flags out of `nimst`, then `k`, `l` and `H` with their values +after it, such as `+ntkl secretkey 50`: + +```json +{"command": "RPL_CHANNELMODEIS", "code": 324, "to": "alice", "params": ["#general", "+nt"], "body": [""]} +{"command": "RPL_CREATIONTIME", "code": 329, "to": "alice", "params": ["#general", "1709251200"], "body": [""]} +``` + +**Channel change.** Only a channel operator can change a channel's modes. +`body[0]` is one change: a sign and one letter. `body[1]` is its value where the +mode takes one. A string of several letters, such as `+mt`, is not accepted. The +changes: + +- `+o ` / `-o `: give or take channel operator. +- `+v ` / `-v `: give or take voice. +- `+i` / `-i`: invite-only on or off. +- `+m` / `-m`: moderated on or off. +- `+s` / `-s`: secret on or off. +- `+t` / `-t`: topic lock on or off. +- `+k ` / `-k`: set or clear the channel key. +- `+l ` / `-l`: set or clear the member limit. The number must be a + positive whole number. +- `+b ` / `-b `: add or remove a ban. `+b` with no mask lists the + bans; anyone may do that. +- `+H ` / `-H`: require a hashcash stamp on every `PRIVMSG`, or stop + requiring one. `bits`, from 1 to 40, is the number of leading zero bits. + +Every member, the operator included, receives a `MODE` event. For `o`, `v`, `i`, +`m`, `s` and `t` its `body` is one string: `["+o bob"]`, `["+m"]`. For `k`, `l` +and `b` it is the change and its value as separate strings: +`["+k", "secretkey"]`, `["-k", "*"]`, `["+l", "50"]`, `["-l"]`, +`["+b", "*!*@*.example.com"]`. + +```json +{ "command": "MODE", "from": "alice", "to": "#general", "body": ["+o bob"] } +``` + +A `+H` or `-H` change sends no event. The operator receives `RPL_CHANNELMODEIS` +(`324`) with the new mode string. + +Listing bans sends one `RPL_BANLIST` (`367`) per ban, with `params` +`[channel, mask, nick who set it, Unix time]`, then `RPL_ENDOFBANLIST` (`368`). + +Errors of a channel `MODE`: + +- `ERR_NEEDMOREPARAMS` (`461`): no `to`, or `+o`, `-o`, `+v`, `-v`, `+k`, `+l`, + `-b` or `+H` without its value. +- `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist. +- `ERR_CHANOPRIVSNEEDED` (`482`): a change from the list above by a user who is + not a channel operator. +- `ERR_UNKNOWNMODE` (`472`), in three cases: + - a change the list above does not have; + - a `+H` value that is not a whole number from 1 to 40; + - a `+l` value that is not a positive whole number. +- `ERR_NOSUCHNICK` (`401`): the `+o`, `-o`, `+v` or `-v` nick does not exist. +- `ERR_USERNOTINCHANNEL` (`441`): that nick is not a member. + +**User modes.** A user can query and change only their own modes; any other nick +gets `ERR_USERSDONTMATCH` (`502`). With no `body`, the server sends +`RPL_UMODEIS` (`221`) with the modes in `body`: `+`, `+o`, `+w` or `+ow`. With a +`body`, `body[0]` is a mode string starting with `+` or `-`, such as `+w` or +`-o`. `w` can be set and unset. `o` can only be unset; `OPER` sets it. A string +that sets `o`, has any other letter or has no letter gets `ERR_UMODEUNKNOWNFLAG` +(`501`) and changes nothing. An accepted change is answered with `RPL_UMODEIS` +(`221`) and the new modes, and nobody else is told. + +```json +{ "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } +``` + +HTTP response: `{"status": "ok"}`, or `{"status": "error"}` with an error +numeric. + +#### `INVITE`: invite a user + +```json +{ "command": "INVITE", "body": ["bob", "#private"] } +``` + +`body[0]` is the nick and `body[1]` the channel; the server adds a missing `#`. +The inviter must be a member. On a `+i` channel the inviter must be a channel +operator. The invite lets the user join a `+i` channel once; the join uses it +up. + +The inviter receives `RPL_INVITING` (`341`) with `params` `[nick, channel]`. The +invited user receives the event, and nobody else does: + +```json +{ "command": "INVITE", "from": "alice", "to": "bob", "body": ["#private"] } +``` + +Errors: + +- `ERR_NEEDMOREPARAMS` (`461`): fewer than two strings in `body`. +- `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist. +- `ERR_NOTONCHANNEL` (`442`): the inviter is not a member. +- `ERR_CHANOPRIVSNEEDED` (`482`): the channel is `+i` and the inviter is not a + channel operator. +- `ERR_NOSUCHNICK` (`401`): no session has the nick. +- `ERR_USERONCHANNEL` (`443`): the user is already a member. + +HTTP response: `{"status": "ok"}`. + +#### `KICK`: remove a user from a channel + +```json +{ "command": "KICK", "to": "#general", "body": ["bob", "misbehaving"] } +``` + +- `to` (required): the channel. The server adds a missing `#`. +- `body[0]` (required): the nick to remove. +- `body[1]` (optional): the reason. Default: the kicker's nick. + +Only a channel operator can kick. Every member, the kicked user included, +receives the event. The kicker is in `from`, the channel in `to`, the kicked +nick in `params` and the reason in `body`: + +```json +{ + "command": "KICK", + "from": "alice", + "to": "#general", + "params": ["bob"], + "body": ["misbehaving"] +} +``` + +The channel is deleted when it has no members left. + +Errors: + +- `ERR_NEEDMOREPARAMS` (`461`): no `to`, or no `body`. +- `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist. +- `ERR_CHANOPRIVSNEEDED` (`482`): the kicker is not a channel operator. +- `ERR_NOSUCHNICK` (`401`): no session has the nick. +- `ERR_USERNOTINCHANNEL` (`441`): the nick is not a member. + +HTTP response: `{"status": "ok"}`. + +#### `AWAY`: set or clear an away message + +```json +{"command": "AWAY", "body": ["back in an hour"]} +{"command": "AWAY"} +``` + +A `body` sets the away message; several strings are joined with spaces. No +`body`, or one empty string, clears it. The reply is `RPL_NOWAWAY` (`306`) or +`RPL_UNAWAY` (`305`). A user who sends a `PRIVMSG` to an away user receives +`RPL_AWAY` (`301`). `USERHOST` marks an away user with `-`. + +HTTP response: `{"status": "ok"}`. + +#### `NAMES`: a channel's members + +```json +{ "command": "NAMES", "to": "#general" } +``` + +The server adds a missing `#`. The reply is `RPL_NAMREPLY` (`353`), when the +channel has members, then `RPL_ENDOFNAMES` (`366`). `RPL_NAMREPLY` has `params` +`["=", channel]` and a `body` with one string. That string holds the members' +hostmasks in nick order, separated by spaces. A `@` before a hostmask marks a +channel operator, and a `+` marks a voiced member: + +```json +{ + "command": "RPL_NAMREPLY", + "code": 353, + "to": "alice", + "params": ["=", "#general"], + "body": ["alice!alice@host2 +bob!bob@host3 @op1!op1@host1"] +} +``` + +Errors: + +- `ERR_NEEDMOREPARAMS` (`461`): no `to`. +- `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist. + +HTTP response: `{"status": "ok"}`. + +#### `LIST`: all channels + +```json +{ "command": "LIST" } +``` + +The reply is one `RPL_LIST` (`322`) per channel, in name order, with `params` +`[channel, member count]` and the topic in `body`, then `RPL_LISTEND` (`323`). +Secret (`+s`) channels the user is not a member of are left out. + +HTTP response: `{"status": "ok"}`. + +#### `WHOIS`: information about a user + +```json +{"command": "WHOIS", "to": "bob"} +{"command": "WHOIS", "body": ["bob"]} +``` + +The nick comes from `to`, or from `body[0]` when `to` is empty. The replies, in +order: + +- `RPL_WHOISUSER` (`311`): `params` `[nick, username, hostname, "*"]`, `body` + `[nick]`. +- `RPL_WHOISSERVER` (`312`): `params` `[nick, server name]`. +- `RPL_WHOISOPERATOR` (`313`): only when the user is a server operator. +- `RPL_WHOISIDLE` (`317`): `params` + `[nick, seconds since the user's last request, Unix time of the session's creation]`. +- `RPL_WHOISCHANNELS` (`319`): `body` with the user's channels separated by + spaces. Secret channels the asker is not in are left out. Absent when there is + none to show. +- `RPL_WHOISACTUALLY` (`338`): only when the asker is a server operator. + `params` `[nick, IP address]` and `body` + `["is actually using host "]`, for the user's newest client. +- `RPL_ENDOFWHOIS` (`318`). + +An unknown nick gets `ERR_NOSUCHNICK` (`401`) and `RPL_ENDOFWHOIS` (`318`). No +nick at all gets `ERR_NEEDMOREPARAMS` (`461`). + +HTTP response: `{"status": "ok"}`, also for an unknown nick. + +#### `WHO`: a channel's members, one reply each + +```json +{ "command": "WHO", "to": "#general" } +``` + +The server adds a missing `#`. The reply is one `RPL_WHOREPLY` (`352`) per +member, with `params` `[channel, username, hostname, server name, nick, "H"]` +and `body` `["0 "]`, then `RPL_ENDOFWHO` (`315`). An unknown channel gets +`RPL_ENDOFWHO` alone. No `to` gets `ERR_NEEDMOREPARAMS` (`461`). + +HTTP response: `{"status": "ok"}`. + +#### `LUSERS`: user and channel counts + +```json +{ "command": "LUSERS" } +``` + +The replies are `RPL_LUSERCLIENT` (`251`), `RPL_LUSEROP` (`252`), +`RPL_LUSERCHANNELS` (`254`) and `RPL_LUSERME` (`255`). The server sends them to +every new client too. + +HTTP response: `{"status": "ok"}`. + +#### `MOTD`: the message of the day + +```json +{ "command": "MOTD" } +``` + +The server sends the welcome replies `001` to `005`, the `LUSERS` replies and +the message of the day again: `RPL_MOTDSTART` (`375`), one `RPL_MOTD` (`372`) +per line, `RPL_ENDOFMOTD` (`376`). When the message of the day is empty, the +welcome and `LUSERS` replies come alone. + +HTTP response: `{"status": "ok"}`. + +#### `USERHOST`: hostmasks of up to five users + +```json +{ "command": "USERHOST", "body": ["alice", "bob"] } +``` + +The `body` holds up to five nicks; further ones are ignored. The reply is +`RPL_USERHOST` (`302`) with one string in `body`: for each nick that exists, +`nick=+username@hostname`, separated by spaces. A `*` after the nick marks a +server operator. A `-` instead of the `+` marks an away user. Unknown nicks are +left out. + +Error: `ERR_NEEDMOREPARAMS` (`461`) without a `body`. + +HTTP response: `{"status": "ok"}`. + +#### `VERSION`, `ADMIN`, `INFO`, `TIME`: server details + +Each takes no fields. + +- `VERSION`: `RPL_VERSION` (`351`) with `params` + `["neoirc-.", server name]`. +- `ADMIN`: `RPL_ADMINME` (`256`) with `params` `[server name]`, then + `RPL_ADMINLOC1` (`257`) `neoirc server`, `RPL_ADMINLOC2` (`258`) + `IRC over HTTP` and `RPL_ADMINEMAIL` (`259`) `admin@`. +- `INFO`: four `RPL_INFO` (`371`) lines, naming the program, its version, its + language and its start time, then `RPL_ENDOFINFO` (`374`). +- `TIME`: `RPL_TIME` (`391`) with `params` `[server name]` and the server's time + in `body`, in RFC 1123 form. + +HTTP response: `{"status": "ok"}`. + +#### `OPER`: become a server operator + +```json +{ "command": "OPER", "body": ["opername", "operpassword"] } +``` + +`body[0]` and `body[1]` are compared with `NEOIRC_OPER_NAME` and +`NEOIRC_OPER_PASSWORD`. On success the session gets user mode `+o` and +`RPL_YOUREOPER` (`381`). The status lasts until the session ends or the user +sends `MODE -o`. + +Errors: + +- `ERR_NEEDMOREPARAMS` (`461`): fewer than two strings in `body`. +- `ERR_NOOPERHOST` (`491`): the name or password is wrong, or either setting is + empty. + +HTTP response: `{"status": "ok"}`. + +#### `KILL`: end another user's session + +```json +{ "command": "KILL", "body": ["bob", "spamming"] } +``` + +Only a server operator can kill. `body[0]` is the nick and `body[1]` the reason, +`KILLed` when none is given. The target's session ends as with `QUIT`. Its +channel peers receive a `QUIT` event with the reason +`Killed ( ())`. A target connected through the IRC +listener is sent `KILL` and `ERROR` lines and disconnected. + +Errors: + +- `ERR_NEEDMOREPARAMS` (`461`): no nick. +- `ERR_NOPRIVILEGES` (`481`): the sender is not a server operator. +- `ERR_NOSUCHNICK` (`401`): no session has the nick. +- `ERR_CANTKILLSERVER` (`483`): the nick is the operator's own. + +HTTP response: `{"status": "ok"}`. + +#### `WALLOPS`: message every user with `+w` + +```json +{ "command": "WALLOPS", "body": ["server restarts in five minutes"] } +``` + +Only a server operator can send it. The strings of `body` are joined with +spaces. Every user with user mode `+w` receives the event, with `to` `*`: + +```json +{ + "command": "WALLOPS", + "from": "alice", + "to": "*", + "body": ["server restarts in five minutes"] +} +``` + +Errors: + +- `ERR_NEEDMOREPARAMS` (`461`): no `body`. +- `ERR_NOPRIVILEGES` (`481`): the sender is not a server operator. + +HTTP response: `{"status": "ok"}`. + +### Numeric replies + +A numeric reply is a message from the server to one client. Over the HTTP API, +`command` holds the reply's name and `code` its number; `from` is the server +name and `to` the client's nick. `005` arrives as `RPL_BOUNCE`, its name in +RFC 2812. Every reply has a `body` with one string, the reply's text; a few +carry an empty string. + +The server sends these to every new client, on `POST /api/v1/session` and +`POST /api/v1/login`, and again on the `MOTD` command. `375` to `376` are left +out when the message of the day is empty (see the `MOTD` setting under +[Configuration](#configuration)). + +- `001` `RPL_WELCOME`: `Welcome to the network, `. +- `002` `RPL_YOURHOST`: `Your host is , running version `. + `` is `dev` in a binary built without one. +- `003` `RPL_CREATED`: `This server was created `. +- `004` `RPL_MYINFO`: `params` `[server name, version, "ow", "Hbiklmnostv"]`. +- `005` `RPL_BOUNCE`: `params` + `["CHANTYPES=#", "NICKLEN=32", "PREFIX=(ov)@+", "CHANMODES=b,k,Hl,imnst", "NETWORK=neoirc", "CASEMAPPING=ascii"]`, + `body` `["are supported by this server"]`. +- `251` `RPL_LUSERCLIENT`: + `There are users and 0 invisible on 1 servers`. +- `252` `RPL_LUSEROP`: `params` `[server operators]`, `body` + `["operator(s) online"]`. +- `254` `RPL_LUSERCHANNELS`: `params` `[channels]`, `body` + `["channels formed"]`. +- `255` `RPL_LUSERME`: `I have clients and 1 servers`. +- `375` `RPL_MOTDSTART`: `- Message of the Day -`. +- `372` `RPL_MOTD`: one per line of the message of the day, each `- `. +- `376` `RPL_ENDOFMOTD`: `End of /MOTD command.` + +Answers to commands. Each line names the command that gets the reply, then what +`params` holds: + +- `221` `RPL_UMODEIS`: user `MODE`. No `params`; the modes are in `body`. +- `256` `RPL_ADMINME`: `ADMIN`. `[server name]`. +- `257` `RPL_ADMINLOC1`, `258` `RPL_ADMINLOC2`, `259` `RPL_ADMINEMAIL`: `ADMIN`. + No `params`. +- `301` `RPL_AWAY`: a `PRIVMSG` to an away user. `[nick]`; the away message is + in `body`. +- `302` `RPL_USERHOST`: `USERHOST`. No `params`. +- `305` `RPL_UNAWAY`, `306` `RPL_NOWAWAY`: `AWAY`. No `params`. +- `311` `RPL_WHOISUSER`: `WHOIS`. `[nick, username, hostname, "*"]`. +- `312` `RPL_WHOISSERVER`: `WHOIS`. `[nick, server name]`. +- `313` `RPL_WHOISOPERATOR`: `WHOIS`. `[nick]`. +- `317` `RPL_WHOISIDLE`: `WHOIS`. `[nick, idle seconds, signon Unix time]`. +- `318` `RPL_ENDOFWHOIS`: `WHOIS`. `[nick]`. +- `319` `RPL_WHOISCHANNELS`: `WHOIS`. `[nick]`; the channels are in `body`. +- `338` `RPL_WHOISACTUALLY`: `WHOIS`. `[nick, IP address]`. +- `315` `RPL_ENDOFWHO`: `WHO`. `[channel]`. +- `352` `RPL_WHOREPLY`: `WHO`. + `[channel, username, hostname, server name, nick, "H"]`. +- `322` `RPL_LIST`: `LIST`. `[channel, member count]`; the topic is in `body`. +- `323` `RPL_LISTEND`: `LIST`. No `params`. +- `324` `RPL_CHANNELMODEIS`: a channel `MODE` query, and a `+H` or `-H` change. + `[channel, mode string]`. +- `329` `RPL_CREATIONTIME`: a channel `MODE` query. `[channel, Unix time]`. +- `331` `RPL_NOTOPIC`: `JOIN` and the channel state after login or + `?initChannelState=1`. `[channel]`. +- `332` `RPL_TOPIC`: `JOIN`, `TOPIC` and the channel state. `[channel]`; the + topic is in `body`. +- `333` `RPL_TOPICWHOTIME`: `JOIN`, `TOPIC` and the channel state. + `[channel, nick who set the topic, Unix time]`. +- `341` `RPL_INVITING`: `INVITE`. `[nick, channel]`. +- `351` `RPL_VERSION`: `VERSION`. `["neoirc-.", server name]`. +- `353` `RPL_NAMREPLY`: `JOIN`, `NAMES` and the channel state. `["=", channel]`; + the members are in `body`. +- `366` `RPL_ENDOFNAMES`: `JOIN`, `NAMES` and the channel state. `[channel]`. +- `367` `RPL_BANLIST`: `MODE #channel +b`. + `[channel, mask, nick who set it, Unix time]`. +- `368` `RPL_ENDOFBANLIST`: `MODE #channel +b`. `[channel]`. +- `371` `RPL_INFO`, `374` `RPL_ENDOFINFO`: `INFO`. No `params`. +- `381` `RPL_YOUREOPER`: `OPER`. No `params`. +- `391` `RPL_TIME`: `TIME`. `[server name]`; the time is in `body`. + +Errors, each with what its `params` holds: + +- `401` `ERR_NOSUCHNICK`: no session has that nick. `params`: `[nick]`. +- `403` `ERR_NOSUCHCHANNEL`: no such channel, or a name `JOIN` does not allow. + `params`: `[channel]`. +- `404` `ERR_CANNOTSENDTOCHAN`: a channel message that is not allowed. `params`: + `[channel]`. +- `411` `ERR_NORECIPIENT`: `PRIVMSG` or `NOTICE` without `to`. `params`: + `[command]`. +- `412` `ERR_NOTEXTTOSEND`: `PRIVMSG` or `NOTICE` without text. `params`: + `[command]`. +- `421` `ERR_UNKNOWNCOMMAND`: a command the server does not know. `params`: + `[command]`. +- `432` `ERR_ERRONEUSNICKNAME`: a nick that is not allowed. `params`: `[nick]`. +- `433` `ERR_NICKNAMEINUSE`: a nick in use. `params`: `[nick]`. +- `441` `ERR_USERNOTINCHANNEL`: the nick in a `KICK`, or in a `MODE` `+o`, `-o`, + `+v` or `-v`, is not a member. `params`: `[nick, channel]`. +- `442` `ERR_NOTONCHANNEL`: the sender is not a member. `params`: `[channel]`. +- `443` `ERR_USERONCHANNEL`: an `INVITE` of a member. `params`: + `[nick, channel]`. +- `461` `ERR_NEEDMOREPARAMS`: a required field is missing. `params`: + `[command]`. +- `471` `ERR_CHANNELISFULL`, `473` `ERR_INVITEONLYCHAN`, `474` + `ERR_BANNEDFROMCHAN`, `475` `ERR_BADCHANNELKEY`: a refused `JOIN`. `params`: + `[channel]`. +- `472` `ERR_UNKNOWNMODE`: a channel mode change that is not accepted. `params`: + the change, such as `["+x"]`. +- `481` `ERR_NOPRIVILEGES`: `KILL` or `WALLOPS` by a user who is not a server + operator. No `params`. +- `482` `ERR_CHANOPRIVSNEEDED`: an action only a channel operator may take. + `params`: `[channel]`. +- `483` `ERR_CANTKILLSERVER`: a `KILL` of oneself. No `params`. +- `491` `ERR_NOOPERHOST`: a failed `OPER`. No `params`. +- `501` `ERR_UMODEUNKNOWNFLAG`: a user mode string that is not accepted. No + `params`. +- `502` `ERR_USERSDONTMATCH`: a user `MODE` for another nick. No `params`. + +### Channel modes + +A channel's modes are set with `MODE` (see +[`MODE`](#mode-query-or-change-modes)). A new channel has `+n` and `+t`. + +- `+b `: ban. A user whose hostmask matches the mask cannot join or send. + The mask is `nick!user@host` with `*` for any run of characters and `?` for + one character; the match ignores letter case. A channel can have any number of + bans. +- `+i`: invite-only. Only a user with an invite can join. On a `+i` channel only + a channel operator can invite. +- `+k `: channel key. A `JOIN` must carry the key in `body[0]`. +- `+l `: member limit. A `JOIN` that would exceed it is refused. +- `+m`: moderated. Only channel operators and voiced members can send. +- `+n`: no external messages. New channels have it. The IRC listener can set and + clear it; the HTTP API cannot. It changes nothing: nobody who is not a member + can send to a channel, with or without it. +- `+s`: secret. `LIST` and `WHOIS` leave the channel out for users who are not + members. `GET /api/v1/channels` still shows it. +- `+t`: topic lock. Only a channel operator can set the topic. +- `+H `: hashcash. Every `PRIVMSG` needs a stamp of at least `bits` + leading zero bits (see [Hashcash](#hashcash)). An IRC listener client cannot + send a `PRIVMSG` to the channel; its `NOTICE` still goes through. + +Two modes belong to a member rather than the channel: + +- `+o`: channel operator, shown as `@` before the name in `RPL_NAMREPLY`. +- `+v`: voice, shown as `+`. A voiced member can send on a moderated channel. + +### User modes + +- `+o`: server operator. `OPER` sets it; `MODE -o` clears it. +- `+w`: receives `WALLOPS`. `MODE +w` sets it and `MODE -w` clears + it. + +### Hostmasks + +Every session has a hostmask in IRC's form, `nick!username@hostname`: + +- `nick`: the session's current nick. +- `username`: the `username` sent at session creation, or the nick. +- `hostname`: the reverse DNS name of the IP address the session was created + from, or that IP address when the lookup gives no name. The lookup waits up to + three seconds. + +Over the HTTP API, the server takes the client's IP address from the first entry +of the `X-Forwarded-For` header, then `X-Real-IP`, then the TCP connection (see +[Reverse proxy](#reverse-proxy)). + +Every user can see a hostmask: in `RPL_WHOISUSER` (`311`), `RPL_WHOREPLY` +(`352`), `RPL_USERHOST` (`302`), `RPL_NAMREPLY` (`353`) and +`GET /api/v1/channels/{name}/members`. Bans match against it. Only a server +operator sees IP addresses, in `RPL_WHOISACTUALLY` (`338`) over the HTTP API, +and only that of the user's newest client. When the lookup gave no name, the +hostname everyone sees is the IP address. + +### Hashcash + +Hashcash is a proof of work. The client finds a string whose SHA-256 hash starts +with a given number of zero bits, and the server checks the hash. Finding one +takes work that doubles with each bit; checking takes one hash. neoirc uses it +in two places. + +#### Session stamps + +Creating a session over the HTTP API needs a stamp unless `NEOIRC_HASHCASH_BITS` +is `0`. `GET /api/v1/server` tells the difficulty in `hashcash_bits`. The stamp +has six fields separated by colons: + +``` +1:bits:date:resource::counter +``` + +- `1`: the version. Always `1`. +- `bits`: the difficulty the stamp claims. It must be at least the server's + `hashcash_bits`. +- `date`: the current date in UTC, as `YYMMDD` or `YYMMDDHHMMSS`. +- `resource`: the server name, as `name` in `GET /api/v1/server`, or `neoirc` + when that is empty. +- An empty field. +- `counter`: a hex number the client varies until the hash fits. + +The SHA-256 hash of the whole stamp string must start with at least +`hashcash_bits` zero bits. `1:20:261027:neoirc::5971e` is a real 20-bit stamp +for 27 October 2026; its hash starts with `00000e`. In pseudocode: + +``` +counter = 0 +loop: + stamp = "1:20:" + date + ":neoirc::" + hex(counter) + if leading_zero_bits(sha256(stamp)) >= 20: return stamp + counter = counter + 1 +``` + +At 20 bits this takes about a million hashes, around a second on a current +computer. Each further bit doubles the work. + +The server accepts a stamp when all of these hold: + +- the version is `1`; +- `bits` is at least the required difficulty; +- `resource` is the server name; +- the date is at most 48 hours in the past and at most one hour in the future; +- the hash has the zero bits; +- the stamp has not been spent. + +The server keeps spent stamps in memory until their date is 48 hours old; a +restart forgets them. The client sends the stamp as `pow_token` in +`POST /api/v1/session`. The web client and `neoirc-cli` compute it on their own. + +#### Channel stamps + +A channel with mode `+H` needs a stamp on every `PRIVMSG`, in `meta.hashcash`. +The stamp is bound to the channel and to the message: + +``` +1:bits:date:channel:bodyhash:counter +``` + +- `channel`: the channel name, with the `#`. +- `bodyhash`: the SHA-256 hash, in lowercase hex, of the message's `body` value, + byte for byte as the request spells it. + +The version, `bits` and date rules of session stamps apply to channel stamps +too, with the channel's `+H` value as the difficulty. + +This message carries a real 20-bit stamp for 27 October 2026, for the `body` +spelled exactly `["hello world"]`: + +```json +{ + "command": "PRIVMSG", + "to": "#general", + "body": ["hello world"], "meta": { - "alg": "ed25519", - "sig": "base64url-encoded-64-byte-signature" + "hashcash": "1:20:261027:#general:d7f54e4fb4d97b407a45ce7ba369ec4cb70d15337fb33a1adfcb4f523aeb1b32:16ec2f" } } ``` ---- +The server checks the stamp after membership, bans and `+m`, so a message +refused for one of those does not spend it. It then records the stamp's hash in +the database and refuses the stamp for at least a year. A missing, invalid or +spent stamp gets `ERR_CANNOTSENDTOCHAN` (`404`), with the reason in `body`. +`NOTICE` needs no stamp. The web client and `neoirc-cli` do not compute channel +stamps. -## Security Model +### Login rate limit -### Threat Model +`POST /api/v1/login` is limited per IP address: `LOGIN_RATE_LIMIT` requests per +second (default `1`) with a burst of `LOGIN_RATE_BURST` (default `5`). A request +over the limit gets `429` `too many login attempts, try again later` with +`Retry-After: 1`. Every 10 minutes the server forgets the addresses whose last +attempt is more than 15 minutes old. Session creation has no rate limit; the +hashcash stamp is its cost. -The server is **trusted for metadata** (it knows who sent what, when, to whom) -but **untrusted for message integrity** (signatures let clients verify that -messages haven't been tampered with). This is the same trust model as email with -PGP/DKIM — the mail server sees everything, but signatures prove authenticity. +The IP address comes from the first entry of `X-Forwarded-For`, else from +`X-Real-IP`, else from the TCP connection. Without a reverse proxy that +overwrites those headers, a client can set them itself and so pick any address +(see [Reverse proxy](#reverse-proxy)). -### Authentication +### Writing a client -- **Cookie-based auth**: Opaque HttpOnly cookies (64 hex chars = 256 bits of - entropy). Cookie values are hashed (SHA-256) before storage and validated on - every request. Cookies are HttpOnly (no JavaScript access), SameSite=Strict - (CSRF protection), and Secure (sent only over HTTPS). -- **Anonymous sessions**: `POST /api/v1/session` requires only a nick and, with - hashcash on (the default), a proof-of-work stamp. No password. The auth cookie - is the sole credential. -- **Password-protected sessions**: The PASS IRC command sets a bcrypt-hashed - password on the session. `POST /api/v1/login` authenticates against the stored - hash and issues a new client cookie. -- **Password security**: Passwords are never stored in plain text. bcrypt - handles salting and key stretching automatically. Sessions without a password - cannot be logged into via `/login`. -- **Cookie security**: Auth cookies should only be transmitted over HTTPS in - production. If a cookie is compromised, the attacker has full access to the - session until QUIT or expiry. +A complete client needs four requests: -### Message Integrity +1. `POST /api/v1/session` to get a cookie, with a stamp if the server requires + one. +2. `POST /api/v1/messages` with `JOIN` for each channel. +3. `GET /api/v1/messages` in a loop, with `timeout=15` and `after` set to the + last `last_id`. +4. `POST /api/v1/messages` to send anything else. -- **Optional signing**: Clients may sign messages using Ed25519. The server - relays signatures verbatim in the `meta` field. -- **Server does not verify signatures**: Verification is purely client-side. - This means the server cannot selectively reject forged messages, but it also - means the server cannot be compelled to enforce a signing policy. -- **Canonicalization**: Messages are canonicalized via RFC 8785 JCS before - signing, ensuring deterministic byte representation regardless of JSON - serialization differences between implementations. +A loop in Python, against a server started with `NEOIRC_HASHCASH_BITS=0`: -### Key Management +```python +import requests, time -- **TOFU (Trust On First Use)**: Clients trust the first public key they see for - a nick. This is the same model as SSH host keys. It's simple and works well - when users don't change keys frequently. -- **No key revocation**: Deliberate omission. Key revocation systems are complex - (CRLs, OCSP, key servers) and rarely work well in practice. If your key is - compromised, change your nick. -- **No CA / PKI**: There is no certificate authority. Identity is a key, not a - name bound to a key by a third party. +BASE = "http://localhost:8080/api/v1" +session = requests.Session() -### DM Privacy +resp = session.post(f"{BASE}/session", json={"nick": "pybot"}) +# requests sends a Secure cookie only over HTTPS. For a plain-HTTP +# server on localhost, send it by hand on every request. +session.headers["Cookie"] = f"neoirc_auth={resp.cookies['neoirc_auth']}" -- **DMs are not end-to-end encrypted** in the current implementation. The server - can read DM content. E2E encryption for DMs is planned (see - [Roadmap](#roadmap)). -- **DMs are stored** in the messages table, subject to the same rotation policy - as channel messages. - -### Transport Security - -- **Clients need HTTPS to keep a session.** The auth cookie is always `Secure`, - and clients send a `Secure` cookie only over HTTPS. The server itself serves - plain HTTP — use a reverse proxy (nginx, Caddy, etc.) for TLS termination. -- **A server on `localhost`**, as in this document's examples, also works over - plain HTTP with curl, `neoirc-cli`, Chrome and Firefox, which treat - `localhost` as secure. Safari does not. Python's `requests` does not either, - so the [Python example](#implementing-long-poll-in-code) sends the cookie - itself. -- **CORS**: The server allows all origins with credentials - (`Access-Control-Allow-Credentials: true`), reflecting the request Origin. - Browsers still send the `SameSite=Strict` auth cookie only on requests from - the same site, so cookie-based auth works from other origins of the same site - but not from other sites. Restrict origins in production via reverse proxy - configuration if needed. -- **Content-Security-Policy**: The server sets a strict CSP header on all - responses, restricting resource loading to same-origin and disabling dangerous - features (object embeds, framing, base tag injection). The embedded SPA works - without `'unsafe-inline'` for scripts or styles. - ---- - -## Federation (Server-to-Server) - -Federation allows multiple neoirc servers to link together, forming a network -where users on different servers can share channels — similar to IRC server -linking. - -**Status:** Not yet implemented. This section documents the design. - -### Link Establishment - -Server links are **manually configured** by operators. There is no -autodiscovery, no mesh networking, no DNS-based lookup. Operators on both -servers must agree to link and configure shared authentication credentials. +session.post(f"{BASE}/messages", json={"command": "JOIN", "to": "#general"}) +last_id = 0 +while True: + try: + resp = session.get(f"{BASE}/messages", + params={"after": last_id, "timeout": 15}, + timeout=20) # longer than the long-poll timeout + except requests.exceptions.Timeout: + continue + except requests.exceptions.RequestException as exc: + print(f"error: {exc}") + time.sleep(2) + continue + if resp.status_code == 401: + break # the session is gone + if resp.status_code != 200: + time.sleep(2) + continue + data = resp.json() + last_id = data["last_id"] + for msg in data["messages"]: + print(f"[{msg['command']}] <{msg.get('from', '')}> " + f"{' '.join(msg['body'])}") ``` -POST /api/v1/federation/link -{ - "server_name": "peer.example.com", - "shared_key": "pre-shared-secret" + +The same loop in a browser, where the cookie is sent automatically: + +```javascript +async function pollLoop() { + let lastId = 0; + while (true) { + try { + const resp = await fetch( + `/api/v1/messages?after=${lastId}&timeout=15`, + { credentials: "same-origin" }, + ); + if (resp.status === 401) break; // the session is gone + if (resp.status !== 200) { + await new Promise((r) => setTimeout(r, 2000)); + continue; + } + const data = await resp.json(); + lastId = data.last_id; + for (const msg of data.messages) handleMessage(msg); + } catch (e) { + await new Promise((r) => setTimeout(r, 2000)); + } + } } ``` -Both servers must configure the link. Authentication uses a pre-shared key -(hashed, never transmitted in plain text after initial setup). +Both loops keep `after` where it was unless the answer is `200`, and stop on +`401`. -### Message Relay +Advice for client authors: -Once linked, servers relay messages using the same IRC envelope format: +- Set the HTTP timeout above the long-poll timeout. With `timeout=15`, give the + request at least 20 seconds. +- Keep `after` moving. Reset it to `0` only to read the queue again. +- Expect your own messages. The server echoes a session's own `PRIVMSG` and + `NOTICE` to all its clients. It does the same with the events of its `JOIN`, + `PART`, `NICK`, `TOPIC`, `MODE` and `KICK`. Show them. A `PRIVMSG` or `NOTICE` + can instead be dropped by the `id` its `POST` response returned; the response + to an event carries no `id`. +- Key a direct-message window by the other user's nick: `to` when `from` is you, + `from` otherwise. +- On `401`, the session is gone or the cookie is wrong. Create a new session, or + log in again when the session has a password. +- Show a message by its `command`: + - `PRIVMSG` as ` text`; + - `NOTICE` as `-nick- text`; + - `JOIN` as `*** nick has joined #channel`; + - `NICK` as `*** oldnick is now known as newnick`; + - `TOPIC` as `*** nick set topic: text`; + - a numeric reply by its `body`. -``` -POST /api/v1/federation/relay -{ - "command": "PRIVMSG", - "from": "alice@server1.example.com", - "to": "#shared-channel", - "body": ["hello from server1"], - "meta": {"sig": "base64...", "alg": "ed25519"} -} -``` +## The IRC listener -Key properties: +The server also accepts plain IRC connections, as in RFC 1459 and RFC 2812, on +`IRC_LISTEN_ADDR` (default `:6667`). Standard IRC clients connect to it. IRC +listener users and HTTP API users share the same channels, nicks and messages: +the listener and the HTTP API use the same database and the same queues. The +listener has no TLS. -- **Signatures are relayed verbatim** — federated servers do not strip, modify, - or re-sign messages. A signature from a user on server1 can be verified by a - user on server2. -- **Nick namespacing**: In federated mode, nicks include a server suffix - (`nick@server`) to prevent collisions. Within a single server, bare nicks are - used. +### Connecting -### State Synchronization +A line ends in LF, with or without a CR before it, and is at most 512 bytes +long, the line ending included. A longer line ends the connection. A connection +registers by sending `NICK` and `USER`. Registering creates a session with the +connection as its only client; no hashcash stamp is needed. The hostname is the +reverse DNS name of the connection's address, looked up when it connects, or the +address itself when the lookup gives no name. Before registration the server +accepts only `PASS`, `NICK`, `USER`, `PING`, `PONG`, `QUIT` and `CAP`; anything +else gets `ERR_NOTREGISTERED` (`451`). -After link establishment, servers exchange a **burst** of state: +A `PASS` before registration sets the new session's password, as the `PASS` +command does over the HTTP API. It does not log in to an existing session. A +password of the wrong length is refused with a `NOTICE` once registration +completes. -1. `NICK` commands for all connected users -2. `JOIN` commands for all shared channel memberships -3. `TOPIC` commands for all channel topics -4. `MODE` commands for all channel modes +After registration the client receives `001` to `005`, the `LUSERS` replies and +the message of the day, or `ERR_NOMOTD` (`422`) when that is empty. The `004` +user modes are `ow` and its channel modes are `Himnostv`. The `005` tokens are +`CHANTYPES=#`, `NICKLEN=32`, `PREFIX=(ov)@+`, `CHANMODES=,,H,imnst` and +`NETWORK=`. -This mirrors IRC's server burst protocol. +`CAP LS` is answered with an empty capability list; other `CAP` subcommands are +ignored. -### S2S Commands +When a connection has sent nothing for 90 seconds, the server sends `PING`. When +nothing arrives in the 30 seconds after that, the server closes the connection. +Any line, such as the client's `PONG`, starts both waits again. -| Command | Description | -| -------- | ---------------------------------- | -| `RELAY` | Relay a message from a remote user | -| `LINK` | Establish server link | -| `UNLINK` | Tear down server link | -| `SYNC` | Request full state synchronization | -| `PING` | Inter-server keepalive | -| `PONG` | Inter-server keepalive response | +Closing the connection ends the session, as `QUIT` does over the HTTP API. After +a `QUIT`, channel peers see the reason the client gave, or `Client quit` when it +gave none. Without a `QUIT`, they see `Connection closed`. Every command after +registration counts as activity for the idle cleanup. -### Federation Endpoints +### Commands -``` -POST /api/v1/federation/link — Establish server link -POST /api/v1/federation/relay — Relay messages between linked servers -GET /api/v1/federation/status — Link status and peer list -POST /api/v1/federation/unlink — Tear down a server link -``` +The listener accepts these commands: ---- +- Connection: `NICK`, `USER`, `PASS`, `QUIT`, `PING`, `PONG`, `CAP`. +- Channels: `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE`. +- Messages: `PRIVMSG`, `NOTICE`. +- Information: `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, + `ADMIN`, `INFO`, `TIME`. +- Server operator: `OPER`, `KILL`, `WALLOPS`. -## Storage +They behave as over the HTTP API, with the IRC syntax and these differences: -### Database +- `JOIN` and `PART` take several channels separated by commas. + `JOIN #a,#b keya,keyb` gives each channel its key. `JOIN` adds a missing `#`; + other commands use the name as given. +- `TOPIC #channel` without a topic queries it. +- `MODE #channel [values...]` takes several letters in one string, such + as `+mt-i` or `+ov alice bob`. It accepts `i`, `m`, `n`, `s`, `t`, `o`, `v` + and `H`, but not `b`, `k` or `l`. A letter whose value is missing is skipped; + any other letter gets `ERR_UNKNOWNMODE` (`472`). The changes made go to every + member as one `MODE` line, such as `+o-v alice bob`. +- A `PRIVMSG` to a `+H` channel always gets `ERR_CANNOTSENDTOCHAN` (`404`): an + IRC line cannot carry a stamp. +- A `NOTICE` to an away user gets `RPL_AWAY` (`301`), as a `PRIVMSG` does. +- `RPL_NAMREPLY` (`353`) lists nicks, not hostmasks. +- `LIST` starts with `RPL_LISTSTART` (`321`). +- `WHOIS` sends `RPL_WHOISCHANNELS` (`319`) with `@` and `+` before the channel + names, then `RPL_WHOISIDLE` (`317`), then `RPL_AWAY` (`301`) for an away user. + It never sends `RPL_WHOISACTUALLY` (`338`). `WHOIS` with two parameters uses + the second as the nick. No parameter gets `ERR_NONICKNAMEGIVEN` (`431`). +- `WHO ` answers for one user. `RPL_WHOREPLY` (`352`) flags are `H@` for a + channel operator and `H+` for a voiced member. +- `NAMES` and `WHO` without a parameter, or for an unknown channel, get only the + end reply. +- `NICK` without a nick gets `ERR_NONICKNAMEGIVEN` (`431`). +- `KICK` without a reason uses the kicked user's nick as the reason. +- `MOTD` sends only `375` to `376`, or `ERR_NOMOTD` (`422`) when the message of + the day is empty, without the welcome and `LUSERS` replies. +- `PASS` after registration answers with a `NOTICE` when it stores the password. + A password of the wrong length also gets a `NOTICE`, not `ERR_NEEDMOREPARAMS` + (`461`). -SQLite by default (single-file, zero-config). The server uses -[modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite), a pure-Go SQLite -implementation — no CGO required, cross-compiles cleanly. +### What IRC clients see -Postgres support is planned for larger deployments but not yet implemented. +Each line of a multi-line `body` reaches an IRC client as its own `PRIVMSG` or +`NOTICE` line. A CR, LF or NUL inside a line is removed first. Other users +appear with the prefix `nick!nick@*`. A mode change made over the HTTP API +arrives as an IRC `MODE` line with each value as its own parameter, such as +`MODE #general +k secretkey`. An `INVITE` arrives as +`:alice!alice@* INVITE bob #general`. Numeric replies, `JOIN`, `PART`, `KICK`, +`TOPIC`, `NICK`, `QUIT` (as `Quit: `) and `WALLOPS` arrive in their IRC +forms. -### Schema +The listener does not queue a client's own commands back to it. It sends the +echo itself, as IRC does, and for `PRIVMSG` and `NOTICE` sends none. Other +clients of the same session do see them. A `WALLOPS` from a client with `+w` +does come back through its queue. -The database schema is managed via embedded SQL migration files in -`internal/db/schema/`. Migrations run automatically on server start. `000.sql` -creates `schema_migrations`, which records the version of each migration -applied; `001_initial.sql` creates the tables below. +An operator's `KILL` of an IRC listener user sends the user `KILL` and +`ERROR :Closing Link` and closes the connection, whichever transport the `KILL` +came from. -**Current tables:** +## The web client -#### `sessions` +The server serves a web client at `/`. It is a single page written with Preact, +built into the binary from `web/src/`. It uses the HTTP API with the browser's +cookie. On loading it calls `GET /api/v1/state?initChannelState=1` to rebuild +its channel tabs. When the server requires a hashcash stamp, it computes one +with the browser's Web Crypto API before creating a session. -| Column | Type | Description | -| --------------- | -------- | -------------------------------------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `uuid` | TEXT | Unique session UUID | -| `nick` | TEXT | Unique nick | -| `username` | TEXT | IRC ident/username portion of the hostmask (defaults to nick) | -| `hostname` | TEXT | Reverse DNS name of the creating client's IP, or the IP itself when it has none | -| `ip` | TEXT | Real IP address of the session creator | -| `is_oper` | INTEGER | Server operator, user mode `+o` (0 = no, 1 = yes) | -| `is_wallops` | INTEGER | User mode `+w`, receives `WALLOPS` (0 = no, 1 = yes) | -| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) | -| `signing_key` | TEXT | Public signing key; nothing sets or reads it yet | -| `away_message` | TEXT | Away message (empty string if not away) | -| `created_at` | DATETIME | Session creation time | -| `last_seen` | DATETIME | Last login, authenticated HTTP API request or IRC listener command (or creation) | +## neoirc-cli -Index on `(uuid)`. - -#### `clients` - -| Column | Type | Description | -| ------------ | -------- | ------------------------------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `uuid` | TEXT | Unique client UUID | -| `session_id` | INTEGER | FK → sessions.id (cascade delete) | -| `token` | TEXT | Auth cookie value (SHA-256 hash of the 64-hex-char cookie) | -| `ip` | TEXT | Real IP address of this client connection | -| `hostname` | TEXT | Reverse DNS name of this client's IP, or the IP itself when it has none | -| `created_at` | DATETIME | Client creation time | -| `last_seen` | DATETIME | Last authenticated HTTP API request or IRC listener command (or creation) | - -Indexes on `(token)` and `(session_id)`. - -#### `channels` - -| Column | Type | Description | -| ----------------- | -------- | --------------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `name` | TEXT | Unique channel name (e.g., `#general`) | -| `topic` | TEXT | Channel topic (default empty) | -| `topic_set_by` | TEXT | Nick of the user who set the topic (default empty) | -| `topic_set_at` | DATETIME | When the topic was last set (null until then) | -| `hashcash_bits` | INTEGER | Mode `+H`: bits a `PRIVMSG` stamp must have (0 = off) | -| `is_moderated` | INTEGER | Mode `+m` (default 0) | -| `is_topic_locked` | INTEGER | Mode `+t` (default 1) | -| `is_invite_only` | INTEGER | Mode `+i` (default 0) | -| `is_secret` | INTEGER | Mode `+s` (default 0) | -| `is_no_external` | INTEGER | Mode `+n` (default 1) | -| `channel_key` | TEXT | Mode `+k`: the key needed to join (empty string = no key) | -| `user_limit` | INTEGER | Mode `+l`: maximum number of members (0 = no limit) | -| `created_at` | DATETIME | Channel creation time | -| `updated_at` | DATETIME | Last change to the topic or to a mode stored in this row | - -#### `channel_bans` - -| Column | Type | Description | -| ------------ | -------- | --------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `channel_id` | INTEGER | FK → channels.id (cascade delete) | -| `mask` | TEXT | Banned `nick!user@host` pattern (`*` and `?` match) | -| `set_by` | TEXT | Nick of the operator who set the ban | -| `created_at` | DATETIME | When the ban was set | - -Unique constraint on `(channel_id, mask)`. Index on `(channel_id)`. - -#### `channel_invites` - -| Column | Type | Description | -| ------------ | -------- | --------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `channel_id` | INTEGER | FK → channels.id (cascade delete) | -| `session_id` | INTEGER | FK → sessions.id (cascade delete) | -| `invited_by` | TEXT | Nick of the user who invited | -| `created_at` | DATETIME | When the invite was made | - -Unique constraint on `(channel_id, session_id)`. Index on `(channel_id)`. An -invite is deleted when the invited user joins the channel. - -#### `channel_members` - -| Column | Type | Description | -| ------------- | -------- | --------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `channel_id` | INTEGER | FK → channels.id (cascade delete) | -| `session_id` | INTEGER | FK → sessions.id (cascade delete) | -| `is_operator` | INTEGER | Channel operator, mode `+o` (0 = no, 1 = yes) | -| `is_voiced` | INTEGER | Voice, mode `+v` (0 = no, 1 = yes) | -| `joined_at` | DATETIME | When the user joined | - -Unique constraint on `(channel_id, session_id)`. - -#### `messages` - -| Column | Type | Description | -| --------------- | -------- | ----------------------------------------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment). Internal ID for queue references. | -| `uuid` | TEXT | UUID v4, exposed to clients as the message `id` | -| `command` | TEXT | IRC command (`PRIVMSG`, `JOIN`, etc.) | -| `msg_from` | TEXT | Sender nick | -| `msg_to` | TEXT | Target (`#channel` or nick) | -| `to_session_id` | INTEGER | FK → sessions.id (set to null on delete): the session a DM or `INVITE` was sent to | -| `to_channel_id` | INTEGER | FK → channels.id (set to null on delete): the channel a channel message was sent to | -| `params` | TEXT | JSON-encoded IRC-style positional parameters | -| `body` | TEXT | JSON-encoded body (array or object) | -| `meta` | TEXT | JSON-encoded metadata | -| `created_at` | DATETIME | Server timestamp | - -Indexes on `(to_session_id, id)`, `(to_channel_id, id)` and `(created_at)`. -`to_session_id` and `to_channel_id` are null on a message sent to neither, such -as `QUIT`. `GET /api/v1/history` selects by one of these two columns rather than -by `msg_to`, because nicks and channel names are reused once released, and -session and channel ids are not. - -#### `spent_hashcash` - -| Column | Type | Description | -| ------------ | -------- | ---------------------------------------------------- | -| `id` | INTEGER | Primary key (auto-increment) | -| `stamp_hash` | TEXT | Unique SHA-256 (hex) of a channel stamp already used | -| `created_at` | DATETIME | When the stamp was used | - -Index on `(created_at)`. Only the stamps of `+H` channel messages are recorded -here, and rows older than a year are pruned; session creation stamps are tracked -in memory. - -#### `client_queues` - -| Column | Type | Description | -| ------------ | -------- | ------------------------------------------------------ | -| `id` | INTEGER | Primary key (auto-increment). Used as the poll cursor. | -| `client_id` | INTEGER | FK → clients.id (cascade delete) | -| `message_id` | INTEGER | FK → messages.id (cascade delete) | -| `created_at` | DATETIME | When the entry was queued | - -Unique constraint on `(client_id, message_id)`. Index on `(client_id, id)`. - -The `client_queues.id` is the monotonically increasing cursor used by -`GET /messages?after=`. This is more reliable than timestamps (no clock skew -issues) and simpler than UUIDs (integer comparison vs. string comparison). - -### Data Lifecycle - -- **Messages**: Pruned automatically when older than `MESSAGE_MAX_AGE` (default - 30 days). -- **Client output queue entries**: Pruned automatically when older than - `QUEUE_MAX_AGE` (default 30 days). -- **Channels**: Deleted when the last member leaves (ephemeral). -- **Sessions**: Both anonymous and password-protected 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: the session is parted from its channels, - deleted, and its QUIT queued to its channel peers in one database transaction, - so when several QUITs or logouts end one session at once only the one that - deletes it broadcasts QUIT, and whatever a new holder of the nick sends - reaches those peers after the QUIT. Idle clients are automatically removed - after `SESSION_IDLE_TIMEOUT` (default 30 days) without an authenticated HTTP - API request or, on the IRC listener, a command — the server runs a background - cleanup loop that, for a session left with no clients, parts the user from all - channels, broadcasts QUIT, and releases the nick. -- **Clients**: Individual client auth cookies are invalidated 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. - -The cleanup loop that does all of this pruning and expiry runs every half -`SESSION_IDLE_TIMEOUT`, but at least a minute apart (every 15 days by default), -the first time that long after the server starts, so rows can outlive their -maximum age by up to that interval. - ---- - -## Configuration - -Configuration comes from environment variables, read by -[Viper](https://github.com/spf13/viper). A `.env` file in the working directory -is also loaded automatically via [godotenv](https://github.com/joho/godotenv), -and Viper also reads a YAML file `neoirc.yaml` from `/etc/neoirc/` or -`$HOME/.config/neoirc/` if one exists, with the same names as keys; an -environment variable wins over the file. An environment variable set to the -empty string counts as unset, so the file's value or the default applies; the -one exception is `IRC_LISTEN_ADDR`, where the empty string disables the IRC -listener. - -| Variable | Type | Default | Description | -| ---------------------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | int | `8080` | HTTP listen port | -| `DBURL` | string | `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)` | SQLite connection string. For file-based: `file:///path/to/db.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`. For in-memory (testing): `file::memory:?cache=shared`. See [SQLite Considerations](#sqlite-considerations). | -| `DEBUG` | bool | `false` | Switch logging to debug level | -| `MESSAGE_MAX_AGE` | string | `720h` | Maximum age of messages as a Go duration string (e.g. `720h`, `24h`). Messages older than this are pruned. Default is 30 days. | -| `SESSION_IDLE_TIMEOUT` | string | `720h` | Idle timeout as a Go duration string (e.g. `720h`, `24h`). Clients with no authenticated HTTP API request or IRC listener command for this long are removed, and a session left with none is expired and its nick released (see [Data Lifecycle](#data-lifecycle)). Default is 30 days. | -| `QUEUE_MAX_AGE` | string | `720h` | Maximum age of client output queue entries as a Go duration string (e.g. `720h`, `24h`). Entries older than this are pruned. Default is 30 days. | -| `MAX_MESSAGE_SIZE` | int | `4096` | Maximum request body size in bytes for `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`; a larger body gets `400` `invalid request body` | -| `MOTD` | string | a built-in banner | Message of the day, sent to clients as MOTD numerics and shown via `GET /api/v1/server` | -| `SERVER_NAME` | string | `""` | Server display name. If empty, the server calls itself `neoirc` in replies and in `GET /api/v1/server` returns it empty. | -| `FEDERATION_KEY` | string | `""` | Shared key for server federation linking (planned) | -| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | -| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | -| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | -| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. | -| `NEOIRC_OPER_NAME` | string | `""` | Server operator (o-line) username. Both name and password must be set to enable OPER. | -| `NEOIRC_OPER_PASSWORD` | string | `""` | Server operator (o-line) password. Both name and password must be set to enable OPER. | -| `LOGIN_RATE_LIMIT` | float | `1` | Allowed login attempts per second per IP address. | -| `LOGIN_RATE_BURST` | int | `5` | Maximum burst of login attempts per IP before rate limiting kicks in. | -| `IRC_LISTEN_ADDR` | string | `:6667` | TCP address for the traditional IRC protocol listener. Set it to the empty string, in the environment or in `neoirc.yaml`, to disable the listener. | -| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | - -### Example `.env` file +`neoirc-cli` is a terminal client for the HTTP API. The Docker build compiles it +to check that it builds and leaves it out of the image. The `Makefile` has no +target for it. Build it from the source tree: ```bash +go build -o bin/neoirc-cli ./cmd/neoirc-cli +``` + +It starts disconnected. Commands start with `/`: + +- `/connect `: create a session on the server at ``, such as + `http://localhost:8080`, with the current nick. The default nick is `guest`. + The client fetches `GET /api/v1/server` first and computes a hashcash stamp + when the server needs one. +- `/nick `: change the nick, or set the nick to use before connecting. +- `/join #channel`, `/part [#channel]`. +- `/msg `: send a direct message. `/query `: open a window + for a user. +- `/topic `: set the current channel's topic. +- `/names`: the members of the current channel. `/list`: all channels. + `/who [#channel]`, `/whois `, `/motd`. +- `/window `, `/w ` or Alt and a digit: switch to window `n`. Window `0` + is the status window. +- `/quit`: send `QUIT` and exit. `/help`: the command list. + +Text without a `/` goes as a `PRIVMSG` to the current target. `/join` and +`/query` set the target to the channel or user they open. `/window`, `/w` and +Alt with a digit set it to the channel or user of the window they switch to. A +switch to window `0` clears it, and so does `/part` of the target's channel. + +The client reads its queue with `timeout=15` and retries after two seconds on an +error. It sends no hashcash stamps for `+H` channels. It sends the auth cookie +over plain HTTP to a server on `localhost` or a loopback address such as +`127.0.0.1`, and otherwise only over HTTPS. + +## Running the server + +### Configuration + +Every setting is an environment variable. The server also reads a `.env` file in +its working directory, and a YAML file `neoirc.yaml` in `/etc/neoirc/` or +`$HOME/.config/neoirc/` with the same names as keys. A variable from the +environment wins over one in `.env`, and both win over `neoirc.yaml`. An +environment variable set to the empty string counts as unset, except +`IRC_LISTEN_ADDR`, where the empty string turns the IRC listener off. A +`neoirc.yaml` that is not valid YAML stops the server at startup. A setting that +does not parse or is out of range also stops the server at startup, and the +error names the setting. + +- `PORT` (default `8080`): the HTTP port. `0` lets the system pick a free port. +- `IRC_LISTEN_ADDR` (default `:6667`): the address of the IRC listener. The + empty string turns the listener off. +- `DBURL` (default + `file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)`): + the SQLite database. A `DBURL` of your own should keep both `_pragma` + parameters: write-ahead logging, and a wait of 5000 ms for a locked database. + It must not set `foreign_keys`; the server adds `_pragma=foreign_keys(1)` + itself and refuses to start otherwise. See [The database](#the-database). +- `SERVER_NAME` (default empty): the server's name in replies, in + `GET /api/v1/server` and as the hashcash resource. When empty, replies and the + hashcash resource use `neoirc`, and `GET /api/v1/server` returns an empty + string. +- `MOTD` (default: a built-in banner): the message of the day. Lines are + separated by newlines. An empty environment variable counts as unset and gives + the banner. To send no message of the day, put `MOTD: ""` in `neoirc.yaml`. +- `NEOIRC_HASHCASH_BITS` (default `20`): the difficulty of the stamp + `POST /api/v1/session` needs. `0` turns the requirement off. +- `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD` (default empty): the name and + password `OPER` accepts. `OPER` fails while either is empty. +- `MAX_MESSAGE_SIZE` (default `4096`): the largest request body, in bytes, for + `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`. It + must be at least `1`. +- `LOGIN_RATE_LIMIT` (default `1`): login attempts allowed per second per IP + address. A fraction such as `0.5` is allowed. It must be above `0`. +- `LOGIN_RATE_BURST` (default `5`): how many attempts an address may make at + once before the rate applies. It must be at least `1`. +- `SESSION_IDLE_TIMEOUT` (default `720h`, 30 days): a duration with a unit, `h` + for hours, `m` for minutes or `s` for seconds, such as `720h` or `36h30m`. A + client with no request for this long is removed, and a session left with no + client ends. See [Data lifecycle](#data-lifecycle). It must be above zero. +- `MESSAGE_MAX_AGE` (default `720h`): messages older than this duration are + deleted. It must be above zero. +- `QUEUE_MAX_AGE` (default `720h`): queue entries older than this duration are + deleted. It must be above zero. +- `DEBUG` (default `false`): `true` turns on debug logging. +- `SENTRY_DSN` (default empty): when set, panics in HTTP requests are reported + to Sentry. A value that is not a valid DSN stops the server at startup. +- `METRICS_USERNAME` and `METRICS_PASSWORD` (default empty): the HTTP basic auth + credentials of `GET /metrics`. The endpoint exists only when + `METRICS_USERNAME` is set. +- `MAINTENANCE_MODE` (default `false`): reported as `maintenanceMode` in the + health check. Nothing else reads it. + +An example `.env`: + +``` PORT=8080 -SERVER_NAME=My NeoIRC Server +SERVER_NAME=My Server MOTD=Welcome! Be excellent to each other. -DEBUG=false DBURL=file:///var/lib/neoirc/state.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000) SESSION_IDLE_TIMEOUT=720h NEOIRC_HASHCASH_BITS=20 ``` ---- +### Docker -## IRC Protocol Listener +`make docker` builds the image, tagged `neoirc`. The build runs the tests and +the linter first and stops when either fails. The image: -neoirc includes an optional traditional IRC wire protocol listener (RFC -1459/2812) that allows standard IRC clients to connect directly. This enables -backward compatibility with existing IRC clients like irssi, weechat, hexchat, -and others. +- is Alpine with the `neoircd` binary and CA certificates; +- runs as the user `neoirc`; +- exposes ports 8080 and 6667; +- keeps its database in `/var/lib/neoirc`; +- has a `HEALTHCHECK` that calls `/.well-known/healthcheck.json` every 30 + seconds. -### Configuration - -The IRC listener is **enabled by default** on `:6667`. To disable it, set -`IRC_LISTEN_ADDR` to an empty string (see [Configuration](#configuration)), -either in the environment: +Run it with a volume for the database and the settings as environment variables: ```bash -IRC_LISTEN_ADDR= -``` - -or in `neoirc.yaml`: - -```yaml -IRC_LISTEN_ADDR: "" -``` - -### Supported Commands - -| Category | Commands | -| ---------- | ---------------------------------------------------------------------------------------- | -| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | -| Channels | `JOIN`, `PART`, `MODE`, `TOPIC`, `NAMES`, `LIST`, `KICK`, `INVITE` | -| Messaging | `PRIVMSG`, `NOTICE` | -| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY`, `USERHOST`, `VERSION`, `ADMIN`, `INFO`, `TIME` | -| Operator | `OPER`, `KILL`, `WALLOPS` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | - -### Protocol Details - -- **Wire format**: CR-LF delimited lines, max 512 bytes per line -- **Connection registration**: Clients must send `NICK` and `USER` to register. - An optional `PASS` before registration sets the session password (8 to 72 - bytes); one outside that range is refused with a `NOTICE` once registration - completes, as `PASS` after registration is. Registering creates a session - without a hashcash stamp; closing the connection ends the session, as `QUIT` - does. -- **PING**: a connection that sends nothing for 90 seconds is sent a `PING`, and - is closed if it sends nothing in the 30 seconds after that; any line, such as - the client's `PONG`, starts both waits again. -- **CAP negotiation**: `CAP LS` gets an empty capability list and `CAP END` is - ignored, for compatibility with modern clients. -- **Channel prefixes**: Channels must start with `#`. `JOIN` prepends it if - omitted; other commands use the name as given. As over the HTTP API, `JOIN` - answers a name that is not `#` followed by 1–63 letters, digits, `_` or `-` - with ERR_NOSUCHCHANNEL (403). -- **First joiner**: The first user to join a channel is automatically granted - operator status (`@`). -- **Channel modes**: `+i` (invite-only), `+m` (moderated), `+n` (no external), - `+s` (secret), `+t` (topic lock), `+H` (hashcash), `+o` (operator), `+v` - (voice); see [MODE](#mode--query-and-change-modes). `+b`, `+k` and `+l` can be - set only over the HTTP API. -- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives - `WALLOPS`). `MODE` for any nick other than your own is rejected with - `ERR_USERSDONTMATCH` (502), for both queries and changes. -- **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel - peers, deletes its session, and then sends the victim a `KILL` and - `ERROR :Closing Link` before closing its socket. This applies to victims on - the IRC listener regardless of whether the `KILL` arrived over IRC or the HTTP - API. - -### Bridge to HTTP API - -Messages sent by IRC clients appear in channels visible to HTTP/JSON API clients -and vice versa. The IRC listener and HTTP API share the same database, broker, -and session infrastructure, so a user connected via IRC and a user connected via -the HTTP API can talk in the same channels. - -Each line of a multi-line `body` reaches an IRC listener user as its own -`PRIVMSG` or `NOTICE` line. A CR, LF or NUL inside a line is removed before it -is sent, so it cannot start a line of its own. A mode change made over the HTTP -API reaches IRC listener members as an IRC `MODE` line with its value as a -separate parameter, e.g. `MODE #general +k secretpass` or -`MODE #general +o bob`. An `INVITE` from either transport reaches the invited -IRC listener user as an IRC `INVITE` line from the inviter, e.g. -`:alice!alice@* INVITE bob #general`. - -### Docker Usage - -To expose the IRC port in Docker (the listener is enabled by default on -`:6667`): - -```bash -docker run -d \ - -p 8080:8080 \ - -p 6667:6667 \ - -v neoirc-data:/var/lib/neoirc \ - neoirc -``` - ---- - -## Entrypoints - -This repository adheres to the -[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) -standard. Each script below has a `make` target of the same name that calls it, -except `script/install-precommit`, which is `make hooks`, and `script/cibuild`, -`script/precommit` and `script/projectname`, which have none. - -- `script/bootstrap`: installs what development needs: make, git, Node and yarn - (for prettier), and Go. -- `script/setup`: makes a fresh clone ready for development: runs - `script/bootstrap`, then `script/install-precommit`. -- `script/test`: builds the `Dockerfile`'s `test` phase, which runs the tests - with `-race`. -- `script/lint`: builds the `Dockerfile`'s `lint` phase, which runs - golangci-lint. -- `script/fmt`: formats the Go code with gofmt and the Markdown with prettier. -- `script/fmt-check`: the same, read-only; fails if anything would change. -- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`. -- `script/docker`: builds the image, tagged `neoirc`. -- `script/cibuild`: the CI build: `script/bootstrap`, `script/check`, then the - image. -- `script/precommit`: run by the git pre-commit hook: fails if `go mod tidy` - changes `go.mod` or `go.sum`, then runs `script/check`. -- `script/install-precommit`: installs that hook. -- `script/projectname`: prints the project name, `neoirc`. - ---- - -## Deployment - -### Docker (Recommended) - -The Docker image is Alpine with a single static binary (`neoircd`) and CA -certificates, run as the user `neoirc`, with a `HEALTHCHECK` on the health check -endpoint. - -```bash -# Build -make docker - -# Run -docker run -p 8080:8080 \ +docker run -d --name neoirc \ + -p 8080:8080 -p 6667:6667 \ -v neoirc-data:/var/lib/neoirc \ -e SERVER_NAME="My Server" \ -e MOTD="Welcome!" \ neoirc ``` -The Dockerfile is a five-stage build: +### Reverse proxy -1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX → - bundled JS via esbuild) into `web/dist/` -2. **lint**: Runs golangci-lint against the Go source (uses empty placeholder - files for `web/dist/` so it runs independently of web-builder for fast - feedback) -3. **test**: Runs the tests with `-race` on the Debian Go image, with the same - placeholder files -4. **builder**: Runs only once lint and test have passed; compiles static - `neoircd` and `neoirc-cli` binaries with the real SPA assets from web-builder - (CLI built to verify compilation, not included in final image) -5. **final**: Minimal Alpine image with the `neoircd` binary and CA certificates +Run the server behind a reverse proxy that terminates TLS. The server itself +speaks plain HTTP, and the auth cookie is `Secure`, so browsers send it only +over HTTPS. The proxy must: -### Binary +- Set `X-Forwarded-For` to the client's address, replacing any value the client + sent. The server reads that header first and trusts it from anyone; it reads + `X-Real-IP` only when `X-Forwarded-For` is absent. Without a proxy that + overwrites the header, a client can choose its own address, which sets its + hostname and lets it dodge the login rate limit. +- Wait at least 60 seconds for a response. A long poll can take 30 seconds, and + the server's own write timeout is 60 seconds. -```bash -# Build from source -make build -# Binary at ./bin/neoircd - -# Run -./bin/neoircd -# Listens on :8080 (HTTP) and :6667 (IRC), writes to /var/lib/neoirc/state.db -``` - -### Reverse Proxy (Production) - -For production, run behind a TLS-terminating reverse proxy. - -**Caddy:** +Caddy: ``` neoirc.example.com { @@ -2829,7 +1820,7 @@ neoirc.example.com { } ``` -**nginx:** +nginx: ```nginx server { @@ -2844,649 +1835,211 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; - proxy_read_timeout 60s; # Must be > long-poll timeout + proxy_read_timeout 60s; } } ``` -**Important:** Set `proxy_read_timeout` (nginx) or equivalent to at least 60 -seconds to accommodate long-poll connections. - -### SQLite Considerations - -- **Journal mode**: the default connection string runs the database in WAL mode - with a 5000 ms busy timeout, through the SQLite driver's - `_pragma=journal_mode(WAL)` and `_pragma=busy_timeout(5000)` parameters. A - `DBURL` of your own should keep both. -- **Single writer**: the server uses one database connection, so reads and - writes take turns. For high-traffic servers, Postgres support is planned. -- **Backup**: Back the database up with - `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`, or copy `state.db` - while the server is stopped, together with `state.db-wal` if one is left - beside it: in WAL mode that file holds recent writes not yet folded into - `state.db`. -- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the - `DBURL` env var to place it elsewhere. - ---- - -## Client Development Guide - -This section explains how to write a client against the neoirc API. The API is -designed to be simple enough that a basic client can be written in any language -with an HTTP client library. - -### Minimal Client Loop - -A complete client needs only four HTTP calls: - -``` -1. POST /api/v1/session → get auth cookie -2. POST /api/v1/messages (JOIN) → join channels -3. GET /api/v1/messages (loop) → receive messages -4. POST /api/v1/messages → send messages -``` - -### Step-by-Step with curl - -These examples, and the Python one below, assume a server run with -`NEOIRC_HASHCASH_BITS=0`. With the default of 20, session creation also needs a -`pow_token` (see [Hashcash Proof-of-Work](#hashcash-proof-of-work)). - -```bash -# 1a. Create a session (cookie saved automatically with -c) -curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/session \ - -H 'Content-Type: application/json' \ - -d '{"nick":"testuser"}' - -# 1b. Optionally set a password for multi-client access -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"PASS","body":["mypassword"]}' - -# 1c. Login from another device (saves new cookie) -curl -s -c cookies2.txt -X POST http://localhost:8080/api/v1/login \ - -H 'Content-Type: application/json' \ - -d '{"nick":"testuser","password":"mypassword"}' - -# 2. Join a channel -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"JOIN","to":"#general"}' - -# 3. Send a message -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"PRIVMSG","to":"#general","body":["hello from curl!"]}' - -# 4. Poll for messages (one-shot) -curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=0" | jq . - -# 5. Long-poll (blocks up to 15s waiting for messages) -curl -s -b cookies.txt "http://localhost:8080/api/v1/messages?after=0&timeout=15" | jq . - -# 6. Send a DM -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"PRIVMSG","to":"othernick","body":["hey!"]}' - -# 7. Change nick -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"NICK","body":["newnick"]}' - -# 8. Set channel topic -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"TOPIC","to":"#general","body":["New topic!"]}' - -# 9. Leave a channel -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"PART","to":"#general","body":["goodbye"]}' - -# 10. Disconnect -curl -s -b cookies.txt -X POST http://localhost:8080/api/v1/messages \ - -H 'Content-Type: application/json' \ - -d '{"command":"QUIT","body":["leaving"]}' -``` - -### Implementing Long-Poll in Code - -The key to real-time messaging is the poll loop. Here's the pattern: - -```python -# Python example — using requests.Session for cookie handling -import requests, json, time - -BASE = "http://localhost:8080/api/v1" -session = requests.Session() -last_id = 0 - -# Create session (the server sets the auth cookie via Set-Cookie) -resp = session.post(f"{BASE}/session", json={"nick": "pybot"}) -print(f"Session: {resp.json()}") -# The auth cookie is Secure, and requests sends it only over HTTPS. -# For a plain-HTTP server on localhost, send it on every request: -session.headers["Cookie"] = f"neoirc_auth={resp.cookies['neoirc_auth']}" - -# Join channel -session.post(f"{BASE}/messages", - json={"command": "JOIN", "to": "#general"}) - -# Poll loop -while True: - try: - resp = session.get(f"{BASE}/messages", - params={"after": last_id, "timeout": 15}, - timeout=20) # HTTP timeout > long-poll timeout - data = resp.json() - if data.get("last_id"): - last_id = data["last_id"] - for msg in data.get("messages", []): - print(f"[{msg['command']}] <{msg.get('from','')}> " - f"{' '.join(msg.get('body', []))}") - except requests.exceptions.Timeout: - continue # Normal — just re-poll - except Exception as e: - print(f"Error: {e}") - time.sleep(2) # Back off on errors -``` - -```javascript -// JavaScript/browser example — cookies sent automatically -async function pollLoop() { - let lastId = 0; - while (true) { - try { - const resp = await fetch( - `/api/v1/messages?after=${lastId}&timeout=15`, - { credentials: "same-origin" }, // Include cookies - ); - if (resp.status === 401) { - /* session expired */ break; - } - const data = await resp.json(); - if (data.last_id) lastId = data.last_id; - for (const msg of data.messages || []) { - handleMessage(msg); - } - } catch (e) { - await new Promise((r) => setTimeout(r, 2000)); // back off - } - } -} -``` - -### Handling Message Types - -Clients should handle these message commands from the queue: - -| Command | Display As | -| --------- | ---------------------------------------------------------- | -| `PRIVMSG` | ` message text` | -| `NOTICE` | `-nick- message text` (do not auto-reply) | -| `JOIN` | `*** nick has joined #channel` | -| `PART` | `*** nick has left #channel (reason)` | -| `QUIT` | `*** nick has quit (reason)` | -| `NICK` | `*** oldnick is now known as newnick` | -| `TOPIC` | `*** nick set topic: new topic` | -| Numerics | Display body text (e.g., welcome messages, error messages) | - -### Error Handling - -- **HTTP 401**: Auth cookie expired or invalid. Re-create session or re-login - (if a password was set). -- **HTTP 404**: Channel not found (`GET /api/v1/channels/{name}/members` and - `GET /api/v1/history`). -- **HTTP 409**: Nick already taken on session creation. A NICK change to a taken - nick, like every other command error, is a numeric in the queue - (ERR_NICKNAMEINUSE 433) with `{"status": "error"}` as the response. -- **HTTP 400**: Malformed request. Check the `error` field in the response. -- **Network errors**: Back off exponentially (1s, 2s, 4s, ..., max 30s). - -### Tips for Client Authors - -1. **Set HTTP timeout > long-poll timeout**: If your long-poll timeout is 15s, - set your HTTP client timeout to at least 20s to avoid cutting off valid - responses. -2. **Always use `after` parameter**: Start with `after=0`, then use `last_id` - from each response. Never reset to 0 unless you want to re-read history. -3. **Handle your own echoed messages**: Channel messages and DMs are echoed back - to the sender. Your client will receive its own messages. Either deduplicate - by `id` or show them (which confirms delivery). -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 auth cookie is - invalid. For sessions without a password, create a new session. For sessions - with a password set (via PASS command), log in again via `POST /api/v1/login` - to get a fresh cookie on the same session. If it fails with a network error, - retry with backoff. - ---- - -## Rate Limiting & Abuse Prevention - -### Hashcash Proof-of-Work - -Session creation (`POST /api/v1/session`) requires a -[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token -(registering on the IRC listener does not). This is the primary defense against -resource exhaustion — no CAPTCHAs, no account registration, no IP-based rate -limits that punish shared networks. - -### How It Works - -1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits` - field (e.g., `20`) indicating the required difficulty. -2. Client computes a hashcash stamp: find a counter value such that the SHA-256 - hash of the stamp string has the required number of leading zero bits. -3. Client includes the stamp in the `pow_token` field of the JSON request body - when creating a session: `POST /api/v1/session`. -4. Server validates the stamp: - - Version is `1` - - Claimed bits ≥ required bits - - Resource matches the server name - - Date is no more than 48 hours old and no more than 1 hour in the future - - SHA-256 hash has the required leading zero bits - - Stamp has not been used before (replay prevention; the spent stamps are - kept in memory, so a restart forgets them) - - The stamp is spent only when the session is created. A request refused with - 400 or 409 leaves it unspent, and the client can send the same stamp again - with another nick or username. - -### Stamp Format - -Standard hashcash format: - -``` -1:bits:date:resource::counter -``` - -| Field | Description | -| ---------- | ----------------------------------------------------------------- | -| `1` | Version (always `1`) | -| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) | -| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) | -| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) | -| (empty) | Extension field (unused) | -| `counter` | Hex counter value found by the client to satisfy the PoW | - -**Example stamp:** `1:20:260310:neoirc::3a2f1b` - -The SHA-256 hash of this entire string must have at least 20 leading zero bits. - -### Computing a Stamp - -```bash -# Pseudocode -bits = 20 -resource = "neoirc" -date = "260310" # YYMMDD in UTC -counter = 0 - -loop: - stamp = "1:{bits}:{date}:{resource}::{hex(counter)}" - hash = SHA-256(stamp) - if leading_zero_bits(hash) >= bits: - return stamp - counter++ -``` - -At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on -average, taking roughly 0.5–2 seconds on modern hardware. - -### Client Integration - -Both the embedded web SPA and the CLI client automatically handle hashcash: - -1. Fetch `GET /api/v1/server` to read `hashcash_bits` -2. If `hashcash_bits > 0`, compute a valid stamp -3. Include the stamp in the `pow_token` field of the JSON body on - `POST /api/v1/session` - -The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256 -computation with batched parallelism. The CLI client uses Go's `crypto/sha256`. - -### Configuration - -Set `NEOIRC_HASHCASH_BITS` to control difficulty: - -| Value | Effect | Approx. Client CPU | -| ----- | ------------------------------------ | ------------------ | -| `0` | Disabled (no proof-of-work required) | — | -| `16` | Light protection | ~1ms | -| `20` | Default — good balance | ~0.5–2s | -| `24` | Strong protection | ~10–30s | -| `28` | Very strong (may frustrate users) | ~2–10min | - -Each additional bit doubles the expected work. An attacker creating 1000 -sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user -creating one session pays once and keeps their session. - -### Why Hashcash and Not Rate Limits? - -- **Little state to track**: No IP tables, no token buckets, no sliding windows. - The server hashes the stamp and remembers the stamps already spent. -- **Works through NATs and proxies**: Doesn't punish shared IPs (university - campuses, corporate networks, Tor exits). Every client computes their own - proof independently. -- **Cost falls on the requester**: The server's verification cost is constant - regardless of difficulty: at most two SHA-256 hashes of the stamp per session - request, one to check it and one to spend it. Only the client does more work. -- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry. No - registration, no email, no phone number, no CAPTCHA. Just compute. -- **Language-agnostic**: SHA-256 is available in every programming language. The - proof computation is trivially implementable in any client. - -### Login Rate Limiting - -The login endpoint (`POST /api/v1/login`) has per-IP rate limiting to prevent -brute-force password attacks. This uses a token-bucket algorithm -(`golang.org/x/time/rate`) with configurable rate and burst. - -| Environment Variable | Default | Description | -| -------------------- | ------- | ---------------------------------------- | -| `LOGIN_RATE_LIMIT` | `1` | Allowed login attempts per second per IP | -| `LOGIN_RATE_BURST` | `5` | Maximum burst of login attempts per IP | - -When the limit is exceeded, the server returns **429 Too Many Requests** with a -`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up every -10 minutes. - -> **⚠️ Security: Reverse Proxy Required for Production Use** -> -> The rate limiter extracts the client IP by checking the `X-Forwarded-For` -> header first, then `X-Real-IP`, and finally falling back to the TCP -> `RemoteAddr`. Both `X-Forwarded-For` and `X-Real-IP` are **client-controlled -> request headers** — any client can set them to arbitrary values. -> -> Without a properly configured reverse proxy in front of this server: -> -> - An attacker can **bypass rate limiting entirely** by rotating -> `X-Forwarded-For` values on each request (each value is treated as a -> distinct IP). -> - An attacker can **deny service to a specific user** by spoofing that user's -> IP in the `X-Forwarded-For` header, exhausting their rate limit bucket. -> -> **Recommendation:** Always deploy behind a reverse proxy (e.g. nginx, Caddy, -> Traefik) that strips or overwrites incoming `X-Forwarded-For` and `X-Real-IP` -> headers with the actual client IP. If running without a reverse proxy, be -> aware that the rate limiting provides no meaningful protection against a -> targeted attack. - -**Why rate limits here but not on session creation?** Session creation is -protected by hashcash proof-of-work (no IP tracking needed). Login involves -bcrypt password verification against a registered account — a fundamentally -different threat model where an attacker targets a specific account. Per-IP rate -limiting is appropriate here because the cost of a wrong guess is borne by the -server (bcrypt), not the client. - ---- - -## Roadmap - -### Implemented (MVP) - -- [x] Session creation with nick claim -- [x] All core commands: PRIVMSG, JOIN, PART, NICK, TOPIC, QUIT, PING -- [x] IRC message envelope format (command, from, to, body, ts, meta) -- [x] Per-client delivery queues with fan-out -- [x] Long-polling with in-memory broker -- [x] Channel messages and DMs -- [x] Ephemeral channels (deleted when empty) -- [x] NICK change with broadcast -- [x] QUIT with broadcast and cleanup -- [x] Embedded web SPA client -- [x] CLI client (neoirc-cli) -- [x] SQLite storage -- [x] Docker deployment -- [x] Prometheus metrics endpoint -- [x] Health check endpoint -- [x] Session expiry — auto-expire idle sessions, release nicks -- [x] Logout endpoint (`POST /api/v1/logout`) -- [x] Current user endpoint (`GET /api/v1/users/me`) -- [x] User count in server info (`GET /api/v1/server`) - -### Post-MVP (Planned) - -- [x] **Hashcash proof-of-work** for session creation (abuse prevention) -- [x] **Client output queue pruning** — delete old client output queue entries - per `QUEUE_MAX_AGE` -- [x] **Message rotation** — prune messages older than `MESSAGE_MAX_AGE` -- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock); only - members can send to a channel, so `+n` (no external) is shown but changes - nothing -- [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), - `+b` (ban), `+k` (key), `+l` (limit) -- [x] **User channel modes** — `+o` (operator), `+v` (voice) with NAMES prefixes -- [x] **KICK command** — operator-only channel kick with broadcast -- [x] **MODE command** — query and set channel/user modes -- [x] **NAMES command** — query channel member list with @/+ prefixes -- [x] **LIST command** — list all channels with member counts -- [x] **WHOIS command** — query user information and channel membership -- [x] **WHO command** — query channel user list -- [x] **LUSERS command** — query server statistics -- [x] **Connection registration numerics** — 001-005 sent on session creation -- [x] **LUSERS numerics** — 251/252/254/255 sent on connect and via /LUSERS -- [x] **KICK command** — remove users from channels (operator-only) -- [x] **Numeric replies** — send IRC numeric codes via the message queue - (001-005 welcome, 251-255 LUSERS, 311-319 WHOIS, 322-329 LIST/MODE, - 331-332 TOPIC, 352-353 WHO/NAMES, 366, 372-376 MOTD, 401-502 errors) -- [x] **Max message size enforcement** — reject request bodies over - `MAX_MESSAGE_SIZE` bytes -- [x] **NOTICE command** — distinct from PRIVMSG over the HTTP API (no RPL_AWAY, - no hashcash check) -- [x] **Multi-client sessions** — set a password via PASS command, then login - from additional devices via `POST /api/v1/login` -- [x] **Cookie-based auth** — HttpOnly cookies replace Bearer tokens for all API - authentication -- [x] **Tier 3 utility commands** — `USERHOST` (302), `VERSION` (351), `ADMIN` - (256–259), `INFO` (371/374), `TIME` (391), `KILL` (operator-only forced - disconnect), `WALLOPS` (operator-only broadcast to `+w` users) -- [x] **User mode +w** — receive `WALLOPS`, set with `MODE +w` / `-w` - -### Future (1.0+) - -- [ ] **PUBKEY command** — public key distribution -- [ ] **Message signing** — Ed25519 signatures with JCS canonicalization -- [ ] **TOFU key management** — client-side key caching and verification -- [ ] **E2E encryption for DMs** — end-to-end encrypted direct messages using - X25519 key exchange -- [ ] **Federation** — server-to-server linking, message relay, state sync -- [ ] **Postgres support** — for high-traffic deployments -- [ ] **Image/file upload** — inline media via a separate upload endpoint, - referenced in message `meta` -- [ ] **Push notifications** — optional webhook/push for mobile clients when - messages arrive during disconnect -- [ ] **Message search** — full-text search over channel history -- [x] **User info command** — WHOIS for querying user info and channels -- [ ] **Connection flood protection** — per-IP connection limits as a complement - to hashcash -- [x] **Invite system** — `INVITE` command for `+i` channels -- [x] **Ban system** — channel-level bans by hostmask pattern - ---- - -## Project Structure - -Following -[gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md): - -``` -neoirc/ -├── cmd/ -│ ├── neoircd/ # Server binary entry point -│ │ └── main.go -│ └── neoirc-cli/ # TUI client entry point -│ └── main.go # Minimal bootstrapping (calls internal/cli) -├── internal/ -│ ├── broker/ # In-memory pub/sub for long-poll notifications -│ │ └── broker.go -│ ├── cli/ # TUI client implementation -│ │ ├── app.go # App struct, command handling, poll loop -│ │ ├── ui.go # tview-based terminal UI -│ │ └── api/ -│ │ ├── client.go # HTTP API client library -│ │ ├── types.go # Request/response types -│ │ └── hashcash.go # Hashcash proof-of-work minting -│ ├── config/ # Viper-based configuration -│ │ └── config.go -│ ├── db/ # Database access and migrations -│ │ ├── db.go # Connection, migration runner -│ │ ├── auth.go # Passwords and login -│ │ ├── errors.go # Error helpers -│ │ ├── queries.go # The other SQL queries and data types -│ │ └── schema/ -│ │ ├── 000.sql # schema_migrations bootstrap -│ │ └── 001_initial.sql -│ ├── globals/ # Application-wide metadata -│ │ └── globals.go -│ ├── handlers/ # HTTP request handlers -│ │ ├── handlers.go # Deps, JSON response helper, cleanup loop -│ │ ├── api.go # Most API endpoint handlers -│ │ ├── auth.go # Login and PASS -│ │ ├── utility.go # Operator commands, user MODE, tier 3 commands -│ │ └── healthcheck.go # Health check handler -│ ├── hashcash/ # Hashcash stamp validation -│ ├── healthcheck/ # Health check logic -│ │ └── healthcheck.go -│ ├── ircserver/ # IRC protocol listener -│ ├── ratelimit/ # Per-IP login rate limiter -│ ├── service/ # Command logic shared by HTTP API and IRC listener -│ ├── stats/ # Runtime statistics (atomic counters) -│ │ └── stats.go -│ ├── logger/ # slog-based logging -│ │ └── logger.go -│ ├── middleware/ # HTTP middleware (logging, CORS, metrics, auth) -│ │ └── middleware.go -│ └── server/ # HTTP server, routing, lifecycle -│ ├── server.go # fx lifecycle, Sentry, signal handling -│ ├── routes.go # chi router setup, all routes -│ └── http.go # HTTP timeouts -├── web/ -│ ├── embed.go # go:embed directive for SPA -│ ├── build.sh # SPA build script (esbuild, runs in Docker) -│ ├── package.json # Node dependencies (preact, esbuild) -│ ├── yarn.lock -│ ├── src/ # SPA source files (JSX + HTML + CSS) -│ │ ├── app.jsx -│ │ ├── index.html -│ │ └── style.css -│ └── dist/ # Generated at Docker build time (not committed) -├── pkg/ -│ └── irc/ # IRC command names and numeric reply codes -├── schema/ # JSON Schema definitions -├── script/ # Entrypoints that the Makefile targets call -├── go.mod -├── go.sum -├── package.json # prettier, for make fmt and make fmt-check -├── Makefile -├── Dockerfile -├── CONVENTIONS.md -└── README.md -``` - -### Required Libraries - -| Purpose | Library | -| ---------- | ------------------------------------------------------- | -| DI | `go.uber.org/fx` | -| Router | `github.com/go-chi/chi/v5` | -| 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` | -| UUIDs | `github.com/google/uuid` | -| Errors | `github.com/getsentry/sentry-go` (optional) | -| TUI Client | `github.com/rivo/tview` + `github.com/gdamore/tcell/v2` | - ---- - -## 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 against this API in an afternoon, the - API is too complex. - -2. **Passwords 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), the PASS command sets a password on the session - — but it's never required. Identity verification at the message layer uses - cryptographic signing, independent of password 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 command - vocabulary. The only new things are the JSON encoding and the HTTP transport. - -4. **HTTP is the only transport** — no WebSockets, no protocol negotiation, and - no raw TCP apart from the [IRC Protocol Listener](#irc-protocol-listener) - kept for standard IRC clients. HTTP is universal, proxy-friendly, - CDN-friendly, and works on every device and network. Long-polling provides - real-time delivery without any of the complexity of persistent connections. - -5. **Server holds state** — clients are stateless. Reconnect, switch devices, - lose connectivity for hours — your messages are waiting in your client queue. - The server is the source of truth for session state, channel membership, and - message history. - -6. **Structured messages** — JSON with extensible metadata. Bodies are always - objects or arrays, never raw strings. This enables deterministic - canonicalization (JCS) for signing and multiline messages without escape - sequences. - -7. **Immutable messages** — no editing, no deletion. Ever. This fits naturally - with cryptographic signatures and creates a trustworthy audit trail. IRC - culture already handles corrections inline ("s/typo/fix/"). - -8. **Simple deployment** — single binary, SQLite default, zero mandatory - external dependencies. `docker run` and you're done. No Redis, no RabbitMQ, - no Kubernetes, no configuration management. - -9. **No eternal logs** — history rotates. Chat should be ephemeral by default. - Channels disappear when empty. Sessions expire when idle. The server does not - aspire to be an archive. - -10. **Federation optional** — a single server works standalone. Linking is - manual and opt-in, like IRC. There is no requirement to participate in a - network. - -11. **Signable messages** — optional Ed25519 signatures with TOFU key - distribution. Servers relay signatures without verification. Trust decisions - are made by clients, not servers. - -12. **No magic** — the protocol has no special cases, no content-type - negotiation, no feature flags. Every message uses the same envelope. Every - command goes through the same endpoint. The simplest implementation is also - the correct one. - ---- - -## Status - -**Implementation in progress.** Core API is functional with: - -- SQLite storage -- All core IRC commands (PRIVMSG, JOIN, PART, NICK, TOPIC, QUIT, PING) -- IRC message envelope format with per-client queue fan-out -- Long-polling with in-memory broker -- Embedded web SPA client -- TUI client (neoirc-cli) -- Docker image -- Prometheus metrics - -See [Roadmap](#roadmap) for what's next. - ---- +The IRC listener has no TLS. + +### Limits and headers + +- The server reads a request within 10 seconds and writes a response within 60 + seconds. A request still running after 60 seconds is cancelled, and the + connection closes without a response. Headers may be up to 1 MiB. +- A long poll waits at most 30 seconds. +- Every response to a well-formed request, except to a cross-origin preflight, + carries a `Content-Security-Policy` header: + `default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`. + The web client works within it. +- Cross-origin requests are allowed from any origin, with credentials. The + allowed methods are `GET`, `POST`, `PUT`, `DELETE` and `OPTIONS`. The allowed + request headers are `Accept`, `Content-Type` and `X-CSRF-Token`. The cookie's + `SameSite=Strict` still keeps it to the same site. + +### Logging + +The server logs with Go's `slog`, one line per event, to standard output. The +lines are JSON, or plain text when standard output is a terminal. Every HTTP +request is logged with its method, URL, protocol, status, latency, remote +address, user agent, referrer and request ID. `DEBUG=true` adds debug-level +lines. + +### The database + +The server stores everything in one SQLite database, through the pure Go driver +`modernc.org/sqlite`. It uses one database connection, so reads and writes take +turns. On its first start it runs `internal/db/schema/001_initial.sql`, which +creates the tables, and records that in `schema_migrations`. + +The tables: + +- `sessions`: one row per session: nick, username, hostname, IP address, flags, + password hash, away message and timestamps. +- `clients`: one row per client: the SHA-256 hash of its cookie, its IP address + and hostname, its creation time and the time of its last request. +- `channels`: name, topic, who set the topic and when, every channel mode and + the creation time. +- `channel_members`: which sessions are in which channel, with their `+o` and + `+v` flags. +- `channel_bans` and `channel_invites`: the bans and the pending invites of each + channel. +- `messages`: every message once, with the session or channel it was sent to. +- `client_queues`: one row per queued message per client. Its row number is the + `last_id` of `GET /api/v1/messages`. +- `spent_hashcash`: the hashes of channel stamps already used. + +Back up the database with +`sqlite3 /var/lib/neoirc/state.db ".backup backup.db"`. Or stop the server and +copy `state.db` together with `state.db-wal`, if that file exists: in +write-ahead logging mode it holds recent writes not yet in `state.db`. + +Do not set `DBURL` to an in-memory database (`file::memory:`). SQLite deletes an +in-memory database when its last connection closes, and the server's connection +pool replaces its connection whenever a query is cancelled. Everything stored +until then is lost. + +### Data lifecycle + +- A channel is deleted when its last member leaves, is kicked, quits or is + removed by the idle cleanup. +- A session ends when any of these happens: + - `QUIT` or `KILL`; + - its last client logs out; + - the connection of an IRC listener user closes; + - the idle cleanup removes its last client. +- With the session go its nick, its password and its channel memberships. Its + messages stay until they age out. +- A session's last request time is updated on every authenticated HTTP request + and on every command on the IRC listener. +- A cleanup runs every half `SESSION_IDLE_TIMEOUT`, but at least a minute apart. + With the default that is every 15 days, the first time 15 days after the + server starts. It deletes clients whose last request is older than + `SESSION_IDLE_TIMEOUT` and ends the sessions left with no client; their + channel peers see a `QUIT` with the reason `Connection closed`. It deletes + messages older than `MESSAGE_MAX_AGE`, queue entries older than + `QUEUE_MAX_AGE` and channel hashcash stamps older than a year. A row can + therefore outlive its age limit by up to one cleanup interval. + +### Shutdown + +On `SIGINT` or `SIGTERM` the server stops. It waits up to five seconds for HTTP +requests in progress, and up to five seconds for the IRC connections to close +and their sessions to end. Then it closes the database. + +## Development + +### Layout + +- `cmd/neoircd/`: the server's `main`, which wires the packages with `fx`. +- `cmd/neoirc-cli/`: the terminal client's `main`. +- `internal/config/`: the settings, read with Viper. +- `internal/server/`: the HTTP server, its routes and its middleware. +- `internal/handlers/`: the HTTP API handlers and the cleanup loop. +- `internal/service/`: the command logic the HTTP API and the IRC listener + share. +- `internal/ircserver/`: the IRC listener: connections, the line parser, and the + relay that turns queued messages into IRC lines. +- `internal/db/`: the database: connection, migrations, queries, and the schema + in `schema/`. +- `internal/broker/`: wakes waiting long polls and IRC relays when a session + gets a message. +- `internal/hashcash/`: stamp checking and minting. +- `internal/ratelimit/`: the per-address login rate limiter. +- `internal/middleware/`: request logging, cross-origin requests, the + `Content-Security-Policy` header and metrics. +- `internal/healthcheck/` and `internal/stats/`: the health check and its + counters. +- `internal/logger/` and `internal/globals/`: logging setup and build + information. +- `internal/cli/`: the terminal client, with its API client in `api/`. +- `internal/dbtest/`: a database per test; only tests import it. +- `pkg/irc/`: the IRC command names and numeric reply codes. +- `web/`: the web client's source, build script and `embed.go`. +- `schema/`: JSON Schema files for the message envelope, with their own + `README.md`. +- `script/`: the entrypoints below. +- `CONVENTIONS.md`, `REPO_POLICIES.md` and `AGENTS.md`: the architecture + conventions, the repository policies and the rules for coding agents. + +### Entrypoints + +This repository adheres to the +[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) +standard. Each script has a `make` target of the same name, except +`script/install-precommit`, which is `make hooks`, and `script/cibuild`, +`script/precommit` and `script/projectname`, which have none. + +- `script/bootstrap`: installs `make`, `git`, Go, Node and `yarn`, and + `prettier` through `yarn`. Docker must already be installed. +- `script/setup`: makes a fresh clone ready: runs `script/bootstrap`, then + `script/install-precommit`. +- `script/test`: builds the `Dockerfile`'s `test` stage, which runs the tests + with `-race`. +- `script/lint`: builds the `Dockerfile`'s `lint` stage, which runs + `golangci-lint`. +- `script/fmt`: formats the Go code with `gofmt` and the Markdown with + `prettier`. +- `script/fmt-check`: the same, read-only; fails if anything would change. +- `script/check`: runs `script/test`, `script/lint` and `script/fmt-check`. +- `script/docker`: builds the image, tagged `neoirc`. +- `script/cibuild`: the CI build: `script/bootstrap`, `script/check`, then the + image. +- `script/precommit`: run by the git pre-commit hook; fails if `go mod tidy` + changes `go.mod` or `go.sum`, then runs `script/check`. +- `script/install-precommit`: installs that hook. +- `script/projectname`: prints the project name, `neoirc`. + +### Building and running from source + +- `make build`: compiles `bin/neoircd`. When `web/dist/` does not exist, it + creates empty placeholder files there, so the binary serves an empty page at + `/`. To include the web client, build it first: in `web/`, run + `yarn install --frozen-lockfile` and `sh build.sh`. +- `make run`: builds and starts the server. `make debug` does the same with + `DEBUG=1`. With the default `DBURL`, both stop at startup when + `/var/lib/neoirc/` does not exist or is not writable. Set `DBURL` to a + writable path to avoid that. +- `make clean`: removes `bin/`. +- `make check`: runs `script/check`. `make test`, `make lint`, `make fmt` and + `make fmt-check` run the script of the same name. +- `make docker`: builds the image. +- `make hooks`: installs the pre-commit hook. + +### Tests and checks + +The tests and the linter run inside Docker, as stages of the `Dockerfile`, so +every developer and the CI run the same toolchain. `make test` builds the `test` +stage: `go test -race` over every package, with a 90-second limit. `make lint` +builds the `lint` stage, which runs `golangci-lint` with the shared +`.golangci.yml`. `make fmt-check` runs `gofmt` and `prettier` on the host. Tests +live next to the code, most in `_test` packages. A test that needs a database +gets an in-memory one of its own from `internal/dbtest`, which holds a second +connection open to keep it. + +The `Dockerfile` has five stages: + +1. `web-builder`: installs the web client's Node dependencies and bundles + `web/src/app.jsx` with `esbuild` into `web/dist/`. +2. `lint`: runs `golangci-lint`, with placeholder files in `web/dist/` so that + it does not wait for the web build. +3. `test`: runs the tests, with the same placeholders. +4. `builder`: runs only after `lint` and `test` have passed. It compiles + `neoircd` with the real web client, and compiles `neoirc-cli` to check that + it builds. +5. The runtime stage: Alpine with `neoircd`. + +The CI workflow in `.gitea/workflows/check.yml` runs `script/cibuild` on every +push. + +## TODO + +Open work is tracked on the issue tracker at +. ## License -MIT +MIT. See `LICENSE`. ## Author diff --git a/internal/cli/api/client.go b/internal/cli/api/client.go index 91222a8..78f746b 100644 --- a/internal/cli/api/client.go +++ b/internal/cli/api/client.go @@ -25,7 +25,10 @@ const ( httpErrThreshold = 400 ) -var errHTTP = errors.New("HTTP error") +var ( + errHTTP = errors.New("HTTP error") + errJoinRefused = errors.New("the server refused the join") +) // Client wraps HTTP calls to the neoirc server API. type Client struct { @@ -206,13 +209,35 @@ func (client *Client) PollMessages( }, nil } -// JoinChannel joins a channel. +// JoinChannel joins a channel. It returns an error unless the +// server answers that the session joined. When the server +// refuses the join, it sends the reason to the message queue as +// an error numeric. func (client *Client) JoinChannel(channel string) error { - return client.SendMessage( + data, err := client.do( + http.MethodPost, "/api/v1/messages", &Message{ //nolint:exhaustruct // only command+to needed Command: irc.CmdJoin, To: channel, }, ) + if err != nil { + return err + } + + var resp struct { + Status string `json:"status"` + } + + err = json.Unmarshal(data, &resp) + if err != nil { + return fmt.Errorf("decode join: %w", err) + } + + if resp.Status != "joined" { + return errJoinRefused + } + + return nil } // PartChannel leaves a channel. diff --git a/internal/cli/app.go b/internal/cli/app.go index 4bacf80..c6926ec 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -34,30 +34,44 @@ type App struct { // Run creates and runs the CLI application. func Run() { - app := &App{ //nolint:exhaustruct - ui: NewUI(), - nick: "guest", - } - - app.ui.OnInput(app.handleInput) - app.ui.SetStatus(app.nick, "", "disconnected") - - app.ui.AddStatus( - "Welcome to neoirc-cli — an IRC-style client", - ) - app.ui.AddStatus( - "Type [yellow]/connect " + - "[white] to begin, " + - "or [yellow]/help[white] for commands", - ) - - err := app.ui.Run() + err := run(NewUI()) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } +// run starts the client on ui and returns when ui stops. Tests +// call it with ui on a simulation screen. +func run(ui *UI) error { + app := &App{ //nolint:exhaustruct + ui: ui, + nick: "guest", + } + + app.ui.OnInput(app.handleInput) + + // SetStatus and AddStatus wait for the event loop, which + // app.ui.Run starts below, so they run on a goroutine. The + // nick is read first, as a typed /nick may change app.nick. + nick := app.nick + + go func() { + app.ui.SetStatus(nick, "", "disconnected") + + app.ui.AddStatus( + "Welcome to neoirc-cli — an IRC-style client", + ) + app.ui.AddStatus( + "Type [yellow]/connect " + + "[white] to begin, " + + "or [yellow]/help[white] for commands", + ) + }() + + return app.ui.Run() +} + func (a *App) handleInput(text string) { if strings.HasPrefix(text, "/") { a.handleCommand(text) @@ -94,22 +108,9 @@ func (a *App) handleInput(text string) { }) if err != nil { a.ui.AddStatus( - "[red]Send error: " + err.Error(), + "[red]Send error: " + escape(err.Error()), ) - - return } - - timestamp := time.Now().Format(timeFormat) - - a.mu.Lock() - nick := a.nick - a.mu.Unlock() - - a.ui.AddLine(target, fmt.Sprintf( - "[gray]%s [green]<%s>[white] %s", - timestamp, nick, text, - )) } func (a *App) handleCommand(text string) { @@ -165,7 +166,7 @@ func (a *App) dispatchInfoCommand(cmd, args string) { a.cmdWhois(args) default: a.ui.AddStatus( - "[red]Unknown command: " + cmd, + "[red]Unknown command: " + escape(cmd), ) } } @@ -181,7 +182,7 @@ func (a *App) cmdConnect(serverURL string) { serverURL = strings.TrimRight(serverURL, "/") - a.ui.AddStatus("Connecting to " + serverURL + "...") + a.ui.AddStatus("Connecting to " + escape(serverURL) + "...") a.mu.Lock() nick := a.nick @@ -191,29 +192,37 @@ func (a *App) cmdConnect(serverURL string) { resp, err := client.CreateSession(nick) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Connection failed: %v", err, - )) + a.ui.AddStatus( + "[red]Connection failed: " + escape(err.Error()), + ) return } + // The poll loop of an earlier connection is stopped in the same + // step that replaces the client, so it never polls with the new + // one. a.mu.Lock() a.client = client a.nick = resp.Nick a.connected = true a.lastQID = 0 + + if a.stopPoll != nil { + close(a.stopPoll) + } + + stop := make(chan struct{}) + a.stopPoll = stop a.mu.Unlock() a.ui.AddStatus(fmt.Sprintf( "[green]Connected! Nick: %s, Session: %d", - resp.Nick, resp.ID, + escape(resp.Nick), resp.ID, )) a.ui.SetStatus(resp.Nick, "", "connected") - a.stopPoll = make(chan struct{}) - - go a.pollLoop() + go a.pollLoop(stop) } func (a *App) cmdNick(nick string) { @@ -235,7 +244,7 @@ func (a *App) cmdNick(nick string) { a.mu.Unlock() a.ui.AddStatus( - "Nick set to " + nick + + "Nick set to " + escape(nick) + " (will be used on connect)", ) @@ -247,9 +256,9 @@ func (a *App) cmdNick(nick string) { Body: []string{nick}, }) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Nick change failed: %v", err, - )) + a.ui.AddStatus( + "[red]Nick change failed: " + escape(err.Error()), + ) return } @@ -260,7 +269,7 @@ func (a *App) cmdNick(nick string) { a.mu.Unlock() a.ui.SetStatus(nick, target, "connected") - a.ui.AddStatus("Nick changed to " + nick) + a.ui.AddStatus("Nick changed to " + escape(nick)) } func (a *App) cmdJoin(channel string) { @@ -288,9 +297,9 @@ func (a *App) cmdJoin(channel string) { err := a.client.JoinChannel(channel) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Join failed: %v", err, - )) + a.ui.AddStatus( + "[red]Join failed: " + escape(err.Error()), + ) return } @@ -301,9 +310,6 @@ func (a *App) cmdJoin(channel string) { a.mu.Unlock() a.ui.SwitchToBuffer(channel) - a.ui.AddLine(channel, - "[yellow]*** Joined "+channel, - ) a.ui.SetStatus(nick, channel, "connected") } @@ -331,17 +337,13 @@ func (a *App) cmdPart(channel string) { err := a.client.PartChannel(channel) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Part failed: %v", err, - )) + a.ui.AddStatus( + "[red]Part failed: " + escape(err.Error()), + ) return } - a.ui.AddLine(channel, - "[yellow]*** Left "+channel, - ) - a.mu.Lock() if a.target == channel { a.target = "" @@ -368,7 +370,6 @@ func (a *App) cmdMsg(args string) { a.mu.Lock() connected := a.connected - nick := a.nick a.mu.Unlock() if !connected { @@ -383,19 +384,10 @@ func (a *App) cmdMsg(args string) { Body: []string{text}, }) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Send failed: %v", err, - )) - - return + a.ui.AddStatus( + "[red]Send failed: " + escape(err.Error()), + ) } - - timestamp := time.Now().Format(timeFormat) - - a.ui.AddLine(target, fmt.Sprintf( - "[gray]%s [green]<%s>[white] %s", - timestamp, nick, text, - )) } func (a *App) cmdQuery(nick string) { @@ -410,10 +402,16 @@ func (a *App) cmdQuery(nick string) { a.mu.Lock() a.target = nick myNick := a.nick + connected := a.connected a.mu.Unlock() + connStatus := "disconnected" + if connected { + connStatus = "connected" + } + a.ui.SwitchToBuffer(nick) - a.ui.SetStatus(myNick, nick, "connected") + a.ui.SetStatus(myNick, nick, connStatus) } func (a *App) cmdTopic(args string) { @@ -447,9 +445,9 @@ func (a *App) cmdTopic(args string) { Body: []string{args}, }) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Topic set failed: %v", err, - )) + a.ui.AddStatus( + "[red]Topic set failed: " + escape(err.Error()), + ) } } @@ -473,9 +471,9 @@ func (a *App) cmdNames() { members, err := a.client.GetMembers(target) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Names failed: %v", err, - )) + a.ui.AddStatus( + "[red]Names failed: " + escape(err.Error()), + ) return } @@ -488,7 +486,7 @@ func (a *App) cmdNames() { a.ui.AddLine(target, fmt.Sprintf( "[cyan]*** Members of %s: %s", - target, strings.Join(nicks, " "), + escape(target), escape(strings.Join(nicks, " ")), )) } @@ -519,9 +517,9 @@ func (a *App) cmdList() { channels, err := a.client.ListChannels() if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]List failed: %v", err, - )) + a.ui.AddStatus( + "[red]List failed: " + escape(err.Error()), + ) return } @@ -530,7 +528,7 @@ func (a *App) cmdList() { for _, ch := range channels { a.ui.AddStatus(fmt.Sprintf( - " %s %s", ch.Name, ch.Topic, + " %s %s", escape(ch.Name), escape(ch.Topic), )) } @@ -552,9 +550,9 @@ func (a *App) cmdMotd() { &api.Message{Command: irc.CmdMotd}, //nolint:exhaustruct ) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]MOTD failed: %v", err, - )) + a.ui.AddStatus( + "[red]MOTD failed: " + escape(err.Error()), + ) } } @@ -590,9 +588,9 @@ func (a *App) cmdWho(args string) { }, ) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]WHO failed: %v", err, - )) + a.ui.AddStatus( + "[red]WHO failed: " + escape(err.Error()), + ) } } @@ -621,9 +619,9 @@ func (a *App) cmdWhois(args string) { }, ) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]WHOIS failed: %v", err, - )) + a.ui.AddStatus( + "[red]WHOIS failed: " + escape(err.Error()), + ) } } @@ -640,24 +638,37 @@ func (a *App) cmdWindow(args string) { _, _ = fmt.Sscanf(args, "%d", &bufIndex) - a.ui.SwitchBuffer(bufIndex) - a.mu.Lock() nick := a.nick + connected := a.connected a.mu.Unlock() - if bufIndex >= 0 && bufIndex < a.ui.BufferCount() { - buf := a.ui.buffers[bufIndex] - if buf.Name != "(status)" { + connStatus := "disconnected" + if connected { + connStatus = "connected" + } + + // Read the name before switching. A message from the server can + // add a buffer at any time, but only at the end, so bufIndex + // still names this buffer when SwitchBuffer runs. + name, found := a.ui.BufferName(bufIndex) + if found { + a.ui.SwitchBuffer(bufIndex) + + if name != "(status)" { a.mu.Lock() - a.target = buf.Name + a.target = name a.mu.Unlock() a.ui.SetStatus( - nick, buf.Name, "connected", + nick, name, connStatus, ) } else { - a.ui.SetStatus(nick, "", "connected") + a.mu.Lock() + a.target = "" + a.mu.Unlock() + + a.ui.SetStatus(nick, "", connStatus) } } } @@ -705,16 +716,23 @@ func (a *App) cmdHelp() { } } -// pollLoop long-polls for messages in the background. -func (a *App) pollLoop() { +// pollLoop long-polls for messages in the background until stop is +// closed. A later /connect closes stop under a.mu as it replaces the +// client and the cursor, so the loop checks stop under a.mu too. A +// stopped loop then neither polls with the new client nor moves the +// new cursor. +func (a *App) pollLoop(stop <-chan struct{}) { for { + a.mu.Lock() + select { - case <-a.stopPoll: + case <-stop: + a.mu.Unlock() + return default: } - a.mu.Lock() client := a.client lastQID := a.lastQID a.mu.Unlock() @@ -732,12 +750,24 @@ func (a *App) pollLoop() { continue } - if result.LastID > 0 { - a.mu.Lock() - a.lastQID = result.LastID + // A poll sent before stop was closed answers for the earlier + // connection. + a.mu.Lock() + + select { + case <-stop: a.mu.Unlock() + + return + default: } + if result.LastID > 0 { + a.lastQID = result.LastID + } + + a.mu.Unlock() + for i := range result.Messages { a.handleServerMessage(&result.Messages[i]) } @@ -785,18 +815,16 @@ func (a *App) handlePrivmsgEvent( lines := msg.BodyLines() text := strings.Join(lines, " ") - if msg.From == myNick { - return - } - + // A direct message shows in the buffer of the other user: + // the recipient of one the user sent, else the sender. target := msg.To - if !strings.HasPrefix(target, "#") { + if !strings.HasPrefix(target, "#") && msg.From != myNick { target = msg.From } a.ui.AddLine(target, fmt.Sprintf( "[gray]%s [green]<%s>[white] %s", - timestamp, msg.From, text, + timestamp, escape(msg.From), escape(text), )) } @@ -809,35 +837,39 @@ func (a *App) handleJoinEvent( a.ui.AddLine(msg.To, fmt.Sprintf( "[gray]%s [yellow]*** %s has joined %s", - timestamp, msg.From, msg.To, + timestamp, escape(msg.From), escape(msg.To), )) } -// handlePartEvent shows another user's part. The server -// also sends the user's own part back to it, which cmdPart -// has already shown; showing it again would add a second -// line and mark the channel just left as unread. +// handlePartEvent shows a part in the channel's buffer. The +// user's own part does not mark the channel unread: the user +// has just left it. func (a *App) handlePartEvent( msg *api.Message, timestamp, myNick string, ) { - if msg.To == "" || msg.From == myNick { + if msg.To == "" { return } lines := msg.BodyLines() reason := strings.Join(lines, " ") + line := fmt.Sprintf( + "[gray]%s [yellow]*** %s has left %s", + timestamp, escape(msg.From), escape(msg.To), + ) + if reason != "" { - a.ui.AddLine(msg.To, fmt.Sprintf( - "[gray]%s [yellow]*** %s has left %s (%s)", - timestamp, msg.From, msg.To, reason, - )) - } else { - a.ui.AddLine(msg.To, fmt.Sprintf( - "[gray]%s [yellow]*** %s has left %s", - timestamp, msg.From, msg.To, - )) + line += " (" + escape(reason) + ")" } + + if msg.From == myNick { + a.ui.AddLineAsRead(msg.To, line) + + return + } + + a.ui.AddLine(msg.To, line) } func (a *App) handleQuitEvent( @@ -849,12 +881,12 @@ func (a *App) handleQuitEvent( if reason != "" { a.ui.AddStatus(fmt.Sprintf( "[gray]%s [yellow]*** %s has quit (%s)", - timestamp, msg.From, reason, + timestamp, escape(msg.From), escape(reason), )) } else { a.ui.AddStatus(fmt.Sprintf( "[gray]%s [yellow]*** %s has quit", - timestamp, msg.From, + timestamp, escape(msg.From), )) } } @@ -881,7 +913,7 @@ func (a *App) handleNickEvent( a.ui.AddStatus(fmt.Sprintf( "[gray]%s [yellow]*** %s is now known as %s", - timestamp, msg.From, newNick, + timestamp, escape(msg.From), escape(newNick), )) } @@ -893,7 +925,7 @@ func (a *App) handleNoticeEvent( a.ui.AddStatus(fmt.Sprintf( "[gray]%s [magenta]--%s-- %s", - timestamp, msg.From, text, + timestamp, escape(msg.From), escape(text), )) } @@ -909,7 +941,7 @@ func (a *App) handleTopicEvent( a.ui.AddLine(msg.To, fmt.Sprintf( "[gray]%s [cyan]*** %s set topic: %s", - timestamp, msg.From, text, + timestamp, escape(msg.From), escape(text), )) } @@ -919,10 +951,12 @@ func (a *App) handleDefaultEvent( lines := msg.BodyLines() text := strings.Join(lines, " ") + // The brackets around the command are escaped with it, as tview + // reads a command such as "[MODE]" as a tag. if text != "" { a.ui.AddStatus(fmt.Sprintf( - "[gray]%s [white][%s] %s", - timestamp, msg.Command, text, + "[gray]%s [white]%s %s", + timestamp, escape("["+msg.Command+"]"), escape(text), )) } } diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 9aa41d7..79571b1 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -1,13 +1,54 @@ package cli import ( + "io" + "net/http" + "net/http/httptest" + "slices" + "strings" "testing" + "time" + "unicode/utf8" "github.com/gdamore/tcell/v2" api "sneak.berlin/go/neoirc/internal/cli/api" "sneak.berlin/go/neoirc/pkg/irc" ) +// The user's nick, a channel it is in, another user's nick and +// a message text, for the tests below. +const ( + testNick = "erin" + testChannel = "#chan" + testOtherNick = "bob" + testText = "hello" +) + +// What the status bar shows while the client is connected, and +// while it is not. +const ( + statusConnected = "[connected]" + statusDisconnected = "[disconnected]" +) + +// eventLoopTimeout is how long a test waits for a typed line to +// be handled, for the welcome lines, for the UI to stop, for a poll +// or for a poll loop to end, before it fails. +const eventLoopTimeout = 5 * time.Second + +// holdTime is how long a test waits to see that something does not +// happen: the second line handled while the first is held, or a +// poll from a poll loop that was stopped. +const holdTime = 200 * time.Millisecond + +// welcomeLines is the number of lines run adds to the status +// window when the client starts. +const welcomeLines = 2 + +// checkInterval is how often TestStartUp reads the status window +// while it waits for the welcome lines. +const checkInterval = 10 * time.Millisecond + func TestMemberPrefix(t *testing.T) { t.Parallel() @@ -53,64 +94,1258 @@ func TestMemberPrefix(t *testing.T) { } // TestPartEvent checks that a PART event adds a line to the -// channel's buffer and marks it unread, except for the -// user's own part, which cmdPart has already shown. +// channel's buffer and marks it unread, except that the +// user's own part does not mark it unread. func TestPartEvent(t *testing.T) { t.Parallel() tests := []struct { name string from string - wantLines int wantUnread int }{ - {name: "own part", from: "erin", wantLines: 0, wantUnread: 0}, - {name: "other member", from: "bob", wantLines: 1, wantUnread: 1}, + {name: "own part", from: testNick, wantUnread: 0}, + {name: "other member", from: testOtherNick, wantUnread: 1}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { t.Parallel() - app := &App{ui: NewUI(), nick: "erin"} + app := &App{ui: NewUI(), nick: testNick} runUI(t, app.ui) app.handleServerMessage(&api.Message{ - Command: irc.CmdPart, From: test.from, To: "#chan", + Command: irc.CmdPart, From: test.from, To: testChannel, }) - var lines, unread int - - app.ui.app.QueueUpdate(func() { - buf := app.ui.getOrCreateBuffer("#chan") - lines = len(buf.Lines) - unread = buf.Unread - }) - - if lines != test.wantLines || unread != test.wantUnread { - t.Fatalf("got %d lines and %d unread, want %d and %d", - lines, unread, test.wantLines, test.wantUnread) + lines, unread := bufferState(app.ui, testChannel) + if lines != 1 || unread != test.wantUnread { + t.Fatalf("got %d lines and %d unread, want 1 and %d", + lines, unread, test.wantUnread) } }) } } -// runUI runs ui on a simulated screen until the test ends. -func runUI(t *testing.T, ui *UI) { - t.Helper() +// TestOwnCommandShownOnce checks that a message, join or +// part made from neoirc-cli shows as one line in the +// channel's buffer once the server's event for it arrives, +// and does not mark the channel unread. +func TestOwnCommandShownOnce(t *testing.T) { + t.Parallel() + tests := []struct { + name string + send func(app *App) + event api.Message + }{ + { + name: "message", + send: func(app *App) { app.handleInput(testText) }, + event: api.Message{ + Command: irc.CmdPrivmsg, From: testNick, To: testChannel, + Body: []string{testText}, + }, + }, + { + name: "join", + send: func(app *App) { app.cmdJoin(testChannel) }, + event: api.Message{ + Command: irc.CmdJoin, From: testNick, To: testChannel, + }, + }, + { + name: "part", + send: func(app *App) { app.cmdPart(testChannel) }, + event: api.Message{ + Command: irc.CmdPart, From: testNick, To: testChannel, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + // The server accepts every command. Only the response + // to a JOIN is read, and it says the session joined. + server := newServer(t, `{"status": "joined"}`) + + app := &App{ + ui: NewUI(), + client: api.NewClient(server.URL), + nick: testNick, + target: testChannel, + connected: true, + } + runUI(t, app.ui) + app.ui.SwitchToBuffer(testChannel) + + test.send(app) + app.handleServerMessage(&test.event) + + lines, unread := bufferState(app.ui, testChannel) + if lines != 1 || unread != 0 { + t.Fatalf("got %d lines and %d unread, want 1 and 0", + lines, unread) + } + }) + } +} + +// TestJoinResponse checks that /join switches to the channel's +// buffer and makes it the target only when the server answers +// that the session joined. +func TestJoinResponse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + response string + // The buffer shown and the target after the join. + want string + }{ + { + name: "joined", + response: `{"status": "joined", "channel": "#chan"}`, + want: testChannel, + }, + { + name: "refused", + response: `{"status": "error"}`, + want: testOtherNick, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + app := &App{ + ui: NewUI(), + client: api.NewClient(newServer(t, test.response).URL), + nick: testNick, + target: testOtherNick, + connected: true, + } + runUI(t, app.ui) + app.ui.SwitchToBuffer(testOtherNick) + + app.cmdJoin(testChannel) + + var shown string + + app.ui.app.QueueUpdate(func() { + shown = app.ui.buffers[app.ui.currentBuffer].Name + }) + + if shown != test.want || app.target != test.want { + t.Fatalf("got buffer %s and target %s, want %s", + shown, app.target, test.want) + } + }) + } +} + +// TestAltDigit checks that Alt and a digit shows that window and +// makes its channel or user the target, as /window does. Window +// 0, the status window, leaves no target. +func TestAltDigit(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + digit rune + // The buffer shown and the target after the key. + wantBuffer string + wantTarget string + }{ + { + name: "channel window", + digit: '1', + wantBuffer: testChannel, + wantTarget: testChannel, + }, + { + name: "status window", + digit: '0', + wantBuffer: "(status)", + wantTarget: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + app := &App{ui: NewUI(), nick: testNick, target: testOtherNick} + handled := handledLines(app) + runUI(t, app.ui) + + // Window 1 is the channel. Window 2, the one shown, is + // the other user's. + app.ui.SwitchToBuffer(testChannel) + app.ui.SwitchToBuffer(testOtherNick) + + key := tcell.NewEventKey(tcell.KeyRune, test.digit, tcell.ModAlt) + app.ui.app.QueueEvent(key) + waitHandled(t, handled, "/window "+string(test.digit)) + + var shown string + + app.ui.app.QueueUpdate(func() { + shown = app.ui.buffers[app.ui.currentBuffer].Name + }) + + if shown != test.wantBuffer || app.target != test.wantTarget { + t.Fatalf("got buffer %s and target %q, want %s and %q", + shown, app.target, test.wantBuffer, test.wantTarget) + } + }) + } +} + +// TestWindowConnectionState checks that typing /window 0, or +// pressing Alt and 0, leaves the status bar showing [connected] +// only while the client is connected, and [disconnected] otherwise. +func TestWindowConnectionState(t *testing.T) { + t.Parallel() + + const line = "/window 0" + + typeLine := func(ui *UI) { + for _, r := range line { + ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) + } + + ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + } + + pressAltZero := func(ui *UI) { + ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, '0', tcell.ModAlt)) + } + + tests := []struct { + name string + send func(ui *UI) + connected bool + want string + }{ + { + name: "typed line, not connected", send: typeLine, + connected: false, want: "[disconnected]", + }, + { + name: "Alt and 0, not connected", send: pressAltZero, + connected: false, want: "[disconnected]", + }, + { + name: "typed line, connected", send: typeLine, + connected: true, want: "[connected]", + }, + { + name: "Alt and 0, connected", send: pressAltZero, + connected: true, want: "[connected]", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + app := &App{ui: NewUI(), nick: testNick, connected: test.connected} + handled := handledLines(app) + runUI(t, app.ui) + + test.send(app.ui) + waitHandled(t, handled, line) + + status := statusBarText(app.ui) + if !strings.Contains(status, test.want) { + t.Fatalf("status bar is %q, want it to show %s", status, test.want) + } + }) + } +} + +// TestQueryConnectionState checks that typing /query bob leaves the +// status bar showing [connected] only while the client is connected, +// and [disconnected] otherwise. +func TestQueryConnectionState(t *testing.T) { + t.Parallel() + + const line = "/query " + testOtherNick + + tests := []struct { + name string + connected bool + want string + }{ + {name: "not connected", connected: false, want: statusDisconnected}, + {name: "connected", connected: true, want: statusConnected}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + app := &App{ui: NewUI(), nick: testNick, connected: test.connected} + handled := handledLines(app) + runUI(t, app.ui) + + for _, r := range line { + app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) + } + + app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + waitHandled(t, handled, line) + + status := statusBarText(app.ui) + if !strings.Contains(status, test.want) { + t.Fatalf("status bar is %q, want it to show %s", status, test.want) + } + }) + } +} + +// TestEventLoopKeepsRunning sends Alt and 1, then a typed line and +// Enter, to a running UI. It checks that both are handled, so the +// event loop did not stop on either. +func TestEventLoopKeepsRunning(t *testing.T) { + t.Parallel() + + app := &App{ui: NewUI(), nick: testNick} + handled := handledLines(app) + runUI(t, app.ui) + + // Window 1 is the channel, and window 0, the status window, is + // shown. The typed line adds a line to the status window, as + // the client is not connected. + app.ui.SwitchToBuffer(testChannel) + app.ui.SwitchBuffer(0) + + app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, '1', tcell.ModAlt)) + + for _, r := range testText { + app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone)) + } + + app.ui.app.QueueEvent(tcell.NewEventKey(tcell.KeyEnter, 0, tcell.ModNone)) + + waitHandled(t, handled, "/window 1") + waitHandled(t, handled, testText) +} + +// TestLinesKeepOrder holds the handling of a first line until a +// second line has been passed in. It fails if the second line is +// handled while the first is still held. +func TestLinesKeepOrder(t *testing.T) { + t.Parallel() + + const firstLine, secondLine = "one", "two" + + ui := NewUI() + handled := make(chan string) + release := make(chan struct{}) + + ui.OnInput(func(text string) { + if text == firstLine { + <-release + } + + handled <- text + }) + + ui.callOnInput(firstLine) + ui.callOnInput(secondLine) + + select { + case got := <-handled: + t.Fatalf("handled %q while the first line was held", got) + case <-time.After(holdTime): + } + + close(release) + waitHandled(t, handled, firstLine) + waitHandled(t, handled, secondLine) +} + +// TestSecondConnectStopsFirstPollLoop connects to one server, then +// to a second that holds every poll. Only the second connection's +// poll loop may poll the second server. +func TestSecondConnectStopsFirstPollLoop(t *testing.T) { + t.Parallel() + + const session = `{"id": 1, "nick": "erin"}` + + // The first server answers every request at once, so the first + // poll loop polls it until the second /connect. + first := newServer(t, session) + second, polled := newHoldingServer(t, session) + + app := &App{ui: NewUI(), nick: testNick} + runUI(t, app.ui) + + app.cmdConnect(first.URL) + app.cmdConnect(second.URL) + waitPoll(t, polled, "the second server") + + // The second connection's poll loop waits on its held poll, so + // another poll comes from the first loop. + select { + case <-polled: + t.Fatal("the first poll loop polled again after the second /connect") + case <-time.After(holdTime): + } + + // Stop the second poll loop as /quit does. /quit itself also + // stops the UI, and runUI's cleanup stopping it again is a data + // race inside tview. + app.mu.Lock() + close(app.stopPoll) + app.mu.Unlock() +} + +// TestSecondConnectIgnoresHeldPoll holds a poll of the earlier +// connection at its server while a second /connect runs, then +// answers it with a cursor and a message. It fails if that answer +// moves the cursor or shows the message. +func TestSecondConnectIgnoresHeldPoll(t *testing.T) { + t.Parallel() + + const session = `{"id": 1, "nick": "erin"}` + + const answer = `{"messages": [{"command": "PRIVMSG", "from": "bob", + "to": "#chan", "body": ["hello"]}], "last_id": 42}` + + // The first server sends on polled when the poll arrives, and + // answers it once release is closed. + polled := make(chan struct{}) + release := make(chan struct{}) + first := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + select { + case polled <- struct{}{}: + case <-request.Context().Done(): + return + } + + select { + case <-release: + _, _ = io.WriteString(writer, answer) + case <-request.Context().Done(): + } + }, + )) + t.Cleanup(first.Close) + t.Cleanup(first.CloseClientConnections) + + // The second server answers every request at once with the + // session. A poll answered with it has no cursor and no messages, + // so the second poll loop never moves the cursor. + second := newServer(t, session) + + // The test runs the earlier connection's poll loop itself, so + // that it can wait for the loop to end. + stop := make(chan struct{}) + app := &App{ + ui: NewUI(), + client: api.NewClient(first.URL), + nick: testNick, + connected: true, + stopPoll: stop, + } + runUI(t, app.ui) + + done := make(chan struct{}) + + go func() { + app.pollLoop(stop) + close(done) + }() + + select { + case <-polled: + case <-time.After(eventLoopTimeout): + t.Fatalf("the first server got no poll within %v", eventLoopTimeout) + } + + app.cmdConnect(second.URL) + close(release) + + select { + case <-done: + case <-time.After(eventLoopTimeout): + t.Fatalf("the earlier poll loop did not end within %v", eventLoopTimeout) + } + + app.mu.Lock() + cursor := app.lastQID + app.mu.Unlock() + + if cursor != 0 { + t.Errorf("the earlier connection's answer moved the cursor to %d", cursor) + } + + lines, _ := bufferState(app.ui, testChannel) + if lines != 0 { + t.Errorf("the earlier connection's message was shown in %s", testChannel) + } + + // Stop the second poll loop as /quit does, without stopping the UI. + app.mu.Lock() + close(app.stopPoll) + app.mu.Unlock() +} + +// TestSecondConnectAfterFailedPoll holds a poll of the earlier +// connection at its server while a second /connect runs, then +// fails it. A poll loop waits pollRetry after a failed poll, then +// polls again. The test fails if the earlier poll loop polls the +// second server. +func TestSecondConnectAfterFailedPoll(t *testing.T) { + t.Parallel() + + const session = `{"id": 1, "nick": "erin"}` + + first, firstPolled := newHoldingServer(t, session) + + // The second server holds the one poll of the second + // connection's poll loop, so any other poll comes from the + // earlier loop. + second, secondPolled := newHoldingServer(t, session) + + // The test runs the earlier connection's poll loop itself, so + // that it can wait for the loop to end. + stop := make(chan struct{}) + app := &App{ + ui: NewUI(), + client: api.NewClient(first.URL), + nick: testNick, + connected: true, + stopPoll: stop, + } + runUI(t, app.ui) + + done := make(chan struct{}) + + go func() { + app.pollLoop(stop) + close(done) + }() + + waitPoll(t, firstPolled, "the first server") + app.cmdConnect(second.URL) + waitPoll(t, secondPolled, "the second server") + + // Closing the held poll's connection makes that poll fail. + first.CloseClientConnections() + + select { + case <-done: + case <-secondPolled: + t.Fatal("the earlier poll loop polled the second server") + case <-time.After(eventLoopTimeout): + t.Fatalf("the earlier poll loop did not end within %v", eventLoopTimeout) + } + + // Stop the second poll loop as /quit does, without stopping the UI. + app.mu.Lock() + close(app.stopPoll) + app.mu.Unlock() +} + +// TestStartUp runs the client's start-up path on a simulation +// screen. It fails unless the event loop starts and the status +// window gets two lines: first the one that holds "Welcome to +// neoirc-cli", then the /connect hint. The status bar must show +// [disconnected]. +func TestStartUp(t *testing.T) { + t.Parallel() + + ui := NewUI() ui.app.SetScreen(tcell.NewSimulationScreen("")) - done := make(chan error) + // One slot, so that run's goroutine can end even after the + // cleanup below has stopped waiting for it. + done := make(chan error, 1) + + go func() { done <- run(ui) }() + + t.Cleanup(func() { + ui.Stop() + + select { + case err := <-done: + if err != nil { + t.Errorf("run: %v", err) + } + case <-time.After(eventLoopTimeout): + t.Error("run did not return after the UI was stopped") + } + }) + + // run adds the welcome lines on a goroutine, so the status + // window is read until it holds two lines. Each read waits for + // the event loop, so the reads run on a goroutine too, which + // this test stops waiting for after eventLoopTimeout. The + // channel has one slot, so that goroutine can still end. + shown := make(chan []string, 1) + + go func() { + for { + var lines []string + + ui.app.QueueUpdate(func() { + lines = slices.Clone(ui.getOrCreateBuffer("(status)").Lines) + }) + + if len(lines) == welcomeLines { + shown <- lines + + return + } + + time.Sleep(checkInterval) + } + }() + + var lines []string + + select { + case lines = <-shown: + case <-time.After(eventLoopTimeout): + t.Fatalf("the status window did not get the welcome lines within %v", + eventLoopTimeout) + } + + if !strings.Contains(lines[0], "Welcome to neoirc-cli") { + t.Errorf("first status line is %q, want the welcome line", lines[0]) + } + + if !strings.Contains(lines[1], "/connect ") { + t.Errorf("second status line is %q, want the /connect hint", lines[1]) + } + + // run sets the status bar before it adds the welcome lines. + status := statusBarText(ui) + if !strings.Contains(status, "[disconnected]") { + t.Errorf("status bar is %q, want it to show [disconnected]", status) + } +} + +// TestStatusBar checks that the status bar shows [connected], and +// shows a nick, target or window name with square brackets as it +// is. +func TestStatusBar(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + nick string + target string + unread string + want string + }{ + { + name: "plain names", + nick: testNick, + target: testChannel, + unread: testOtherNick, + want: " [connected] erin [1:#chan] #chan [Act: 2:bob(1)]", + }, + { + name: "nicks with brackets", + nick: "erin[away]", + target: "bob[m]", + unread: "dave[afk]", + want: " [connected] erin[away] [1:bob[m]] bob[m] [Act: 2:dave[afk](1)]", + }, + { + name: "nicks with brackets and symbols", + nick: "bob[|[]", + target: "x[^[]", + unread: testOtherNick, + want: " [connected] bob[|[] [1:x[^[]] x[^[] [Act: 2:bob(1)]", + }, + { + // /query takes any text as the target. + name: "query target with brackets", + nick: testNick, + target: "[:::y|z]", + unread: testOtherNick, + want: " [connected] erin [1:[:::y|z]] [:::y|z] [Act: 2:bob(1)]", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + ui := NewUI() + runUI(t, ui) + + // Window 1, the one shown, is the target's. Window 2 + // gets one unread line. + ui.SwitchToBuffer(test.target) + ui.AddLine(test.unread, testText) + ui.SetStatus(test.nick, test.target, "connected") + + got := statusBarText(ui) + if got != test.want { + t.Fatalf("status bar is %q, want %q", got, test.want) + } + }) + } +} + +// TestUnreadLine checks that a line added to a window that is not +// shown puts that window in the status bar's unread list at once, +// and that the status bar keeps the nick, target and connection +// state last set. +func TestUnreadLine(t *testing.T) { + t.Parallel() + + ui := NewUI() + runUI(t, ui) + + // Window 1, the one shown, is the channel's. The line goes to + // window 2 after SetStatus, so only AddLine can show it in the + // status bar. + ui.SwitchToBuffer(testChannel) + ui.SetStatus(testNick, testChannel, "connected") + ui.AddLine(testOtherNick, testText) + + const want = " [connected] erin [1:#chan] #chan [Act: 2:bob(1)]" + + got := statusBarText(ui) + if got != want { + t.Fatalf("status bar is %q, want %q", got, want) + } +} + +// TestOwnEventFromOtherClient checks that a message, notice, +// join or part that the user's session makes from another +// client shows as one line in the buffer it belongs to. +func TestOwnEventFromOtherClient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + event api.Message + buffer string + }{ + { + name: "channel message", + event: api.Message{ + Command: irc.CmdPrivmsg, From: testNick, To: testChannel, + Body: []string{testText}, + }, + buffer: testChannel, + }, + { + name: "direct message", + event: api.Message{ + Command: irc.CmdPrivmsg, From: testNick, To: testOtherNick, + Body: []string{testText}, + }, + buffer: testOtherNick, + }, + { + name: "notice", + event: api.Message{ + Command: irc.CmdNotice, From: testNick, To: testChannel, + Body: []string{testText}, + }, + buffer: "(status)", + }, + { + name: "join", + event: api.Message{ + Command: irc.CmdJoin, From: testNick, To: testChannel, + }, + buffer: testChannel, + }, + { + name: "part", + event: api.Message{ + Command: irc.CmdPart, From: testNick, To: testChannel, + }, + buffer: testChannel, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + app := &App{ui: NewUI(), nick: testNick} + runUI(t, app.ui) + + app.handleServerMessage(&test.event) + + lines, _ := bufferState(app.ui, test.buffer) + if lines != 1 { + t.Fatalf("got %d lines in %s, want 1", lines, test.buffer) + } + }) + } +} + +// TestBracketsInMessageWindow checks that each kind of line drawn +// from a server event shows nicks, channel names, texts, reasons +// and topics holding square brackets as written. +func TestBracketsInMessageWindow(t *testing.T) { + t.Parallel() + + // Every event is sent to this channel and has a body of one line. + const channel = "#chan[x]" + + tests := []struct { + name string + command string + from string + body string + // Whether the line goes to the channel's window rather than + // the status window, which is the one shown at start. + inChannel bool + // How the line ends, after the timestamps. + want string + }{ + { + name: "message", command: irc.CmdPrivmsg, inChannel: true, + from: "bob[|[]", body: "x[^[] [:::y|z] [red]", + want: " x[^[] [:::y|z] [red]", + }, + { + name: "joined", command: irc.CmdJoin, from: "x[^[]", inChannel: true, + want: "*** x[^[] has joined #chan[x]", + }, + { + name: "left", command: irc.CmdPart, inChannel: true, + from: "erin[away]", body: "[:::y|z]", + want: "*** erin[away] has left #chan[x] ([:::y|z])", + }, + { + name: "topic", command: irc.CmdTopic, inChannel: true, + from: "dave[afk]", body: "[red]news", + want: "*** dave[afk] set topic: [red]news", + }, + { + name: "quit", command: irc.CmdQuit, from: "carol[x]", body: "bye [red]", + want: "*** carol[x] has quit (bye [red])", + }, + { + name: "quit without reason", command: irc.CmdQuit, from: "frank[x]", + want: "*** frank[x] has quit", + }, + { + name: "nick change", command: irc.CmdNick, from: "erin[m]", body: "erin[x]", + want: "*** erin[m] is now known as erin[x]", + }, + { + name: "notice", command: irc.CmdNotice, from: "bob[x]", body: "[::b]note", + want: "--bob[x]-- [::b]note", + }, + { + name: "other command", command: irc.CmdMode, body: "+v dave[afk]", + want: "[MODE] +v dave[afk]", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + app := &App{ui: NewUI(), nick: testNick} + screen := runUI(t, app.ui) + + if test.inChannel { + app.ui.SwitchToBuffer(channel) + } + + app.handleServerMessage(&api.Message{ + Command: test.command, From: test.from, To: channel, + Body: []string{test.body}, + }) + + got, _ := shownLine(app.ui, screen, 0) + if !strings.HasSuffix(got, test.want) { + t.Fatalf("message window shows %q, want it to end %q", got, test.want) + } + }) + } +} + +// TestBracketsInCommandErrors checks that when the server refuses +// a typed command, the error the client shows holds the server's +// answer as written. +func TestBracketsInCommandErrors(t *testing.T) { + t.Parallel() + + server := newRefusingServer(t) + + tests := []struct { + typed string + // The start of the line, before the error. + reply string + }{ + {typed: testText, reply: "Send error"}, + {typed: "/nick bob", reply: "Nick change failed"}, + {typed: "/join #x", reply: "Join failed"}, + {typed: "/part", reply: "Part failed"}, + {typed: "/msg bob hi", reply: "Send failed"}, + {typed: "/topic hi", reply: "Topic set failed"}, + {typed: "/names", reply: "Names failed"}, + {typed: "/list", reply: "List failed"}, + {typed: "/motd", reply: "MOTD failed"}, + {typed: "/who", reply: "WHO failed"}, + {typed: "/whois bob", reply: "WHOIS failed"}, + } + + for _, test := range tests { + t.Run(test.reply, func(t *testing.T) { + t.Parallel() + + app := &App{ + ui: NewUI(), + client: api.NewClient(server.URL), + nick: testNick, + target: testChannel, + connected: true, + } + screen := runUI(t, app.ui) + + app.handleInput(test.typed) + + want := test.reply + ": HTTP error 400: [red]no" + + got, _ := shownLine(app.ui, screen, 0) + if !strings.HasSuffix(got, want) { + t.Fatalf("message window shows %q, want it to end %q", got, want) + } + }) + } +} + +// TestBracketsInCommandReplies checks that the other lines the +// client draws for a typed command show square brackets as +// written, whether they come from the typed line or the server. +func TestBracketsInCommandReplies(t *testing.T) { + t.Parallel() + + const channel = "#chan[x]" + + refusing := newRefusingServer(t) + session := newServer(t, `{"id": 1, "nick": "erin[x]"}`) + + tests := []struct { + name string + // The server the client is connected to before the line is + // typed, if any. + server *httptest.Server + typed string + // Whether the line goes to the channel's window rather than + // the status window, and which line of the window it is, + // counting from 0. + inChannel bool + line int + want string + }{ + {name: "unknown command", typed: "/x[red]", want: "Unknown command: /x[red]"}, + { + name: "nick set", typed: "/nick erin[x]", + want: "Nick set to erin[x] (will be used on connect)", + }, + { + name: "nick changed", server: session, typed: "/nick erin[x]", + want: "Nick changed to erin[x]", + }, + { + name: "connecting", typed: "/connect " + refusing.URL + "/[x]", + want: "Connecting to " + refusing.URL + "/[x]...", + }, + { + name: "connection failed", typed: "/connect " + refusing.URL, line: 1, + want: "Connection failed: HTTP error 400: [red]no", + }, + { + name: "connected", typed: "/connect " + session.URL, line: 1, + want: "Connected! Nick: erin[x], Session: 1", + }, + { + name: "members", server: newServer(t, `[{"nick": "bob[x]"}]`), + typed: "/names", inChannel: true, want: "*** Members of #chan[x]: bob[x]", + }, + { + name: "channel list", typed: "/list", line: 1, want: " #chan[x] [red]news", + server: newServer(t, `[{"name": "#chan[x]", "topic": "[red]news"}]`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + app := &App{ui: NewUI(), nick: testNick, target: channel} + + if test.server != nil { + app.client = api.NewClient(test.server.URL) + app.connected = true + } + + screen := runUI(t, app.ui) + + // Stop the poll loop a /connect starts, as /quit does. + // Cleanups run last first, so this runs before the UI + // stops. + t.Cleanup(func() { + app.mu.Lock() + defer app.mu.Unlock() + + if app.stopPoll != nil { + close(app.stopPoll) + } + }) + + if test.inChannel { + app.ui.SwitchToBuffer(channel) + } + + app.handleInput(test.typed) + + got, _ := shownLine(app.ui, screen, test.line) + if !strings.HasSuffix(got, test.want) { + t.Fatalf("message window shows %q, want it to end %q", got, test.want) + } + }) + } +} + +// TestColourTagInMessage checks that a colour tag in a message +// shows as written and leaves the message text white, as the +// client draws it. +func TestColourTagInMessage(t *testing.T) { + t.Parallel() + + const want = " [red]x" + + app := &App{ui: NewUI(), nick: testNick} + screen := runUI(t, app.ui) + app.ui.SwitchToBuffer(testChannel) + + app.handleServerMessage(&api.Message{ + Command: irc.CmdPrivmsg, From: testOtherNick, To: testChannel, + Body: []string{"[red]x"}, + }) + + got, colour := shownLine(app.ui, screen, 0) + if !strings.HasSuffix(got, want) || colour != tcell.ColorWhite { + t.Fatalf("message window shows %q, its last character in %v; "+ + "want it to end %q, in white", got, colour, want) + } +} + +// bufferState returns the number of lines in the named +// buffer and how many of them are unread. +func bufferState(ui *UI, name string) (int, int) { + var lines, unread int + + ui.app.QueueUpdate(func() { + buf := ui.getOrCreateBuffer(name) + lines = len(buf.Lines) + unread = buf.Unread + }) + + return lines, unread +} + +// statusBarText returns the text the status bar shows. +// GetText(true) leaves out whatever tview reads as a tag, as +// drawing does. +func statusBarText(ui *UI) string { + var text string + + ui.app.QueueUpdate(func() { + text = ui.statusBar.GetText(true) + }) + + return text +} + +// shownLine returns the given line of the message window, counting +// from 0, as the screen shows it, without the blanks after it, and +// the colour of its last character. Each line in these tests takes +// one row of the screen. +func shownLine( + ui *UI, screen tcell.SimulationScreen, line int, +) (string, tcell.Color) { + var ( + text string + colour tcell.Color + ) + + ui.app.QueueUpdate(func() { + cells, width, _ := screen.GetContents() + cells = cells[line*width : (line+1)*width] + + var row strings.Builder + + for _, cell := range cells { + row.WriteString(string(cell.Runes)) + } + + text = strings.TrimRight(row.String(), " ") + if text == "" { + return + } + + // Each character in these tests takes one cell. + last := cells[utf8.RuneCountInString(text)-1] + colour, _, _ = last.Style.Decompose() + }) + + return text, colour +} + +// handledLines makes app.ui pass each typed line to +// app.handleInput, as Run does, and returns a channel that +// receives each line once it has been handled. +func handledLines(app *App) <-chan string { + handled := make(chan string) + + app.ui.OnInput(func(text string) { + app.handleInput(text) + + handled <- text + }) + + return handled +} + +// waitHandled fails the test unless want is the next line handled, +// within eventLoopTimeout. +func waitHandled(t *testing.T, handled <-chan string, want string) { + t.Helper() + + select { + case got := <-handled: + if got != want { + t.Fatalf("handled %q, want %q", got, want) + } + case <-time.After(eventLoopTimeout): + t.Fatalf("%q was not handled within %v", want, eventLoopTimeout) + } +} + +// newServer starts a server that answers every request with +// body until the test ends. +func newServer(t *testing.T, body string) *httptest.Server { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(writer, body) + }, + )) + t.Cleanup(server.Close) + + return server +} + +// newRefusingServer starts a server that answers every request +// with the status 400 Bad Request and the body "[red]no" until +// the test ends. The client shows that body in its error. +func newRefusingServer(t *testing.T) *httptest.Server { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(writer, "[red]no") + }, + )) + t.Cleanup(server.Close) + + return server +} + +// newHoldingServer starts a server that sends on the returned +// channel for each poll and holds the poll until its connection +// closes. It answers any other request with body. +func newHoldingServer( + t *testing.T, body string, +) (*httptest.Server, <-chan struct{}) { + t.Helper() + + polled := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc( + func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || + request.URL.Path != "/api/v1/messages" { + _, _ = io.WriteString(writer, body) + + return + } + + select { + case polled <- struct{}{}: + case <-request.Context().Done(): + } + + <-request.Context().Done() + }, + )) + // Close waits for the held polls, which end only once their + // connections close. Cleanups run last first, so the connections + // close before Close runs. + t.Cleanup(server.Close) + t.Cleanup(server.CloseClientConnections) + + return server, polled +} + +// waitPoll fails the test unless server gets a poll, sent on +// polled, within eventLoopTimeout. +func waitPoll(t *testing.T, polled <-chan struct{}, server string) { + t.Helper() + + select { + case <-polled: + case <-time.After(eventLoopTimeout): + t.Fatalf("%s got no poll within %v", server, eventLoopTimeout) + } +} + +// runUI runs ui on a simulated screen until the test ends, and +// returns the screen. +// +//nolint:ireturn // tcell has no exported type for the screen +func runUI(t *testing.T, ui *UI) tcell.SimulationScreen { + t.Helper() + + screen := tcell.NewSimulationScreen("") + ui.app.SetScreen(screen) + + // One slot, so that Run's goroutine can end even after the + // cleanup below has stopped waiting for it. + done := make(chan error, 1) go func() { done <- ui.Run() }() t.Cleanup(func() { ui.Stop() - err := <-done - if err != nil { - t.Errorf("run ui: %v", err) + select { + case err := <-done: + if err != nil { + t.Errorf("run ui: %v", err) + } + case <-time.After(eventLoopTimeout): + t.Error("the UI did not stop: its event loop stopped responding") } }) + + return screen } diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 80f5fc9..80708a6 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -16,7 +16,10 @@ type Buffer struct { Unread int } -// UI manages the terminal interface. +// UI manages the terminal interface. Most of its methods wait for +// the tview event loop to run their update. Code that runs on that +// loop, such as a key handler, must not call them: it would wait +// for itself. type UI struct { app *tview.Application messages *tview.TextView @@ -27,7 +30,17 @@ type UI struct { buffers []*Buffer currentBuffer int + // The nick, target and connection state last passed to + // SetStatus. + nick string + target string + connStatus string + onInput func(string) + // inputDone is closed once the line last passed to onInput + // has been handled. Only callOnInput, on the event loop, uses + // it. + inputDone chan struct{} } // NewUI creates the tview-based IRC-like UI. @@ -95,6 +108,19 @@ func (ui *UI) AddLine(bufferName, line string) { }) } +// AddLineAsRead adds a line to the specified buffer, as +// AddLine does, but does not mark the buffer unread. +func (ui *UI) AddLineAsRead(bufferName, line string) { + ui.app.QueueUpdateDraw(func() { + buf := ui.getOrCreateBuffer(bufferName) + buf.Lines = append(buf.Lines, line) + + if ui.buffers[ui.currentBuffer] == buf { + _, _ = fmt.Fprintln(ui.messages, line) + } + }) +} + // AddStatus adds a line to the status buffer. func (ui *UI) AddStatus(line string) { ts := time.Now().Format("15:04") @@ -159,13 +185,32 @@ func (ui *UI) SetStatus( nick, target, connStatus string, ) { ui.app.QueueUpdateDraw(func() { - ui.renderStatusBar(nick, target, connStatus) + ui.nick = nick + ui.target = target + ui.connStatus = connStatus + + ui.refreshStatusBar() }) } -// BufferCount returns the number of buffers. -func (ui *UI) BufferCount() int { - return len(ui.buffers) +// BufferName returns the name of the buffer at index bufIndex, +// and whether there is such a buffer. +func (ui *UI) BufferName(bufIndex int) (string, bool) { + var ( + name string + found bool + ) + + ui.app.QueueUpdate(func() { + if bufIndex < 0 || bufIndex >= len(ui.buffers) { + return + } + + name = ui.buffers[bufIndex].Name + found = true + }) + + return name, found } // BufferIndex returns the index of a named buffer. @@ -190,9 +235,21 @@ func (ui *UI) initMessages() { ui.messages.SetBorder(false) } +// escape returns text so that the message window shows it exactly +// as written. It writes each "[" as "[[:]": a "[" followed by +// another "[" never starts a tag, and the tag "[:]" changes +// nothing. tview.Escape is not enough: it does not escape a bracket +// pair holding a symbol such as "|" or "^", and nothing it adds +// stops a tag with a URL, such as "[:::y|z]". +func escape(text string) string { + return strings.ReplaceAll(text, "[", "[[:]") +} + func (ui *UI) initStatusBar() { - ui.statusBar = tview.NewTextView(). - SetDynamicColors(true) + // Colour tags stay off. With them on, tview reads text in + // square brackets, such as "[connected]" or part of a nick, + // as a tag and drops it. + ui.statusBar = tview.NewTextView() ui.statusBar.SetBackgroundColor(tcell.ColorNavy) ui.statusBar.SetTextColor(tcell.ColorWhite) } @@ -213,10 +270,7 @@ func (ui *UI) initInput() { } ui.input.SetText("") - - if ui.onInput != nil { - ui.onInput(text) - } + ui.callOnInput(text) }) } @@ -227,10 +281,11 @@ func (ui *UI) initKeyCapture() { return event } + // Alt and a digit runs /window with that digit. + // SwitchBuffer alone would leave the target as it was. r := event.Rune() if r >= '0' && r <= '9' { - idx := int(r - '0') - ui.SwitchBuffer(idx) + ui.callOnInput("/window " + string(r)) return nil } @@ -240,8 +295,35 @@ func (ui *UI) initKeyCapture() { ) } +// callOnInput passes text to the input callback on a new +// goroutine. The key handlers call it on the event loop, and the +// callback waits for that loop to update the screen, so they must +// not wait for the callback. The goroutine first waits for the +// line before it to be handled, which keeps lines in the order +// they were typed. +func (ui *UI) callOnInput(text string) { + if ui.onInput == nil { + return + } + + previous := ui.inputDone + done := make(chan struct{}) + ui.inputDone = done + + go func() { + if previous != nil { + <-previous + } + + ui.onInput(text) + close(done) + }() +} + +// refreshStatusBar redraws the status bar with the nick, target +// and connection state last passed to SetStatus. func (ui *UI) refreshStatusBar() { - // Placeholder; full refresh needs nick/target context. + ui.renderStatusBar(ui.nick, ui.target, ui.connStatus) } func (ui *UI) renderStatusBar( diff --git a/internal/config/config.go b/internal/config/config.go index 23f85b6..f345dac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,8 +3,12 @@ package config import ( "errors" + "fmt" "log/slog" + "math" "os" + "strconv" + "time" "github.com/spf13/viper" "go.uber.org/fx" @@ -28,6 +32,8 @@ Type /help for available commands.` const DefaultDBURL = "file:///var/lib/neoirc/state.db" + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)" +var errOutOfRange = errors.New("setting out of range") + // Params defines the dependencies for creating a Config. type Params struct { fx.In @@ -63,6 +69,8 @@ type Config struct { } // New creates a new Config by reading from files and environment variables. +// It returns an error when a number, true/false or duration setting does +// not parse or is out of range. func New( _ fx.Lifecycle, params Params, ) (*Config, error) { @@ -107,29 +115,27 @@ func New( cfg := &Config{ DBURL: viper.GetString("DBURL"), - Debug: viper.GetBool("DEBUG"), - Port: viper.GetInt("PORT"), SentryDSN: viper.GetString("SENTRY_DSN"), - MaintenanceMode: viper.GetBool("MAINTENANCE_MODE"), MetricsUsername: viper.GetString("METRICS_USERNAME"), MetricsPassword: viper.GetString("METRICS_PASSWORD"), MessageMaxAge: viper.GetString("MESSAGE_MAX_AGE"), - MaxMessageSize: viper.GetInt("MAX_MESSAGE_SIZE"), QueueMaxAge: viper.GetString("QUEUE_MAX_AGE"), MOTD: viper.GetString("MOTD"), ServerName: viper.GetString("SERVER_NAME"), FederationKey: viper.GetString("FEDERATION_KEY"), SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), - HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"), OperName: viper.GetString("NEOIRC_OPER_NAME"), OperPassword: viper.GetString("NEOIRC_OPER_PASSWORD"), - LoginRateLimit: viper.GetFloat64("LOGIN_RATE_LIMIT"), - LoginRateBurst: viper.GetInt("LOGIN_RATE_BURST"), IRCListenAddr: viper.GetString("IRC_LISTEN_ADDR"), log: log, params: ¶ms, } + err = parseSettings(cfg) + if err != nil { + return nil, err + } + // Viper counts an empty environment variable as unset, but an // empty IRC_LISTEN_ADDR must turn the IRC listener off. ircListenAddr, set := os.LookupEnv("IRC_LISTEN_ADDR") @@ -144,3 +150,93 @@ func New( return cfg, nil } + +// parseSettings reads the number and true/false settings into cfg and +// checks the duration settings. Each number and duration must be above +// zero. PORT and NEOIRC_HASHCASH_BITS may also be zero: port 0 lets the +// system pick a port, and 0 bits turns proof of work off. The error +// names each setting that does not parse or is out of range. +func parseSettings(cfg *Config) error { + return errors.Join( + parseInt("PORT", 0, &cfg.Port), + parseInt("MAX_MESSAGE_SIZE", 1, &cfg.MaxMessageSize), + parseInt("NEOIRC_HASHCASH_BITS", 0, &cfg.HashcashBits), + parseInt("LOGIN_RATE_BURST", 1, &cfg.LoginRateBurst), + parseFloat("LOGIN_RATE_LIMIT", &cfg.LoginRateLimit), + parseBool("DEBUG", &cfg.Debug), + parseBool("MAINTENANCE_MODE", &cfg.MaintenanceMode), + checkDuration("MESSAGE_MAX_AGE", cfg.MessageMaxAge), + checkDuration("QUEUE_MAX_AGE", cfg.QueueMaxAge), + checkDuration("SESSION_IDLE_TIMEOUT", cfg.SessionIdleTimeout), + ) +} + +// parseInt sets *value to the setting key read as a whole number. It +// returns an error when the value does not parse or is below minimum. +func parseInt(key string, minimum int, value *int) error { + raw := viper.GetString(key) + + parsed, err := strconv.Atoi(raw) + if err != nil { + return fmt.Errorf("cannot parse %s: %w", key, err) + } + + if parsed < minimum { + return fmt.Errorf("%w: %s is %s, but must be at least %d", + errOutOfRange, key, raw, minimum) + } + + *value = parsed + + return nil +} + +// parseFloat sets *value to the setting key read as a floating-point +// number. It returns an error when the value does not parse or is not a +// finite number above zero. +func parseFloat(key string, value *float64) error { + raw := viper.GetString(key) + + parsed, err := strconv.ParseFloat(raw, 64) + if err != nil { + return fmt.Errorf("cannot parse %s: %w", key, err) + } + + if math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 { + return fmt.Errorf( + "%w: %s is %s, but must be a finite number above zero", + errOutOfRange, key, raw) + } + + *value = parsed + + return nil +} + +// parseBool sets *value to the setting key read as true or false. +func parseBool(key string, value *bool) error { + parsed, err := strconv.ParseBool(viper.GetString(key)) + if err != nil { + return fmt.Errorf("cannot parse %s: %w", key, err) + } + + *value = parsed + + return nil +} + +// checkDuration returns an error naming the setting key when value is +// not a Go duration above zero. +func checkDuration(key, value string) error { + duration, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("cannot parse %s: %w", key, err) + } + + if duration <= 0 { + return fmt.Errorf("%w: %s is %s, but must be above zero", + errOutOfRange, key, value) + } + + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index fc8163a..dc57b38 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,6 +2,7 @@ package config_test import ( "os" + "strings" "testing" "time" @@ -10,7 +11,8 @@ import ( "sneak.berlin/go/neoirc/internal/logger" ) -func newConfig(t *testing.T) *config.Config { +// loadConfig runs config.New and returns its result. +func loadConfig(t *testing.T) (*config.Config, error) { t.Helper() globs := &globals.Globals{ @@ -24,9 +26,15 @@ func newConfig(t *testing.T) *config.Config { t.Fatal(err) } - cfg, err := config.New( + return config.New( nil, config.Params{Globals: globs, Logger: log}, ) +} + +func newConfig(t *testing.T) *config.Config { + t.Helper() + + cfg, err := loadConfig(t) if err != nil { t.Fatal(err) } @@ -34,6 +42,145 @@ func newConfig(t *testing.T) *config.Config { return cfg } +func TestUnsetSettingsKeepDefaults(t *testing.T) { + // Viper counts an empty environment variable as unset. + t.Setenv("PORT", "") + t.Setenv("MAX_MESSAGE_SIZE", "") + t.Setenv("NEOIRC_HASHCASH_BITS", "") + t.Setenv("LOGIN_RATE_BURST", "") + t.Setenv("LOGIN_RATE_LIMIT", "") + t.Setenv("DEBUG", "") + t.Setenv("MAINTENANCE_MODE", "") + t.Setenv("MESSAGE_MAX_AGE", "") + t.Setenv("QUEUE_MAX_AGE", "") + t.Setenv("SESSION_IDLE_TIMEOUT", "") + + // Each duration setting defaults to 30 days. + const thirtyDays = "720h" + + want := settings{ + Port: 8080, + MaxMessageSize: 4096, + HashcashBits: 20, + LoginRateBurst: 5, + LoginRateLimit: 1, + Debug: false, + MaintenanceMode: false, + MessageMaxAge: thirtyDays, + QueueMaxAge: thirtyDays, + SessionIdleTimeout: thirtyDays, + } + + got := settingsOf(newConfig(t)) + if got != want { + t.Errorf("settings = %+v, want %+v", got, want) + } +} + +func TestValidSettingsAreUsed(t *testing.T) { + // Each whole number is the lowest value its setting allows, and 1ns + // is the shortest duration allowed. + t.Setenv("PORT", "0") + t.Setenv("MAX_MESSAGE_SIZE", "1") + t.Setenv("NEOIRC_HASHCASH_BITS", "0") + t.Setenv("LOGIN_RATE_BURST", "1") + t.Setenv("LOGIN_RATE_LIMIT", "0.5") + t.Setenv("DEBUG", "true") + t.Setenv("MAINTENANCE_MODE", "true") + t.Setenv("MESSAGE_MAX_AGE", "1ns") + t.Setenv("QUEUE_MAX_AGE", "1h30m") + t.Setenv("SESSION_IDLE_TIMEOUT", "45m") + + want := settings{ + Port: 0, + MaxMessageSize: 1, + HashcashBits: 0, + LoginRateBurst: 1, + LoginRateLimit: 0.5, + Debug: true, + MaintenanceMode: true, + MessageMaxAge: "1ns", + QueueMaxAge: "1h30m", + SessionIdleTimeout: "45m", + } + + got := settingsOf(newConfig(t)) + if got != want { + t.Errorf("settings = %+v, want %+v", got, want) + } +} + +// settings holds the Config fields whose settings config.New parses. +type settings struct { + Port int + MaxMessageSize int + HashcashBits int + LoginRateBurst int + LoginRateLimit float64 + Debug bool + MaintenanceMode bool + MessageMaxAge string + QueueMaxAge string + SessionIdleTimeout string +} + +func settingsOf(cfg *config.Config) settings { + return settings{ + Port: cfg.Port, + MaxMessageSize: cfg.MaxMessageSize, + HashcashBits: cfg.HashcashBits, + LoginRateBurst: cfg.LoginRateBurst, + LoginRateLimit: cfg.LoginRateLimit, + Debug: cfg.Debug, + MaintenanceMode: cfg.MaintenanceMode, + MessageMaxAge: cfg.MessageMaxAge, + QueueMaxAge: cfg.QueueMaxAge, + SessionIdleTimeout: cfg.SessionIdleTimeout, + } +} + +func TestBadSettingStopsStartup(t *testing.T) { + // For each setting, the first value does not parse and the others + // parse but are out of range. + tests := []struct { + key string + values []string + }{ + {key: "PORT", values: []string{"80a", "-1"}}, + {key: "MAX_MESSAGE_SIZE", values: []string{"4k", "0", "-4096"}}, + {key: "NEOIRC_HASHCASH_BITS", values: []string{"twenty", "-1"}}, + {key: "LOGIN_RATE_BURST", values: []string{"five", "0", "-5"}}, + {key: "LOGIN_RATE_LIMIT", values: []string{ + "fast", "0", "-1", "NaN", "Inf", "-Inf", + }}, + {key: "DEBUG", values: []string{"yes"}}, + {key: "MAINTENANCE_MODE", values: []string{"on"}}, + {key: "MESSAGE_MAX_AGE", values: []string{"30d", "0", "-1h"}}, + {key: "QUEUE_MAX_AGE", values: []string{"720", "0s", "-24h"}}, + {key: "SESSION_IDLE_TIMEOUT", values: []string{"a month", "0", "-1m"}}, + } + + for _, test := range tests { + for _, value := range test.values { + t.Run(test.key+"="+value, func(t *testing.T) { + t.Setenv(test.key, value) + + _, err := loadConfig(t) + if err == nil { + t.Fatalf("%s=%q: got no error", test.key, value) + } + + msg := err.Error() + if !strings.Contains(msg, test.key) || + !strings.Contains(msg, value) { + t.Errorf("error %q does not name %s and %q", + msg, test.key, value) + } + }) + } + } +} + func TestIRCListenAddrUnsetKeepsDefault(t *testing.T) { // t.Setenv puts back whatever value was there after the test. t.Setenv("IRC_LISTEN_ADDR", "") diff --git a/internal/db/auth.go b/internal/db/auth.go index 4927853..03e8999 100644 --- a/internal/db/auth.go +++ b/internal/db/auth.go @@ -10,13 +10,6 @@ import ( "golang.org/x/crypto/bcrypt" ) -//nolint:gochecknoglobals // var so tests can override via SetBcryptCost -var bcryptCost = bcrypt.DefaultCost - -// SetBcryptCost overrides the bcrypt cost. -// Use bcrypt.MinCost in tests to avoid slow hashing. -func SetBcryptCost(cost int) { bcryptCost = cost } - var errNoPassword = errors.New( "account has no password set", ) @@ -29,7 +22,7 @@ func (database *Database) SetPassword( password string, ) error { hash, err := bcrypt.GenerateFromPassword( - []byte(password), bcryptCost, + []byte(password), bcrypt.DefaultCost, ) if err != nil { return fmt.Errorf("hash password: %w", err) diff --git a/internal/db/db.go b/internal/db/db.go index e2dce3a..922ee2e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -5,9 +5,11 @@ import ( "context" "database/sql" "embed" + "errors" "fmt" "io/fs" "log/slog" + "net/url" "sort" "strconv" "strings" @@ -22,6 +24,10 @@ import ( const minMigrationParts = 2 +var errForeignKeysInDBURL = errors.New( + "DBURL must not set foreign_keys, which the server always turns on", +) + // SchemaFiles contains embedded SQL migration files. // //go:embed schema/*.sql @@ -79,17 +85,39 @@ func New( return database, nil } -// GetDB returns the underlying sql.DB connection. -func (database *Database) GetDB() *sql.DB { - return database.conn -} - func (database *Database) connect(ctx context.Context) error { dbURL := database.params.Config.DBURL if dbURL == "" { dbURL = config.DefaultDBURL } + // SQLite keeps the foreign keys setting per connection. The driver + // runs each _pragma parameter on every connection it opens. So the + // one added below also covers the connection the pool opens after + // a cancelled query breaks the old one. A foreign_keys pragma in + // DBURL could undo it, because the driver sorts the pragmas. + _, query, hasQuery := strings.Cut(dbURL, "?") + + urlParams, err := url.ParseQuery(query) + if err != nil { + return fmt.Errorf("parse DBURL: %w", err) + } + + for _, pragma := range urlParams["_pragma"] { + if strings.Contains(strings.ToLower(pragma), "foreign_keys") { + return fmt.Errorf( + "%w: _pragma=%s", errForeignKeysInDBURL, pragma, + ) + } + } + + separator := "?" + if hasQuery { + separator = "&" + } + + dbURL += separator + "_pragma=foreign_keys(1)" + database.log.Info( "connecting to database", "url", dbURL, ) @@ -109,13 +137,6 @@ func (database *Database) connect(ctx context.Context) error { database.conn = conn database.log.Info("database connected") - _, err = database.conn.ExecContext( - ctx, "PRAGMA foreign_keys = ON", - ) - if err != nil { - return fmt.Errorf("enable foreign keys: %w", err) - } - return database.runMigrations(ctx) } diff --git a/internal/db/db_test.go b/internal/db/db_test.go index c79fdfc..6d10283 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -1,19 +1,34 @@ package db_test import ( + "context" + "errors" + "net/url" "path/filepath" "strings" "testing" + "time" "go.uber.org/fx/fxtest" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" + "sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/logger" ) const wantBusyTimeoutMS = 5000 +const ( + // endlessQuery counts the rows of a recursive query that never + // stops, so it runs until its context ends. + endlessQuery = `WITH RECURSIVE n(x) AS + (SELECT 1 UNION ALL SELECT x + 1 FROM n) + SELECT COUNT(*) FROM n` + + endlessQueryTimeout = 100 * time.Millisecond +) + // TestDefaultDBURLSetsWALAndBusyTimeout opens a file database // with the default URL's parameters the way the server does, // and checks the journal mode and busy timeout it ends up with. @@ -73,3 +88,191 @@ func TestDefaultDBURLSetsWALAndBusyTimeout(t *testing.T) { ) } } + +// TestForeignKeysOnAfterCancelledQuery cancels a query while it +// runs, which makes the pool close its only connection and open a +// new one. A TEMP table lasts only as long as its connection, so +// its absence shows the connection was replaced. The test then +// checks that the file database keeps its tables and that the new +// connection has foreign keys on. +func TestForeignKeysOnAfterCancelledQuery(t *testing.T) { + t.Parallel() + + _, defaultParams, _ := strings.Cut(config.DefaultDBURL, "?") + + urlParams := map[string]string{ + "default parameters": "?" + defaultParams, + "no parameters": "", + } + + for name, params := range urlParams { + t.Run(name, func(t *testing.T) { + t.Parallel() + + dbURL := "file://" + + filepath.Join(t.TempDir(), "state.db") + params + + database, err := startDatabase(t, dbURL) + if err != nil { + t.Fatal(err) + } + + _, err = database.GetDB().ExecContext( + t.Context(), "CREATE TEMP TABLE first_connection (x)", + ) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout( + t.Context(), endlessQueryTimeout, + ) + defer cancel() + + _, err = database.GetDB().ExecContext(ctx, endlessQuery) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("endless query: err = %v", err) + } + + tempTables := queryInt( + t, database, "SELECT COUNT(*) FROM sqlite_temp_schema", + ) + if tempTables != 0 { + t.Fatal("the pool kept its first connection") + } + + migrations := queryInt( + t, database, "SELECT COUNT(*) FROM schema_migrations", + ) + if migrations == 0 { + t.Error("schema_migrations is empty") + } + + foreignKeys := queryInt(t, database, "PRAGMA foreign_keys") + if foreignKeys != 1 { + t.Errorf("foreign_keys = %d, want 1", foreignKeys) + } + }) + } +} + +// TestNewURLKeepsDataAfterCancelledQuery cancels a query on a test +// database while it runs, which makes the pool close its only +// connection and open a new one. A TEMP table lasts only as long as +// its connection, so its absence shows the connection was replaced. +// The database must still have its tables and rows. +func TestNewURLKeepsDataAfterCancelledQuery(t *testing.T) { + t.Parallel() + + database, err := startDatabase(t, dbtest.NewURL(t)) + if err != nil { + t.Fatal(err) + } + + _, err = database.GetOrCreateChannel(t.Context(), "#test") + if err != nil { + t.Fatal(err) + } + + _, err = database.GetDB().ExecContext( + t.Context(), "CREATE TEMP TABLE first_connection (x)", + ) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(t.Context(), endlessQueryTimeout) + defer cancel() + + _, err = database.GetDB().ExecContext(ctx, endlessQuery) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("endless query: err = %v", err) + } + + tempTables := queryInt( + t, database, "SELECT COUNT(*) FROM sqlite_temp_schema", + ) + if tempTables != 0 { + t.Fatal("the pool kept its first connection") + } + + channels := queryInt(t, database, "SELECT COUNT(*) FROM channels") + if channels != 1 { + t.Errorf("channels = %d, want 1", channels) + } +} + +// TestDBURLSettingForeignKeysFailsStartup checks that the database +// refuses to start when DBURL sets foreign_keys itself, however it +// is spelled. +func TestDBURLSettingForeignKeysFailsStartup(t *testing.T) { + t.Parallel() + + pragmas := []string{ + "foreign_keys(0)", + "foreign_keys(off)", + "foreign_keys=0", + "FOREIGN_KEYS(FALSE)", + " Foreign_Keys = 1", + } + + for _, pragma := range pragmas { + t.Run(pragma, func(t *testing.T) { + t.Parallel() + + dbURL := "file://" + + filepath.Join(t.TempDir(), "state.db") + + "?_pragma=" + url.QueryEscape(pragma) + + _, err := startDatabase(t, dbURL) + if err == nil { + t.Fatal("the database started") + } + + if !strings.Contains(err.Error(), "DBURL") { + t.Errorf("error %q does not name DBURL", err) + } + }) + } +} + +// startDatabase starts a Database for dbURL the way the server does. +// It returns the error that stopped it from starting, if any. +func startDatabase(t *testing.T, dbURL string) (*db.Database, error) { + t.Helper() + + lifecycle := fxtest.NewLifecycle(t) + + log, err := logger.New(lifecycle, logger.Params{ + Globals: &globals.Globals{}, + }) + if err != nil { + t.Fatal(err) + } + + database, err := db.New(lifecycle, db.Params{ + Logger: log, + Config: &config.Config{DBURL: dbURL}, + }) + if err != nil { + t.Fatal(err) + } + + t.Cleanup(lifecycle.RequireStop) + + return database, lifecycle.Start(t.Context()) +} + +// queryInt runs a query that returns a single number. +func queryInt(t *testing.T, database *db.Database, query string) int { + t.Helper() + + var result int + + err := database.GetDB().QueryRowContext(t.Context(), query).Scan(&result) + if err != nil { + t.Fatal(err) + } + + return result +} diff --git a/internal/db/export_test.go b/internal/db/export_test.go index f233380..f24537c 100644 --- a/internal/db/export_test.go +++ b/internal/db/export_test.go @@ -5,21 +5,17 @@ import ( "database/sql" "fmt" "log/slog" - "sync/atomic" + "testing" + "time" + + "sneak.berlin/go/neoirc/internal/dbtest" ) -//nolint:gochecknoglobals // test counter -var testDBCounter atomic.Int64 - // NewTestDatabase creates an in-memory database for testing. -func NewTestDatabase() (*Database, error) { - counter := testDBCounter.Add(1) +func NewTestDatabase(tb testing.TB) (*Database, error) { + tb.Helper() - dsn := fmt.Sprintf( - "file:testdb%d?mode=memory"+ - "&cache=shared&_pragma=foreign_keys(1)", - counter, - ) + dsn := dbtest.NewURL(tb) + "&_pragma=foreign_keys(1)" conn, err := sql.Open("sqlite", dsn) if err != nil { @@ -72,3 +68,78 @@ func (database *Database) ExecForTest( return nil } + +// GetDB returns the underlying sql.DB connection. +func (database *Database) GetDB() *sql.DB { + return database.conn +} + +// GetOrCreateChannel returns channel id, creating if needed. +// Uses INSERT OR IGNORE to avoid TOCTOU races. +func (database *Database) GetOrCreateChannel( + ctx context.Context, + name string, +) (int64, error) { + now := time.Now() + + _, err := database.conn.ExecContext(ctx, + `INSERT OR IGNORE INTO channels + (name, created_at, updated_at) + VALUES (?, ?, ?)`, + name, now, now) + if err != nil { + return 0, fmt.Errorf("create channel: %w", err) + } + + var channelID int64 + + err = database.conn.QueryRowContext( + ctx, + "SELECT id FROM channels WHERE name = ?", + name, + ).Scan(&channelID) + if err != nil { + return 0, fmt.Errorf("get channel: %w", err) + } + + return channelID, nil +} + +// JoinChannel adds a session to a channel. +func (database *Database) JoinChannel( + ctx context.Context, + channelID, sessionID int64, +) error { + _, err := database.conn.ExecContext(ctx, + `INSERT OR IGNORE INTO channel_members + (channel_id, session_id, joined_at) + VALUES (?, ?, ?)`, + channelID, sessionID, time.Now()) + if err != nil { + return fmt.Errorf("join channel: %w", err) + } + + return nil +} + +// HasChannelInvite checks if a session has been invited +// to a channel. +func (database *Database) HasChannelInvite( + ctx context.Context, + channelID, sessionID int64, +) (bool, error) { + var count int + + err := database.conn.QueryRowContext(ctx, + `SELECT COUNT(*) FROM channel_invites + WHERE channel_id = ? AND session_id = ?`, + channelID, sessionID, + ).Scan(&count) + if err != nil { + return false, fmt.Errorf( + "check invite: %w", err, + ) + } + + return count > 0, nil +} diff --git a/internal/db/main_test.go b/internal/db/main_test.go deleted file mode 100644 index e9cd045..0000000 --- a/internal/db/main_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package db_test - -import ( - "os" - "testing" - - "golang.org/x/crypto/bcrypt" - "sneak.berlin/go/neoirc/internal/db" -) - -func TestMain(m *testing.M) { - db.SetBcryptCost(bcrypt.MinCost) - os.Exit(m.Run()) -} diff --git a/internal/db/queries.go b/internal/db/queries.go index e83e2a0..1f5799c 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -320,29 +320,6 @@ type ClientHostInfo struct { Hostname string } -// GetClientHostInfo returns the IP and hostname for a -// client. -func (database *Database) GetClientHostInfo( - ctx context.Context, - clientID int64, -) (*ClientHostInfo, error) { - var info ClientHostInfo - - err := database.conn.QueryRowContext( - ctx, - `SELECT ip, hostname - FROM clients WHERE id = ?`, - clientID, - ).Scan(&info.IP, &info.Hostname) - if err != nil { - return nil, fmt.Errorf( - "get client host info: %w", err, - ) - } - - return &info, nil -} - // SetSessionOper sets the is_oper flag on a session. func (database *Database) SetSessionOper( ctx context.Context, @@ -433,54 +410,6 @@ func (database *Database) GetChannelByName( return channelID, nil } -// GetOrCreateChannel returns channel id, creating if needed. -// Uses INSERT OR IGNORE to avoid TOCTOU races. -func (database *Database) GetOrCreateChannel( - ctx context.Context, - name string, -) (int64, error) { - now := time.Now() - - _, err := database.conn.ExecContext(ctx, - `INSERT OR IGNORE INTO channels - (name, created_at, updated_at) - VALUES (?, ?, ?)`, - name, now, now) - if err != nil { - return 0, fmt.Errorf("create channel: %w", err) - } - - var channelID int64 - - err = database.conn.QueryRowContext( - ctx, - "SELECT id FROM channels WHERE name = ?", - name, - ).Scan(&channelID) - if err != nil { - return 0, fmt.Errorf("get channel: %w", err) - } - - return channelID, nil -} - -// JoinChannel adds a session to a channel. -func (database *Database) JoinChannel( - ctx context.Context, - channelID, sessionID int64, -) error { - _, err := database.conn.ExecContext(ctx, - `INSERT OR IGNORE INTO channel_members - (channel_id, session_id, joined_at) - VALUES (?, ?, ?)`, - channelID, sessionID, time.Now()) - if err != nil { - return fmt.Errorf("join channel: %w", err) - } - - return nil -} - // Errors that CreateOrJoinChannel refuses a join with. var ( ErrBannedFromChannel = errors.New("banned from channel") @@ -1127,23 +1056,6 @@ func (database *Database) GetChannelMemberIDs( return scanInt64s(rows) } -// GetSessionChannelIDs returns channel IDs for a session. -func (database *Database) GetSessionChannelIDs( - ctx context.Context, - sessionID int64, -) ([]int64, error) { - rows, err := database.conn.QueryContext(ctx, - `SELECT channel_id FROM channel_members - WHERE session_id = ?`, sessionID) - if err != nil { - return nil, fmt.Errorf( - "get session channel ids: %w", err, - ) - } - - return scanInt64s(rows) -} - // InsertMessage stores a message and returns its DB ID. // toSessionID is the session a DM or INVITE is sent to and // toChannelID the channel a channel message is sent to; @@ -2460,28 +2372,6 @@ func (database *Database) AddChannelInvite( return nil } -// HasChannelInvite checks if a session has been invited -// to a channel. -func (database *Database) HasChannelInvite( - ctx context.Context, - channelID, sessionID int64, -) (bool, error) { - var count int - - err := database.conn.QueryRowContext(ctx, - `SELECT COUNT(*) FROM channel_invites - WHERE channel_id = ? AND session_id = ?`, - channelID, sessionID, - ).Scan(&count) - if err != nil { - return false, fmt.Errorf( - "check invite: %w", err, - ) - } - - return count > 0, nil -} - // --- Tier 2: Secret (+s) --- // IsChannelSecret checks if a channel has +s mode. diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 881fec6..c44ea2a 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -13,7 +13,7 @@ import ( func setupTestDB(t *testing.T) *db.Database { t.Helper() - database, err := db.NewTestDatabase() + database, err := db.NewTestDatabase(t) if err != nil { t.Fatal(err) } @@ -125,7 +125,7 @@ func TestCreateSessionStoresIP(t *testing.T) { database := setupTestDB(t) ctx := t.Context() - sessionID, clientID, _, err := database.CreateSession( + sessionID, _, _, err := database.CreateSession( ctx, "ipuser", "ident", "host.example.com", "192.168.1.42", ) @@ -147,8 +147,8 @@ func TestCreateSessionStoresIP(t *testing.T) { ) } - clientInfo, err := database.GetClientHostInfo( - ctx, clientID, + clientInfo, err := database.GetLatestClientForSession( + ctx, sessionID, ) if err != nil { t.Fatal(err) @@ -169,19 +169,6 @@ func TestCreateSessionStoresIP(t *testing.T) { } } -func TestGetClientHostInfoNotFound(t *testing.T) { - t.Parallel() - - database := setupTestDB(t) - - _, err := database.GetClientHostInfo( - t.Context(), 99999, - ) - if err == nil { - t.Fatal("expected error for nonexistent client") - } -} - func TestGetSessionHostInfoNotFound(t *testing.T) { t.Parallel() diff --git a/internal/db/testing.go b/internal/db/testing.go deleted file mode 100644 index d036611..0000000 --- a/internal/db/testing.go +++ /dev/null @@ -1,25 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "log/slog" -) - -// NewTestDatabaseFromConn creates a Database wrapping an -// existing *sql.DB connection. Intended for integration -// tests in other packages. -func NewTestDatabaseFromConn(conn *sql.DB) *Database { - return &Database{ //nolint:exhaustruct - conn: conn, - log: slog.Default(), - } -} - -// RunMigrations applies all schema migrations. Exposed -// for integration tests in other packages. -func (database *Database) RunMigrations( - ctx context.Context, -) error { - return database.runMigrations(ctx) -} diff --git a/internal/dbtest/dbtest.go b/internal/dbtest/dbtest.go new file mode 100644 index 0000000..8346bf1 --- /dev/null +++ b/internal/dbtest/dbtest.go @@ -0,0 +1,59 @@ +// Package dbtest gives each test a database of its own and runs raw +// SQL on it. Only tests may import it. +package dbtest + +import ( + "crypto/rand" + "database/sql" + "testing" + + _ "modernc.org/sqlite" // registers the sqlite driver +) + +// NewURL returns the URL of a new, empty in-memory database for one +// test. SQLite deletes an in-memory database when its last connection +// closes. A pool closes a connection when a query on it is cancelled +// while it runs. So this function holds a connection of its own open +// until the test ends. +func NewURL(tb testing.TB) string { + tb.Helper() + + // A random name, so that no other test can share the database. + dbURL := "file:" + rand.Text() + "?mode=memory&cache=shared" + + pool, err := sql.Open("sqlite", dbURL) + if err != nil { + tb.Fatalf("open test database: %v", err) + } + + tb.Cleanup(func() { _ = pool.Close() }) + + conn, err := pool.Conn(tb.Context()) + if err != nil { + tb.Fatalf("connect to test database: %v", err) + } + + tb.Cleanup(func() { _ = conn.Close() }) + + return dbURL +} + +// Exec runs one raw SQL statement on the database at dbURL, over a +// connection of its own. A test uses it for what the methods of +// db.Database cannot do, such as creating a trigger that makes a +// write fail. +func Exec(tb testing.TB, dbURL, statement string) { + tb.Helper() + + pool, err := sql.Open("sqlite", dbURL) + if err != nil { + tb.Fatalf("open test database: %v", err) + } + + defer func() { _ = pool.Close() }() + + _, err = pool.ExecContext(tb.Context(), statement) + if err != nil { + tb.Fatalf("run %q: %v", statement, err) + } +} diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 792354d..0843b31 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -188,7 +188,7 @@ func (hdlr *Handlers) handleCreateSession( var payload createRequest - err := json.NewDecoder(request.Body).Decode(&payload) + err := decodeJSON(request, &payload) if err != nil { hdlr.respondError( writer, request, @@ -412,7 +412,7 @@ func (hdlr *Handlers) deliverWelcome( // 004 RPL_MYINFO hdlr.enqueueNumeric( ctx, clientID, irc.RplMyInfo, nick, - []string{srvName, version, "", "ikmnostl"}, + []string{srvName, version, service.UserModes, "Hbiklmnostv"}, "", ) @@ -925,7 +925,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { var payload commandRequest - err := json.NewDecoder(request.Body).Decode(&payload) + err := decodeJSON(request, &payload) if err != nil { hdlr.respondError( writer, request, @@ -994,7 +994,7 @@ func (hdlr *Handlers) dispatchCommand( hdlr.handlePrivmsg( writer, request, sessionID, clientID, nick, - command, target, body, meta, bodyLines, + command, target, body, meta, ) case irc.CmdJoin: hdlr.handleJoin( @@ -1139,8 +1139,18 @@ func (hdlr *Handlers) handlePrivmsg( nick, command, target string, body json.RawMessage, meta json.RawMessage, - bodyLines func() []string, ) { + lines, ok := checkBodyAndMeta(body, meta) + if !ok { + hdlr.respondError( + writer, request, + "invalid request body", + http.StatusBadRequest, + ) + + return + } + if target == "" { hdlr.enqueueNumeric( request.Context(), clientID, @@ -1155,7 +1165,6 @@ func (hdlr *Handlers) handlePrivmsg( return } - lines := bodyLines() if len(lines) == 0 { hdlr.enqueueNumeric( request.Context(), clientID, @@ -1189,6 +1198,44 @@ func (hdlr *Handlers) handlePrivmsg( ) } +// checkBodyAndMeta returns the lines in body. It returns false +// when body is present and is not an array of strings, or meta +// is present and is not an object. Each line is decoded as any +// because decoding into []string turns a null line into "". A +// null body or meta decodes without an error into nil. +func checkBodyAndMeta(body, meta json.RawMessage) ([]string, bool) { + var lines []string + + if body != nil { + var values []any + + err := json.Unmarshal(body, &values) + if err != nil || values == nil { + return nil, false + } + + for _, value := range values { + line, isString := value.(string) + if !isString { + return nil, false + } + + lines = append(lines, line) + } + } + + if meta != nil { + var fields map[string]any + + err := json.Unmarshal(meta, &fields) + if err != nil || fields == nil { + return nil, false + } + } + + return lines, true +} + // respondIRCError enqueues a numeric error reply, notifies // the broker, and sends HTTP 200 with {"status":"error"}. func (hdlr *Handlers) respondIRCError( @@ -3191,7 +3238,7 @@ func (hdlr *Handlers) handleWho( // 315 RPL_ENDOFWHO (empty result) hdlr.enqueueNumeric( ctx, clientID, irc.RplEndOfWho, nick, - []string{target}, + []string{channel}, "End of /WHO list", ) hdlr.broker.Notify(sessionID) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 6ca6421..00c6be4 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -1,22 +1,19 @@ -// Tests use a global viper instance for configuration, -// making parallel execution unsafe. -// -//nolint:paralleltest package handlers_test import ( "bytes" "context" - "crypto/rand" "database/sql" "encoding/json" "errors" "fmt" "io" + "maps" "net/http" "net/http/httptest" "net/url" "os" + "reflect" "slices" "strconv" "strings" @@ -32,6 +29,7 @@ import ( api "sneak.berlin/go/neoirc/internal/cli/api" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" + "sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/handlers" "sneak.berlin/go/neoirc/internal/hashcash" @@ -49,22 +47,25 @@ import ( const templateDBURL = "file:template?mode=memory&cache=shared" func TestMain(m *testing.M) { - db.SetBcryptCost(bcrypt.MinCost) + template := fx.New( + fx.NopLogger, + fx.Supply( + &globals.Globals{}, + &config.Config{DBURL: templateDBURL}, + ), + fx.Provide(logger.New, newTestDB), + // fx makes the database only if something asks for it. + fx.Invoke(func(*db.Database) {}), + ) - template, err := sql.Open("sqlite", templateDBURL) - if err != nil { - panic(err) - } - - err = db.NewTestDatabaseFromConn(template). - RunMigrations(context.Background()) + err := template.Start(context.Background()) if err != nil { panic(err) } code := m.Run() - _ = template.Close() + _ = template.Stop(context.Background()) os.Exit(code) } @@ -139,10 +140,18 @@ type testServer struct { t *testing.T fxApp *fxtest.App broker *broker.Broker + dbURL string database *db.Database handlers *handlers.Handlers } +// viperMu makes tests start their servers one at a time, +// because config.New writes the global viper instance and +// the server reads it as it starts. +// +//nolint:gochecknoglobals // guards a global, so must be one +var viperMu sync.Mutex + func newTestServer( t *testing.T, ) *testServer { @@ -158,13 +167,10 @@ func newTestServerWith( ) *testServer { t.Helper() - // A random name, so that no other test can share it, not - // even one that has finished but still holds its database - // open. - dbURL := fmt.Sprintf( - "file:%s?mode=memory&cache=shared", - rand.Text(), - ) + viperMu.Lock() + defer viperMu.Unlock() + + dbURL := dbtest.NewURL(t) copyTemplateDB(t, dbURL) var ( @@ -229,6 +235,7 @@ func newTestServerWith( t: t, fxApp: app, broker: brk, + dbURL: dbURL, database: database, handlers: hdlr, } @@ -625,6 +632,8 @@ func findNumeric( // --- Tests --- func TestCreateSessionValid(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) cookie := tserver.createSession("alice") @@ -634,6 +643,8 @@ func TestCreateSessionValid(t *testing.T) { } func TestWelcomeNumeric(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("welcomer") @@ -648,6 +659,8 @@ func TestWelcomeNumeric(t *testing.T) { } func TestJoinNumerics(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("jnumtest") @@ -675,6 +688,8 @@ func TestJoinNumerics(t *testing.T) { } func TestCreateSessionDuplicate(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) tserver.createSession("alice") @@ -694,6 +709,8 @@ func TestCreateSessionDuplicate(t *testing.T) { // second test server leaves the first one's database alone and // starts on an empty one of its own. func TestEachTestServerHasItsOwnDatabase(t *testing.T) { + t.Parallel() + first := newTestServer(t) first.createSession("samenick") @@ -714,6 +731,8 @@ func TestEachTestServerHasItsOwnDatabase(t *testing.T) { } func TestCreateSessionEmpty(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp := postSession(t, tserver, "") @@ -728,6 +747,8 @@ func TestCreateSessionEmpty(t *testing.T) { } func TestCreateSessionInvalidChars(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp := postSession(t, tserver, "hello world") @@ -742,6 +763,8 @@ func TestCreateSessionInvalidChars(t *testing.T) { } func TestCreateSessionNumericStart(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp := postSession(t, tserver, "123abc") @@ -756,6 +779,8 @@ func TestCreateSessionNumericStart(t *testing.T) { } func TestCreateSessionMalformed(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp, err := doRequest( @@ -777,7 +802,107 @@ func TestCreateSessionMalformed(t *testing.T) { } } +func (tserver *testServer) postBody( + path, token, body string, +) (int, map[string]any) { + tserver.t.Helper() + + resp, err := doRequestAuth( + tserver.t, http.MethodPost, tserver.url(path), token, + strings.NewReader(body), + ) + if err != nil { + tserver.t.Fatalf("post %s: %v", path, err) + } + + defer func() { _ = resp.Body.Close() }() + + var result map[string]any + + _ = json.NewDecoder(resp.Body).Decode(&result) + + return resp.StatusCode, result +} + +// TestRequestBodyLimit sends each endpoint a small valid JSON value +// followed by other bytes. Only whitespace may follow the value, and +// the whole body counts towards MAX_MESSAGE_SIZE. +func TestRequestBodyLimit(t *testing.T) { + t.Parallel() + + const ( + maxMessageSize = 4096 // the default MAX_MESSAGE_SIZE + nick = "alice_bodylimit" + ) + + endpoints := []struct { + name, path, value string + acceptedStatus int + }{ + { + "create session", apiSession, + `{"nick":"bob_bodylimit"}`, + http.StatusCreated, + }, + { + "login", "/api/v1/login", + `{"nick":"` + nick + `","password":"` + testPassword + `"}`, + http.StatusOK, + }, + { + "post message", apiMessages, + `{"command":"PING"}`, + http.StatusOK, + }, + } + + for _, endpoint := range endpoints { + t.Run(endpoint.name, func(t *testing.T) { + t.Parallel() + + tserver := newTestServer(t) + token := tserver.createSession(nick) + + tserver.setPassword(nick) + + bytesToLimit := maxMessageSize - len(endpoint.value) + + for _, trailing := range []string{ + strings.Repeat(" ", bytesToLimit+1), + strings.Repeat("x", bytesToLimit+1), + "x", + "{}", + } { + status, result := tserver.postBody( + endpoint.path, token, endpoint.value+trailing, + ) + if status != http.StatusBadRequest || + result["error"] != "invalid request body" { + t.Errorf( + "%d bytes of %.10q after the value: expected "+ + "400 invalid request body, got %d: %v", + len(trailing), trailing, status, result, + ) + } + } + + status, result := tserver.postBody( + endpoint.path, token, + endpoint.value+strings.Repeat(" ", bytesToLimit), + ) + if status != endpoint.acceptedStatus { + t.Errorf( + "body of exactly the limit: expected %d, got %d: %v", + endpoint.acceptedStatus, status, result, + ) + } + }) + } +} + func TestAuthNoCookie(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) status, _ := tserver.getState("") @@ -787,6 +912,8 @@ func TestAuthNoCookie(t *testing.T) { } func TestAuthBadCookie(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) status, _ := tserver.getState( @@ -798,6 +925,8 @@ func TestAuthBadCookie(t *testing.T) { } func TestAuthValidToken(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("authtest") @@ -815,6 +944,8 @@ func TestAuthValidToken(t *testing.T) { } func TestJoinChannel(t *testing.T) { + t.Parallel() + const channel = "#test" tserver := newTestServer(t) @@ -840,6 +971,8 @@ func TestJoinChannel(t *testing.T) { } func TestJoinWithoutHash(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("joiner2") @@ -864,6 +997,8 @@ func TestJoinWithoutHash(t *testing.T) { } func TestPartChannel(t *testing.T) { + t.Parallel() + const channel = "#test" tserver := newTestServer(t) @@ -896,6 +1031,8 @@ func TestPartChannel(t *testing.T) { } func TestJoinMissingTo(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("joiner3") @@ -920,6 +1057,8 @@ func TestJoinMissingTo(t *testing.T) { } func TestChannelMessage(t *testing.T) { + t.Parallel() + const channel = "#test" tserver := newTestServer(t) @@ -965,7 +1104,11 @@ func TestChannelMessage(t *testing.T) { } } +// TestMessageMissingBody checks that a PRIVMSG with no body, +// or an empty one, gets ERR_NOTEXTTOSEND. func TestMessageMissingBody(t *testing.T) { + t.Parallel() + const channel = "#test" tserver := newTestServer(t) @@ -977,24 +1120,31 @@ func TestMessageMissingBody(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) - status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: privmsgCmd, toKey: channel, - }) - if status != http.StatusOK { - t.Fatalf("expected 200, got %d", status) - } + for _, request := range []map[string]any{ + {commandKey: privmsgCmd, toKey: channel}, + {commandKey: privmsgCmd, toKey: channel, bodyKey: []string{}}, + } { + status, _ := tserver.sendCommand(token, request) + if status != http.StatusOK { + t.Fatalf("%v: expected 200, got %d", request, status) + } - msgs, _ := tserver.pollMessages(token, lastID) + var msgs []map[string]any - if !findNumeric(msgs, "412") { - t.Fatalf( - "expected ERR_NOTEXTTOSEND (412), got %v", - msgs, - ) + msgs, lastID = tserver.pollMessages(token, lastID) + + if !findNumeric(msgs, "412") { + t.Fatalf( + "%v: expected ERR_NOTEXTTOSEND (412), got %v", + request, msgs, + ) + } } } func TestMessageMissingTo(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("noto") @@ -1018,7 +1168,135 @@ func TestMessageMissingTo(t *testing.T) { } } +// TestMessageInvalidBodyOrMeta checks that a PRIVMSG or +// NOTICE, to a channel or a nick, whose body is not an array +// of strings or whose meta is not an object gets 400 and +// reaches nobody. +func TestMessageInvalidBodyOrMeta(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fields map[string]any + }{ + {"null line", map[string]any{bodyKey: []any{"hi", nil}}}, + {"number line", map[string]any{bodyKey: []any{"hi", 1}}}, + {"object line", map[string]any{bodyKey: []any{map[string]any{}}}}, + {"null body", map[string]any{bodyKey: nil}}, + {"object body", map[string]any{bodyKey: map[string]any{}}}, + {"null meta", map[string]any{bodyKey: []string{"hi"}, metaKey: nil}}, + {"array meta", map[string]any{bodyKey: []string{"hi"}, metaKey: []string{}}}, + {"string meta", map[string]any{bodyKey: []string{"hi"}, metaKey: "hi"}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + const channel = "#badmsg" + + tserver := newTestServer(t) + aliceToken := tserver.createSession("alice_badmsg") + bobToken := tserver.createSession("bob_badmsg") + + tserver.sendCommand(aliceToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + tserver.sendCommand(bobToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + + _, bobLastID := tserver.pollMessages(bobToken, 0) + + for _, command := range []string{privmsgCmd, irc.CmdNotice} { + for _, target := range []string{channel, "bob_badmsg"} { + request := map[string]any{ + commandKey: command, toKey: target, + } + maps.Copy(request, tc.fields) + + status, result := tserver.sendCommand( + aliceToken, request, + ) + if status != http.StatusBadRequest || + result["error"] != "invalid request body" { + t.Errorf( + "%s to %s: expected 400 invalid "+ + "request body, got %d: %v", + command, target, status, result, + ) + } + } + } + + msgs, _ := tserver.pollMessages(bobToken, bobLastID) + if len(msgs) != 0 { + t.Fatalf("expected no messages, bob got %v", msgs) + } + }) + } +} + +// TestMessageRelaysBodyAndMeta checks that a PRIVMSG or +// NOTICE, to a channel or a nick, reaches its recipient with +// body and meta as sent. +func TestMessageRelaysBodyAndMeta(t *testing.T) { + t.Parallel() + + const channel = "#relaymsg" + + tserver := newTestServer(t) + aliceToken := tserver.createSession("alice_relaymsg") + bobToken := tserver.createSession("bob_relaymsg") + + tserver.sendCommand(aliceToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + tserver.sendCommand(bobToken, map[string]any{ + commandKey: joinCmd, toKey: channel, + }) + + _, bobLastID := tserver.pollMessages(bobToken, 0) + + body := []any{"line one", ""} + meta := map[string]any{"sig": "abc", "alg": "ed25519"} + + for _, command := range []string{privmsgCmd, irc.CmdNotice} { + for _, target := range []string{channel, "bob_relaymsg"} { + status, result := tserver.sendCommand( + aliceToken, + map[string]any{ + commandKey: command, toKey: target, + bodyKey: body, metaKey: meta, + }, + ) + if status != http.StatusOK { + t.Fatalf( + "%s to %s: expected 200, got %d: %v", + command, target, status, result, + ) + } + + var msgs []map[string]any + + msgs, bobLastID = tserver.pollMessages( + bobToken, bobLastID, + ) + if len(msgs) != 1 || + msgs[0][commandKey] != command || + !reflect.DeepEqual(msgs[0][bodyKey], body) || + !reflect.DeepEqual(msgs[0][metaKey], meta) { + t.Fatalf( + "%s to %s: bob got %v", command, target, msgs, + ) + } + } + } +} + func TestNonMemberCannotSend(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) aliceToken := tserver.createSession("alice_nosend") bobToken := tserver.createSession("bob_nosend") @@ -1054,6 +1332,8 @@ func TestNonMemberCannotSend(t *testing.T) { } func TestDirectMessage(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) aliceToken := tserver.createSession("alice_dm") bobToken := tserver.createSession("bob_dm") @@ -1095,6 +1375,8 @@ func TestDirectMessage(t *testing.T) { } func TestDMToNonexistentUser(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("dmsender") @@ -1120,6 +1402,8 @@ func TestDMToNonexistentUser(t *testing.T) { } func TestNickChange(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("nick_test") @@ -1144,6 +1428,8 @@ func TestNickChange(t *testing.T) { } func TestNickSameAsCurrent(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("same_nick") @@ -1157,6 +1443,8 @@ func TestNickSameAsCurrent(t *testing.T) { } func TestNickCollision(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("nickuser") @@ -1183,6 +1471,8 @@ func TestNickCollision(t *testing.T) { } func TestNickInvalid(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("nickval") @@ -1207,6 +1497,8 @@ func TestNickInvalid(t *testing.T) { } func TestNickEmptyBody(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("nicknobody") @@ -1230,6 +1522,8 @@ func TestNickEmptyBody(t *testing.T) { } func TestTopic(t *testing.T) { + t.Parallel() + const channel = "#topictest" tserver := newTestServer(t) @@ -1261,6 +1555,8 @@ func TestTopic(t *testing.T) { } func TestTopicMissingTo(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("topicnoto") @@ -1285,6 +1581,8 @@ func TestTopicMissingTo(t *testing.T) { } func TestTopicMissingBody(t *testing.T) { + t.Parallel() + const channel = "#topictest" tserver := newTestServer(t) @@ -1314,6 +1612,8 @@ func TestTopicMissingBody(t *testing.T) { } func TestTopicNonMember(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) aliceToken := tserver.createSession("alice_topic") bobToken := tserver.createSession("bob_topic") @@ -1350,6 +1650,8 @@ func TestTopicNonMember(t *testing.T) { } func TestPing(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("ping_user") @@ -1369,6 +1671,8 @@ func TestPing(t *testing.T) { } func TestQuit(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("quitter") observerToken := tserver.createSession("observer") @@ -1417,6 +1721,8 @@ const quitRaceTrials = 5 // clients of a session log out at once, the session's QUIT // is sent once. func TestConcurrentLogoutsSendOneQuit(t *testing.T) { + t.Parallel() + checkQuitRace(t, func(tserver *testServer, tokens []string) { tserver.postConcurrently(apiLogout, nil, tokens...) }) @@ -1426,6 +1732,8 @@ func TestConcurrentLogoutsSendOneQuit(t *testing.T) { // clients of a session send QUIT at once, the session's // QUIT is sent once. func TestConcurrentQuitsSendOneQuit(t *testing.T) { + t.Parallel() + quit, err := json.Marshal(map[string]any{commandKey: irc.CmdQuit}) if err != nil { t.Fatal(err) @@ -1457,9 +1765,7 @@ func checkQuitRace( firstToken := tserver.createSession(nick) observerToken := tserver.createSession("raceobserver") - tserver.sendCommand(firstToken, map[string]any{ - commandKey: irc.CmdPass, bodyKey: []string{testPassword}, - }) + tserver.setPassword(nick) tokens := []string{firstToken, tserver.login(nick)} @@ -1498,6 +1804,8 @@ func checkQuitRace( // its channel peer gets no QUIT, and that the other // client's logout then sends one QUIT. func TestLogoutOfOneClientKeepsSession(t *testing.T) { + t.Parallel() + const ( nick = "twoclients" channel = "#logouts" @@ -1507,9 +1815,7 @@ func TestLogoutOfOneClientKeepsSession(t *testing.T) { firstToken := tserver.createSession(nick) observerToken := tserver.createSession("logoutobserver") - tserver.sendCommand(firstToken, map[string]any{ - commandKey: irc.CmdPass, bodyKey: []string{testPassword}, - }) + tserver.setPassword(nick) secondToken := tserver.login(nick) @@ -1560,6 +1866,8 @@ func TestLogoutOfOneClientKeepsSession(t *testing.T) { // nothing: the client's token still works, the session is // still on its channel and its channel peer has no QUIT. func TestFailedLogoutChangesNothing(t *testing.T) { + t.Parallel() + const ( nick = "stuckleaver" channel = "#stuck" @@ -1577,15 +1885,12 @@ func TestFailedLogoutChangesNothing(t *testing.T) { _, lastID := tserver.pollMessages(observerToken, 0) - _, err := tserver.database.GetDB().ExecContext(t.Context(), + dbtest.Exec(t, tserver.dbURL, `CREATE TRIGGER keep_sessions BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions are kept'); END`) - if err != nil { - t.Fatal(err) - } resp, err := doRequestAuth( t, http.MethodPost, tserver.url(apiLogout), token, nil, @@ -1637,6 +1942,8 @@ const ( // wakes the long poll of a user who shares a channel with // it. func TestQuitWakesPeerLongPoll(t *testing.T) { + t.Parallel() + const reason = "gone fishing" checkQuitWakesPoll(t, reason, func(tserver *testServer, token string) { @@ -1650,6 +1957,8 @@ func TestQuitWakesPeerLongPoll(t *testing.T) { // a session's last client wakes the long poll of a user who // shares a channel with it. func TestLastLogoutWakesPeerLongPoll(t *testing.T) { + t.Parallel() + checkQuitWakesPoll(t, "Connection closed", func(tserver *testServer, token string) { tserver.logout(token) }) @@ -1815,6 +2124,24 @@ func (tserver *testServer) longPoll( return result.Messages, nil } +// setPassword gives the session nick the password testPassword +// without sending PASS. It hashes at bcrypt.MinCost, and a login +// compares at the cost stored in the hash, so both are fast. +func (tserver *testServer) setPassword(nick string) { + tserver.t.Helper() + + hash, err := bcrypt.GenerateFromPassword( + []byte(testPassword), bcrypt.MinCost, + ) + if err != nil { + tserver.t.Fatalf("hash password: %v", err) + } + + dbtest.Exec(tserver.t, tserver.dbURL, + "UPDATE sessions SET password_hash = '"+string(hash)+ + "' WHERE nick = '"+nick+"'") +} + // login logs in to the session nick with testPassword and // returns the new client's token. func (tserver *testServer) login(nick string) string { @@ -1890,6 +2217,8 @@ func (tserver *testServer) postConcurrently( } func TestUnknownCommand(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("cmdtest") @@ -1913,6 +2242,8 @@ func TestUnknownCommand(t *testing.T) { } func TestEmptyCommand(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("emptycmd") @@ -1925,6 +2256,8 @@ func TestEmptyCommand(t *testing.T) { } func TestHistory(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("historian") @@ -1972,6 +2305,8 @@ func TestHistory(t *testing.T) { } func TestHistoryNonMember(t *testing.T) { + t.Parallel() + const channel = "#secret" tserver := newTestServer(t) @@ -2102,6 +2437,8 @@ func (tserver *testServer) getAllHistory( } func TestHistoryPaging(t *testing.T) { + t.Parallel() + const ( channel = "#paging" msgCount = 5 @@ -2149,6 +2486,8 @@ func TestHistoryPaging(t *testing.T) { // and none sent to another session, and checks that the id // of a DM sent to another session is not accepted as before. func TestHistoryDMPaging(t *testing.T) { + t.Parallel() + const ( firstNick = "readerFirst" laterNick = "readerLater" @@ -2220,6 +2559,8 @@ func TestHistoryDMPaging(t *testing.T) { } func TestHistoryUnknownBefore(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("lost") @@ -2280,6 +2621,8 @@ func TestHistoryUnknownBefore(t *testing.T) { // released by QUIT does not get the DMs sent to its previous // holder. func TestHistoryReusedNick(t *testing.T) { + t.Parallel() + const victim = "victimV" tserver := newTestServer(t) @@ -2324,6 +2667,8 @@ func TestHistoryReusedNick(t *testing.T) { // again under the name of a deleted one starts with no // history, even when the old one had a key. func TestHistoryRecreatedChannel(t *testing.T) { + t.Parallel() + const channel = "#priv" tserver := newTestServer(t) @@ -2372,6 +2717,8 @@ func TestHistoryRecreatedChannel(t *testing.T) { } func TestChannelList(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("lister") @@ -2421,6 +2768,8 @@ func TestChannelList(t *testing.T) { } func TestChannelMembers(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("membertest") @@ -2451,6 +2800,8 @@ func TestChannelMembers(t *testing.T) { } func TestLongPoll(t *testing.T) { + t.Parallel() + const channel = "#longpoll" tserver := newTestServer(t) @@ -2518,6 +2869,8 @@ func TestLongPoll(t *testing.T) { } func TestLongPollTimeout(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("lp_timeout") @@ -2563,6 +2916,8 @@ func TestLongPollTimeout(t *testing.T) { // poll waits, is returned at once and not when the poll times // out, and that the poll then holds no broker wait channel. func TestLongPollReturnsMessageQueuedAfterFirstRead(t *testing.T) { + t.Parallel() + const ( nick = "lp_late" sender = "lp_late_sender" @@ -2646,6 +3001,8 @@ var errReadFailed = errors.New("read failed") // whether it is answered at once, after its timeout, or with // an error because a read of the queue failed. func TestGetMessagesRemovesWaitChannel(t *testing.T) { + t.Parallel() + const ( // longTimeout, in seconds, is the timeout of the // requests that must be answered without waiting. @@ -2692,6 +3049,8 @@ func TestGetMessagesRemovesWaitChannel(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + t.Parallel() + const nick = "wc_poller" tserver := newTestServer(t) @@ -2782,6 +3141,8 @@ func (tserver *testServer) getMessagesFailingRead( // a long poll whose client gives up while it waits leaves no // broker wait channel behind. func TestLongPollRemovesWaitChannelWhenClientGivesUp(t *testing.T) { + t.Parallel() + const ( nick = "wc_leaver" @@ -2916,6 +3277,8 @@ func (tserver *testServer) queueMessage( } func TestEphemeralChannelCleanup(t *testing.T) { + t.Parallel() + const channel = "#ephemeral" tserver := newTestServer(t) @@ -2960,6 +3323,8 @@ func TestEphemeralChannelCleanup(t *testing.T) { } func TestConcurrentSessions(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) var waitGroup sync.WaitGroup @@ -3021,6 +3386,8 @@ func TestConcurrentSessions(t *testing.T) { } func TestServerInfo(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp, err := doRequest( @@ -3043,6 +3410,8 @@ func TestServerInfo(t *testing.T) { } func TestHealthcheck(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp, err := doRequest( @@ -3079,6 +3448,8 @@ func TestHealthcheck(t *testing.T) { } func TestHealthcheckRuntimeStatsFields(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp, err := doRequest( @@ -3122,6 +3493,8 @@ func TestHealthcheckRuntimeStatsFields(t *testing.T) { } func TestHealthcheckRuntimeStatsValues(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("statsuser") @@ -3240,6 +3613,8 @@ func postJSONExpectStatus( } func TestPassCommand(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("passuser") @@ -3279,6 +3654,8 @@ const ( // counts bytes: "é" is two bytes in UTF-8, so 72 of them are // refused. func TestPassCommandLength(t *testing.T) { + t.Parallel() + cases := []struct { name string password string @@ -3294,6 +3671,8 @@ func TestPassCommandLength(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("pwlength") @@ -3338,6 +3717,8 @@ func TestPassCommandLength(t *testing.T) { } func TestPassCommandEmpty(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("emptypw") @@ -3364,15 +3745,12 @@ func TestPassCommandEmpty(t *testing.T) { } func TestLoginValid(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) - // Create session and set password via PASS command. - token := tserver.createSession("loginuser") - - tserver.sendCommand(token, map[string]any{ - commandKey: irc.CmdPass, - bodyKey: []string{testPassword}, - }) + tserver.createSession("loginuser") + tserver.setPassword("loginuser") // Login with nick + password. loginBody, err := json.Marshal(map[string]string{ @@ -3432,15 +3810,12 @@ func TestLoginValid(t *testing.T) { } func TestLoginWrongPassword(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) - // Create session and set password via PASS command. - token := tserver.createSession("wrongpwuser") - - tserver.sendCommand(token, map[string]any{ - commandKey: irc.CmdPass, - bodyKey: []string{"correctpass1"}, - }) + tserver.createSession("wrongpwuser") + tserver.setPassword("wrongpwuser") postJSONExpectStatus( t, tserver, "/api/v1/login", @@ -3453,6 +3828,8 @@ func TestLoginWrongPassword(t *testing.T) { } func TestLoginNonexistentUser(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) postJSONExpectStatus( @@ -3466,6 +3843,8 @@ func TestLoginNonexistentUser(t *testing.T) { } func TestSessionCookie(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) body, err := json.Marshal( @@ -3532,6 +3911,8 @@ func TestSessionCookie(t *testing.T) { } func TestSessionStillWorks(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) // Verify anonymous session creation still works. @@ -3599,6 +3980,8 @@ func getNumericParams( } func TestWhoisShowsHostInfo(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSessionWithUsername( @@ -3694,6 +4077,8 @@ func (tserver *testServer) createSessionWithUsername( } func TestWhoShowsHostInfo(t *testing.T) { + t.Parallel() + const channel = "#whotest" tserver := newTestServer(t) @@ -3758,7 +4143,34 @@ func assertWhoReplyUsername( ) } +func TestWhoUnknownChannelEndReplyAddsHash(t *testing.T) { + t.Parallel() + + tserver := newTestServer(t) + + token := tserver.createSession("whoasker") + + _, lastID := tserver.pollMessages(token, 0) + + msgs, _ := sendAndPoll(tserver, token, lastID, map[string]any{ + commandKey: irc.CmdWho, + toKey: "nosuch", + }) + + endOfWho := findNumericWithParams(msgs, "315") + if endOfWho == nil { + t.Fatalf("expected RPL_ENDOFWHO (315), got %v", msgs) + } + + params := getNumericParams(endOfWho) + if !slices.Equal(params, []string{"#nosuch"}) { + t.Fatalf("expected params [#nosuch], got %v", params) + } +} + func TestSessionUsernameDefault(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) // Create session without specifying username. @@ -3805,6 +4217,8 @@ func TestSessionUsernameDefault(t *testing.T) { } func TestLoginRateLimitExceeded(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) // Exhaust the burst (default: 5 per IP) using @@ -3868,15 +4282,12 @@ func TestLoginRateLimitExceeded(t *testing.T) { } func TestLoginRateLimitAllowsNormalUse(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) - // Create session and set password via PASS command. - token := tserver.createSession("normaluser") - - tserver.sendCommand(token, map[string]any{ - commandKey: irc.CmdPass, - bodyKey: []string{testPassword}, - }) + tserver.createSession("normaluser") + tserver.setPassword("normaluser") // A single login should succeed without rate limiting. loginBody, err := json.Marshal(map[string]string{ @@ -3908,6 +4319,8 @@ func TestLoginRateLimitAllowsNormalUse(t *testing.T) { } func TestNickBroadcastToChannels(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) aliceToken := tserver.createSession("nick_a") bobToken := tserver.createSession("nick_b") @@ -3957,6 +4370,8 @@ func mintTestChannelHashcash( } func TestChannelHashcashSetMode(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("hcmode_user") @@ -3991,6 +4406,8 @@ func TestChannelHashcashSetMode(t *testing.T) { } func TestChannelHashcashQueryMode(t *testing.T) { + t.Parallel() + const channel = "#hcquery" tserver := newTestServer(t) @@ -4035,6 +4452,8 @@ func TestChannelHashcashQueryMode(t *testing.T) { } func TestChannelHashcashClearMode(t *testing.T) { + t.Parallel() + const channel = "#hcclear" tserver := newTestServer(t) @@ -4078,6 +4497,8 @@ func TestChannelHashcashClearMode(t *testing.T) { } func TestChannelHashcashRejectNoStamp(t *testing.T) { + t.Parallel() + const channel = "#hcreject" tserver := newTestServer(t) @@ -4121,6 +4542,8 @@ func TestChannelHashcashRejectNoStamp(t *testing.T) { } func TestChannelHashcashAcceptValidStamp(t *testing.T) { + t.Parallel() + const ( channel = "#hcaccept" text = "hello world" @@ -4186,6 +4609,8 @@ func TestChannelHashcashAcceptValidStamp(t *testing.T) { } func TestChannelHashcashRejectReplayedStamp(t *testing.T) { + t.Parallel() + const ( channel = "#hcreplay" text = "unique msg" @@ -4320,6 +4745,8 @@ func (tserver *testServer) sendConcurrently( // in several requests at once and checks that exactly one is // accepted and the other member receives exactly one copy. func TestChannelHashcashRejectConcurrentReplay(t *testing.T) { + t.Parallel() + const ( channel = "#hcrace" text = "one stamp" @@ -4395,6 +4822,8 @@ func TestChannelHashcashRejectConcurrentReplay(t *testing.T) { } func TestChannelHashcashNoRequirementWorks(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("hcnone_user") @@ -4423,6 +4852,8 @@ func TestChannelHashcashNoRequirementWorks(t *testing.T) { } func TestChannelHashcashInvalidBitsRange(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("hcbits_user") @@ -4450,6 +4881,8 @@ func TestChannelHashcashInvalidBitsRange(t *testing.T) { } func TestChannelHashcashMissingBitsArg(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("hcnoarg_user") @@ -4528,6 +4961,8 @@ func sessionCount(t *testing.T, tserver *testServer) int64 { // for its nick, and that no refused request creates a // session. func TestRefusedSessionLeavesStampUnspent(t *testing.T) { + t.Parallel() + const ( bits = 2 badNick = "bad nick" @@ -4592,6 +5027,8 @@ func TestRefusedSessionLeavesStampUnspent(t *testing.T) { } func TestNamesShowsHostmask(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) queryToken, lastID := setupChannelWithIdentMember( @@ -4613,6 +5050,8 @@ func TestNamesShowsHostmask(t *testing.T) { } func TestNamesOnJoinShowsHostmask(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) // First user joins to populate the channel. @@ -4739,6 +5178,8 @@ func newTestServerWithOper( } func TestOperCommandSuccess(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) token := tserver.createSession("operuser") @@ -4762,6 +5203,8 @@ func TestOperCommandSuccess(t *testing.T) { } func TestOperCommandFailure(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) token := tserver.createSession("badoper") @@ -4785,6 +5228,8 @@ func TestOperCommandFailure(t *testing.T) { } func TestOperCommandNeedMoreParams(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) token := tserver.createSession("shortoper") @@ -4808,6 +5253,8 @@ func TestOperCommandNeedMoreParams(t *testing.T) { } func TestOperWhoisShowsClientInfo(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) // Create a target user. @@ -4876,6 +5323,8 @@ func TestOperWhoisShowsClientInfo(t *testing.T) { } func TestNonOperWhoisHidesClientInfo(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) // Create a target user. @@ -4912,6 +5361,8 @@ func TestNonOperWhoisHidesClientInfo(t *testing.T) { } func TestWhoisShowsOperatorStatus(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) // Create oper user and authenticate. @@ -4951,6 +5402,8 @@ func TestWhoisShowsOperatorStatus(t *testing.T) { } func TestOperNoOlineConfigured(t *testing.T) { + t.Parallel() + // Standard test server has no oper configured. tserver := newTestServer(t) @@ -4981,6 +5434,8 @@ const kickCmd = "KICK" // TestOperatorAutoGrantOnCreate verifies that the first // user to join a channel (the creator) gets +o. func TestOperatorAutoGrantOnCreate(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("creator") @@ -5031,6 +5486,8 @@ func TestOperatorAutoGrantOnCreate(t *testing.T) { // TestSecondJoinerNotOperator verifies that subsequent // joiners do NOT get +o. func TestSecondJoinerNotOperator(t *testing.T) { + t.Parallel() + const channel = "#optest2" tserver := newTestServer(t) @@ -5085,6 +5542,8 @@ func TestSecondJoinerNotOperator(t *testing.T) { // TestModeGrantOperator tests MODE +o. func TestModeGrantOperator(t *testing.T) { + t.Parallel() + const channel = "#modeop" tserver := newTestServer(t) @@ -5148,6 +5607,8 @@ func TestModeGrantOperator(t *testing.T) { // TestModeRevokeOperator tests MODE -o. func TestModeRevokeOperator(t *testing.T) { + t.Parallel() + const channel = "#revoke" tserver := newTestServer(t) @@ -5202,6 +5663,8 @@ func TestModeRevokeOperator(t *testing.T) { // TestModeVoice tests MODE +v/-v. func TestModeVoice(t *testing.T) { + t.Parallel() + const channel = "#voice" tserver := newTestServer(t) @@ -5277,6 +5740,8 @@ func TestModeVoice(t *testing.T) { // TestModeNonOpCannotGrant verifies non-operators // cannot grant +o or +v. func TestModeNonOpCannotGrant(t *testing.T) { + t.Parallel() + const channel = "#noperm" tserver := newTestServer(t) @@ -5336,6 +5801,8 @@ func TestModeNonOpCannotGrant(t *testing.T) { // TestModeratedChannelBlocksNonVoiced tests +m mode. func TestModeratedChannelBlocksNonVoiced(t *testing.T) { + t.Parallel() + const channel = "#moderated" tserver := newTestServer(t) @@ -5379,6 +5846,8 @@ func TestModeratedChannelBlocksNonVoiced(t *testing.T) { // TestModeratedChannelAllowsOp tests that operators can // send in +m channels. func TestModeratedChannelAllowsOp(t *testing.T) { + t.Parallel() + const channel = "#modop" tserver := newTestServer(t) @@ -5426,6 +5895,8 @@ func TestModeratedChannelAllowsOp(t *testing.T) { // TestModeratedChannelAllowsVoiced tests that voiced // users can send in +m channels. func TestModeratedChannelAllowsVoiced(t *testing.T) { + t.Parallel() + const channel = "#modvoice" tserver := newTestServer(t) @@ -5479,6 +5950,8 @@ func TestModeratedChannelAllowsVoiced(t *testing.T) { // TestTopicLockDefaultOn verifies new channels have +t. func TestTopicLockDefaultOn(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("topiclock") @@ -5522,6 +5995,8 @@ func TestTopicLockDefaultOn(t *testing.T) { // TestTopicLockEnforced verifies non-operators cannot // change topic when +t is active. func TestTopicLockEnforced(t *testing.T) { + t.Parallel() + const channel = "#tlock" tserver := newTestServer(t) @@ -5558,6 +6033,8 @@ func TestTopicLockEnforced(t *testing.T) { // TestTopicLockOpCanChange verifies operators CAN change // topic when +t is active. func TestTopicLockOpCanChange(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) opToken := tserver.createSession("topicop2") @@ -5590,6 +6067,8 @@ func TestTopicLockOpCanChange(t *testing.T) { // TestTopicLockDisabled verifies that -t allows anyone // to change topic. func TestTopicLockDisabled(t *testing.T) { + t.Parallel() + const channel = "#tloff" tserver := newTestServer(t) @@ -5635,6 +6114,8 @@ func TestTopicLockDisabled(t *testing.T) { // TestKickByOperator tests that an operator can kick // a user. func TestKickByOperator(t *testing.T) { + t.Parallel() + const channel = "#kicktest" tserver := newTestServer(t) @@ -5692,6 +6173,8 @@ func TestKickByOperator(t *testing.T) { // TestKickByNonOperator tests that non-operators cannot // kick. func TestKickByNonOperator(t *testing.T) { + t.Parallel() + const channel = "#kickperm" tserver := newTestServer(t) @@ -5732,6 +6215,8 @@ func TestKickByNonOperator(t *testing.T) { // TestKickTargetNotInChannel tests kicking a user not // in the channel. func TestKickTargetNotInChannel(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) opToken := tserver.createSession("op_kicknot") _ = tserver.createSession("outsider") @@ -5762,6 +6247,8 @@ func TestKickTargetNotInChannel(t *testing.T) { // TestKickBroadcastToChannel verifies the KICK message // is broadcast to all channel members. func TestKickBroadcastToChannel(t *testing.T) { + t.Parallel() + const channel = "#kickbc" tserver := newTestServer(t) @@ -5799,6 +6286,8 @@ func TestKickBroadcastToChannel(t *testing.T) { // TestNoticeNoAwayReply verifies NOTICE doesn't trigger // RPL_AWAY. func TestNoticeNoAwayReply(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) senderToken := tserver.createSession("noticesend") awayToken := tserver.createSession("noticeaway") @@ -5832,6 +6321,8 @@ func TestNoticeNoAwayReply(t *testing.T) { // TestPrivmsgTriggersAway verifies PRIVMSG DOES trigger // RPL_AWAY. func TestPrivmsgTriggersAway(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) senderToken := tserver.createSession("msgsend") awayToken := tserver.createSession("msgaway") @@ -5865,6 +6356,8 @@ func TestPrivmsgTriggersAway(t *testing.T) { // TestNoticeSkipsHashcash verifies NOTICE bypasses // hashcash on +H channels. func TestNoticeSkipsHashcash(t *testing.T) { + t.Parallel() + const channel = "#hcnotice" tserver := newTestServer(t) @@ -5903,6 +6396,8 @@ func TestNoticeSkipsHashcash(t *testing.T) { // TestModeratedNoticeBlocked verifies +m blocks NOTICE // from non-voiced/non-op users too. func TestModeratedNoticeBlocked(t *testing.T) { + t.Parallel() + const channel = "#modnotice" tserver := newTestServer(t) @@ -5943,6 +6438,8 @@ func TestModeratedNoticeBlocked(t *testing.T) { // TestNonOpCannotSetModerated verifies non-operators // cannot set +m. func TestNonOpCannotSetModerated(t *testing.T) { + t.Parallel() + const channel = "#modperm" tserver := newTestServer(t) @@ -5975,6 +6472,8 @@ func TestNonOpCannotSetModerated(t *testing.T) { // TestISupportPrefix verifies PREFIX=(ov)@+ is in 005. func TestISupportPrefix(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("isupport") @@ -6002,6 +6501,8 @@ func TestISupportPrefix(t *testing.T) { // TestModeQueryShowsModerated verifies MODE query shows // +m when set. func TestModeQueryShowsModerated(t *testing.T) { + t.Parallel() + const channel = "#mqtest" tserver := newTestServer(t) @@ -6043,6 +6544,8 @@ func TestModeQueryShowsModerated(t *testing.T) { // TestKickDefaultReason verifies KICK uses kicker's // nick as default reason. func TestKickDefaultReason(t *testing.T) { + t.Parallel() + const channel = "#kickdef" tserver := newTestServer(t) @@ -6084,6 +6587,8 @@ const ( // TestBanAddRemoveList verifies +b add, list, and -b // remove via MODE commands. func TestBanAddRemoveList(t *testing.T) { + t.Parallel() + const channel = "#bans" tserver := newTestServer(t) @@ -6153,6 +6658,8 @@ func TestBanAddRemoveList(t *testing.T) { // TestBanBlocksJoin verifies that a banned user cannot // join a channel. func TestBanBlocksJoin(t *testing.T) { + t.Parallel() + const channel = "#banjoin" tserver := newTestServer(t) @@ -6186,6 +6693,8 @@ func TestBanBlocksJoin(t *testing.T) { // TestBanBlocksPrivmsg verifies that a banned user who // is already in a channel cannot send messages. func TestBanBlocksPrivmsg(t *testing.T) { + t.Parallel() + const channel = "#banmsg" tserver := newTestServer(t) @@ -6226,6 +6735,8 @@ func TestBanBlocksPrivmsg(t *testing.T) { // TestInviteOnlyJoin verifies +i behavior: join rejected // without invite, accepted with invite. func TestInviteOnlyJoin(t *testing.T) { + t.Parallel() + const channel = "#invonly" tserver := newTestServer(t) @@ -6279,6 +6790,8 @@ func TestInviteOnlyJoin(t *testing.T) { // TestSecretChannelHiddenFromList verifies +s hides a // channel from LIST for non-members. func TestSecretChannelHiddenFromList(t *testing.T) { + t.Parallel() + const channel = "#secret" tserver := newTestServer(t) @@ -6350,6 +6863,8 @@ func TestSecretChannelHiddenFromList(t *testing.T) { // TestChannelKeyJoin verifies +k behavior: wrong/missing // key is rejected, correct key allows join. func TestChannelKeyJoin(t *testing.T) { + t.Parallel() + const channel = "#keyed" tserver := newTestServer(t) @@ -6414,6 +6929,8 @@ func TestChannelKeyJoin(t *testing.T) { // TestUserLimitEnforcement verifies +l behavior: blocks // join when at capacity. func TestUserLimitEnforcement(t *testing.T) { + t.Parallel() + const channel = "#limited" tserver := newTestServer(t) @@ -6458,6 +6975,8 @@ func TestUserLimitEnforcement(t *testing.T) { // TestModeStringIncludesNewModes verifies that querying // channel mode returns the new modes (+i, +s, +k, +l). func TestModeStringIncludesNewModes(t *testing.T) { + t.Parallel() + const channel = "#modestr" tserver := newTestServer(t) @@ -6512,6 +7031,8 @@ func TestModeStringIncludesNewModes(t *testing.T) { // TestISUPPORT verifies the 005 numeric includes the // updated CHANMODES string. func TestISUPPORT(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("isupport") @@ -6535,9 +7056,37 @@ func TestISUPPORT(t *testing.T) { } } +// TestMyInfoListsModes verifies the 004 numeric lists the +// user modes o and w as its third parameter, and the HTTP +// API's channel modes Hbiklmnostv as its fourth. +func TestMyInfoListsModes(t *testing.T) { + t.Parallel() + + tserver := newTestServer(t) + token := tserver.createSession("myinfo") + + msgs, _ := tserver.pollMessages(token, 0) + + myInfo := findNumericWithParams(msgs, "004") + if myInfo == nil { + t.Fatal("expected 004 RPL_MYINFO") + } + + params := getNumericParams(myInfo) + if len(params) != 4 || params[2] != "ow" || + params[3] != "Hbiklmnostv" { + t.Fatalf( + "004 params %v, want four ending ow Hbiklmnostv", + params, + ) + } +} + // TestNonOpCannotSetModes verifies non-operators // cannot set +i, +s, +k, +l, +b. func TestNonOpCannotSetModes(t *testing.T) { + t.Parallel() + const channel = "#noperm" tserver := newTestServer(t) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 21d9bc6..172a790 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "net/http" "strings" @@ -55,7 +54,7 @@ func (hdlr *Handlers) handleLogin( var payload loginRequest - err := json.NewDecoder(request.Body).Decode(&payload) + err := decodeJSON(request, &payload) if err != nil { hdlr.respondError( writer, request, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2326fff..58ba9c5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "io" "log/slog" "net/http" "time" @@ -159,6 +160,18 @@ func (hdlr *Handlers) respondError( ) } +// decodeJSON reads the whole request body and decodes it into dest. +// It fails if the body is larger than the http.MaxBytesReader limit, +// or holds anything other than one JSON value and whitespace. +func decodeJSON(request *http.Request, dest any) error { + body, err := io.ReadAll(request.Body) + if err != nil { + return err + } + + return json.Unmarshal(body, dest) +} + func (hdlr *Handlers) idleTimeout() time.Duration { raw := hdlr.params.Config.SessionIdleTimeout if raw == "" { diff --git a/internal/handlers/utility_test.go b/internal/handlers/utility_test.go index f5dea41..adcc9b8 100644 --- a/internal/handlers/utility_test.go +++ b/internal/handlers/utility_test.go @@ -1,4 +1,3 @@ -//nolint:paralleltest // global viper, as in api_test.go package handlers_test import ( @@ -72,6 +71,8 @@ func numericBody( } func TestUserhost(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("alice") @@ -109,6 +110,8 @@ func TestUserhost(t *testing.T) { } func TestVersionAdminInfoTime(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("frank") @@ -147,6 +150,8 @@ func TestVersionAdminInfoTime(t *testing.T) { } func TestKill(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) victimToken := tserver.createSession("victim") @@ -213,6 +218,8 @@ func TestKill(t *testing.T) { } func TestWallops(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) receiverToken := tserver.createSession("receiver") @@ -258,6 +265,8 @@ func TestWallops(t *testing.T) { } func TestUserMode(t *testing.T) { + t.Parallel() + const ( nick = "alice" other = "other" diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 43e00e5..c2b4e16 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -371,6 +371,7 @@ func (c *Conn) handleQuit(msg *Message) { c.mu.Lock() c.closed = true + c.quitReason = reason c.mu.Unlock() } @@ -1226,9 +1227,12 @@ func (c *Conn) handlePassPostReg( return } + // The IRC listener cannot add a connection to an existing + // session; only the HTTP API login can. c.sendFromServer("NOTICE", c.currentNick(), - "Password set. You can reconnect using "+ - "PASS with your nick.", + "Password set. Another client can log in to this "+ + "session over the HTTP API: POST /api/v1/login "+ + "with your nick and this password.", ) } diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index 3dcca86..0584e19 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -69,6 +69,11 @@ type Conn struct { lastQueueID int64 closed bool killed bool + + // quitReason is the reason in the QUIT that peers are + // sent when the connection ends. The client's QUIT + // replaces it. + quitReason string } func newConn( @@ -87,14 +92,15 @@ func newConn( } conn := &Conn{ //nolint:exhaustruct // zero-value defaults - conn: tcpConn, - log: log, - database: database, - brk: brk, - cfg: cfg, - svc: svc, - serverSfx: srvName, - remoteIP: host, + conn: tcpConn, + log: log, + database: database, + brk: brk, + cfg: cfg, + svc: svc, + serverSfx: srvName, + remoteIP: host, + quitReason: "Connection closed", } conn.commands = conn.buildCommandMap() @@ -278,6 +284,7 @@ func (c *Conn) cleanup(ctx context.Context) { wasKilled := c.killed sessID := c.sessionID nick := c.nick + quitReason := c.quitReason c.closed = true c.mu.Unlock() @@ -287,9 +294,7 @@ func (c *Conn) cleanup(ctx context.Context) { // KILL has already sent the QUIT and deleted the // session. if !wasKilled { - c.svc.BroadcastQuit( - ctx, sessID, nick, "Connection closed", - ) + c.svc.BroadcastQuit(ctx, sessID, nick, quitReason) } } @@ -605,7 +610,7 @@ func (c *Conn) deliverWelcome() { ) c.sendNumeric( irc.RplMyInfo, - c.serverSfx, "neoirc", "", "mnst", + c.serverSfx, "neoirc", service.UserModes, "Himnostv", ) c.sendNumeric( irc.RplIsupport, diff --git a/internal/ircserver/relay.go b/internal/ircserver/relay.go index 2275adc..2933410 100644 --- a/internal/ircserver/relay.go +++ b/internal/ircserver/relay.go @@ -32,8 +32,12 @@ func (c *Conn) relayMessages(ctx context.Context) { // still signals waitCh. waitCh := c.brk.Wait(c.sessionID) - // Drain any available messages. - delivered := c.drainQueue(ctx) + // Drain any available messages. The drain ignores the + // cancel that comes when the connection ends, because a + // query cancelled while it runs leaves the SQLite + // connection marked broken, and the pool closes it. The + // loop still stops on ctx. + delivered := c.drainQueue(context.WithoutCancel(ctx)) if delivered { c.brk.Remove(c.sessionID, waitCh) diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index a40c281..5db0982 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "golang.org/x/crypto/bcrypt" + "sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/service" ) @@ -316,7 +318,7 @@ func TestRelayNickDiffersOnlyInCase(t *testing.T) { {"JOIN #case", ":Alice!Alice@* JOIN #case"}, {"NICK Alicia", ":Alice!Alice@* NICK Alicia"}, {"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT - {"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"}, + {"QUIT", ":Alice!Alice@* QUIT :Quit: Client quit"}, } { capitalAlice.send(step.line) @@ -583,8 +585,10 @@ func ownSessionCopies( // aliceWithHTTPClient connects alice to the IRC listener and // has her join #chan, which bob is on too, then adds an HTTP API -// client to her session with the password her connection set, -// as POST /api/v1/login does. It returns her connection, bob's +// client to her session with a password, as POST /api/v1/login +// does. It stores that password without sending PASS. It hashes +// at bcrypt.MinCost, and a login compares at the cost stored in +// the hash, so both are fast. It returns her connection, bob's // connection, her session and her HTTP API client. func aliceWithHTTPClient( t *testing.T, env *testEnv, @@ -594,15 +598,24 @@ func aliceWithHTTPClient( const password = "alicepass" alice := env.dial(t) - alice.send("PASS " + password) alice.register("alice") - // Registration stores the password before the JOIN runs. alice.joinAndDrain("#chan") bob := env.dial(t) bob.register("bob") bob.joinAndDrain("#chan") + hash, err := bcrypt.GenerateFromPassword( + []byte(password), bcrypt.MinCost, + ) + if err != nil { + t.Fatalf("hash password: %v", err) + } + + dbtest.Exec(t, env.dbURL, + "UPDATE sessions SET password_hash = '"+string(hash)+ + "' WHERE nick = 'alice'") + sessionID, httpClientID, _, err := env.database.LoginUser( t.Context(), "alice", password, "127.0.0.1", "localhost", ) diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index a530b36..2ca7d02 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -3,14 +3,15 @@ package ircserver_test import ( "bufio" "context" - "crypto/rand" "database/sql" + "encoding/json" "errors" "fmt" "log/slog" "net" "os" "runtime" + "slices" "strings" "sync/atomic" "testing" @@ -20,23 +21,20 @@ import ( "sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" + "sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/ircserver" "sneak.berlin/go/neoirc/internal/logger" + "sneak.berlin/go/neoirc/pkg/irc" _ "modernc.org/sqlite" ) const testTimeout = 5 * time.Second -func TestMain(m *testing.M) { - db.SetBcryptCost(4) - - os.Exit(m.Run()) -} - // testEnv holds the shared test infrastructure. type testEnv struct { + dbURL string database *db.Database brk *broker.Broker cfg *config.Config @@ -107,57 +105,29 @@ func newTestEnvWithResolver( ) *testEnv { t.Helper() - // A random name, so that no other test environment, not - // even an earlier run of the same test, can share it. - dsn := fmt.Sprintf( - "file:%s?mode=memory&cache=shared", - rand.Text(), - ) + dbURL := dbtest.NewURL(t) + lifecycle := fxtest.NewLifecycle(t) - conn, err := sql.Open("sqlite", dsn) - if err != nil { - t.Fatalf("open db: %v", err) - } - - t.Cleanup(func() { - err := conn.Close() - if err != nil { - t.Logf("close db: %v", err) - } + dbLog, err := logger.New(lifecycle, logger.Params{ + Globals: &globals.Globals{}, }) - - conn.SetMaxOpenConns(1) - - _, err = conn.ExecContext( - t.Context(), "PRAGMA foreign_keys = ON", - ) if err != nil { - t.Fatalf("pragma: %v", err) + t.Fatalf("new logger: %v", err) } - database := db.NewTestDatabaseFromConn(conn) - - err = database.RunMigrations(t.Context()) + database, err := db.New(lifecycle, db.Params{ + Logger: dbLog, + Config: &config.Config{DBURL: dbURL}, + }) if err != nil { - t.Fatalf("migrate: %v", err) + t.Fatalf("new database: %v", err) } + lifecycle.RequireStart() + t.Cleanup(lifecycle.RequireStop) + brk := broker.New() - var listenConfig net.ListenConfig - - listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - - addr := listener.Addr().String() - - err = listener.Close() - if err != nil { - t.Fatalf("close listener: %v", err) - } - log := slog.New(slog.NewTextHandler( os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}, //nolint:exhaustruct @@ -167,7 +137,9 @@ func newTestEnvWithResolver( log, cfg, database, brk, interval, deadline, resolve, ) - err = srv.Start(addr) + // Port 0 makes the system pick a free port. dial reads it + // back from the server's listener. + err = srv.Start("127.0.0.1:0") if err != nil { t.Fatalf("start irc server: %v", err) } @@ -177,6 +149,7 @@ func newTestEnvWithResolver( t.Cleanup(srv.Stop) return &testEnv{ + dbURL: dbURL, database: database, brk: brk, cfg: cfg, @@ -505,6 +478,22 @@ func TestWelcomeContainsNick(t *testing.T) { } } +// TestMyInfoListsModes checks that RPL_MYINFO lists the user +// modes o and w, then the channel modes MODE accepts on the +// IRC listener. It compares the whole line, so a missing or +// extra mode fails it. +func TestMyInfoListsModes(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + lines := env.dial(t).register("carol") + + want := ":test.irc 004 carol test.irc neoirc ow Himnostv" + if !slices.Contains(lines, want) { + t.Errorf("no line %q in %v", want, lines) + } +} + func TestPingPong(t *testing.T) { t.Parallel() @@ -875,6 +864,107 @@ func TestQuit(t *testing.T) { assertContains(t, lines, "goodbye", "QUIT reason") } +// TestQuitReasonReachesPeers checks the reason that an IRC +// listener user's channel peers see in its QUIT, on both the +// IRC listener and the HTTP API. It is the reason the user +// gave, Client quit for a QUIT without one, or Connection +// closed when the connection ends without a QUIT. +func TestQuitReasonReachesPeers(t *testing.T) { + t.Parallel() + + for _, step := range []struct { + name, quit, want string + }{ + {"QUIT with a reason", "QUIT :gone home", "gone home"}, + {"QUIT without a reason", irc.CmdQuit, "Client quit"}, + {"connection closed without QUIT", "", "Connection closed"}, + } { + t.Run(step.name, func(t *testing.T) { + t.Parallel() + + ircLine, apiBody := quitSeenByPeers(t, step.quit) + + want := ":quitter!quitter@* QUIT :Quit: " + step.want + if ircLine != want { + t.Errorf("IRC peer got %q, want %q", ircLine, want) + } + + if len(apiBody) != 1 || apiBody[0] != step.want { + t.Errorf("HTTP API peer got %q, want [%q]", apiBody, step.want) + } + }) + } +} + +// quitSeenByPeers starts a server on which quitter, ircpeer on +// the IRC listener and apipeer on the HTTP API share a channel. +// quitter sends the line quit, or closes its connection when +// quit is empty. It returns the QUIT line ircpeer is sent and +// the body of the QUIT apipeer is sent. +func quitSeenByPeers(t *testing.T, quit string) (string, []string) { + t.Helper() + + env := newTestEnv(t) + ctx := t.Context() + + ircPeer := env.dial(t) + ircPeer.register("ircpeer") + ircPeer.joinAndDrain("#bye") + + apiPeerID, apiPeerClientID, _, err := env.database.CreateSession( + ctx, "apipeer", "apipeer", "", "", + ) + if err != nil { + t.Fatalf("create peer session: %v", err) + } + + _, _, err = env.database.CreateOrJoinChannel(ctx, "#bye", apiPeerID, "") + if err != nil { + t.Fatalf("join peer: %v", err) + } + + quitter := env.dial(t) + quitter.register("quitter") + quitter.joinAndDrain("#bye") + + if quit == "" { + err = quitter.conn.Close() + if err != nil { + t.Fatalf("close: %v", err) + } + } else { + quitter.send(quit) + } + + lines := ircPeer.readUntil(func(l string) bool { + return strings.Contains(l, " QUIT ") + }) + + // The QUIT is queued to both peers at once, so apipeer has + // it by now. + msgs, _, err := env.database.PollMessages(ctx, apiPeerClientID, 0, 0) + if err != nil { + t.Fatalf("poll peer: %v", err) + } + + for _, msg := range msgs { + if msg.Command == irc.CmdQuit && msg.From == "quitter" { + var body []string + + err = json.Unmarshal(msg.Body, &body) + if err != nil { + t.Fatalf("decode QUIT body %s: %v", msg.Body, err) + } + + return lines[len(lines)-1], body + } + } + + t.Fatalf("apipeer was not sent the QUIT: %v", msgs) + + return "", nil +} + // TestStopEndsSessions checks that stopping the server ends // a registered connection's session before Stop returns, as // closing the connection does: the session, its client and @@ -899,12 +989,9 @@ func TestStopEndsSessions(t *testing.T) { t.Fatalf("create peer session: %v", err) } - channelID, err := env.database.GetChannelByName(ctx, "#shut") - if err != nil { - t.Fatalf("get channel: %v", err) - } - - err = env.database.JoinChannel(ctx, channelID, peerID) + channelID, _, err := env.database.CreateOrJoinChannel( + ctx, "#shut", peerID, "", + ) if err != nil { t.Fatalf("join peer: %v", err) } @@ -962,11 +1049,16 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) { client.register("stuck") - // The test database has one connection. While the test - // holds it, the cleanup's first query waits for it. - held, err := env.database.GetDB().Conn(t.Context()) + // While a connection of the test's own holds a write + // transaction, the cleanup's first write waits for it to end. + holder, err := sql.Open("sqlite", env.dbURL) if err != nil { - t.Fatalf("take the database connection: %v", err) + t.Fatalf("open a second database connection: %v", err) + } + + _, err = holder.ExecContext(t.Context(), "BEGIN IMMEDIATE") + if err != nil { + t.Fatalf("begin a write transaction: %v", err) } stopped := make(chan struct{}) @@ -982,10 +1074,11 @@ func TestStopReturnsWhileCleanupIsStuck(t *testing.T) { t.Error("Stop did not return while a cleanup was stuck") } - // Lets the cleanup finish before the database is closed. - err = held.Close() + // Closing ends the transaction, which lets the cleanup finish + // before the database is closed. + err = holder.Close() if err != nil { - t.Errorf("release the database connection: %v", err) + t.Errorf("close the second database connection: %v", err) } } @@ -1144,6 +1237,9 @@ func TestAwaySetAndClear(t *testing.T) { assertContains(t, clearLines, " 305 ", "RPL_UNAWAY") } +// TestHandlePassPostRegistration checks the NOTICE that +// confirms PASS after registration. It points to the HTTP API +// login, the only way to add a client to an existing session. func TestHandlePassPostRegistration(t *testing.T) { t.Parallel() @@ -1156,7 +1252,11 @@ func TestHandlePassPostRegistration(t *testing.T) { ) assertContains( - t, lines, "Password set", "password confirmation", + t, lines, + " NOTICE passuser :Password set. Another client can log "+ + "in to this session over the HTTP API: "+ + "POST /api/v1/login with your nick and this password.", + "password confirmation", ) } @@ -1257,14 +1357,11 @@ func TestPassPostRegistrationNotStored(t *testing.T) { client.register("failpass") - _, err := env.database.GetDB().ExecContext(t.Context(), + dbtest.Exec(t, env.dbURL, `CREATE TRIGGER refuse_password BEFORE UPDATE OF password_hash ON sessions BEGIN SELECT RAISE(ABORT, 'refused'); END`, ) - if err != nil { - t.Fatalf("create trigger: %v", err) - } client.send("PASS :mypassword123") diff --git a/internal/server/export_test.go b/internal/server/export_test.go index 3908b62..ef72b69 100644 --- a/internal/server/export_test.go +++ b/internal/server/export_test.go @@ -15,6 +15,12 @@ func NewTestSPAServer() *Server { return srv } +// Listener returns the listener the start hook bound for the +// HTTP server. +func (srv *Server) Listener() net.Listener { + return srv.listener +} + // ServeUntilShutdown runs serveUntilShutdown, which serves the // HTTP server on listener, and returns when it does. func (srv *Server) ServeUntilShutdown(listener net.Listener) { diff --git a/internal/server/server.go b/internal/server/server.go index 0bd8951..c5715ab 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -50,6 +50,7 @@ type Server struct { sentryEnabled bool log *slog.Logger httpServer *http.Server + listener net.Listener router *chi.Mux params Params mw *middleware.Middleware @@ -179,6 +180,8 @@ func (srv *Server) startHTTP(ctx context.Context) error { return fmt.Errorf("http listen: %w", err) } + srv.listener = listener + srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields Addr: listenAddr, ReadTimeout: httpReadTimeout, diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 148f28e..6a44326 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -5,7 +5,6 @@ package server_test import ( - "crypto/rand" "fmt" "net" "net/http" @@ -19,6 +18,7 @@ import ( "sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" + "sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/handlers" "sneak.berlin/go/neoirc/internal/healthcheck" @@ -45,10 +45,9 @@ const ( // for a long poll the HTTP server is serving to finish, and // that the poll gets its response. func TestStopWaitsForRequestInProgress(t *testing.T) { - port := freePort(t) - addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port)) + var srv *server.Server - app, brk, database := startApp(t, port) + app, brk, database := startApp(t, fx.Populate(&srv)) sessionID, _, token, err := database.CreateSession( t.Context(), "poller", "poller", "", "", @@ -57,9 +56,12 @@ func TestStopWaitsForRequestInProgress(t *testing.T) { t.Fatalf("create session: %v", err) } - waitUntil(t, "the HTTP server to listen", func() bool { - return canConnect(t, addr) - }) + tcpAddr, ok := srv.Listener().Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("listener address %v is not TCP", srv.Listener().Addr()) + } + + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(tcpAddr.Port)) statuses := make(chan int, 1) @@ -145,7 +147,7 @@ func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) { func TestServeFailureStopsApp(t *testing.T) { var srv *server.Server - app, _, _ := startApp(t, freePort(t), fx.Populate(&srv)) + app, _, _ := startApp(t, fx.Populate(&srv)) shutdowns := app.Wait() @@ -168,7 +170,7 @@ func TestServeFailureStopsApp(t *testing.T) { func TestServeAfterStopDoesNotStopApp(t *testing.T) { var srv *server.Server - app, _, _ := startApp(t, freePort(t), fx.Populate(&srv)) + app, _, _ := startApp(t, fx.Populate(&srv)) shutdowns := app.Wait() @@ -186,16 +188,15 @@ func TestServeAfterStopDoesNotStopApp(t *testing.T) { } } -// startApp starts the app newApp builds, with the HTTP server -// listening on port. +// startApp starts the app newApp builds, with the HTTP port +// set to 0, so the system gives the server a free port. func startApp( t *testing.T, - port int, options ...fx.Option, ) (*fxtest.App, *broker.Broker, *db.Database) { t.Helper() - app, brk, database := newApp(t, port, options...) + app, brk, database := newApp(t, 0, options...) app.RequireStart() @@ -241,10 +242,7 @@ func newApp( stats.New, ), fx.Decorate(func(cfg *config.Config) *config.Config { - // A random name, so that no other test can share it. - cfg.DBURL = fmt.Sprintf( - "file:%s?mode=memory&cache=shared", rand.Text(), - ) + cfg.DBURL = dbtest.NewURL(t) cfg.Port = port return cfg @@ -257,21 +255,6 @@ func newApp( return app, brk, database } -// freePort returns a TCP port that nothing was listening on a -// moment ago. -func freePort(t *testing.T) int { - t.Helper() - - listener := closedListener(t) - - tcpAddr, ok := listener.Addr().(*net.TCPAddr) - if !ok { - t.Fatalf("listener address %v is not TCP", listener.Addr()) - } - - return tcpAddr.Port -} - // closedListener returns a TCP listener that has already been // closed, so that accepting a connection on it fails. func closedListener(t *testing.T) net.Listener { @@ -294,22 +277,6 @@ func closedListener(t *testing.T) net.Listener { return listener } -// canConnect reports whether addr accepts a TCP connection. -func canConnect(t *testing.T, addr string) bool { - t.Helper() - - var dialer net.Dialer - - conn, err := dialer.DialContext(t.Context(), "tcp", addr) - if err != nil { - return false - } - - _ = conn.Close() - - return true -} - // waitUntil calls done every checkStep until it returns true, // and fails the test if it does not within testTimeout. func waitUntil(t *testing.T, what string, done func() bool) { diff --git a/internal/service/service.go b/internal/service/service.go index f16340d..2036ad8 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -1169,6 +1169,12 @@ func (s *Service) ApplyUserMode( return s.QueryUserMode(ctx, sessionID) } +// UserModes lists the user modes the server supports, as +// RPL_MYINFO sends them: o (server operator) and w +// (receives WALLOPS). parseUserModeString must accept the +// same letters. +const UserModes = "ow" + // parseUserModeString parses a user mode string such as // "+w", "-o" or "+w-o" into the new value of each flag it // names, or nil for a flag it does not name; a later letter diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 3ad3279..f27eff8 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -6,11 +6,9 @@ package service_test import ( "context" - "crypto/rand" "encoding/json" "errors" "fmt" - "os" "slices" "strings" "sync" @@ -19,10 +17,10 @@ import ( "go.uber.org/fx" "go.uber.org/fx/fxtest" - "golang.org/x/crypto/bcrypt" "sneak.berlin/go/neoirc/internal/broker" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/db" + "sneak.berlin/go/neoirc/internal/dbtest" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/hashcash" "sneak.berlin/go/neoirc/internal/logger" @@ -30,14 +28,10 @@ import ( "sneak.berlin/go/neoirc/pkg/irc" ) -func TestMain(m *testing.M) { - db.SetBcryptCost(bcrypt.MinCost) - os.Exit(m.Run()) -} - // testEnv holds all dependencies for a service test. type testEnv struct { svc *service.Service + dbURL string db *db.Database broker *broker.Broker app *fxtest.App @@ -46,13 +40,7 @@ type testEnv struct { func newTestEnv(t *testing.T) *testEnv { t.Helper() - // A random name, so that no other test can share it, not - // even one that has finished but still holds its database - // open. - dbURL := fmt.Sprintf( - "file:%s?mode=memory&cache=shared", - rand.Text(), - ) + dbURL := dbtest.NewURL(t) var ( database *db.Database @@ -117,6 +105,7 @@ func newTestEnv(t *testing.T) *testEnv { return &testEnv{ svc: svc, + dbURL: dbURL, db: database, broker: brk, app: app, @@ -946,19 +935,16 @@ func TestSendDirectMessageTargetQuits(t *testing.T) { sid := createSession(ctx, t, env.db, "alice") createSession(ctx, t, env.db, "bob") - _, err := env.db.GetDB().ExecContext(ctx, + dbtest.Exec(t, env.dbURL, `CREATE TRIGGER delete_recipient BEFORE INSERT ON messages BEGIN DELETE FROM sessions WHERE id = NEW.to_session_id; END`) - if err != nil { - t.Fatalf("create trigger: %v", err) - } body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson - _, err = env.svc.SendDirectMessage( + _, err := env.svc.SendDirectMessage( ctx, sid, 0, "alice", irc.CmdPrivmsg, "bob", body, nil, ) @@ -984,15 +970,12 @@ func TestSendChannelMessageChannelDeleted(t *testing.T) { t.Fatalf("join alice: %v", err) } - _, err = env.db.GetDB().ExecContext(ctx, + dbtest.Exec(t, env.dbURL, `CREATE TRIGGER delete_recipient BEFORE INSERT ON messages BEGIN DELETE FROM channels WHERE id = NEW.to_channel_id; END`) - if err != nil { - t.Fatalf("create trigger: %v", err) - } body, _ := json.Marshal([]string{"hi"}) //nolint:errchkjson diff --git a/schema/README.md b/schema/README.md index 4a91ae6..995e9e7 100644 --- a/schema/README.md +++ b/schema/README.md @@ -1,98 +1,101 @@ -# Message Schemas +# Message schemas -JSON Schema definitions (draft 2020-12) for the neoirc protocol. Messages use -**IRC command names and numeric reply codes** (RFC 1459/2812) encoded as JSON -over HTTP. +This directory holds JSON Schema files (draft 2020-12) for the messages that the +neoirc HTTP API carries. `message.json` describes the fields that every message +can have. `commands/` has files for some commands. `numerics/` has files for +some of the numeric replies. A numeric reply is a server reply named by a +three-digit number, such as `001`. `README.md` at the repository root documents +the protocol. -## Envelope +The server can also run an IRC listener for ordinary IRC clients. The IRC +listener sends plain IRC lines, which these files do not describe. -Every message is a JSON object with a `command` field. The format maps directly -to IRC wire format: +## Message fields + +Every message is one JSON object, which `message.json` calls the envelope. These +are the fields it can have: + +- `command`: the IRC command name, such as `PRIVMSG`, or the name of a numeric + reply, such as `RPL_WELCOME`. Always present. +- `code`: the number of a numeric reply, such as `1` for `RPL_WELCOME`. Absent + on other messages. +- `id`: the message's UUID, a unique ID that the server assigns. +- `from`: the sender's nick, or the server name on a numeric reply or a `PONG`. +- `to`: the target, such as a channel name starting with `#` or a nick. +- `params`: an array of strings with the message's other parameters, in order. + `KICK` events and many numeric replies have it. The HTTP API ignores `params` + sent by a client. +- `body`: an array of strings. It is never a bare string. In a `PRIVMSG` or + `NOTICE`, each string is one line of text. +- `ts`: the time the server stored the message. It is an RFC 3339 time in UTC, + such as `2026-10-27T12:00:00.123Z`. +- `meta`: an object of extra data, such as a hashcash stamp in `meta.hashcash`. + A hashcash stamp is a short string that takes a computer some work to make. A + `PRIVMSG` to a channel with mode `+H` needs one. The HTTP API relays the + `meta` of a `PRIVMSG` or `NOTICE` unchanged. It drops the `meta` of every + other command. + +Here is one `PRIVMSG` as the IRC listener sends it and as the HTTP API sends it. +The JSON leaves out `id`, `ts` and `meta`: ``` -IRC: :nick PRIVMSG #channel :hello world -JSON: {"command": "PRIVMSG", "from": "nick", "to": "#channel", "body": ["hello world"]} - -IRC: :server 353 nick = #channel :user1 @op1 +voice1 -JSON: {"command": "353", "to": "nick", "params": ["=", "#channel"], "body": ["user1 @op1 +voice1"]} - -Multiline: {"command": "PRIVMSG", "to": "#ch", "body": ["line 1", "line 2"]} -Structured: {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."}} +IRC: :alice!alice@* PRIVMSG #general :hello world +JSON: {"command": "PRIVMSG", "from": "alice", "to": "#general", "body": ["hello world"]} ``` -Common fields (see `message.json` for full schema): +In the JSON, a numeric reply has its name in `command` and its number in `code`. +This `353` reply lists the members of `#general`. The JSON again leaves out +`id`, `ts` and `meta`: -| Field | Type | Description | -| --------- | --------------- | ---------------------------------------------------- | -| `id` | string (uuid) | Server-assigned message UUID | -| `command` | string | IRC command or 3-digit numeric code | -| `from` | string | Source nick or server name (IRC prefix) | -| `to` | string | Target: #channel or nick | -| `params` | string[] | Middle parameters (mainly for numerics) | -| `body` | array \| object | Structured body — never a raw string (see below) | -| `ts` | string | ISO 8601 timestamp (server-assigned, not in raw IRC) | -| `meta` | object | Extensible metadata (signatures, hashes, etc.) | +``` +{"command": "RPL_NAMREPLY", "code": 353, "from": "neoirc", "to": "alice", + "params": ["=", "#general"], "body": ["@op1!op1@host1 alice!alice@host2"]} +``` -**Structured bodies:** `body` is always an array of strings (for text) or an -object (for structured data like PUBKEY). Never a raw string. This enables: +A `PRIVMSG` whose `body` has two strings is a two-line message: -- Multiline messages without escape sequences -- Deterministic canonicalization via RFC 8785 JCS for signing -- Structured data where needed +``` +{"command": "PRIVMSG", "to": "#general", "body": ["line one", "line two"]} +``` -## Commands +## Files -IRC commands used for client↔server and server↔server communication. +Most command files describe both the message a client sends and the message the +server sends. Their field descriptions say where the two differ. The examples in +the files leave out `id`, `ts` and `meta`. -| Command | File | RFC | Description | -| --------- | ----------------------- | ----------- | -------------------------- | -| `PRIVMSG` | `commands/PRIVMSG.json` | 1459 §4.4.1 | Message to channel or user | -| `NOTICE` | `commands/NOTICE.json` | 1459 §4.4.2 | Notice (no auto-reply) | -| `JOIN` | `commands/JOIN.json` | 1459 §4.2.1 | Join a channel | -| `PART` | `commands/PART.json` | 1459 §4.2.2 | Leave a channel | -| `QUIT` | `commands/QUIT.json` | 1459 §4.1.6 | User disconnected | -| `NICK` | `commands/NICK.json` | 1459 §4.1.2 | Change nickname | -| `TOPIC` | `commands/TOPIC.json` | 1459 §4.2.4 | Get/set channel topic | -| `MODE` | `commands/MODE.json` | 1459 §4.2.3 | Set channel/user modes | -| `KICK` | `commands/KICK.json` | 1459 §4.2.8 | Kick user from channel | -| `PING` | `commands/PING.json` | 1459 §4.6.2 | Keepalive | -| `PONG` | `commands/PONG.json` | 1459 §4.6.3 | Keepalive response | -| `PUBKEY` | `commands/PUBKEY.json` | (extension) | Announce/relay signing key | +Commands, in `commands/`: -## Numeric Replies +- `PRIVMSG.json`: a message to a channel or a user. +- `NOTICE.json`: a notice, which is a message that a client must not answer + automatically. +- `JOIN.json`: joining a channel. +- `PART.json`: leaving a channel. +- `QUIT.json`: leaving the server. +- `NICK.json`: changing nick. +- `TOPIC.json`: setting a channel's topic. +- `MODE.json`: querying or changing a channel's modes or the user's own modes. +- `KICK.json`: a channel operator removing a user from a channel. +- `PING.json`: a check that the server still answers. +- `PONG.json`: the answer to a `PING`, which is the HTTP response to it. -Three-digit codes for server responses, per IRC convention. +Numeric replies, in `numerics/`: -### Success / Informational (0xx–3xx) - -| Code | Name | File | Description | -| ----- | -------------- | ------------------- | ------------------------------ | -| `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation | -| `002` | RPL_YOURHOST | `numerics/002.json` | Server host info | -| `003` | RPL_CREATED | `numerics/003.json` | Server creation date | -| `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes | -| `322` | RPL_LIST | `numerics/322.json` | Channel list entry | -| `323` | RPL_LISTEND | `numerics/323.json` | End of channel list | -| `332` | RPL_TOPIC | `numerics/332.json` | Channel topic | -| `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list | -| `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list | -| `372` | RPL_MOTD | `numerics/372.json` | MOTD line | -| `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD | -| `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD | - -### Errors (4xx) - -| Code | Name | File | Description | -| ----- | -------------------- | ------------------- | ----------------------- | -| `401` | ERR_NOSUCHNICK | `numerics/401.json` | No such nick/channel | -| `403` | ERR_NOSUCHCHANNEL | `numerics/403.json` | No such channel | -| `433` | ERR_NICKNAMEINUSE | `numerics/433.json` | Nickname already in use | -| `442` | ERR_NOTONCHANNEL | `numerics/442.json` | Not on that channel | -| `482` | ERR_CHANOPRIVSNEEDED | `numerics/482.json` | Not channel operator | - -## Federation (S2S) - -Server-to-server messages use the same command format. Federated servers relay -messages with an additional `origin` field in `meta` to track the source server. -The PING/PONG commands serve as inter-server keepalives. State sync after link -establishment uses a burst of JOIN, NICK, TOPIC, and MODE commands. +- `001.json`: `RPL_WELCOME`, which welcomes the user. +- `002.json`: `RPL_YOURHOST`, the server's name and version. +- `003.json`: `RPL_CREATED`, the date the server last started. +- `004.json`: `RPL_MYINFO`, the server's name, its version and its lists of + modes. +- `322.json`: `RPL_LIST`, one channel in the answer to `LIST`. +- `323.json`: `RPL_LISTEND`, the end of the answer to `LIST`. +- `332.json`: `RPL_TOPIC`, a channel's topic. +- `353.json`: `RPL_NAMREPLY`, a channel's members. +- `366.json`: `RPL_ENDOFNAMES`, the end of the member list. +- `372.json`: `RPL_MOTD`, one line of the message of the day. +- `375.json`: `RPL_MOTDSTART`, the start of the message of the day. +- `376.json`: `RPL_ENDOFMOTD`, the end of the message of the day. +- `401.json`: `ERR_NOSUCHNICK`, the nick does not exist. +- `403.json`: `ERR_NOSUCHCHANNEL`, the channel does not exist. +- `433.json`: `ERR_NICKNAMEINUSE`, the nick is taken. +- `442.json`: `ERR_NOTONCHANNEL`, the user is not on that channel. +- `482.json`: `ERR_CHANOPRIVSNEEDED`, the user is not a channel operator. diff --git a/schema/commands/JOIN.json b/schema/commands/JOIN.json index 988cabf..4f3806a 100644 --- a/schema/commands/JOIN.json +++ b/schema/commands/JOIN.json @@ -2,15 +2,25 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/JOIN.json", "title": "JOIN", - "description": "Join a channel. C2S: request to join. S2C: notification that a user joined. RFC 1459 §4.2.1.", + "description": "Join a channel. A client sends it to join a channel, which the server creates if it does not exist. The server sends it to every member of the channel when a user joins, the new member included. RFC 1459 §4.2.1.", "$ref": "../message.json", "properties": { "command": { "const": "JOIN" }, - "from": { "type": "string", "description": "Nick that joined (S2C)." }, - "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" } + "from": { "type": "string", "description": "The nick that joined. The server sets it." }, + "to": { + "type": "string", + "description": "The channel name: # and then 1 to 63 of a-z, A-Z, 0-9, _ and -. The server adds the # if a client leaves it out.", + "pattern": "^#?[a-zA-Z0-9_-]{1,63}$" + }, + "body": { + "type": "array", + "items": { "type": "string" }, + "description": "From a client: the channel key as the first string, which a channel with mode +k needs. From the server: the channel name. After a login, and for GET /api/v1/state?initChannelState=1, the server queues a JOIN for each of the session's channels. That JOIN has an empty body." + } }, "required": ["command", "to"], "examples": [ - { "command": "JOIN", "from": "alice", "to": "#general" } + { "command": "JOIN", "to": "#general" }, + { "command": "JOIN", "from": "alice", "to": "#general", "body": ["#general"] } ] } diff --git a/schema/commands/KICK.json b/schema/commands/KICK.json index c744419..8f6778b 100644 --- a/schema/commands/KICK.json +++ b/schema/commands/KICK.json @@ -2,28 +2,33 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/KICK.json", "title": "KICK", - "description": "Kick a user from a channel. RFC 1459 §4.2.8.", + "description": "Remove a user from a channel. A client sends it, and only a channel operator may. The server sends it to every member of the channel, the removed user included. RFC 1459 §4.2.8.", "$ref": "../message.json", "properties": { "command": { "const": "KICK" }, - "from": { "type": "string", "description": "Nick that performed the kick." }, - "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, + "from": { "type": "string", "description": "The nick that removed the user. The server sets it." }, + "to": { + "type": "string", + "description": "The channel name. The server adds the # if a client leaves it out.", + "pattern": "^#?[a-zA-Z0-9_-]{1,63}$" + }, "params": { "type": "array", "items": { "type": "string" }, - "description": "Kicked nick. e.g. [\"alice\"].", + "description": "The removed nick. Only the server sends it.", "minItems": 1, "maxItems": 1 }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Optional kick reason.", - "maxItems": 1 + "description": "From a client: the nick to remove, then an optional reason. From the server: the reason, as one string.", + "minItems": 1 } }, - "required": ["command", "to", "params"], + "required": ["command", "to", "body"], "examples": [ + { "command": "KICK", "to": "#general", "body": ["troll", "Behave"] }, { "command": "KICK", "from": "op1", "to": "#general", "params": ["troll"], "body": ["Behave"] } ] } diff --git a/schema/commands/MODE.json b/schema/commands/MODE.json index eaa3a6e..59ca16c 100644 --- a/schema/commands/MODE.json +++ b/schema/commands/MODE.json @@ -2,28 +2,29 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/MODE.json", "title": "MODE", - "description": "Set or query channel/user modes. RFC 1459 §4.2.3.", + "description": "Query or change a channel's modes or the user's own modes. A client sends it. The server sends it to every member of the channel when a channel mode changes. A +H or -H sent over the HTTP API is the exception. The server answers it with RPL_CHANNELMODEIS (324) and sends no MODE. RFC 1459 §4.2.3.", "$ref": "../message.json", "properties": { "command": { "const": "MODE" }, "from": { "type": "string", - "description": "Nick that set the mode (S2C only)." + "description": "The nick that changed the mode. The server sets it." }, "to": { "type": "string", - "description": "Channel name.", - "pattern": "^#[a-zA-Z0-9_-]+$" + "description": "A channel name, which starts with #, or the user's own nick." }, - "params": { + "body": { "type": "array", "items": { "type": "string" }, - "description": "Mode string and optional target nick. e.g. [\"+o\", \"alice\"].", - "examples": [["+o", "alice"], ["-m"], ["+v", "bob"]] + "description": "From a client: the mode change and then its value, as separate strings, such as \"+o\" and \"bob\". A MODE without a body is a query. From the server: the change and its value, as one string, such as \"+o bob\", or as separate strings, such as \"+k\" and \"secret\".", + "examples": [["+o", "bob"], ["-m"], ["+o bob"], ["+k", "secret"]] } }, - "required": ["command", "to", "params"], + "required": ["command", "to"], "examples": [ - { "command": "MODE", "from": "op1", "to": "#general", "params": ["+o", "alice"] } + { "command": "MODE", "to": "#general", "body": ["+o", "bob"] }, + { "command": "MODE", "from": "op1", "to": "#general", "body": ["+o bob"] }, + { "command": "MODE", "from": "op1", "to": "#general", "body": ["+k", "secret"] } ] } diff --git a/schema/commands/NICK.json b/schema/commands/NICK.json index a6ec615..4aa4e9c 100644 --- a/schema/commands/NICK.json +++ b/schema/commands/NICK.json @@ -2,21 +2,21 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/NICK.json", "title": "NICK", - "description": "Change nickname. C2S: request new nick. S2C: notification of nick change. RFC 1459 §4.1.2.", + "description": "Change nick. A client sends it to change its nick. The server sends it to the user's own clients and to the users who share a channel with the user. RFC 1459 §4.1.2.", "$ref": "../message.json", "properties": { "command": { "const": "NICK" }, - "from": { "type": "string", "description": "Old nick (S2C)." }, + "from": { "type": "string", "description": "The old nick. The server sets it." }, "body": { "type": "array", "items": { "type": "string" }, - "description": "New nick (single-element array).", - "minItems": 1, - "maxItems": 1 + "description": "The new nick, as the first string. The server sends one string.", + "minItems": 1 } }, "required": ["command", "body"], "examples": [ + { "command": "NICK", "body": ["newnick"] }, { "command": "NICK", "from": "oldnick", "body": ["newnick"] } ] } diff --git a/schema/commands/NOTICE.json b/schema/commands/NOTICE.json index f425d87..5471173 100644 --- a/schema/commands/NOTICE.json +++ b/schema/commands/NOTICE.json @@ -2,20 +2,21 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/NOTICE.json", "title": "NOTICE", - "description": "Send a notice. Like PRIVMSG but must not trigger automatic replies. RFC 1459 §4.4.2.", + "description": "A notice: a message to a channel or a user that a client must not answer automatically. The server relays it as it relays a PRIVMSG. A NOTICE to a channel with mode +H needs no hashcash stamp. RFC 1459 §4.4.2.", "$ref": "../message.json", "properties": { "command": { "const": "NOTICE" }, - "from": { "type": "string" }, - "to": { "type": "string", "description": "Target: #channel, nick, or * (global)." }, + "from": { "type": "string", "description": "The sender's nick. The server sets it." }, + "to": { "type": "string", "description": "A channel name, which starts with #, or a nick." }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Notice text lines." + "description": "The lines of the notice, one string per line.", + "minItems": 1 } }, "required": ["command", "to", "body"], "examples": [ - { "command": "NOTICE", "from": "server.example.com", "to": "*", "body": ["Server restarting in 5 minutes"] } + { "command": "NOTICE", "from": "alice", "to": "#general", "body": ["Back in five minutes"] } ] } diff --git a/schema/commands/PART.json b/schema/commands/PART.json index 9152725..281275b 100644 --- a/schema/commands/PART.json +++ b/schema/commands/PART.json @@ -2,17 +2,20 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PART.json", "title": "PART", - "description": "Leave a channel. C2S: request to leave. S2C: notification that a user left. RFC 1459 §4.2.2.", + "description": "Leave a channel. A client sends it to leave. The server sends it to every member of the channel when a user leaves, the leaving user included. RFC 1459 §4.2.2.", "$ref": "../message.json", "properties": { "command": { "const": "PART" }, - "from": { "type": "string", "description": "Nick that left (S2C)." }, - "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, + "from": { "type": "string", "description": "The nick that left. The server sets it." }, + "to": { + "type": "string", + "description": "The channel name. The server adds the # if a client leaves it out.", + "pattern": "^#?[a-zA-Z0-9_-]{1,63}$" + }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Optional part reason.", - "maxItems": 1 + "description": "The reason for leaving. A client may send one as the first string. The server always sends one string, which is empty when no reason was given." } }, "required": ["command", "to"], diff --git a/schema/commands/PING.json b/schema/commands/PING.json index 5702a15..ba6b120 100644 --- a/schema/commands/PING.json +++ b/schema/commands/PING.json @@ -2,19 +2,13 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PING.json", "title": "PING", - "description": "Keepalive. C2S or S2S. Server responds with PONG. RFC 1459 §4.6.2.", + "description": "A check that the server still answers. A client sends it. The server ignores every field but 'command'. Its answer is a PONG in the HTTP response. RFC 1459 §4.6.2.", "$ref": "../message.json", "properties": { - "command": { "const": "PING" }, - "body": { - "type": "array", - "items": { "type": "string" }, - "description": "Opaque token to be echoed in PONG (single-element array).", - "maxItems": 1 - } + "command": { "const": "PING" } }, "required": ["command"], "examples": [ - { "command": "PING", "body": ["1707580000"] } + { "command": "PING" } ] } diff --git a/schema/commands/PONG.json b/schema/commands/PONG.json index e440b91..2e01b34 100644 --- a/schema/commands/PONG.json +++ b/schema/commands/PONG.json @@ -2,20 +2,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PONG.json", "title": "PONG", - "description": "Keepalive response. S2C or S2S. RFC 1459 §4.6.3.", + "description": "The answer to a PING. The server sends it as the HTTP response to the PING, with only 'command' and 'from'. A client's PONG gets ERR_UNKNOWNCOMMAND (421). RFC 1459 §4.6.3.", "$ref": "../message.json", "properties": { "command": { "const": "PONG" }, - "from": { "type": "string", "description": "Responding server name." }, - "body": { - "type": "array", - "items": { "type": "string" }, - "description": "Echoed token from PING (single-element array).", - "maxItems": 1 - } + "from": { "type": "string", "description": "The server name." } }, - "required": ["command"], + "required": ["command", "from"], "examples": [ - { "command": "PONG", "from": "server.example.com", "body": ["1707580000"] } + { "command": "PONG", "from": "neoirc" } ] } diff --git a/schema/commands/PRIVMSG.json b/schema/commands/PRIVMSG.json index 9f31d9c..562d56e 100644 --- a/schema/commands/PRIVMSG.json +++ b/schema/commands/PRIVMSG.json @@ -2,16 +2,16 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/PRIVMSG.json", "title": "PRIVMSG", - "description": "Send a message to a channel or user. C2S: client sends to server. S2C: server relays to recipients. RFC 1459 §4.4.1.", + "description": "A message to a channel or a user. A client sends it. The server relays it to every member of the channel, or to the user, and to the sender's own clients. RFC 1459 §4.4.1.", "$ref": "../message.json", "properties": { "command": { "const": "PRIVMSG" }, - "from": { "type": "string", "description": "Sender nick (set by server on relay)." }, - "to": { "type": "string", "description": "Target: #channel or nick.", "examples": ["#general", "alice"] }, + "from": { "type": "string", "description": "The sender's nick. The server sets it." }, + "to": { "type": "string", "description": "A channel name, which starts with #, or a nick.", "examples": ["#general", "alice"] }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Message lines. One string per line.", + "description": "The lines of the message, one string per line.", "minItems": 1 } }, @@ -19,6 +19,6 @@ "examples": [ { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["hello world"] }, { "command": "PRIVMSG", "from": "bob", "to": "#general", "body": ["line one", "line two"] }, - { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"], "meta": { "sig": "base64...", "alg": "ed25519" } } + { "command": "PRIVMSG", "from": "bob", "to": "alice", "body": ["hey"] } ] } diff --git a/schema/commands/PUBKEY.json b/schema/commands/PUBKEY.json deleted file mode 100644 index 5c377f4..0000000 --- a/schema/commands/PUBKEY.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://sneak.berlin/go/neoirc/schema/commands/PUBKEY.json", - "title": "PUBKEY", - "description": "Announce or relay a user's public signing key. C2S: client announces key to channel or server. S2C: server relays to channel members. Protocol extension (not in RFC 1459). Body is a structured object (not an array) containing the key material.", - "$ref": "../message.json", - "properties": { - "command": { "const": "PUBKEY" }, - "from": { "type": "string", "description": "Nick announcing the key (set by server on relay)." }, - "to": { - "type": "string", - "description": "Target: #channel to announce to channel members, or omit for server-wide announcement." - }, - "body": { - "type": "object", - "description": "Key material.", - "properties": { - "alg": { - "type": "string", - "description": "Key algorithm.", - "enum": ["ed25519"] - }, - "key": { - "type": "string", - "description": "Base64-encoded public key." - } - }, - "required": ["alg", "key"], - "additionalProperties": false - } - }, - "required": ["command", "body"], - "examples": [ - { "command": "PUBKEY", "from": "alice", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } }, - { "command": "PUBKEY", "from": "alice", "to": "#general", "body": { "alg": "ed25519", "key": "base64-encoded-pubkey" } } - ] -} diff --git a/schema/commands/QUIT.json b/schema/commands/QUIT.json index f6b3abc..4cc2800 100644 --- a/schema/commands/QUIT.json +++ b/schema/commands/QUIT.json @@ -2,20 +2,20 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/QUIT.json", "title": "QUIT", - "description": "User disconnected. S2C only. RFC 1459 §4.1.6.", + "description": "A user left the server. A client sends it to end its session. The server sends it to the users who shared a channel with the user who left. RFC 1459 §4.1.6.", "$ref": "../message.json", "properties": { "command": { "const": "QUIT" }, - "from": { "type": "string", "description": "Nick that quit." }, + "from": { "type": "string", "description": "The nick that quit. The server sets it." }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Optional quit reason.", - "maxItems": 1 + "description": "The reason for leaving. A client may send one as the first string, and the server uses 'Client quit' when it does not. The server always sends one string." } }, - "required": ["command", "from"], + "required": ["command"], "examples": [ - { "command": "QUIT", "from": "alice", "body": ["Connection reset"] } + { "command": "QUIT", "body": ["Gone for the day"] }, + { "command": "QUIT", "from": "alice", "body": ["Connection closed"] } ] } diff --git a/schema/commands/TOPIC.json b/schema/commands/TOPIC.json index 3af085a..748ec21 100644 --- a/schema/commands/TOPIC.json +++ b/schema/commands/TOPIC.json @@ -2,20 +2,24 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/commands/TOPIC.json", "title": "TOPIC", - "description": "Get or set channel topic. C2S: set topic (body present) or query (body absent). S2C: topic change notification. RFC 1459 §4.2.4.", + "description": "Set a channel's topic. A client sends it to set the topic. The server sends it to every member of the channel when the topic changes. RFC 1459 §4.2.4.", "$ref": "../message.json", "properties": { "command": { "const": "TOPIC" }, - "from": { "type": "string", "description": "Nick that changed the topic (S2C)." }, - "to": { "type": "string", "description": "Channel name.", "pattern": "^#[a-zA-Z0-9_-]+$" }, + "from": { "type": "string", "description": "The nick that set the topic. The server sets it." }, + "to": { + "type": "string", + "description": "The channel name. The server adds the # if a client leaves it out.", + "pattern": "^#?[a-zA-Z0-9_-]{1,63}$" + }, "body": { "type": "array", "items": { "type": "string" }, - "description": "New topic text (single-element array). Empty array clears the topic.", - "maxItems": 1 + "description": "The new topic. The server joins a client's strings with spaces, and sends the topic as one string. [\"\"] clears the topic.", + "minItems": 1 } }, - "required": ["command", "to"], + "required": ["command", "to", "body"], "examples": [ { "command": "TOPIC", "from": "alice", "to": "#general", "body": ["Welcome to the channel"] } ] diff --git a/schema/message.json b/schema/message.json index 26b92e0..9ceeb00 100644 --- a/schema/message.json +++ b/schema/message.json @@ -2,67 +2,55 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/message.json", "title": "IRC Message Envelope", - "description": "Base envelope for all messages. Mirrors IRC wire format (RFC 1459/2812) encoded as JSON over HTTP. The 'command' field carries either an IRC command name (PRIVMSG, JOIN, etc.) or a three-digit numeric reply code (001, 353, 433, etc.).", + "description": "Every message in the HTTP API is one JSON object with these fields. Only 'command' is always present.", "type": "object", "properties": { "id": { "type": "string", "format": "uuid", - "description": "Server-assigned message UUID. Present on all server-originated messages." + "description": "The message's UUID, which the server assigns. Present on every message the server queues for a client." }, "command": { "type": "string", - "description": "IRC command name (PRIVMSG, JOIN, NICK, etc.) or three-digit numeric reply code (001, 353, 433, etc.).", - "examples": ["PRIVMSG", "JOIN", "001", "353", "433"] + "description": "An IRC command name, such as PRIVMSG, or the name of a numeric reply, such as RPL_WELCOME. The server reads a client's command in any case and sends it in upper case.", + "examples": ["PRIVMSG", "JOIN", "RPL_WELCOME", "ERR_NICKNAMEINUSE"] + }, + "code": { + "type": "integer", + "minimum": 1, + "maximum": 999, + "description": "The number of a numeric reply, such as 1 for RPL_WELCOME. Absent on other messages." }, "from": { "type": "string", - "description": "Source — nick for user messages, server name for server messages. Equivalent to IRC prefix." + "description": "The sender's nick, or the server name on a numeric reply or a PONG. The server sets it and ignores it from a client." }, "to": { "type": "string", - "description": "Target — channel (#name) or nick. Equivalent to first IRC parameter for most commands." + "description": "The target: a channel name, such as #general, or a nick. A WALLOPS has * here." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "Additional parameters (used primarily by numeric replies). Equivalent to IRC middle parameters." + "description": "The message's other parameters, in order. KICK events and many numeric replies have it. The server ignores it from a client." }, "body": { - "oneOf": [ - { - "type": "array", - "items": { "type": "string" }, - "description": "Array of strings (one per line for text messages)." - }, - { - "type": "object", - "description": "Structured data (e.g. PUBKEY key material).", - "additionalProperties": true - } - ], - "description": "Message body. MUST be an array or object, never a raw string. Arrays represent lines of text; objects carry structured data. This enables deterministic canonicalization (RFC 8785 JCS) for signing." + "type": "array", + "items": { "type": "string" }, + "description": "An array of strings, never a bare string. In a PRIVMSG or NOTICE, each string is one line of text." }, "ts": { "type": "string", "format": "date-time", - "description": "Server-assigned timestamp (ISO 8601). Not present in original IRC; added for HTTP transport." + "description": "The time the server stored the message, as an RFC 3339 time in UTC." }, "meta": { "type": "object", - "description": "Extensible metadata. Used for signatures (meta.sig, meta.alg), hashes (meta.hash), and client extensions.", + "description": "An object of extra data. Every message the server queues for a client has meta, which is {} when there is none. The server keeps a client's meta only on a PRIVMSG or NOTICE, and relays it unchanged.", "properties": { - "sig": { + "hashcash": { "type": "string", - "description": "Base64-encoded cryptographic signature over the canonical message form." - }, - "alg": { - "type": "string", - "description": "Signature algorithm (e.g. 'ed25519')." - }, - "hash": { - "type": "string", - "description": "Hash of the canonical message form (e.g. 'sha256:base64...')." + "description": "A hashcash stamp. A PRIVMSG to a channel with mode +H needs one." } }, "additionalProperties": true diff --git a/schema/numerics/001.json b/schema/numerics/001.json index 90174d9..f5705a0 100644 --- a/schema/numerics/001.json +++ b/schema/numerics/001.json @@ -2,32 +2,45 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/001.json", "title": "001 RPL_WELCOME", - "description": "Welcome message sent after successful session creation. RFC 2812 \u00a75.1.", + "description": "Welcomes the user. Sent after a session is created, after a login, and in answer to MOTD. RFC 2812 §5.1.", "$ref": "../message.json", "properties": { "command": { - "const": "001" + "const": "RPL_WELCOME" + }, + "code": { + "const": 1 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { "type": "string", - "description": "Target nick." + "description": "The user's nick." }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Welcome text lines." + "description": "One string: 'Welcome to the network, ' and the user's nick.", + "minItems": 1, + "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", "body" ], "examples": [ { - "command": "001", + "command": "RPL_WELCOME", + "code": 1, + "from": "neoirc", "to": "alice", "body": [ "Welcome to the network, alice" diff --git a/schema/numerics/002.json b/schema/numerics/002.json index b199b2a..750147e 100644 --- a/schema/numerics/002.json +++ b/schema/numerics/002.json @@ -2,34 +2,48 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/002.json", "title": "002 RPL_YOURHOST", - "description": "Server host info sent after session creation. RFC 2812 \u00a75.1.", + "description": "The server's name and version. Sent right after RPL_WELCOME. RFC 2812 §5.1.", "$ref": "../message.json", "properties": { "command": { - "const": "002" + "const": "RPL_YOURHOST" + }, + "code": { + "const": 2 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Host info lines." + "description": "One string: 'Your host is ', the server name, ', running version ' and the version.", + "minItems": 1, + "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", "body" ], "examples": [ { - "command": "002", + "command": "RPL_YOURHOST", + "code": 2, + "from": "neoirc", "to": "alice", "body": [ - "Your host is neoirc.example.com, running version 0.1.0" + "Your host is neoirc, running version dev" ] } ] diff --git a/schema/numerics/003.json b/schema/numerics/003.json index 2f9e2e4..8406e0d 100644 --- a/schema/numerics/003.json +++ b/schema/numerics/003.json @@ -2,31 +2,45 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/003.json", "title": "003 RPL_CREATED", - "description": "Server creation date. RFC 2812 \u00a75.1.", + "description": "The date the server last started. Sent right after RPL_YOURHOST. RFC 2812 §5.1.", "$ref": "../message.json", "properties": { "command": { - "const": "003" + "const": "RPL_CREATED" + }, + "code": { + "const": 3 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Text lines." + "description": "One string: 'This server was created ' and the start date as YYYY-MM-DD.", + "minItems": 1, + "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", "body" ], "examples": [ { - "command": "003", + "command": "RPL_CREATED", + "code": 3, + "from": "neoirc", "to": "alice", "body": [ "This server was created 2026-02-01" diff --git a/schema/numerics/004.json b/schema/numerics/004.json index 0fbe9ca..23d78fc 100644 --- a/schema/numerics/004.json +++ b/schema/numerics/004.json @@ -2,37 +2,64 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/004.json", "title": "004 RPL_MYINFO", - "description": "Server info (name, version, available modes). RFC 2812 \u00a75.1.", + "description": "The server's name, its version, and its lists of user and channel modes. Sent right after RPL_CREATED. RFC 2812 §5.1.", "$ref": "../message.json", "properties": { "command": { - "const": "004" + "const": "RPL_MYINFO" + }, + "code": { + "const": 4 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "[server_name, version, user_modes, channel_modes]." + "description": "The server name, the version, the user modes 'ow' and the channel modes 'Hbiklmnostv'.", + "minItems": 4, + "maxItems": 4 + }, + "body": { + "type": "array", + "items": { + "type": "string" + }, + "description": "One empty string.", + "minItems": 1, + "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", - "params" + "params", + "body" ], "examples": [ { - "command": "004", + "command": "RPL_MYINFO", + "code": 4, + "from": "neoirc", "to": "alice", "params": [ - "neoirc.example.com", - "0.1.0", - "o", - "imnst+ov" + "neoirc", + "dev", + "ow", + "Hbiklmnostv" + ], + "body": [ + "" ] } ] diff --git a/schema/numerics/322.json b/schema/numerics/322.json index 5344a97..92d757a 100644 --- a/schema/numerics/322.json +++ b/schema/numerics/322.json @@ -2,38 +2,55 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/322.json", "title": "322 RPL_LIST", - "description": "Channel list entry. One per channel in response to LIST. RFC 1459 \u00a76.2.", + "description": "One channel in the answer to LIST. The answer has one per channel, except a secret channel, one with mode +s, that the user is not on. RFC 1459 §6.2.", "$ref": "../message.json", "properties": { "command": { - "const": "322" + "const": "RPL_LIST" + }, + "code": { + "const": 322 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "[channel, visible_count]." + "description": "The channel name and its number of members.", + "minItems": 2, + "maxItems": 2 }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Channel topic." + "description": "One string: the channel's topic, empty when it has none.", + "minItems": 1, + "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", - "params" + "params", + "body" ], "examples": [ { - "command": "322", + "command": "RPL_LIST", + "code": 322, + "from": "neoirc", "to": "alice", "params": [ "#general", diff --git a/schema/numerics/323.json b/schema/numerics/323.json index 182a496..a48e696 100644 --- a/schema/numerics/323.json +++ b/schema/numerics/323.json @@ -2,26 +2,49 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/323.json", "title": "323 RPL_LISTEND", - "description": "End of channel list. RFC 1459 \u00a76.2.", + "description": "The end of the answer to LIST. RFC 1459 §6.2.", "$ref": "../message.json", "properties": { "command": { - "const": "323" + "const": "RPL_LISTEND" + }, + "code": { + "const": 323 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "body": { "type": "array", "items": { "type": "string" }, - "description": "End of /LIST", + "description": "One string: 'End of /LIST'.", + "minItems": 1, "maxItems": 1 } }, "required": [ "command", - "to" + "code", + "from", + "to", + "body" + ], + "examples": [ + { + "command": "RPL_LISTEND", + "code": 323, + "from": "neoirc", + "to": "alice", + "body": [ + "End of /LIST" + ] + } ] } diff --git a/schema/numerics/332.json b/schema/numerics/332.json index 9ab8917..0f49788 100644 --- a/schema/numerics/332.json +++ b/schema/numerics/332.json @@ -2,39 +2,55 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/332.json", "title": "332 RPL_TOPIC", - "description": "Channel topic (sent on JOIN or TOPIC query). RFC 1459 \u00a76.2.", + "description": "A channel's topic. Sent to a client after it sets the topic, and after it joins a channel that has one. Also sent for each of a session's channels that has one after a login, and for GET /api/v1/state?initChannelState=1. RFC 1459 §6.2.", "$ref": "../message.json", "properties": { "command": { - "const": "332" + "const": "RPL_TOPIC" + }, + "code": { + "const": 332 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "[channel]." + "description": "The channel name.", + "minItems": 1, + "maxItems": 1 }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Topic text." + "description": "One string: the topic.", + "minItems": 1, + "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", "params", "body" ], "examples": [ { - "command": "332", + "command": "RPL_TOPIC", + "code": 332, + "from": "neoirc", "to": "alice", "params": [ "#general" diff --git a/schema/numerics/353.json b/schema/numerics/353.json index 24430c4..be9ceb8 100644 --- a/schema/numerics/353.json +++ b/schema/numerics/353.json @@ -2,46 +2,62 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/353.json", "title": "353 RPL_NAMREPLY", - "description": "Channel member list. Sent on JOIN or NAMES query. RFC 1459 \u00a76.2.", + "description": "A channel's members. Sent to a client after it joins a channel, and in answer to NAMES. Also sent for each of a session's channels after a login, and for GET /api/v1/state?initChannelState=1. RFC 1459 §6.2.", "$ref": "../message.json", "properties": { "command": { - "const": "353" + "const": "RPL_NAMREPLY" + }, + "code": { + "const": 353 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "[channel_type, channel]. channel_type: = (public), * (private), @ (secret)." + "description": "'=' and the channel name. The server always sends '='.", + "minItems": 2, + "maxItems": 2 }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Space-separated list of nicks. Prefixed with @ for ops, + for voiced." + "description": "One string: the members as nick!user@host, separated by spaces. '@' comes before a channel operator and '+' before a voiced member. The host is '*' when the server does not know it.", + "minItems": 1, + "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", "params", "body" ], "examples": [ { - "command": "353", + "command": "RPL_NAMREPLY", + "code": 353, + "from": "neoirc", "to": "alice", "params": [ "=", "#general" ], "body": [ - "@op1 alice bob +voiced1" + "@op1!op1@host1 alice!alice@host2 +bob!bob@host3" ] } ] diff --git a/schema/numerics/366.json b/schema/numerics/366.json index 4051c8d..10da824 100644 --- a/schema/numerics/366.json +++ b/schema/numerics/366.json @@ -2,34 +2,62 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/366.json", "title": "366 RPL_ENDOFNAMES", - "description": "End of NAMES list. RFC 1459 \u00a76.2.", + "description": "The end of a channel's member list. It comes after the RPL_NAMREPLY for the channel. RFC 1459 §6.2.", "$ref": "../message.json", "properties": { "command": { - "const": "366" + "const": "RPL_ENDOFNAMES" + }, + "code": { + "const": 366 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "[channel]." + "description": "The channel name.", + "minItems": 1, + "maxItems": 1 }, "body": { "type": "array", "items": { "type": "string" }, - "description": "End of /NAMES list", + "description": "One string: 'End of /NAMES list'.", + "minItems": 1, "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", - "params" + "params", + "body" + ], + "examples": [ + { + "command": "RPL_ENDOFNAMES", + "code": 366, + "from": "neoirc", + "to": "alice", + "params": [ + "#general" + ], + "body": [ + "End of /NAMES list" + ] + } ] } diff --git a/schema/numerics/372.json b/schema/numerics/372.json index 5e25bba..ba441c3 100644 --- a/schema/numerics/372.json +++ b/schema/numerics/372.json @@ -2,31 +2,45 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/372.json", "title": "372 RPL_MOTD", - "description": "MOTD line. One message per line of the MOTD. RFC 2812 \u00a75.1.", + "description": "One line of the message of the day. The server sends one per line, after RPL_MOTDSTART. RFC 2812 §5.1.", "$ref": "../message.json", "properties": { "command": { - "const": "372" + "const": "RPL_MOTD" + }, + "code": { + "const": 372 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "body": { "type": "array", "items": { "type": "string" }, - "description": "MOTD line text (prefixed with '- ')." + "description": "One string: '- ' and the line.", + "minItems": 1, + "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", "body" ], "examples": [ { - "command": "372", + "command": "RPL_MOTD", + "code": 372, + "from": "neoirc", "to": "alice", "body": [ "- Welcome to our server!" diff --git a/schema/numerics/375.json b/schema/numerics/375.json index ffc7b3b..c02c37a 100644 --- a/schema/numerics/375.json +++ b/schema/numerics/375.json @@ -2,25 +2,49 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/375.json", "title": "375 RPL_MOTDSTART", - "description": "Start of MOTD. RFC 2812 \u00a75.1.", + "description": "The start of the message of the day. Sent after RPL_LUSERME (255) when the server has a message of the day. RFC 2812 §5.1.", "$ref": "../message.json", "properties": { "command": { - "const": "375" + "const": "RPL_MOTDSTART" + }, + "code": { + "const": 375 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Text lines." + "description": "One string: '- ', the server name and ' Message of the Day -'.", + "minItems": 1, + "maxItems": 1 } }, "required": [ "command", - "to" + "code", + "from", + "to", + "body" + ], + "examples": [ + { + "command": "RPL_MOTDSTART", + "code": 375, + "from": "neoirc", + "to": "alice", + "body": [ + "- neoirc Message of the Day -" + ] + } ] } diff --git a/schema/numerics/376.json b/schema/numerics/376.json index 5851d8d..b6cae01 100644 --- a/schema/numerics/376.json +++ b/schema/numerics/376.json @@ -2,26 +2,49 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/376.json", "title": "376 RPL_ENDOFMOTD", - "description": "End of MOTD. RFC 2812 \u00a75.1.", + "description": "The end of the message of the day. RFC 2812 §5.1.", "$ref": "../message.json", "properties": { "command": { - "const": "376" + "const": "RPL_ENDOFMOTD" + }, + "code": { + "const": 376 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "body": { "type": "array", "items": { "type": "string" }, - "description": "End of /MOTD command", + "description": "One string: 'End of /MOTD command.'.", + "minItems": 1, "maxItems": 1 } }, "required": [ "command", - "to" + "code", + "from", + "to", + "body" + ], + "examples": [ + { + "command": "RPL_ENDOFMOTD", + "code": 376, + "from": "neoirc", + "to": "alice", + "body": [ + "End of /MOTD command." + ] + } ] } diff --git a/schema/numerics/401.json b/schema/numerics/401.json index 7fd1609..6977f75 100644 --- a/schema/numerics/401.json +++ b/schema/numerics/401.json @@ -2,45 +2,61 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/401.json", "title": "401 ERR_NOSUCHNICK", - "description": "No such nick/channel. RFC 1459 \u00a76.1.", + "description": "No user has that nick. RFC 1459 §6.1.", "$ref": "../message.json", "properties": { "command": { - "const": "401" + "const": "ERR_NOSUCHNICK" + }, + "code": { + "const": 401 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "[target_nick]." + "description": "The nick that was not found.", + "minItems": 1, + "maxItems": 1 }, "body": { "type": "array", "items": { "type": "string" }, - "description": "No such nick/channel", + "description": "One string: 'No such nick' in answer to a PRIVMSG or NOTICE, and 'No such nick/channel' in answer to anything else.", + "minItems": 1, "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", - "params" + "params", + "body" ], "examples": [ { - "command": "401", + "command": "ERR_NOSUCHNICK", + "code": 401, + "from": "neoirc", "to": "alice", "params": [ "bob" ], "body": [ - "No such nick/channel" + "No such nick" ] } ] diff --git a/schema/numerics/403.json b/schema/numerics/403.json index 30c64d2..6cde31b 100644 --- a/schema/numerics/403.json +++ b/schema/numerics/403.json @@ -2,39 +2,55 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/403.json", "title": "403 ERR_NOSUCHCHANNEL", - "description": "No such channel. RFC 1459 \u00a76.1.", + "description": "No channel has that name. A JOIN with a channel name that the server does not allow gets it too. RFC 1459 §6.1.", "$ref": "../message.json", "properties": { "command": { - "const": "403" + "const": "ERR_NOSUCHCHANNEL" + }, + "code": { + "const": 403 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "[channel_name]." + "description": "The channel name.", + "minItems": 1, + "maxItems": 1 }, "body": { "type": "array", "items": { "type": "string" }, - "description": "No such channel", + "description": "One string: 'No such channel'.", + "minItems": 1, "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", - "params" + "params", + "body" ], "examples": [ { - "command": "403", + "command": "ERR_NOSUCHCHANNEL", + "code": 403, + "from": "neoirc", "to": "alice", "params": [ "#nonexistent" diff --git a/schema/numerics/433.json b/schema/numerics/433.json index 831783c..34b6686 100644 --- a/schema/numerics/433.json +++ b/schema/numerics/433.json @@ -2,42 +2,58 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/433.json", "title": "433 ERR_NICKNAMEINUSE", - "description": "Nickname is already in use. RFC 1459 \u00a76.1.", + "description": "Another user has the nick. Sent in answer to a NICK. RFC 1459 §6.1.", "$ref": "../message.json", "properties": { "command": { - "const": "433" + "const": "ERR_NICKNAMEINUSE" + }, + "code": { + "const": 433 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick, which has not changed." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "[requested_nick]." + "description": "The nick that was asked for.", + "minItems": 1, + "maxItems": 1 }, "body": { "type": "array", "items": { "type": "string" }, - "description": "Nickname is already in use", + "description": "One string: 'Nickname is already in use'.", + "minItems": 1, "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", - "params" + "params", + "body" ], "examples": [ { - "command": "433", - "to": "*", + "command": "ERR_NICKNAMEINUSE", + "code": 433, + "from": "neoirc", + "to": "alice", "params": [ - "alice" + "bob" ], "body": [ "Nickname is already in use" diff --git a/schema/numerics/442.json b/schema/numerics/442.json index 76addce..ee906cb 100644 --- a/schema/numerics/442.json +++ b/schema/numerics/442.json @@ -2,34 +2,62 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/442.json", "title": "442 ERR_NOTONCHANNEL", - "description": "You're not on that channel. RFC 1459 \u00a76.1.", + "description": "The user is not on that channel. RFC 1459 §6.1.", "$ref": "../message.json", "properties": { "command": { - "const": "442" + "const": "ERR_NOTONCHANNEL" + }, + "code": { + "const": 442 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "[channel]." + "description": "The channel name.", + "minItems": 1, + "maxItems": 1 }, "body": { "type": "array", "items": { "type": "string" }, - "description": "You're not on that channel", + "description": "One string: 'You're not on that channel'.", + "minItems": 1, "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", - "params" + "params", + "body" + ], + "examples": [ + { + "command": "ERR_NOTONCHANNEL", + "code": 442, + "from": "neoirc", + "to": "alice", + "params": [ + "#general" + ], + "body": [ + "You're not on that channel" + ] + } ] } diff --git a/schema/numerics/482.json b/schema/numerics/482.json index 7ecc1f2..67a1e4e 100644 --- a/schema/numerics/482.json +++ b/schema/numerics/482.json @@ -2,34 +2,62 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sneak.berlin/go/neoirc/schema/numerics/482.json", "title": "482 ERR_CHANOPRIVSNEEDED", - "description": "You're not channel operator. RFC 1459 \u00a76.1.", + "description": "The user is not a channel operator, and the command needs one. RFC 1459 §6.1.", "$ref": "../message.json", "properties": { "command": { - "const": "482" + "const": "ERR_CHANOPRIVSNEEDED" + }, + "code": { + "const": 482 + }, + "from": { + "type": "string", + "description": "The server name." }, "to": { - "type": "string" + "type": "string", + "description": "The user's nick." }, "params": { "type": "array", "items": { "type": "string" }, - "description": "[channel]." + "description": "The channel name.", + "minItems": 1, + "maxItems": 1 }, "body": { "type": "array", "items": { "type": "string" }, - "description": "You're not channel operator", + "description": "One string: 'You're not channel operator'.", + "minItems": 1, "maxItems": 1 } }, "required": [ "command", + "code", + "from", "to", - "params" + "params", + "body" + ], + "examples": [ + { + "command": "ERR_CHANOPRIVSNEEDED", + "code": 482, + "from": "neoirc", + "to": "alice", + "params": [ + "#general" + ], + "body": [ + "You're not channel operator" + ] + } ] } diff --git a/web/src/app.jsx b/web/src/app.jsx index c3ad50f..8d97eaf 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -7,6 +7,11 @@ const RECONNECT_DELAY = 3000; const MEMBER_REFRESH_INTERVAL = 10000; const ACTION_PREFIX = "\x01ACTION "; const ACTION_SUFFIX = "\x01"; +// SERVER_TAB is the Server tab's name, under which its messages and +// unread count are stored. No nick or channel name can contain *, so a +// user with the nick Server gets a direct-message tab of its own. The +// tab bar labels this tab Server. +const SERVER_TAB = "*server*"; // Hashcash proof-of-work helpers using Web Crypto API. @@ -270,9 +275,9 @@ function UserList({ members, onNickClick }) { function App() { const [loggedIn, setLoggedIn] = useState(false); const [nick, setNick] = useState(""); - const [tabs, setTabs] = useState([{ type: "server", name: "Server" }]); + const [tabs, setTabs] = useState([{ type: "server", name: SERVER_TAB }]); const [activeTab, setActiveTab] = useState(0); - const [messages, setMessages] = useState({ Server: [] }); + const [messages, setMessages] = useState({ [SERVER_TAB]: [] }); const [members, setMembers] = useState({}); const [topics, setTopics] = useState({}); const [unread, setUnread] = useState({}); @@ -355,6 +360,26 @@ function App() { .catch(() => {}); }, []); + // removeChannelTab closes a channel's tab. The tab the user was + // reading stays active, found by its name; when it was the closed tab, + // the Server tab shows. + const removeChannelTab = useCallback((name) => { + setTabs((prev) => { + const remaining = prev.filter( + (t) => !(t.type === "channel" && t.name === name), + ); + // Preact runs this update at once, on the newest active tab. + setActiveTab((active) => { + const reading = prev[active]?.name; + const idx = remaining.findIndex((t) => t.name === reading); + + return idx >= 0 ? idx : 0; + }); + + return remaining; + }); + }, []); + const processMessage = useCallback( (msg) => { const body = Array.isArray(msg.body) ? msg.body.join("\n") : ""; @@ -424,6 +449,13 @@ function App() { break; } case "PART": { + // The user's own PART, sent from this client or another, + // closes the tab and adds no message or unread count. + if (msg.from === nickRef.current) { + removeChannelTab(msg.to); + + break; + } const reason = body ? " (" + body + ")" : ""; const text = `${msg.from} has parted ${msg.to}${reason}`; if (msg.to) addMessage(msg.to, { ...base, text, system: true }); @@ -431,6 +463,29 @@ function App() { break; } + case "KICK": { + // The kicked nick is in params; the body is the reason. + const kicked = Array.isArray(msg.params) ? msg.params[0] : ""; + const reason = body ? " (" + body + ")" : ""; + const text = `${kicked} was kicked from ${msg.to} by ${msg.from}${reason}`; + if (msg.to) addMessage(msg.to, { ...base, text, system: true }); + if (kicked === nickRef.current) { + // The channel's tab closes, so the Server tab gets the line + // too. It needs an id of its own: addMessage drops a message + // whose id it has already seen. + addMessage(SERVER_TAB, { + ...base, + id: msg.id + "-server", + text, + system: true, + }); + removeChannelTab(msg.to); + } else if (msg.to && msg.to.startsWith("#")) { + refreshMembers(msg.to); + } + + break; + } case "QUIT": { const reason = body ? " (" + body + ")" : ""; const text = `${msg.from} has quit${reason}`; @@ -518,7 +573,7 @@ function App() { const chName = msg.params[0]; const count = msg.params[1]; const chTopic = body || ""; - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: `${chName} (${count} users): ${chTopic.trim()}`, system: true, @@ -528,7 +583,7 @@ function App() { break; } case 323: - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: body || "End of channel list", system: true, @@ -541,7 +596,7 @@ function App() { const whoCh = msg.params[0]; const whoNick = msg.params[4]; const whoFlags = msg.params.length > 5 ? msg.params[5] : ""; - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: `${whoCh} ${whoNick} ${whoFlags}`, system: true, @@ -551,7 +606,7 @@ function App() { break; } case 315: - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: body || "End of /WHO list", system: true, @@ -562,7 +617,7 @@ function App() { // RPL_WHOISUSER — params nick, user, host, *; body realname. if (Array.isArray(msg.params) && msg.params.length >= 3) { const [wiNick, wiUser, wiHost] = msg.params; - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: `${wiNick} is ${wiUser}@${wiHost} (${body})`, system: true, @@ -576,7 +631,7 @@ function App() { if (Array.isArray(msg.params) && msg.params.length >= 2) { const wiNick = msg.params[0]; const wiServer = msg.params[1]; - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: `${wiNick} on ${wiServer}`, system: true, @@ -588,7 +643,7 @@ function App() { case 313: { // RPL_WHOISOPERATOR — nick; body "is an IRC operator". if (Array.isArray(msg.params) && msg.params.length >= 1) { - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: `${msg.params[0]} ${body}`, system: true, @@ -602,7 +657,7 @@ function App() { if (Array.isArray(msg.params) && msg.params.length >= 3) { const [wiNick, idle, signon] = msg.params; const signedOn = new Date(Number(signon) * 1000).toLocaleString(); - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: `${wiNick} has been idle ${idle}s, signed on ${signedOn}`, system: true, @@ -615,7 +670,7 @@ function App() { // RPL_WHOISCHANNELS — nick, channels. if (Array.isArray(msg.params) && msg.params.length >= 1) { const wiNick = msg.params[0]; - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: `${wiNick} is on: ${body}`, system: true, @@ -625,7 +680,7 @@ function App() { break; } case 318: - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: body || "End of /WHOIS list", system: true, @@ -635,7 +690,7 @@ function App() { case 324: { // RPL_CHANNELMODEIS — channel, mode string. if (Array.isArray(msg.params) && msg.params.length >= 2) { - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: `${msg.params[0]} modes: ${msg.params[1]}`, system: true, @@ -647,16 +702,16 @@ function App() { case 375: case 372: case 376: - addMessage("Server", { ...base, text: body, system: true }); + addMessage(SERVER_TAB, { ...base, text: body, system: true }); break; default: if (body) { - addMessage("Server", { ...base, text: body, system: true }); + addMessage(SERVER_TAB, { ...base, text: body, system: true }); } } }, - [addMessage, refreshMembers], + [addMessage, refreshMembers, removeChannelTab], ); // Long-poll loop. @@ -778,7 +833,7 @@ function App() { ); setNick(userNick); setLoggedIn(true); - addSystemMessage("Server", `Connected as ${userNick}`); + addSystemMessage(SERVER_TAB, `Connected as ${userNick}`); if (isResumed) { // Request MOTD on resumed sessions (new sessions @@ -802,10 +857,11 @@ function App() { // Fresh session — join any previously saved channels. for (const ch of saved) { try { - await api("/messages", { + const res = await api("/messages", { method: "POST", body: JSON.stringify({ command: "JOIN", to: ch }), }); + if (res?.status !== "joined") continue; setTabs((prev) => { if (prev.find((t) => t.type === "channel" && t.name === ch)) return prev; @@ -825,18 +881,30 @@ function App() { name = name.trim(); if (!name.startsWith("#")) name = "#" + name; try { - await api("/messages", { + const res = await api("/messages", { method: "POST", body: JSON.stringify({ command: "JOIN", to: name }), }); + // A refused join opens no tab. The error numeric that the + // server sends to the message queue tells the user why. + if (res?.status !== "joined") return; + // Show the channel's tab. Preact runs this update at once on the + // newest tab list, so the tab is found there by name: the user's + // own JOIN event may already have added it, and other tabs may + // have closed. setTabs((prev) => { - if (prev.find((t) => t.type === "channel" && t.name === name)) + const idx = prev.findIndex( + (t) => t.type === "channel" && t.name === name, + ); + if (idx >= 0) { + setActiveTab(idx); + return prev; + } + setActiveTab(prev.length); return [...prev, { type: "channel", name }]; }); - const newIdx = tabsRef.current.length; - setActiveTab(newIdx); try { const hist = await api( `/history?target=${encodeURIComponent(name)}&limit=50`, @@ -849,7 +917,7 @@ function App() { } } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `Failed to join ${name}: ${err.data?.error || "error"}`, ); } @@ -865,10 +933,7 @@ function App() { } catch (e) { // Ignore. } - setTabs((prev) => - prev.filter((t) => !(t.type === "channel" && t.name === name)), - ); - setActiveTab(0); + removeChannelTab(name); }; const closeTab = (idx) => { @@ -883,16 +948,23 @@ function App() { const openDM = (targetNick) => { if (targetNick === nickRef.current) return; + // Show the direct-message tab. Preact runs this update at once on + // the newest tab list, so the tab is found there by name: the + // server's copy of the user's message may already have added it, + // and other tabs may have opened or closed. setTabs((prev) => { - if (prev.find((t) => t.type === "dm" && t.name === targetNick)) + const idx = prev.findIndex( + (t) => t.type === "dm" && t.name === targetNick, + ); + if (idx >= 0) { + setActiveTab(idx); + return prev; + } + setActiveTab(prev.length); return [...prev, { type: "dm", name: targetNick }]; }); - const idx = tabs.findIndex( - (t) => t.type === "dm" && t.name === targetNick, - ); - setActiveTab(idx >= 0 ? idx : tabs.length); }; const handleCommand = async (text) => { @@ -903,7 +975,7 @@ function App() { switch (cmd) { case "/join": { if (parts[1]) joinChannel(parts[1]); - else addSystemMessage("Server", "Usage: /join #channel"); + else addSystemMessage(SERVER_TAB, "Usage: /join #channel"); break; } @@ -912,7 +984,7 @@ function App() { const reason = parts.slice(1).join(" ") || undefined; partChannel(tab.name, reason); } else { - addSystemMessage("Server", "You are not in a channel"); + addSystemMessage(SERVER_TAB, "You are not in a channel"); } break; @@ -933,19 +1005,19 @@ function App() { openDM(target); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `Message failed: ${err.data?.error || "error"}`, ); } } else { - addSystemMessage("Server", "Usage: /msg "); + addSystemMessage(SERVER_TAB, "Usage: /msg "); } break; } case "/me": { if (tab.type === "server") { - addSystemMessage("Server", "Cannot use /me in server window"); + addSystemMessage(SERVER_TAB, "Cannot use /me in server window"); break; } @@ -967,7 +1039,7 @@ function App() { ); } } else { - addSystemMessage("Server", "Usage: /me "); + addSystemMessage(SERVER_TAB, "Usage: /me "); } break; @@ -981,19 +1053,19 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `Nick change failed: ${err.data?.error || "error"}`, ); } } else { - addSystemMessage("Server", "Usage: /nick "); + addSystemMessage(SERVER_TAB, "Usage: /nick "); } break; } case "/topic": { if (tab.type !== "channel") { - addSystemMessage("Server", "You are not in a channel"); + addSystemMessage(SERVER_TAB, "You are not in a channel"); break; } @@ -1010,13 +1082,13 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `Topic change failed: ${err.data?.error || "error"}`, ); } } else { addSystemMessage( - "Server", + SERVER_TAB, `Current topic for ${tab.name}: ${topics[tab.name] || "(none)"}`, ); } @@ -1025,7 +1097,7 @@ function App() { } case "/mode": { if (tab.type !== "channel") { - addSystemMessage("Server", "You are not in a channel"); + addSystemMessage(SERVER_TAB, "You are not in a channel"); break; } @@ -1043,7 +1115,7 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `MODE failed: ${err.data?.error || "error"}`, ); } @@ -1074,7 +1146,7 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `Failed to request MOTD: ${err.data?.error || "error"}`, ); } @@ -1098,13 +1170,13 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `Message failed: ${err.data?.error || "error"}`, ); } } } else { - addSystemMessage("Server", "Usage: /query [message]"); + addSystemMessage(SERVER_TAB, "Usage: /query [message]"); } break; @@ -1117,7 +1189,7 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `Failed to list channels: ${err.data?.error || "error"}`, ); } @@ -1134,12 +1206,12 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `WHO failed: ${err.data?.error || "error"}`, ); } } else { - addSystemMessage("Server", "Usage: /who #channel"); + addSystemMessage(SERVER_TAB, "Usage: /who #channel"); } break; @@ -1153,12 +1225,12 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `WHOIS failed: ${err.data?.error || "error"}`, ); } } else { - addSystemMessage("Server", "Usage: /whois "); + addSystemMessage(SERVER_TAB, "Usage: /whois "); } break; @@ -1189,13 +1261,13 @@ function App() { " /help — Show this help", ]; for (const line of helpLines) { - addSystemMessage("Server", line); + addSystemMessage(SERVER_TAB, line); } break; } default: - addSystemMessage("Server", `Unknown command: ${cmd}`); + addSystemMessage(SERVER_TAB, `Unknown command: ${cmd}`); } }; @@ -1224,7 +1296,7 @@ function App() { if (tab.type === "server") { addSystemMessage( - "Server", + SERVER_TAB, "Cannot send messages to the server window. Use /join #channel first.", ); @@ -1295,7 +1367,7 @@ function App() { key={tab.name} > - {tab.type === "dm" ? tab.name : tab.name} + {tab.type === "server" ? "Server" : tab.name} {unread[tab.name] > 0 && i !== activeTab && ( ({unread[tab.name]})