Author SHA1 Message Date
clawbot 22c65f9100 Rewrite README.md sentence by sentence (closes #190)
check / check (push) Canceled after 0s
README.md is rewritten from scratch against the prose rules on issue 190
and checked statement by statement against the code on next. It leads
with what the program is and how to use it, documents the HTTP API, the
IRC listener, the web client and neoirc-cli, puts every setting in a
reference section, and ends with development, testing and a TODO
section that points at the tracker. Every reply number is in backticks,
every error list is bulleted, and a claim that holds for only one of
the HTTP API and the IRC listener says which. Where the old text
disagreed with the code, the PR body lists the disagreement; the code
decides.

Model: fable-5-1
2026-10-27 15:57:55 +00:00
+146 -215
View File
@@ -25,7 +25,7 @@ neoirc is IRC with a different transport. The commands are IRC's: `PRIVMSG`,
replies, such as `353` and `433`. Channels start with `#`. The first user to 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. join a channel creates it, and a channel disappears when its last member leaves.
The main differences from IRC: Four things differ from IRC:
- A client talks to the server over HTTP. It sends commands with one `POST` - A client talks to the server over HTTP. It sends commands with one `POST`
request and reads its messages with one `GET` request. request and reads its messages with one `GET` request.
@@ -48,8 +48,9 @@ The repository ships three programs:
IRC ties a session to one TCP connection. When a phone drops the connection, the 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 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 keeps the session on the server, so a client can disconnect and come back
nick, its channels and the messages it missed. without losing anything. Everything else stays IRC: the same commands, the same
replies, the same channel modes.
## Getting started ## Getting started
@@ -122,14 +123,12 @@ seen as `after`, so it reads each entry once.
Three kinds of message reach a queue: Three kinds of message reach a queue:
- **Chat messages**: `PRIVMSG` and `NOTICE`. The server echoes a session's own - Chat messages: `PRIVMSG` and `NOTICE`. The server echoes a session's own to
chat messages to every client of the session, except the IRC connection that every client of the session, except an IRC connection that sent one.
sent one.
- **Events**: `JOIN`, `PART`, `NICK`, `QUIT`, `TOPIC`, `MODE`, `KICK`, `INVITE` - **Events**: `JOIN`, `PART`, `NICK`, `QUIT`, `TOPIC`, `MODE`, `KICK`, `INVITE`
and `WALLOPS`, telling the client what other users did. When a client's own 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 `JOIN`, `PART`, `NICK`, `TOPIC`, `MODE` or `KICK` causes an event, every
client of its session receives it too; an IRC connection that sent the command client of its session receives it too.
gets the IRC echo instead.
- **Numeric replies**: the server's answers to commands, such as `RPL_TOPIC` or - **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 `ERR_NOSUCHNICK`. A numeric reply goes only to the client that sent the
command. command.
@@ -186,8 +185,8 @@ 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. other client must save it and send it back, as `curl -c` and `curl -b` do.
The cookie is `Secure`. Chrome, Firefox, `curl` and `neoirc-cli` send it to a 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 any server on `localhost` over plain HTTP too; Python's `requests` does not. For
other host they send it only over HTTPS, which a reverse proxy provides. every other host the server needs HTTPS, which a reverse proxy provides.
`QUIT`, `POST /api/v1/logout`, `KILL` and the idle cleanup invalidate the `QUIT`, `POST /api/v1/logout`, `KILL` and the idle cleanup invalidate the
cookie. After that, every request with it gets `401`. cookie. After that, every request with it gets `401`.
@@ -202,7 +201,7 @@ No auth. Request body:
{ {
"nick": "alice", "nick": "alice",
"username": "alice", "username": "alice",
"pow_token": "1:20:261027:neoirc::5971e" "pow_token": "1:20:260310:neoirc::3a2f1"
} }
``` ```
@@ -222,9 +221,8 @@ Response `201`, with the cookie:
`id` is the session's number. The new client's queue already holds the welcome `id` is the session's number. The new client's queue already holds the welcome
replies `001` to `005` and the `LUSERS` replies (see replies `001` to `005` and the `LUSERS` replies (see
[Numeric replies](#numeric-replies)). It also holds the message of the day, [Numeric replies](#numeric-replies)). It also holds the message of the day when
unless that is empty (see the `MOTD` setting under `MOTD` is set.
[Configuration](#configuration)).
Errors: Errors:
@@ -235,11 +233,9 @@ Errors:
requires one. requires one.
- `402` `invalid hashcash stamp: <reason>`. - `402` `invalid hashcash stamp: <reason>`.
- `409` `nick already taken`. - `409` `nick already taken`.
- `500` `internal error`.
The server checks the stamp before the nick, and spends it only when the session 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 is created. A request refused with `400` or `409` leaves the stamp unspent.
unspent.
#### `POST /api/v1/login`: add a client to a session #### `POST /api/v1/login`: add a client to a session
@@ -257,7 +253,7 @@ Response `200`, with a new cookie for the new client:
``` ```
The new client's queue holds the welcome replies and the `LUSERS` replies, then 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 the message of the day when `MOTD` is set. For each channel of the session it
then holds: then holds:
- a `JOIN` event from the session's own nick with an empty `body`; - a `JOIN` event from the session's own nick with an empty `body`;
@@ -284,16 +280,16 @@ Needs auth. Response `200`:
"id": 1, "id": 1,
"nick": "alice", "nick": "alice",
"channels": [ "channels": [
{ "id": 2, "name": "#dev", "topic": "" }, { "id": 1, "name": "#general", "topic": "Welcome!" },
{ "id": 1, "name": "#general", "topic": "Welcome!" } { "id": 2, "name": "#dev", "topic": "" }
] ]
} }
``` ```
`channels` lists the channels the session has joined, sorted by name. With `channels` lists the channels the session has joined, sorted by name. With
`?initChannelState=1`, the server also queues, for each channel, the `JOIN` `?initChannelState=1`, the server also fills the calling client's queue as
event, the topic replies and the member list, as `POST /api/v1/login` does. The `POST /api/v1/login` does. The web client uses this when it loads, to restore
web client uses this when it loads, to restore its channel tabs. its channel tabs.
`GET /api/v1/users/me` is the same endpoint under another path. `GET /api/v1/users/me` is the same endpoint under another path.
@@ -302,11 +298,9 @@ web client uses this when it loads, to restore its channel tabs.
Needs auth. Query parameters: Needs auth. Query parameters:
- `after` (default `0`): return only queue entries with a number greater than - `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 this. Pass the `last_id` of the previous response.
number counts as `0`.
- `timeout` (default `0`): how many seconds to wait for a message when the queue - `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, has none. `0` answers at once. The server caps it at `30`.
or one that is not a whole number, counts as `0`.
Response `200`: Response `200`:
@@ -333,9 +327,9 @@ the last entry returned, or the `after` value when there was none.
When the queue has nothing after `after` and `timeout` is above `0`, the server 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 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 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 messages queued for this client, or an empty `messages` array when there are
array. That happens when the message was for another client of the session, or none, as when the message was for another client of the session. A client that
when the time ran out. A client that closes the connection first gets no answer. closes the connection first gets no answer.
A client loop is: call with `after=0`, then call again with `after` set to each 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 response's `last_id`. Passing an earlier `after` reads the entries again. Queue
@@ -371,15 +365,14 @@ Needs auth. Query parameters:
- `limit` (default `50`): at most this many messages, from 1 to 500. Any other - `limit` (default `50`): at most this many messages, from 1 to 500. Any other
value means 50. value means 50.
Response `200`: an array of `PRIVMSG` messages, oldest first. History holds only Response `200`: an array of `PRIVMSG` messages, oldest first. Only `PRIVMSG` is
`PRIVMSG`; events and `NOTICE` are left out. kept in history; events and `NOTICE` are not.
For a channel, the caller must be a member. For a nick, only the caller's own 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 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 session, under any nick it has held. A session that takes a released nick sees
caller sent. A session that takes a released nick sees none of the messages sent none of the messages sent to the nick's earlier holder. A channel created again
to the nick's earlier holder. A channel created again after it was deleted after it was deleted starts with no history.
starts with no history.
To page back, pass the `id` of the oldest message of a page as `before`. An To page back, pass the `id` of the oldest message of a page as `before`. An
empty array means there is nothing older. empty array means there is nothing older.
@@ -406,8 +399,8 @@ Needs auth. Response `200`: every channel on the server, sorted by name, secret
```json ```json
[ [
{ "id": 2, "name": "#dev", "topic": "" }, { "id": 1, "name": "#general", "topic": "Welcome!" },
{ "id": 1, "name": "#general", "topic": "Welcome!" } { "id": 2, "name": "#dev", "topic": "" }
] ]
``` ```
@@ -429,9 +422,8 @@ Needs auth. `{name}` is the channel name without the `#`. Response `200`:
] ]
``` ```
The members are sorted by nick. `id` is the member's session number. `lastSeen` `id` is the member's session number. `lastSeen` is the time of the session's
is the time of the session's last request. An unknown channel gets `404` last request. An unknown channel gets `404` `channel not found`.
`channel not found`.
#### `POST /api/v1/logout`: remove this client #### `POST /api/v1/logout`: remove this client
@@ -530,17 +522,16 @@ with these fields:
- `params`: an array of strings with further parameters. `KICK` events and many - `params`: an array of strings with further parameters. `KICK` events and many
numeric replies have it. The server ignores one sent by a client. 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 - `body`: an array of strings. For chat it is the lines of text, one string per
line. A `body` is never a bare string; a client's `body` that is not an array line. A `body` is never a bare string. Every numeric reply has a `body`; a
of strings counts as missing. Every numeric reply has a `body`; a few, such as few, such as `RPL_CHANNELMODEIS`, carry one empty string.
`RPL_CHANNELMODEIS`, carry one empty string.
- `ts`: when the server stored the message: UTC, RFC 3339 with fractional - `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. seconds, such as `2026-02-10T20:00:00.123456789Z`; trailing zeros are dropped.
The server assigns it. The server assigns it.
- `meta`: an object. A client may put anything in it. On a `PRIVMSG` or `NOTICE` - `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 the server stores and relays it unchanged, and `{}` when the client sent none.
message carries `{}`. On every other command the server drops it, and the On every other command the server drops it, and the event carries `{}`. The
event carries `{}`. The server reads one key: `meta.hashcash` on a `PRIVMSG` server reads one key: `meta.hashcash` on a `PRIVMSG` to a channel with mode
to a channel with mode `+H`. `+H`.
### Commands ### Commands
@@ -564,8 +555,7 @@ otherwise, an error numeric comes with the HTTP response `{"status": "error"}`.
{"command": "PRIVMSG", "to": "bob", "body": ["hey"]} {"command": "PRIVMSG", "to": "bob", "body": ["hey"]}
``` ```
- `to` (required): a channel name, or a nick for a direct message. The server - `to` (required): a channel name, or a nick for a direct message.
does not add a missing `#` here: a `to` without one is a nick.
- `body` (required): one or more lines. - `body` (required): one or more lines.
- `meta` (optional): relayed unchanged. A `PRIVMSG` to a `+H` channel carries - `meta` (optional): relayed unchanged. A `PRIVMSG` to a `+H` channel carries
its stamp in `meta.hashcash`. its stamp in `meta.hashcash`.
@@ -612,7 +602,7 @@ The same as `PRIVMSG`, with three differences:
- The server sends no `RPL_AWAY` for a notice. - The server sends no `RPL_AWAY` for a notice.
- A notice to a `+H` channel needs no hashcash stamp. - A notice to a `+H` channel needs no hashcash stamp.
- A notice does not appear in history (see - A notice is not kept in history (see
[`GET /api/v1/history`](#get-apiv1history-past-messages)). [`GET /api/v1/history`](#get-apiv1history-past-messages)).
A client that receives a `NOTICE` must not answer it automatically. That is the A client that receives a `NOTICE` must not answer it automatically. That is the
@@ -836,8 +826,8 @@ changes:
positive whole number. positive whole number.
- `+b <mask>` / `-b <mask>`: add or remove a ban. `+b` with no mask lists the - `+b <mask>` / `-b <mask>`: add or remove a ban. `+b` with no mask lists the
bans; anyone may do that. bans; anyone may do that.
- `+H <bits>` / `-H`: require a hashcash stamp on every `PRIVMSG`, or stop - `+H <bits>` / `-H`: require a hashcash stamp of `bits` leading zero bits on
requiring one. `bits`, from 1 to 40, is the number of leading zero bits. every `PRIVMSG`, from 1 to 40, or stop requiring one.
Every member, the operator included, receives a `MODE` event. For `o`, `v`, `i`, 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` `m`, `s` and `t` its `body` is one string: `["+o bob"]`, `["+m"]`. For `k`, `l`
@@ -860,8 +850,8 @@ Errors of a channel `MODE`:
- `ERR_NEEDMOREPARAMS` (`461`): no `to`, or `+o`, `-o`, `+v`, `-v`, `+k`, `+l`, - `ERR_NEEDMOREPARAMS` (`461`): no `to`, or `+o`, `-o`, `+v`, `-v`, `+k`, `+l`,
`-b` or `+H` without its value. `-b` or `+H` without its value.
- `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist. - `ERR_NOSUCHCHANNEL` (`403`): the channel does not exist.
- `ERR_CHANOPRIVSNEEDED` (`482`): a change from the list above by a user who is - `ERR_CHANOPRIVSNEEDED` (`482`): a change by a user who is not a channel
not a channel operator. operator.
- `ERR_UNKNOWNMODE` (`472`), in three cases: - `ERR_UNKNOWNMODE` (`472`), in three cases:
- a change the list above does not have; - a change the list above does not have;
- a `+H` value that is not a whole number from 1 to 40; - a `+H` value that is not a whole number from 1 to 40;
@@ -874,9 +864,9 @@ gets `ERR_USERSDONTMATCH` (`502`). With no `body`, the server sends
`RPL_UMODEIS` (`221`) with the modes in `body`: `+`, `+o`, `+w` or `+ow`. With a `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 `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 `-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` with any other letter, or without a letter, gets `ERR_UMODEUNKNOWNFLAG` (`501`)
(`501`) and changes nothing. An accepted change is answered with `RPL_UMODEIS` and changes nothing. An accepted change is answered with `RPL_UMODEIS` (`221`)
(`221`) and the new modes, and nobody else is told. and the new modes, and nobody else is told.
```json ```json
{ "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } { "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] }
@@ -959,9 +949,9 @@ HTTP response: `{"status": "ok"}`.
``` ```
A `body` sets the away message; several strings are joined with spaces. No 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 `body` clears it. The reply is `RPL_NOWAWAY` (`306`) or `RPL_UNAWAY` (`305`). A
`RPL_UNAWAY` (`305`). A user who sends a `PRIVMSG` to an away user receives user who sends a `PRIVMSG` to an away user receives `RPL_AWAY` (`301`).
`RPL_AWAY` (`301`). `USERHOST` marks an away user with `-`. `USERHOST` marks an away user with `-`.
HTTP response: `{"status": "ok"}`. HTTP response: `{"status": "ok"}`.
@@ -973,9 +963,9 @@ HTTP response: `{"status": "ok"}`.
The server adds a missing `#`. The reply is `RPL_NAMREPLY` (`353`), when the 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 has members, then `RPL_ENDOFNAMES` (`366`). `RPL_NAMREPLY` has `params`
`["=", channel]` and a `body` with one string: the members' hostmasks in nick `["=", channel]` and a `body` with one string: the members' hostmasks separated
order, separated by spaces, each with `@` before a channel operator and `+` by spaces, each with `@` before a channel operator and `+` before a voiced
before a voiced member: member:
```json ```json
{ {
@@ -983,7 +973,7 @@ before a voiced member:
"code": 353, "code": 353,
"to": "alice", "to": "alice",
"params": ["=", "#general"], "params": ["=", "#general"],
"body": ["alice!alice@host2 +bob!bob@host3 @op1!op1@host1"] "body": ["@op1!op1@host1 alice!alice@host2 +bob!bob@host3"]
} }
``` ```
@@ -1000,7 +990,7 @@ HTTP response: `{"status": "ok"}`.
{ "command": "LIST" } { "command": "LIST" }
``` ```
The reply is one `RPL_LIST` (`322`) per channel, in name order, with `params` The reply is one `RPL_LIST` (`322`) per channel, with `params`
`[channel, member count]` and the topic in `body`, then `RPL_LISTEND` (`323`). `[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. Secret (`+s`) channels the user is not a member of are left out.
@@ -1068,8 +1058,8 @@ HTTP response: `{"status": "ok"}`.
The server sends the welcome replies `001` to `005`, the `LUSERS` replies and 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`) 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 per line, `RPL_ENDOFMOTD` (`376`). With an empty `MOTD` setting, the welcome and
welcome and `LUSERS` replies come alone. `LUSERS` replies come alone.
HTTP response: `{"status": "ok"}`. HTTP response: `{"status": "ok"}`.
@@ -1178,14 +1168,11 @@ 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 RFC 2812. Every reply has a `body` with one string, the reply's text; a few
carry an empty string. carry an empty string.
The server sends these to every new client, on `POST /api/v1/session` and Sent to every new client, on `POST /api/v1/session`, `POST /api/v1/login` and
`POST /api/v1/login`, and again on the `MOTD` command. `375` to `376` are left `MOTD`; `375` to `376` only when `MOTD` is set:
out when the message of the day is empty (see the `MOTD` setting under
[Configuration](#configuration)).
- `001` `RPL_WELCOME`: `Welcome to the network, <nick>`. - `001` `RPL_WELCOME`: `Welcome to the network, <nick>`.
- `002` `RPL_YOURHOST`: `Your host is <server name>, running version <version>`. - `002` `RPL_YOURHOST`: `Your host is <server name>, running version <version>`.
`<version>` is `dev` in a binary built without one.
- `003` `RPL_CREATED`: `This server was created <date the server started>`. - `003` `RPL_CREATED`: `This server was created <date the server started>`.
- `004` `RPL_MYINFO`: `params` `[server name, version, "", "ikmnostl"]`. - `004` `RPL_MYINFO`: `params` `[server name, version, "", "ikmnostl"]`.
- `005` `RPL_BOUNCE`: `params` - `005` `RPL_BOUNCE`: `params`
@@ -1202,49 +1189,32 @@ out when the message of the day is empty (see the `MOTD` setting under
- `372` `RPL_MOTD`: one per line of the message of the day, each `- <line>`. - `372` `RPL_MOTD`: one per line of the message of the day, each `- <line>`.
- `376` `RPL_ENDOFMOTD`: `End of /MOTD command.` - `376` `RPL_ENDOFMOTD`: `End of /MOTD command.`
Answers to commands. Each line names the command that gets the reply, then what Answers to commands, as described under each command:
`params` holds:
- `221` `RPL_UMODEIS`: user `MODE`. No `params`; the modes are in `body`. - `221` `RPL_UMODEIS`: user `MODE`.
- `256` `RPL_ADMINME`: `ADMIN`. `[server name]`. - `256` `RPL_ADMINME`, `257` `RPL_ADMINLOC1`, `258` `RPL_ADMINLOC2`, `259`
- `257` `RPL_ADMINLOC1`, `258` `RPL_ADMINLOC2`, `259` `RPL_ADMINEMAIL`: `ADMIN`. `RPL_ADMINEMAIL`: `ADMIN`.
No `params`. - `301` `RPL_AWAY`: a `PRIVMSG` to an away user.
- `301` `RPL_AWAY`: a `PRIVMSG` to an away user. `[nick]`; the away message is - `302` `RPL_USERHOST`: `USERHOST`.
in `body`. - `305` `RPL_UNAWAY`, `306` `RPL_NOWAWAY`: `AWAY`.
- `302` `RPL_USERHOST`: `USERHOST`. No `params`. - `311` `RPL_WHOISUSER`, `312` `RPL_WHOISSERVER`, `313` `RPL_WHOISOPERATOR`,
- `305` `RPL_UNAWAY`, `306` `RPL_NOWAWAY`: `AWAY`. No `params`. `317` `RPL_WHOISIDLE`, `318` `RPL_ENDOFWHOIS`, `319` `RPL_WHOISCHANNELS`,
- `311` `RPL_WHOISUSER`: `WHOIS`. `[nick, username, hostname, "*"]`. `338` `RPL_WHOISACTUALLY`: `WHOIS`.
- `312` `RPL_WHOISSERVER`: `WHOIS`. `[nick, server name]`. - `315` `RPL_ENDOFWHO`, `352` `RPL_WHOREPLY`: `WHO`.
- `313` `RPL_WHOISOPERATOR`: `WHOIS`. `[nick]`. - `322` `RPL_LIST`, `323` `RPL_LISTEND`: `LIST`.
- `317` `RPL_WHOISIDLE`: `WHOIS`. `[nick, idle seconds, signon Unix time]`. - `324` `RPL_CHANNELMODEIS`, `329` `RPL_CREATIONTIME`: a channel `MODE` query;
- `318` `RPL_ENDOFWHOIS`: `WHOIS`. `[nick]`. `324` also after `+H` and `-H`.
- `319` `RPL_WHOISCHANNELS`: `WHOIS`. `[nick]`; the channels are in `body`. - `331` `RPL_NOTOPIC`, `332` `RPL_TOPIC`, `333` `RPL_TOPICWHOTIME`: `JOIN`,
- `338` `RPL_WHOISACTUALLY`: `WHOIS`. `[nick, IP address]`. `TOPIC` and the channel state after login or `?initChannelState=1`. `333` has
- `315` `RPL_ENDOFWHO`: `WHO`. `[channel]`. `params` `[channel, nick who set the topic, Unix time]`.
- `352` `RPL_WHOREPLY`: `WHO`. - `341` `RPL_INVITING`: `INVITE`.
`[channel, username, hostname, server name, nick, "H"]`. - `351` `RPL_VERSION`: `VERSION`.
- `322` `RPL_LIST`: `LIST`. `[channel, member count]`; the topic is in `body`. - `353` `RPL_NAMREPLY`, `366` `RPL_ENDOFNAMES`: `JOIN`, `NAMES` and the channel
- `323` `RPL_LISTEND`: `LIST`. No `params`. state after login or `?initChannelState=1`.
- `324` `RPL_CHANNELMODEIS`: a channel `MODE` query, and a `+H` or `-H` change. - `367` `RPL_BANLIST`, `368` `RPL_ENDOFBANLIST`: `MODE #channel +b`.
`[channel, mode string]`. - `371` `RPL_INFO`, `374` `RPL_ENDOFINFO`: `INFO`.
- `329` `RPL_CREATIONTIME`: a channel `MODE` query. `[channel, Unix time]`. - `381` `RPL_YOUREOPER`: `OPER`.
- `331` `RPL_NOTOPIC`: `JOIN` and the channel state after login or - `391` `RPL_TIME`: `TIME`.
`?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-<version>.", 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: Errors, each with what its `params` holds:
@@ -1335,9 +1305,9 @@ of the `X-Forwarded-For` header, then `X-Real-IP`, then the TCP connection (see
Every user can see a hostmask: in `RPL_WHOISUSER` (`311`), `RPL_WHOREPLY` Every user can see a hostmask: in `RPL_WHOISUSER` (`311`), `RPL_WHOREPLY`
(`352`), `RPL_USERHOST` (`302`), `RPL_NAMREPLY` (`353`) and (`352`), `RPL_USERHOST` (`302`), `RPL_NAMREPLY` (`353`) and
`GET /api/v1/channels/{name}/members`. Bans match against it. Only a server `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, operator sees IP addresses, in `RPL_WHOISACTUALLY` (`338`), and only that of the
and only that of the user's newest client. When the lookup gave no name, the user's newest client. When the lookup gave no name, the hostname everyone sees
hostname everyone sees is the IP address. is the IP address.
### Hashcash ### Hashcash
@@ -1366,8 +1336,7 @@ has six fields separated by colons:
- `counter`: a hex number the client varies until the hash fits. - `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 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 `hashcash_bits` zero bits. Example: `1:20:260310:neoirc::3a2f1b`. In pseudocode:
for 27 October 2026; its hash starts with `00000e`. In pseudocode:
``` ```
counter = 0 counter = 0
@@ -1389,9 +1358,9 @@ The server accepts a stamp when all of these hold:
- the hash has the zero bits; - the hash has the zero bits;
- the stamp has not been spent. - the stamp has not been spent.
The server keeps spent stamps in memory until their date is 48 hours old; a Spent stamps are kept in memory for 48 hours; a restart forgets them. The client
restart forgets them. The client sends the stamp as `pow_token` in sends the stamp as `pow_token` in `POST /api/v1/session`. The web client and
`POST /api/v1/session`. The web client and `neoirc-cli` compute it on their own. `neoirc-cli` compute it on their own.
#### Channel stamps #### Channel stamps
@@ -1409,26 +1378,22 @@ The stamp is bound to the channel and to the message:
The version, `bits` and date rules of session stamps apply to channel stamps The version, `bits` and date rules of session stamps apply to channel stamps
too, with the channel's `+H` value as the difficulty. 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` Example:
spelled exactly `["hello world"]`:
```json ```json
{ {
"command": "PRIVMSG", "command": "PRIVMSG",
"to": "#general", "to": "#general",
"body": ["hello world"], "body": ["hello world"],
"meta": { "meta": { "hashcash": "1:20:260317:#general:a1b2c3...:1f4a" }
"hashcash": "1:20:261027:#general:d7f54e4fb4d97b407a45ce7ba369ec4cb70d15337fb33a1adfcb4f523aeb1b32:16ec2f"
}
} }
``` ```
The server checks the stamp after membership, bans and `+m`, so a message 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 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 the database and refuses the stamp for a year. A missing, invalid or spent stamp
spent stamp gets `ERR_CANNOTSENDTOCHAN` (`404`), with the reason in `body`. gets `ERR_CANNOTSENDTOCHAN` (`404`), with the reason in `body`. `NOTICE` needs
`NOTICE` needs no stamp. The web client and `neoirc-cli` do not compute channel no stamp. The web client and `neoirc-cli` do not compute channel stamps.
stamps.
### Login rate limit ### Login rate limit
@@ -1439,10 +1404,9 @@ over the limit gets `429` `too many login attempts, try again later` with
attempt is more than 15 minutes old. Session creation has no rate limit; the attempt is more than 15 minutes old. Session creation has no rate limit; the
hashcash stamp is its cost. hashcash stamp is its cost.
The IP address comes from the first entry of `X-Forwarded-For`, else from The IP address comes from `X-Forwarded-For` or `X-Real-IP` when present. Without
`X-Real-IP`, else from the TCP connection. Without a reverse proxy that a reverse proxy that sets those headers, a client can set them itself and so
overwrites those headers, a client can set them itself and so pick any address pick any address (see [Reverse proxy](#reverse-proxy)).
(see [Reverse proxy](#reverse-proxy)).
### Writing a client ### Writing a client
@@ -1476,22 +1440,16 @@ while True:
resp = session.get(f"{BASE}/messages", resp = session.get(f"{BASE}/messages",
params={"after": last_id, "timeout": 15}, params={"after": last_id, "timeout": 15},
timeout=20) # longer than the long-poll timeout timeout=20) # longer than the long-poll timeout
data = resp.json()
last_id = data.get("last_id", last_id)
for msg in data.get("messages", []):
print(f"[{msg['command']}] <{msg.get('from', '')}> "
f"{' '.join(msg.get('body', []))}")
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
continue continue
except requests.exceptions.RequestException as exc: except Exception as exc:
print(f"error: {exc}") print(f"error: {exc}")
time.sleep(2) 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'])}")
``` ```
The same loop in a browser, where the cookie is sent automatically: The same loop in a browser, where the cookie is sent automatically:
@@ -1506,10 +1464,6 @@ async function pollLoop() {
{ credentials: "same-origin" }, { credentials: "same-origin" },
); );
if (resp.status === 401) break; // the session is gone 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(); const data = await resp.json();
lastId = data.last_id; lastId = data.last_id;
for (const msg of data.messages) handleMessage(msg); for (const msg of data.messages) handleMessage(msg);
@@ -1520,9 +1474,6 @@ async function pollLoop() {
} }
``` ```
Both loops keep `after` where it was unless the answer is `200`, and stop on
`401`.
Advice for client authors: Advice for client authors:
- Set the HTTP timeout above the long-poll timeout. With `timeout=15`, give the - Set the HTTP timeout above the long-poll timeout. With `timeout=15`, give the
@@ -1530,9 +1481,7 @@ Advice for client authors:
- Keep `after` moving. Reset it to `0` only to read the queue again. - 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 - 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`, `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` `PART`, `NICK`, `TOPIC`, `MODE` and `KICK`. Show them, or drop them by `id`.
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, - Key a direct-message window by the other user's nick: `to` when `from` is you,
`from` otherwise. `from` otherwise.
- On `401`, the session is gone or the cookie is wrong. Create a new session, or - On `401`, the session is gone or the cookie is wrong. Create a new session, or
@@ -1570,7 +1519,7 @@ password of the wrong length is refused with a `NOTICE` once registration
completes. completes.
After registration the client receives `001` to `005`, the `LUSERS` replies and 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` the message of the day, or `ERR_NOMOTD` (`422`) when `MOTD` is empty. The `004`
modes are `mnst` and the `005` tokens `CHANTYPES=#`, `NICKLEN=32`, modes are `mnst` and the `005` tokens `CHANTYPES=#`, `NICKLEN=32`,
`PREFIX=(ov)@+`, `CHANMODES=,,H,imnst` and `NETWORK=<server name>`. `PREFIX=(ov)@+`, `CHANMODES=,,H,imnst` and `NETWORK=<server name>`.
@@ -1623,11 +1572,6 @@ They behave as over the HTTP API, with the IRC syntax and these differences:
end reply. end reply.
- `NICK` without a nick gets `ERR_NONICKNAMEGIVEN` (`431`). - `NICK` without a nick gets `ERR_NONICKNAMEGIVEN` (`431`).
- `KICK` without a reason uses the kicked user's nick as the reason. - `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 a password of the wrong length with a
`NOTICE`, not `ERR_NEEDMOREPARAMS` (`461`), and a stored password with a
`NOTICE` too.
### What IRC clients see ### What IRC clients see
@@ -1636,9 +1580,8 @@ Each line of a multi-line `body` reaches an IRC client as its own `PRIVMSG` or
appear with the prefix `nick!nick@*`. A mode change made over the HTTP API 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 arrives as an IRC `MODE` line with each value as its own parameter, such as
`MODE #general +k secretkey`. An `INVITE` arrives as `MODE #general +k secretkey`. An `INVITE` arrives as
`:alice!alice@* INVITE bob #general`. Numeric replies, `JOIN`, `PART`, `KICK`, `:alice!alice@* INVITE bob #general`. Numeric replies, `KICK`, `TOPIC`, `NICK`,
`TOPIC`, `NICK`, `QUIT` (as `Quit: <reason>`) and `WALLOPS` arrive in their IRC `QUIT` (as `Quit: <reason>`) and `WALLOPS` arrive in their IRC forms.
forms.
The listener does not queue a client's own commands back to it. It sends the 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 echo itself, as IRC does, and for `PRIVMSG` and `NOTICE` sends none. Other
@@ -1692,8 +1635,7 @@ target as it was. `/part` of the target's channel clears it.
The client reads its queue with `timeout=15` and retries after two seconds on an 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 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 to a server on `localhost` over plain HTTP, and otherwise only over HTTPS.
`127.0.0.1`, and otherwise only over HTTPS.
## Running the server ## Running the server
@@ -1701,14 +1643,12 @@ over plain HTTP to a server on `localhost` or a loopback address such as
Every setting is an environment variable. The server also reads a `.env` file in 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 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 `$HOME/.config/neoirc/` with the same names as keys. An environment variable
environment wins over one in `.env`, and both win over `neoirc.yaml`. An wins over the file. An environment variable set to the empty string counts as
environment variable set to the empty string counts as unset, except unset, except `IRC_LISTEN_ADDR`, where the empty string turns the IRC listener
`IRC_LISTEN_ADDR`, where the empty string turns the IRC listener off. A off. A `neoirc.yaml` that is not valid YAML stops the server at startup.
`neoirc.yaml` that is not valid YAML stops the server at startup.
- `PORT` (default `8080`): the HTTP port. A value that is not a whole number - `PORT` (default `8080`): the HTTP port.
counts as `0`, and the system then picks a free port.
- `IRC_LISTEN_ADDR` (default `:6667`): the address of the IRC listener. The - `IRC_LISTEN_ADDR` (default `:6667`): the address of the IRC listener. The
empty string turns the listener off. empty string turns the listener off.
- `DBURL` (default - `DBURL` (default
@@ -1722,34 +1662,27 @@ environment variable set to the empty string counts as unset, except
hashcash resource use `neoirc`, and `GET /api/v1/server` returns an empty hashcash resource use `neoirc`, and `GET /api/v1/server` returns an empty
string. string.
- `MOTD` (default: a built-in banner): the message of the day. Lines are - `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 separated by newlines.
the banner. To send no message of the day, put `MOTD: ""` in `neoirc.yaml`.
- `NEOIRC_HASHCASH_BITS` (default `20`): the difficulty of the stamp - `NEOIRC_HASHCASH_BITS` (default `20`): the difficulty of the stamp
`POST /api/v1/session` needs. `0` turns the requirement off, and so does a `POST /api/v1/session` needs. `0` turns the requirement off.
value that is not a whole number.
- `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD` (default empty): the name and - `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD` (default empty): the name and
password `OPER` accepts. `OPER` fails while either is empty. password `OPER` accepts. `OPER` fails while either is empty.
- `MAX_MESSAGE_SIZE` (default `4096`): the largest request body, in bytes, for - `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`. A `POST /api/v1/session`, `POST /api/v1/login` and `POST /api/v1/messages`.
value of `0` or less, or one that is not a whole number, means the default. - `LOGIN_RATE_LIMIT` (default `1`) and `LOGIN_RATE_BURST` (default `5`): login
- `LOGIN_RATE_LIMIT` (default `1`): login attempts allowed per second per IP attempts allowed per second per IP address, and the burst above that rate. A
address. A fraction such as `0.5` is allowed. A value of `0` or less, or one value of `0` or less means the default.
that is not a number, means the default. - `SESSION_IDLE_TIMEOUT` (default `720h`, 30 days): a Go duration such as `720h`
- `LOGIN_RATE_BURST` (default `5`): how many attempts an address may make at or `24h`. A client with no request for this long is removed, and a session
once before the rate applies. A value of `0` or less, or one that is not a left with no client ends. See [Data lifecycle](#data-lifecycle). A value that
whole number, means the default. is not a duration is logged, and the default applies.
- `SESSION_IDLE_TIMEOUT` (default `720h`, 30 days): a duration with a unit, `h` - `MESSAGE_MAX_AGE` (default `720h`): messages older than this are deleted. A
for hours, `m` for minutes or `s` for seconds, such as `720h` or `36h30m`. A value that is not a duration is logged, and no message is deleted.
client with no request for this long is removed, and a session left with no - `QUEUE_MAX_AGE` (default `720h`): queue entries older than this are deleted. A
client ends. See [Data lifecycle](#data-lifecycle). A value that is not a value that is not a duration is logged, and no entry is deleted.
duration is logged, and the default applies.
- `MESSAGE_MAX_AGE` (default `720h`): messages older than this duration are
deleted. A value that is not a duration is logged, and no message is deleted.
- `QUEUE_MAX_AGE` (default `720h`): queue entries older than this duration are
deleted. A value that is not a duration is logged, and no entry is deleted.
- `DEBUG` (default `false`): `true` turns on debug logging. - `DEBUG` (default `false`): `true` turns on debug logging.
- `SENTRY_DSN` (default empty): when set, panics in HTTP requests are reported - `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. to Sentry.
- `METRICS_USERNAME` and `METRICS_PASSWORD` (default empty): the HTTP basic auth - `METRICS_USERNAME` and `METRICS_PASSWORD` (default empty): the HTTP basic auth
credentials of `GET /metrics`. The endpoint exists only when credentials of `GET /metrics`. The endpoint exists only when
`METRICS_USERNAME` is set. `METRICS_USERNAME` is set.
@@ -1769,8 +1702,7 @@ NEOIRC_HASHCASH_BITS=20
### Docker ### Docker
`make docker` builds the image, tagged `neoirc`. The build runs the tests and `make docker` builds the image, tagged `neoirc`. The image:
the linter first and stops when either fails. The image:
- is Alpine with the `neoircd` binary and CA certificates; - is Alpine with the `neoircd` binary and CA certificates;
- runs as the user `neoirc`; - runs as the user `neoirc`;
@@ -1796,11 +1728,10 @@ 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 speaks plain HTTP, and the auth cookie is `Secure`, so browsers send it only
over HTTPS. The proxy must: over HTTPS. The proxy must:
- Set `X-Forwarded-For` to the client's address, replacing any value the client - Set `X-Forwarded-For` or `X-Real-IP` to the client's address, replacing any
sent. The server reads that header first and trusts it from anyone; it reads value the client sent. The server trusts these headers from anyone. Without a
`X-Real-IP` only when `X-Forwarded-For` is absent. Without a proxy that proxy that overwrites them, a client can choose its own address, which sets
overwrites the header, a client can choose its own address, which sets its its hostname and lets it dodge the login rate limit.
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 - 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. the server's own write timeout is 60 seconds.
@@ -1837,8 +1768,8 @@ The IRC listener has no TLS.
### Limits and headers ### Limits and headers
- The server reads a request within 10 seconds and writes a response within 60 - 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 seconds. A request is cancelled after 60 seconds and gets `504` with no body.
connection closes without a response. Headers may be up to 1 MiB. Headers may be up to 1 MiB.
- A long poll waits at most 30 seconds. - A long poll waits at most 30 seconds.
- Every response to a well-formed request, except to a cross-origin preflight, - Every response to a well-formed request, except to a cross-origin preflight,
carries a `Content-Security-Policy` header: carries a `Content-Security-Policy` header:
@@ -1853,16 +1784,16 @@ The IRC listener has no TLS.
The server logs with Go's `slog`, one line per event, to standard output. The 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 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 request is logged with its method, URL, status, latency, remote address, user
address, user agent, referrer and request ID. `DEBUG=true` adds debug-level agent and request ID. `DEBUG=true` adds debug-level lines.
lines.
### The database ### The database
The server stores everything in one SQLite database, through the pure Go driver 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 `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 turns. On startup it creates the tables from
creates the tables, and records that in `schema_migrations`. `internal/db/schema/001_initial.sql` if they do not exist, and records the
schema version in `schema_migrations`.
The tables: The tables:
@@ -1962,8 +1893,8 @@ standard. Each script has a `make` target of the same name, except
`script/install-precommit`, which is `make hooks`, and `script/cibuild`, `script/install-precommit`, which is `make hooks`, and `script/cibuild`,
`script/precommit` and `script/projectname`, which have none. `script/precommit` and `script/projectname`, which have none.
- `script/bootstrap`: installs `make`, `git`, Go, Node and `yarn`, and prettier - `script/bootstrap`: installs what development needs: `make`, `git`, Node and
through `yarn`. Docker must already be installed. `yarn` (for prettier) and Go.
- `script/setup`: makes a fresh clone ready: runs `script/bootstrap`, then - `script/setup`: makes a fresh clone ready: runs `script/bootstrap`, then
`script/install-precommit`. `script/install-precommit`.
- `script/test`: builds the `Dockerfile`'s `test` stage, which runs the tests - `script/test`: builds the `Dockerfile`'s `test` stage, which runs the tests