From 76ef8ffaafdfa443fa674974482c3552c3230c6f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 02:23:34 +0100 Subject: [PATCH 01/37] Show neoirc-cli's own messages, joins and parts from the server's events (closes #186) neoirc-cli wrote its own line when it sent a message, joined or parted, and ignored the server's event for its own nick, except a JOIN, so a join showed twice and what the session did from another client never showed. The HTTP API queues these events to every client of the session, so the CLI now writes no line of its own when it sends and shows the event as it shows anyone else's. A direct message the user sent goes in the recipient's buffer. The user's own part does not mark the channel unread, as the user has just left it. Model: opus-5-5 --- internal/cli/app.go | 70 ++++------- internal/cli/app_internal_test.go | 189 +++++++++++++++++++++++++++--- internal/cli/ui.go | 13 ++ 3 files changed, 205 insertions(+), 67 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 4bacf80..eae6d63 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -96,20 +96,7 @@ func (a *App) handleInput(text string) { a.ui.AddStatus( "[red]Send error: " + 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) { @@ -301,9 +288,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") } @@ -338,10 +322,6 @@ func (a *App) cmdPart(channel string) { return } - a.ui.AddLine(channel, - "[yellow]*** Left "+channel, - ) - a.mu.Lock() if a.target == channel { a.target = "" @@ -368,7 +348,6 @@ func (a *App) cmdMsg(args string) { a.mu.Lock() connected := a.connected - nick := a.nick a.mu.Unlock() if !connected { @@ -386,16 +365,7 @@ func (a *App) cmdMsg(args string) { a.ui.AddStatus(fmt.Sprintf( "[red]Send failed: %v", err, )) - - return } - - 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) { @@ -785,12 +755,10 @@ 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 } @@ -813,31 +781,35 @@ func (a *App) handleJoinEvent( )) } -// 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, msg.From, 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 += " (" + reason + ")" } + + if msg.From == myNick { + a.ui.AddLineAsRead(msg.To, line) + + return + } + + a.ui.AddLine(msg.To, line) } func (a *App) handleQuitEvent( diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 9aa41d7..bb9e49f 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -1,6 +1,8 @@ package cli import ( + "net/http" + "net/http/httptest" "testing" "github.com/gdamore/tcell/v2" @@ -8,6 +10,15 @@ import ( "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" +) + func TestMemberPrefix(t *testing.T) { t.Parallel() @@ -53,48 +64,190 @@ 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) } }) } } +// 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 and answers nothing. + server := httptest.NewServer(http.HandlerFunc( + func(http.ResponseWriter, *http.Request) {}, + )) + t.Cleanup(server.Close) + + 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) + } + }) + } +} + +// 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) + } + }) + } +} + +// 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 +} + // runUI runs ui on a simulated screen until the test ends. func runUI(t *testing.T, ui *UI) { t.Helper() diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 80f5fc9..b36d714 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -95,6 +95,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") -- 2.54.0 From e1173eba9ebe0f3d9b66217ac2aabfd232090908 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 03:11:33 +0100 Subject: [PATCH 02/37] Close a channel's web tab when the user parts or is kicked (closes #185) A PART event from the user's own nick now closes the channel's tab, whether this web client or another client of the session sent it. It adds no message or unread count. A KICK event now goes into the channel's tab as "nick was kicked from channel by kicker (reason)", with the kicked nick taken from params. Before, it fell through to the Server tab, which showed only the reason. When the kicked nick is the user's own, the tab closes. The tab removal that partChannel did moves into removeChannelTab, which partChannel and both events share. Model: opus-5-5 --- web/src/app.jsx | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/web/src/app.jsx b/web/src/app.jsx index c3ad50f..11607d8 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -355,6 +355,14 @@ function App() { .catch(() => {}); }, []); + // removeChannelTab closes a channel's tab and shows the Server tab. + const removeChannelTab = useCallback((name) => { + setTabs((prev) => + prev.filter((t) => !(t.type === "channel" && t.name === name)), + ); + setActiveTab(0); + }, []); + const processMessage = useCallback( (msg) => { const body = Array.isArray(msg.body) ? msg.body.join("\n") : ""; @@ -424,6 +432,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 +446,20 @@ 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) { + 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}`; @@ -656,7 +685,7 @@ function App() { } } }, - [addMessage, refreshMembers], + [addMessage, refreshMembers, removeChannelTab], ); // Long-poll loop. @@ -865,10 +894,7 @@ function App() { } catch (e) { // Ignore. } - setTabs((prev) => - prev.filter((t) => !(t.type === "channel" && t.name === name)), - ); - setActiveTab(0); + removeChannelTab(name); }; const closeTab = (idx) => { -- 2.54.0 From fcc94ca700ca9f50b84623a3126714929d93ffe5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 04:30:41 +0100 Subject: [PATCH 03/37] Show a joined channel's web tab whichever reply comes first (closes #193) After a join request returned, joinChannel made tab number tabsRef.current.length active. When the user's own JOIN event had already added the tab, or the tab was already open, that index was one past the last tab. The view then fell back to the Server tab and typed lines were dropped. joinChannel now finds the channel's tab by name inside its own tab-list update and makes that tab active there. Preact runs that update at once on the newest tab list, so the position is right even when the JOIN event, or the close of another tab, landed first. Model: opus-5-5 --- web/src/app.jsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/web/src/app.jsx b/web/src/app.jsx index 11607d8..c707e1b 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -858,14 +858,23 @@ function App() { method: "POST", body: JSON.stringify({ command: "JOIN", to: name }), }); + // 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`, -- 2.54.0 From 229c89e1f85cf89df5021eeb50b112097dfab8af Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 05:09:01 +0100 Subject: [PATCH 04/37] Open a channel only when the server accepts the join (closes #197) When the server refuses a JOIN, POST /api/v1/messages answers 200 with status "error" and sends the error numeric to the queue. Both clients opened the channel anyway. JoinChannel in internal/cli/api now returns an error unless the status is "joined". cmdJoin then keeps its buffer and target and prints its usual "Join failed" line. The web client opens no tab for a refused join, whether it came from /join or from the saved channels a fresh session joins again. Model: opus-5-5 --- internal/cli/api/client.go | 31 ++++++++++-- internal/cli/app_internal_test.go | 78 +++++++++++++++++++++++++++++-- web/src/app.jsx | 8 +++- 3 files changed, 107 insertions(+), 10 deletions(-) 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_internal_test.go b/internal/cli/app_internal_test.go index bb9e49f..15dba69 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -1,6 +1,7 @@ package cli import ( + "io" "net/http" "net/http/httptest" "testing" @@ -138,11 +139,9 @@ func TestOwnCommandShownOnce(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() - // The server accepts every command and answers nothing. - server := httptest.NewServer(http.HandlerFunc( - func(http.ResponseWriter, *http.Request) {}, - )) - t.Cleanup(server.Close) + // 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(), @@ -166,6 +165,60 @@ func TestOwnCommandShownOnce(t *testing.T) { } } +// 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) + } + }) + } +} + // 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. @@ -248,6 +301,21 @@ func bufferState(ui *UI, name string) (int, int) { return lines, unread } +// 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 +} + // runUI runs ui on a simulated screen until the test ends. func runUI(t *testing.T, ui *UI) { t.Helper() diff --git a/web/src/app.jsx b/web/src/app.jsx index c707e1b..cd18ff1 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -831,10 +831,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; @@ -854,10 +855,13 @@ 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 -- 2.54.0 From 2331c15aadb0ed8ca33f49d168346249d8b85da5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 05:52:48 +0100 Subject: [PATCH 05/37] Keep the web tab the user reads, and show a kick in the Server tab (closes #195) removeChannelTab made the Server tab active whenever it closed a channel's tab. When a part or kick came from another client, the user lost the channel they were reading. It now finds the tab the user was reading by its name among the remaining tabs and keeps it active. Only when that was the closed tab does the Server tab show. When the kicked nick is the user's own, the kick line also goes into the Server tab, which counts it as unread. That copy carries the event's id with "-server" added, since addMessage drops an id it has already seen. Model: opus-5-5 --- web/src/app.jsx | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/web/src/app.jsx b/web/src/app.jsx index cd18ff1..364f11e 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -355,12 +355,24 @@ function App() { .catch(() => {}); }, []); - // removeChannelTab closes a channel's tab and shows the Server tab. + // 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) => - prev.filter((t) => !(t.type === "channel" && t.name === name)), - ); - setActiveTab(0); + 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( @@ -453,6 +465,15 @@ function App() { 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", { + ...base, + id: msg.id + "-server", + text, + system: true, + }); removeChannelTab(msg.to); } else if (msg.to && msg.to.startsWith("#")) { refreshMembers(msg.to); -- 2.54.0 From d3b169a641e7a06fe42fc5eebb7e7bd9b1e74df5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 06:29:27 +0100 Subject: [PATCH 06/37] Give the web Server tab a name no nick can take (closes #200) The web client stored each tab's messages and unread count under the tab's name, and the Server tab was named Server. A user with the nick Server got a direct-message tab with the same name, so the two tabs showed one message list. The Server tab is now stored under "*server*". No nick or channel name can contain *, so that user's tab keeps its own messages. The tab bar still labels the tab Server. Model: opus-5-5 --- web/src/app.jsx | 91 ++++++++++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/web/src/app.jsx b/web/src/app.jsx index 364f11e..6c1d112 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({}); @@ -468,7 +473,7 @@ function App() { // 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", { + addMessage(SERVER_TAB, { ...base, id: msg.id + "-server", text, @@ -568,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, @@ -578,7 +583,7 @@ function App() { break; } case 323: - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: body || "End of channel list", system: true, @@ -591,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, @@ -601,7 +606,7 @@ function App() { break; } case 315: - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: body || "End of /WHO list", system: true, @@ -612,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, @@ -626,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, @@ -638,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, @@ -652,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, @@ -665,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, @@ -675,7 +680,7 @@ function App() { break; } case 318: - addMessage("Server", { + addMessage(SERVER_TAB, { ...base, text: body || "End of /WHOIS list", system: true, @@ -685,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, @@ -697,12 +702,12 @@ 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 }); } } }, @@ -828,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 @@ -912,7 +917,7 @@ function App() { } } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `Failed to join ${name}: ${err.data?.error || "error"}`, ); } @@ -963,7 +968,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; } @@ -972,7 +977,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; @@ -993,19 +998,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; } @@ -1027,7 +1032,7 @@ function App() { ); } } else { - addSystemMessage("Server", "Usage: /me "); + addSystemMessage(SERVER_TAB, "Usage: /me "); } break; @@ -1041,19 +1046,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; } @@ -1070,13 +1075,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)"}`, ); } @@ -1085,7 +1090,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; } @@ -1103,7 +1108,7 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `MODE failed: ${err.data?.error || "error"}`, ); } @@ -1134,7 +1139,7 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `Failed to request MOTD: ${err.data?.error || "error"}`, ); } @@ -1158,13 +1163,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; @@ -1177,7 +1182,7 @@ function App() { }); } catch (err) { addSystemMessage( - "Server", + SERVER_TAB, `Failed to list channels: ${err.data?.error || "error"}`, ); } @@ -1194,12 +1199,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; @@ -1213,12 +1218,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; @@ -1249,13 +1254,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}`); } }; @@ -1284,7 +1289,7 @@ function App() { if (tab.type === "server") { addSystemMessage( - "Server", + SERVER_TAB, "Cannot send messages to the server window. Use /join #channel first.", ); @@ -1355,7 +1360,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]}) -- 2.54.0 From c61cc847d9d22711fa9e6f86a48d55011d9047bf Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 07:07:39 +0100 Subject: [PATCH 07/37] Show the direct-message web tab even when other tabs change first (closes #202) openDM took the direct-message tab's position from the tab list of the render its caller came from. /msg calls it only after the PRIVMSG request returns. By then the server's copy of the message, or a join or part from another client of the session, may have changed the tabs. The active tab then became another tab or none, and the next typed line went to the wrong place or was dropped. openDM now finds the tab by name inside its setTabs update and makes it active there, as joinChannel does. Model: opus-5-5 --- web/src/app.jsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/web/src/app.jsx b/web/src/app.jsx index 6c1d112..8d97eaf 100644 --- a/web/src/app.jsx +++ b/web/src/app.jsx @@ -948,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) => { -- 2.54.0 From a7f9bebe6a8070442413c4ec24adaf40cbcd14d7 Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 27 Oct 2026 08:28:28 +0100 Subject: [PATCH 08/37] Run the internal/handlers tests in parallel (closes #194) The handlers tests ran one at a time because config.New writes the global viper instance. Each test spends most of its time in SQLite under the race detector, so run in a row they took longer than the 20-second target. Every test and subtest now calls t.Parallel(), as the ircserver and db tests already do. A mutex in newTestServerWith makes tests start their servers one at a time, since config.New writes viper and the server reads it as it starts. No test is removed or otherwise changed. Model: opus-5-5 Co-authored-by: clawbot --- internal/handlers/api_test.go | 278 +++++++++++++++++++++++++++++- internal/handlers/utility_test.go | 11 +- 2 files changed, 284 insertions(+), 5 deletions(-) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 6ca6421..9201f1c 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -1,7 +1,3 @@ -// Tests use a global viper instance for configuration, -// making parallel execution unsafe. -// -//nolint:paralleltest package handlers_test import ( @@ -143,6 +139,13 @@ type testServer struct { 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,6 +161,9 @@ func newTestServerWith( ) *testServer { t.Helper() + viperMu.Lock() + defer viperMu.Unlock() + // A random name, so that no other test can share it, not // even one that has finished but still holds its database // open. @@ -625,6 +631,8 @@ func findNumeric( // --- Tests --- func TestCreateSessionValid(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) cookie := tserver.createSession("alice") @@ -634,6 +642,8 @@ func TestCreateSessionValid(t *testing.T) { } func TestWelcomeNumeric(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("welcomer") @@ -648,6 +658,8 @@ func TestWelcomeNumeric(t *testing.T) { } func TestJoinNumerics(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("jnumtest") @@ -675,6 +687,8 @@ func TestJoinNumerics(t *testing.T) { } func TestCreateSessionDuplicate(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) tserver.createSession("alice") @@ -694,6 +708,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 +730,8 @@ func TestEachTestServerHasItsOwnDatabase(t *testing.T) { } func TestCreateSessionEmpty(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp := postSession(t, tserver, "") @@ -728,6 +746,8 @@ func TestCreateSessionEmpty(t *testing.T) { } func TestCreateSessionInvalidChars(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp := postSession(t, tserver, "hello world") @@ -742,6 +762,8 @@ func TestCreateSessionInvalidChars(t *testing.T) { } func TestCreateSessionNumericStart(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp := postSession(t, tserver, "123abc") @@ -756,6 +778,8 @@ func TestCreateSessionNumericStart(t *testing.T) { } func TestCreateSessionMalformed(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp, err := doRequest( @@ -778,6 +802,8 @@ func TestCreateSessionMalformed(t *testing.T) { } func TestAuthNoCookie(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) status, _ := tserver.getState("") @@ -787,6 +813,8 @@ func TestAuthNoCookie(t *testing.T) { } func TestAuthBadCookie(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) status, _ := tserver.getState( @@ -798,6 +826,8 @@ func TestAuthBadCookie(t *testing.T) { } func TestAuthValidToken(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("authtest") @@ -815,6 +845,8 @@ func TestAuthValidToken(t *testing.T) { } func TestJoinChannel(t *testing.T) { + t.Parallel() + const channel = "#test" tserver := newTestServer(t) @@ -840,6 +872,8 @@ func TestJoinChannel(t *testing.T) { } func TestJoinWithoutHash(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("joiner2") @@ -864,6 +898,8 @@ func TestJoinWithoutHash(t *testing.T) { } func TestPartChannel(t *testing.T) { + t.Parallel() + const channel = "#test" tserver := newTestServer(t) @@ -896,6 +932,8 @@ func TestPartChannel(t *testing.T) { } func TestJoinMissingTo(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("joiner3") @@ -920,6 +958,8 @@ func TestJoinMissingTo(t *testing.T) { } func TestChannelMessage(t *testing.T) { + t.Parallel() + const channel = "#test" tserver := newTestServer(t) @@ -966,6 +1006,8 @@ func TestChannelMessage(t *testing.T) { } func TestMessageMissingBody(t *testing.T) { + t.Parallel() + const channel = "#test" tserver := newTestServer(t) @@ -995,6 +1037,8 @@ func TestMessageMissingBody(t *testing.T) { } func TestMessageMissingTo(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("noto") @@ -1019,6 +1063,8 @@ func TestMessageMissingTo(t *testing.T) { } func TestNonMemberCannotSend(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) aliceToken := tserver.createSession("alice_nosend") bobToken := tserver.createSession("bob_nosend") @@ -1054,6 +1100,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 +1143,8 @@ func TestDirectMessage(t *testing.T) { } func TestDMToNonexistentUser(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("dmsender") @@ -1120,6 +1170,8 @@ func TestDMToNonexistentUser(t *testing.T) { } func TestNickChange(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("nick_test") @@ -1144,6 +1196,8 @@ func TestNickChange(t *testing.T) { } func TestNickSameAsCurrent(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("same_nick") @@ -1157,6 +1211,8 @@ func TestNickSameAsCurrent(t *testing.T) { } func TestNickCollision(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("nickuser") @@ -1183,6 +1239,8 @@ func TestNickCollision(t *testing.T) { } func TestNickInvalid(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("nickval") @@ -1207,6 +1265,8 @@ func TestNickInvalid(t *testing.T) { } func TestNickEmptyBody(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("nicknobody") @@ -1230,6 +1290,8 @@ func TestNickEmptyBody(t *testing.T) { } func TestTopic(t *testing.T) { + t.Parallel() + const channel = "#topictest" tserver := newTestServer(t) @@ -1261,6 +1323,8 @@ func TestTopic(t *testing.T) { } func TestTopicMissingTo(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("topicnoto") @@ -1285,6 +1349,8 @@ func TestTopicMissingTo(t *testing.T) { } func TestTopicMissingBody(t *testing.T) { + t.Parallel() + const channel = "#topictest" tserver := newTestServer(t) @@ -1314,6 +1380,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 +1418,8 @@ func TestTopicNonMember(t *testing.T) { } func TestPing(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("ping_user") @@ -1369,6 +1439,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 +1489,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 +1500,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) @@ -1498,6 +1574,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" @@ -1560,6 +1638,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" @@ -1637,6 +1717,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 +1732,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) }) @@ -1890,6 +1974,8 @@ func (tserver *testServer) postConcurrently( } func TestUnknownCommand(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("cmdtest") @@ -1913,6 +1999,8 @@ func TestUnknownCommand(t *testing.T) { } func TestEmptyCommand(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("emptycmd") @@ -1925,6 +2013,8 @@ func TestEmptyCommand(t *testing.T) { } func TestHistory(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("historian") @@ -1972,6 +2062,8 @@ func TestHistory(t *testing.T) { } func TestHistoryNonMember(t *testing.T) { + t.Parallel() + const channel = "#secret" tserver := newTestServer(t) @@ -2102,6 +2194,8 @@ func (tserver *testServer) getAllHistory( } func TestHistoryPaging(t *testing.T) { + t.Parallel() + const ( channel = "#paging" msgCount = 5 @@ -2149,6 +2243,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 +2316,8 @@ func TestHistoryDMPaging(t *testing.T) { } func TestHistoryUnknownBefore(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("lost") @@ -2280,6 +2378,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 +2424,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 +2474,8 @@ func TestHistoryRecreatedChannel(t *testing.T) { } func TestChannelList(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("lister") @@ -2421,6 +2525,8 @@ func TestChannelList(t *testing.T) { } func TestChannelMembers(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("membertest") @@ -2451,6 +2557,8 @@ func TestChannelMembers(t *testing.T) { } func TestLongPoll(t *testing.T) { + t.Parallel() + const channel = "#longpoll" tserver := newTestServer(t) @@ -2518,6 +2626,8 @@ func TestLongPoll(t *testing.T) { } func TestLongPollTimeout(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("lp_timeout") @@ -2563,6 +2673,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 +2758,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 +2806,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 +2898,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 +3034,8 @@ func (tserver *testServer) queueMessage( } func TestEphemeralChannelCleanup(t *testing.T) { + t.Parallel() + const channel = "#ephemeral" tserver := newTestServer(t) @@ -2960,6 +3080,8 @@ func TestEphemeralChannelCleanup(t *testing.T) { } func TestConcurrentSessions(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) var waitGroup sync.WaitGroup @@ -3021,6 +3143,8 @@ func TestConcurrentSessions(t *testing.T) { } func TestServerInfo(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp, err := doRequest( @@ -3043,6 +3167,8 @@ func TestServerInfo(t *testing.T) { } func TestHealthcheck(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp, err := doRequest( @@ -3079,6 +3205,8 @@ func TestHealthcheck(t *testing.T) { } func TestHealthcheckRuntimeStatsFields(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) resp, err := doRequest( @@ -3122,6 +3250,8 @@ func TestHealthcheckRuntimeStatsFields(t *testing.T) { } func TestHealthcheckRuntimeStatsValues(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("statsuser") @@ -3240,6 +3370,8 @@ func postJSONExpectStatus( } func TestPassCommand(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("passuser") @@ -3279,6 +3411,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 +3428,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 +3474,8 @@ func TestPassCommandLength(t *testing.T) { } func TestPassCommandEmpty(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("emptypw") @@ -3364,6 +3502,8 @@ func TestPassCommandEmpty(t *testing.T) { } func TestLoginValid(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) // Create session and set password via PASS command. @@ -3432,6 +3572,8 @@ func TestLoginValid(t *testing.T) { } func TestLoginWrongPassword(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) // Create session and set password via PASS command. @@ -3453,6 +3595,8 @@ func TestLoginWrongPassword(t *testing.T) { } func TestLoginNonexistentUser(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) postJSONExpectStatus( @@ -3466,6 +3610,8 @@ func TestLoginNonexistentUser(t *testing.T) { } func TestSessionCookie(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) body, err := json.Marshal( @@ -3532,6 +3678,8 @@ func TestSessionCookie(t *testing.T) { } func TestSessionStillWorks(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) // Verify anonymous session creation still works. @@ -3599,6 +3747,8 @@ func getNumericParams( } func TestWhoisShowsHostInfo(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSessionWithUsername( @@ -3694,6 +3844,8 @@ func (tserver *testServer) createSessionWithUsername( } func TestWhoShowsHostInfo(t *testing.T) { + t.Parallel() + const channel = "#whotest" tserver := newTestServer(t) @@ -3759,6 +3911,8 @@ func assertWhoReplyUsername( } func TestSessionUsernameDefault(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) // Create session without specifying username. @@ -3805,6 +3959,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,6 +4024,8 @@ func TestLoginRateLimitExceeded(t *testing.T) { } func TestLoginRateLimitAllowsNormalUse(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) // Create session and set password via PASS command. @@ -3908,6 +4066,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 +4117,8 @@ func mintTestChannelHashcash( } func TestChannelHashcashSetMode(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("hcmode_user") @@ -3991,6 +4153,8 @@ func TestChannelHashcashSetMode(t *testing.T) { } func TestChannelHashcashQueryMode(t *testing.T) { + t.Parallel() + const channel = "#hcquery" tserver := newTestServer(t) @@ -4035,6 +4199,8 @@ func TestChannelHashcashQueryMode(t *testing.T) { } func TestChannelHashcashClearMode(t *testing.T) { + t.Parallel() + const channel = "#hcclear" tserver := newTestServer(t) @@ -4078,6 +4244,8 @@ func TestChannelHashcashClearMode(t *testing.T) { } func TestChannelHashcashRejectNoStamp(t *testing.T) { + t.Parallel() + const channel = "#hcreject" tserver := newTestServer(t) @@ -4121,6 +4289,8 @@ func TestChannelHashcashRejectNoStamp(t *testing.T) { } func TestChannelHashcashAcceptValidStamp(t *testing.T) { + t.Parallel() + const ( channel = "#hcaccept" text = "hello world" @@ -4186,6 +4356,8 @@ func TestChannelHashcashAcceptValidStamp(t *testing.T) { } func TestChannelHashcashRejectReplayedStamp(t *testing.T) { + t.Parallel() + const ( channel = "#hcreplay" text = "unique msg" @@ -4320,6 +4492,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 +4569,8 @@ func TestChannelHashcashRejectConcurrentReplay(t *testing.T) { } func TestChannelHashcashNoRequirementWorks(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("hcnone_user") @@ -4423,6 +4599,8 @@ func TestChannelHashcashNoRequirementWorks(t *testing.T) { } func TestChannelHashcashInvalidBitsRange(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("hcbits_user") @@ -4450,6 +4628,8 @@ func TestChannelHashcashInvalidBitsRange(t *testing.T) { } func TestChannelHashcashMissingBitsArg(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) token := tserver.createSession("hcnoarg_user") @@ -4528,6 +4708,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 +4774,8 @@ func TestRefusedSessionLeavesStampUnspent(t *testing.T) { } func TestNamesShowsHostmask(t *testing.T) { + t.Parallel() + tserver := newTestServer(t) queryToken, lastID := setupChannelWithIdentMember( @@ -4613,6 +4797,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 +4925,8 @@ func newTestServerWithOper( } func TestOperCommandSuccess(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) token := tserver.createSession("operuser") @@ -4762,6 +4950,8 @@ func TestOperCommandSuccess(t *testing.T) { } func TestOperCommandFailure(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) token := tserver.createSession("badoper") @@ -4785,6 +4975,8 @@ func TestOperCommandFailure(t *testing.T) { } func TestOperCommandNeedMoreParams(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) token := tserver.createSession("shortoper") @@ -4808,6 +5000,8 @@ func TestOperCommandNeedMoreParams(t *testing.T) { } func TestOperWhoisShowsClientInfo(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) // Create a target user. @@ -4876,6 +5070,8 @@ func TestOperWhoisShowsClientInfo(t *testing.T) { } func TestNonOperWhoisHidesClientInfo(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) // Create a target user. @@ -4912,6 +5108,8 @@ func TestNonOperWhoisHidesClientInfo(t *testing.T) { } func TestWhoisShowsOperatorStatus(t *testing.T) { + t.Parallel() + tserver := newTestServerWithOper(t) // Create oper user and authenticate. @@ -4951,6 +5149,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 +5181,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 +5233,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 +5289,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 +5354,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 +5410,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 +5487,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 +5548,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 +5593,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 +5642,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 +5697,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 +5742,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 +5780,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 +5814,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 +5861,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 +5920,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 +5962,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 +5994,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 +6033,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 +6068,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 +6103,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 +6143,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 +6185,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 +6219,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 +6248,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 +6291,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 +6334,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 +6405,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 +6440,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 +6482,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 +6537,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 +6610,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 +6676,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 +6722,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 +6778,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") @@ -6538,6 +6806,8 @@ func TestISUPPORT(t *testing.T) { // 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/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" -- 2.54.0 From d8f8748525cb8899d0babcb75243256721ea4aae Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 10:20:09 +0100 Subject: [PATCH 09/37] Let an IRC relay finish its queue read when its connection ends (closes #205) When an IRC listener connection ends, serve cancels the context its relay uses. If the cancel came while the relay was reading the queue, the SQLite driver interrupted the query and reported the connection broken, and the pool closed its only connection. In the tests that deletes the in-memory database, so in TestIntegrationKill alice never got bob's QUIT. In production the new connection runs without PRAGMA foreign_keys = ON. The relay now reads the queue without that cancel. It still stops at its next check of the context. Model: opus-5-5 --- internal/ircserver/relay.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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) -- 2.54.0 From 53009cf9a0214a0fa0f0008d0da5403076d29dcf Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 11:14:01 +0100 Subject: [PATCH 10/37] Keep foreign keys on for every database connection (closes #207) SQLite keeps the foreign keys setting per connection, and the server turned it on once with a PRAGMA. A query cancelled while it runs makes the pool replace its only connection, and the new one ran without foreign keys until a restart. connect now adds `_pragma=foreign_keys(1)` to the database URL. The driver runs it on every connection it opens. The server refuses to start when DBURL sets foreign_keys itself, because the driver sorts the pragmas and that one could win. One test cancels a query and checks, with a TEMP table, that the connection was replaced and that foreign keys are on. Another checks the startup error for several spellings. Model: opus-5-5 --- README.md | 2 +- internal/db/db.go | 40 +++++++++-- internal/db/db_test.go | 156 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7acab63..411b41a 100644 --- a/README.md +++ b/README.md @@ -2611,7 +2611,7 @@ 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). | +| `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`. It must not set `foreign_keys`. 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. | diff --git a/internal/db/db.go b/internal/db/db.go index e2dce3a..b669061 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 @@ -90,6 +96,33 @@ func (database *Database) connect(ctx context.Context) error { 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 +142,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..9b9833f 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -1,9 +1,13 @@ package db_test import ( + "context" + "errors" + "net/url" "path/filepath" "strings" "testing" + "time" "go.uber.org/fx/fxtest" "sneak.berlin/go/neoirc/internal/config" @@ -14,6 +18,16 @@ import ( 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 +87,145 @@ 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) + } + }) + } +} + +// 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 +} -- 2.54.0 From d444831f41432bc8a1bb08580a2fd4c28999cdb8 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 12:26:48 +0100 Subject: [PATCH 11/37] Keep each test database when the pool replaces its connection (closes #209) SQLite deletes a shared in-memory database when its last connection closes. A query cancelled while it runs makes the pool close its connection, so a test could lose all its tables and rows at random. The new package internal/dbtest has NewURL. It returns the URL of a new in-memory database with a random name and holds one connection to it open until the test ends. The db, handlers, ircserver, server and service tests take their database URL from it. The test-support lint rule keeps the package out of the server binary. A new test cancels a query on such a database, checks with a TEMP table that the connection was replaced, and checks that a row is still there. Model: opus-5-5 --- .golangci.yml | 4 +++ README.md | 1 + internal/db/db_test.go | 47 +++++++++++++++++++++++++++++++ internal/db/export_test.go | 17 ++++------- internal/db/queries_test.go | 2 +- internal/dbtest/dbtest.go | 39 +++++++++++++++++++++++++ internal/handlers/api_test.go | 10 ++----- internal/ircserver/server_test.go | 11 ++------ internal/server/server_test.go | 7 ++--- internal/service/service_test.go | 10 ++----- 10 files changed, 106 insertions(+), 42 deletions(-) create mode 100644 internal/dbtest/dbtest.go 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/README.md b/README.md index 411b41a..5489b9a 100644 --- a/README.md +++ b/README.md @@ -3342,6 +3342,7 @@ neoirc/ │ │ └── schema/ │ │ ├── 000.sql # schema_migrations bootstrap │ │ └── 001_initial.sql +│ ├── dbtest/ # Gives each test its own database; only tests import it │ ├── globals/ # Application-wide metadata │ │ └── globals.go │ ├── handlers/ # HTTP request handlers diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 9b9833f..6d10283 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -12,6 +12,7 @@ import ( "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" ) @@ -155,6 +156,52 @@ func TestForeignKeysOnAfterCancelledQuery(t *testing.T) { } } +// 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. diff --git a/internal/db/export_test.go b/internal/db/export_test.go index f233380..5dd9384 100644 --- a/internal/db/export_test.go +++ b/internal/db/export_test.go @@ -5,21 +5,16 @@ import ( "database/sql" "fmt" "log/slog" - "sync/atomic" + "testing" + + "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 { diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 881fec6..a39f88a 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) } diff --git a/internal/dbtest/dbtest.go b/internal/dbtest/dbtest.go new file mode 100644 index 0000000..add7f03 --- /dev/null +++ b/internal/dbtest/dbtest.go @@ -0,0 +1,39 @@ +// Package dbtest gives each test a database of its own. 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 +} diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 9201f1c..a883330 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -3,7 +3,6 @@ package handlers_test import ( "bytes" "context" - "crypto/rand" "database/sql" "encoding/json" "errors" @@ -28,6 +27,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" @@ -164,13 +164,7 @@ func newTestServerWith( viperMu.Lock() defer viperMu.Unlock() - // 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) copyTemplateDB(t, dbURL) var ( diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index a530b36..f30b826 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -3,7 +3,6 @@ package ircserver_test import ( "bufio" "context" - "crypto/rand" "database/sql" "errors" "fmt" @@ -20,6 +19,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/ircserver" "sneak.berlin/go/neoirc/internal/logger" @@ -107,14 +107,7 @@ 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(), - ) - - conn, err := sql.Open("sqlite", dsn) + conn, err := sql.Open("sqlite", dbtest.NewURL(t)) if err != nil { t.Fatalf("open db: %v", err) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 148f28e..874231e 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" @@ -241,10 +241,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 diff --git a/internal/service/service_test.go b/internal/service/service_test.go index 3ad3279..a51710a 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -6,7 +6,6 @@ package service_test import ( "context" - "crypto/rand" "encoding/json" "errors" "fmt" @@ -23,6 +22,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/hashcash" "sneak.berlin/go/neoirc/internal/logger" @@ -46,13 +46,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 -- 2.54.0 From ecf47c37f520dfc882ca9ea020ac24e6e1d6c53f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 13:34:45 +0100 Subject: [PATCH 12/37] Start test servers on a port the system picks (closes #211) The internal/server and internal/ircserver tests picked a port by opening a listener, reading its port and closing it. They then started a server on that number. Another process could take the port in between, and a test failed with "address already in use". The IRC server tests now start the server on 127.0.0.1:0 and read the port back from its listener. The HTTP server tests start the app with port 0. The HTTP server now keeps the listener it binds, so the stop test sends its long poll to the port the app was given. Model: opus-5-5 --- internal/ircserver/server_test.go | 18 ++-------- internal/server/export_test.go | 6 ++++ internal/server/server.go | 3 ++ internal/server/server_test.go | 56 +++++++------------------------ 4 files changed, 25 insertions(+), 58 deletions(-) diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index f30b826..1cc4316 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -137,20 +137,6 @@ func newTestEnvWithResolver( 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 @@ -160,7 +146,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) } 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 874231e..6a44326 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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() @@ -254,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 { @@ -291,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) { -- 2.54.0 From 83a1585ddc1a7592b1c403d9f63217c61c7307c1 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 16:09:17 +0100 Subject: [PATCH 13/37] Rewrite schema/README.md sentence by sentence Rewrite the description of the JSON Schema files from scratch, without tables. Describe the fields of a message as the HTTP API sends them: the reply name in `command` and its number in `code`. Drop the federation section, which describes nothing the server does. List each place where a schema file disagrees with the HTTP API, so that a reader is not misled. The files themselves are unchanged. Part of https://git.eeqj.de/sneak/neoirc/issues/190. Model: fable-5-1 (writing); opus-5-5 (rework) --- schema/README.md | 226 ++++++++++++++++++++++++++++++----------------- 1 file changed, 147 insertions(+), 79 deletions(-) diff --git a/schema/README.md b/schema/README.md index 4a91ae6..6d66f1c 100644 --- a/schema/README.md +++ b/schema/README.md @@ -1,98 +1,166 @@ -# 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 server message. +- `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. +Commands, in `commands/`: -| 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 | +- `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`: a user joined a channel. +- `PART.json`: a user left a channel. +- `QUIT.json`: a user left the server. +- `NICK.json`: a user changed nick. +- `TOPIC.json`: a channel topic was set. +- `MODE.json`: a channel mode was changed. +- `KICK.json`: a channel operator removed a user from a channel. +- `PING.json`: a check that the server still answers. +- `PONG.json`: the answer to a `PING`. +- `PUBKEY.json`: an announcement of a user's public signing key. The server has + no such command. It answers a `PUBKEY` with `ERR_UNKNOWNCOMMAND` (`421`). -## Numeric Replies +Numeric replies, in `numerics/`: -Three-digit codes for server responses, per IRC convention. +- `001.json`: `RPL_WELCOME`, which welcomes a new user. +- `002.json`: `RPL_YOURHOST`, the server's name and version. +- `003.json`: `RPL_CREATED`, which says when the server was created. +- `004.json`: `RPL_MYINFO`, the server's 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. -### Success / Informational (0xx–3xx) +## Where the files differ from the HTTP API -| 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 | +Where a schema file and the HTTP API disagree, the HTTP API is right: -### Errors (4xx) +- `message.json` and the files in `numerics/` put the three-digit code in + `command`. They have no `code` field. The HTTP API puts the reply's name in + `command` and its number in `code`. +- `message.json` allows an object as `body`. The HTTP API reads only arrays of + strings. A `PRIVMSG` with an object as `body` gets `ERR_NOTEXTTOSEND` (`412`). +- `message.json` says `to` is a channel or a nick. A `WALLOPS` message has `*` + there. +- `message.json` allows `meta` on every message. The HTTP API keeps a client's + `meta` only on a `PRIVMSG` or `NOTICE`. +- `message.json` describes signature fields in `meta`. The server never checks a + signature. +- `message.json` does not describe `meta.hashcash`. The HTTP API reads it in a + `PRIVMSG` to a channel with mode `+H`. +- `JOIN.json` has no `body`. A client's `JOIN` can carry the channel key in + `body`. When a user joins, the `JOIN` event repeats the channel name in + `body`. +- `JOIN.json` allows a channel name of any length. The server allows at most 63 + characters after the `#`. It answers a longer name with `ERR_NOSUCHCHANNEL` + (`403`). +- `KICK.json` puts the kicked nick in `params`. The HTTP API reads a client's + `KICK` from `body`: the nick, then an optional reason. +- `MODE.json` puts the mode change in `params`. The HTTP API uses `body` + instead. A client sends the change and any value as separate strings, such as + `"+o"` and `"bob"`. In a `MODE` event, `body` holds the change as one string, + such as `"+o bob"`, or as two strings, such as `"+k"` and `"secret"`. +- `NOTICE.json` allows `*` as `to`. The server answers a `NOTICE` to `*` with + `ERR_NOSUCHNICK` (`401`). +- `NOTICE.json` has an example notice from the server to `*`. The server sends + no such notice. +- `PING.json` and `PONG.json` describe a token that the `PONG` repeats. The HTTP + API ignores the `body` of a `PING`. Its answer is the HTTP response + `{"command": "PONG", "from": ""}`. +- `PING.json` and `PONG.json` also describe messages between servers. The server + does not connect to other IRC servers. +- `PUBKEY.json` describes a command the server does not have. +- `QUIT.json` says that `QUIT` comes only from the server. A client can send it + too, to end its session. A client's `QUIT` can carry a reason in `body`. +- `TOPIC.json` says a `TOPIC` without a `body` queries the topic. The HTTP API + answers such a `TOPIC` with `ERR_NEEDMOREPARAMS` (`461`). +- `TOPIC.json` says an empty array clears the topic. The HTTP API answers `[]` + with `ERR_NEEDMOREPARAMS` (`461`). A `body` of `[""]` clears the topic. +- `003.json` describes the server's creation date. The HTTP API sends the date + the server last started. +- `004.json` has example mode lists. The HTTP API sends an empty string as the + user modes and `ikmnostl` as the channel modes. +- `322.json` says `LIST` gets one reply per channel. The server leaves out a + secret channel, one with mode `+s`, unless the user is on it. +- `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such + query. It sends `RPL_TOPIC` after a client sets the topic, and after a `JOIN` + to a channel that has one. +- `353.json` says `body` lists nicks. The HTTP API lists `nick!user@host` in + place of each nick, such as `@op1!op1@host1`. +- `353.json` says the first parameter marks a channel as public, private or + secret. The server always sends `=`, the mark for a public channel. +- `376.json` gives the text as `End of /MOTD command`. The HTTP API adds a full + stop. +- `401.json` gives the text as `No such nick/channel`. The server sends + `No such nick` in answer to a `PRIVMSG` or `NOTICE` to an unknown nick. +- `433.json` has an example with `*` as `to`. The HTTP API sends `433` only in + answer to a `NICK`, with the user's nick as `to`. -| 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. +The HTTP API also accepts some messages that the files do not allow, such as a +`JOIN` whose `to` has no `#`. -- 2.54.0 From fe23bb5045d17ba1e988d1515c51adcc643b827a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 28 Oct 2026 00:47:53 +0100 Subject: [PATCH 14/37] Stop startup when a setting does not parse (closes #219) config.New read the number and true/false settings with viper's GetInt, GetFloat64 and GetBool. These return zero or false for a value that does not parse, so a typo silently changed how the server ran. config.New now parses these settings with strconv and checks the duration settings with time.ParseDuration. Each number and duration must also be finite and above zero. PORT and NEOIRC_HASHCASH_BITS may be zero: port 0 lets the system pick a port, and 0 bits turns proof of work off. A bad value makes config.New return an error naming the setting and its value, and startup stops. Unset settings keep their defaults. Model: opus-5-5 --- internal/config/config.go | 110 ++++++++++++++++++++++-- internal/config/config_test.go | 151 ++++++++++++++++++++++++++++++++- 2 files changed, 252 insertions(+), 9 deletions(-) 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", "") -- 2.54.0 From 967930f9bc4056334f0681f547b85e1229da982b Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Wed, 28 Oct 2026 01:30:33 +0100 Subject: [PATCH 15/37] Make Alt and a digit set the send target as /window does (closes #221) In neoirc-cli, Alt and a digit switched the window but kept the send target, so typed text went to the window the user had left. The key now runs /window with that digit through the input handler. /window 0 now clears the send target. The status window has no target, but the command used to keep the previous one. Model: opus-5-5 --- internal/cli/app.go | 4 +++ internal/cli/app_internal_test.go | 57 +++++++++++++++++++++++++++++++ internal/cli/ui.go | 7 ++-- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index eae6d63..acadfc3 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -627,6 +627,10 @@ func (a *App) cmdWindow(args string) { nick, buf.Name, "connected", ) } else { + a.mu.Lock() + a.target = "" + a.mu.Unlock() + a.ui.SetStatus(nick, "", "connected") } } diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 15dba69..54586f2 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -219,6 +219,63 @@ func TestJoinResponse(t *testing.T) { } } +// 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} + app.ui.OnInput(app.handleInput) + 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.GetInputCapture()(key) + + 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) + } + }) + } +} + // 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. diff --git a/internal/cli/ui.go b/internal/cli/ui.go index b36d714..581ba0b 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -240,10 +240,13 @@ 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) + if ui.onInput != nil { + ui.onInput("/window " + string(r)) + } return nil } -- 2.54.0 From fe734af375b440138dc90372d4fec2c2a92c665c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 01:08:43 +0100 Subject: [PATCH 16/37] Keep neoirc-cli responding when a key or a typed line updates the screen (closes #223) The Enter handler of the input line and the Alt and digit key handler ran the typed line on the tview event loop. Every UI method that changes the screen waits for that loop, so the client stopped responding. Each line now runs on its own goroutine, which first waits for the line before it, so lines keep the order they were typed in. /window now runs off the event loop. It reads the buffer name through the new BufferName, which runs on the loop, and switches only when that buffer exists. One new test drives a running UI on a simulation screen with Alt and 1, then a typed line and Enter. Another holds the first line and checks that the second waits for it. Model: opus-5-5 --- internal/cli/app.go | 17 +++-- internal/cli/app_internal_test.go | 120 ++++++++++++++++++++++++++++-- internal/cli/ui.go | 64 +++++++++++++--- 3 files changed, 177 insertions(+), 24 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index acadfc3..c8a1fb4 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -610,21 +610,24 @@ func (a *App) cmdWindow(args string) { _, _ = fmt.Sscanf(args, "%d", &bufIndex) - a.ui.SwitchBuffer(bufIndex) - a.mu.Lock() nick := a.nick a.mu.Unlock() - if bufIndex >= 0 && bufIndex < a.ui.BufferCount() { - buf := a.ui.buffers[bufIndex] - if buf.Name != "(status)" { + // 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, "connected", ) } else { a.mu.Lock() diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 54586f2..3f133a5 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gdamore/tcell/v2" api "sneak.berlin/go/neoirc/internal/cli/api" @@ -20,6 +21,14 @@ const ( testText = "hello" ) +// eventLoopTimeout is how long a test waits for a typed line to +// be handled, or for the UI to stop, before it fails. +const eventLoopTimeout = 5 * time.Second + +// holdTime is how long TestLinesKeepOrder holds the first line, +// which gives the second line time to start too early. +const holdTime = 200 * time.Millisecond + func TestMemberPrefix(t *testing.T) { t.Parallel() @@ -251,7 +260,7 @@ func TestAltDigit(t *testing.T) { t.Parallel() app := &App{ui: NewUI(), nick: testNick, target: testOtherNick} - app.ui.OnInput(app.handleInput) + handled := handledLines(app) runUI(t, app.ui) // Window 1 is the channel. Window 2, the one shown, is @@ -260,7 +269,8 @@ func TestAltDigit(t *testing.T) { app.ui.SwitchToBuffer(testOtherNick) key := tcell.NewEventKey(tcell.KeyRune, test.digit, tcell.ModAlt) - app.ui.app.GetInputCapture()(key) + app.ui.app.QueueEvent(key) + waitHandled(t, handled, "/window "+string(test.digit)) var shown string @@ -276,6 +286,68 @@ func TestAltDigit(t *testing.T) { } } +// 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) +} + // 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. @@ -358,6 +430,36 @@ func bufferState(ui *UI, name string) (int, int) { return lines, unread } +// 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 { @@ -379,16 +481,22 @@ func runUI(t *testing.T, ui *UI) { 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 <- 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") } }) } diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 581ba0b..563df0d 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 @@ -28,6 +31,10 @@ type UI struct { currentBuffer int 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. @@ -176,9 +183,24 @@ func (ui *UI) SetStatus( }) } -// 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. @@ -226,10 +248,7 @@ func (ui *UI) initInput() { } ui.input.SetText("") - - if ui.onInput != nil { - ui.onInput(text) - } + ui.callOnInput(text) }) } @@ -244,9 +263,7 @@ func (ui *UI) initKeyCapture() { // SwitchBuffer alone would leave the target as it was. r := event.Rune() if r >= '0' && r <= '9' { - if ui.onInput != nil { - ui.onInput("/window " + string(r)) - } + ui.callOnInput("/window " + string(r)) return nil } @@ -256,6 +273,31 @@ 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) + }() +} + func (ui *UI) refreshStatusBar() { // Placeholder; full refresh needs nick/target context. } -- 2.54.0 From 59f1208d8df2d3bcd98fef8a880bff317edfaef5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 02:05:24 +0100 Subject: [PATCH 17/37] Show the neoirc-cli screen without waiting at start-up (closes #226) Run added the start-up status bar and welcome lines through SetStatus and AddStatus before it started the tview event loop. Both wait for that loop, so neoirc-cli stopped before it showed its screen. They now run on a goroutine, as typed lines do. Run now passes a new UI to run, which holds the start-up path and returns its error. TestStartUp calls run with a simulation screen. It fails unless, within five seconds, the status window holds two lines: first the one with "Welcome to neoirc-cli", then the /connect hint. Model: opus-5-5 --- internal/cli/app.go | 50 +++++++++++------- internal/cli/app_internal_test.go | 85 ++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 19 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index c8a1fb4..fbed5d6 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) diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 3f133a5..6c5853b 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -4,6 +4,8 @@ import ( "io" "net/http" "net/http/httptest" + "slices" + "strings" "testing" "time" @@ -22,13 +24,22 @@ const ( ) // eventLoopTimeout is how long a test waits for a typed line to -// be handled, or for the UI to stop, before it fails. +// be handled, for the welcome lines, or for the UI to stop, before +// it fails. const eventLoopTimeout = 5 * time.Second // holdTime is how long TestLinesKeepOrder holds the first line, // which gives the second line time to start too early. 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() @@ -348,6 +359,78 @@ func TestLinesKeepOrder(t *testing.T) { waitHandled(t, handled, secondLine) } +// 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. +func TestStartUp(t *testing.T) { + t.Parallel() + + ui := NewUI() + ui.app.SetScreen(tcell.NewSimulationScreen("")) + + // 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]) + } +} + // 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. -- 2.54.0 From 6387d6878688f482dc2da9639353b61887c0203f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 02:53:10 +0100 Subject: [PATCH 18/37] Show the connection state in the neoirc-cli status bar (closes #229) The status bar had colour tags turned on, so tview read "[connected]" and "[disconnected]" as tags and dropped them. It dropped all or part of a nick, a target or a window name holding square brackets the same way. The status bar now has colour tags turned off. It wrote none of its own, so every bracket shows as it is. TestStartUp checks that the status bar shows [disconnected] at start-up. TestStatusBar checks [connected], and checks nicks and a /query target that hold brackets. Model: opus-5-5 --- internal/cli/app_internal_test.go | 87 ++++++++++++++++++++++++++++++- internal/cli/ui.go | 6 ++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 6c5853b..11ddd9a 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -362,7 +362,8 @@ func TestLinesKeepOrder(t *testing.T) { // 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. +// neoirc-cli", then the /connect hint. The status bar must show +// [disconnected]. func TestStartUp(t *testing.T) { t.Parallel() @@ -429,6 +430,77 @@ func TestStartUp(t *testing.T) { 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) + } + }) + } } // TestOwnEventFromOtherClient checks that a message, notice, @@ -513,6 +585,19 @@ func bufferState(ui *UI, name string) (int, int) { 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 +} + // 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. diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 563df0d..f702a8d 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -226,8 +226,10 @@ func (ui *UI) initMessages() { } 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) } -- 2.54.0 From 0662e87f49852d6a7c7167a1313aafd90ce2f540 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 03:13:09 +0100 Subject: [PATCH 19/37] Keep the connection state in the status bar on /window (closes #225) /window set the status bar to [connected] whatever the connection state, and Alt and a digit runs /window. Before /connect, both made the status bar say [connected] while the client had no session. /window now reads the connection state with the nick and shows [connected] only while the client is connected, [disconnected] otherwise. TestWindowConnectionState types /window 0 and presses Alt and 0, connected and not, and checks the status bar. Model: opus-5-5 --- internal/cli/app.go | 10 ++++- internal/cli/app_internal_test.go | 63 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index fbed5d6..3e0bb80 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -626,8 +626,14 @@ func (a *App) cmdWindow(args string) { a.mu.Lock() nick := a.nick + connected := a.connected a.mu.Unlock() + 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. @@ -641,14 +647,14 @@ func (a *App) cmdWindow(args string) { a.mu.Unlock() a.ui.SetStatus( - nick, name, "connected", + nick, name, connStatus, ) } else { a.mu.Lock() a.target = "" a.mu.Unlock() - a.ui.SetStatus(nick, "", "connected") + a.ui.SetStatus(nick, "", connStatus) } } } diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 11ddd9a..9d097fa 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -297,6 +297,69 @@ func TestAltDigit(t *testing.T) { } } +// 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) + } + }) + } +} + // 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. -- 2.54.0 From cc18397ee129f7c92e93cf522e60db4753299428 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 04:17:09 +0100 Subject: [PATCH 20/37] List the user modes in the 004 reply (closes #220) Both transports sent an empty string as the user modes in RPL_MYINFO. On the IRC listener that left an empty parameter in the middle of the line, so a client that splits on spaces read the channel modes as the user modes. The reply now lists o and w, the user modes the server supports. Both transports read them from one constant in internal/service, next to the code that decides which user modes MODE accepts. The README and the schema notes now show what the HTTP API sends. Model: opus-5-5 --- README.md | 2 +- internal/handlers/api.go | 2 +- internal/handlers/api_test.go | 24 ++++++++++++++++++++++++ internal/ircserver/conn.go | 2 +- internal/ircserver/server_test.go | 16 ++++++++++++++++ internal/service/service.go | 6 ++++++ schema/README.md | 4 ++-- 7 files changed, 51 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5489b9a..0474e54 100644 --- a/README.md +++ b/README.md @@ -1236,7 +1236,7 @@ IRC listener sends the usual 3-digit code. Over the HTTP API, 005 arrives named | `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"]}` | +| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","ow","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"]}` | diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 792354d..7c0574c 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -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, "ikmnostl"}, "", ) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index a883330..d72d6de 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -6797,6 +6797,30 @@ func TestISUPPORT(t *testing.T) { } } +// TestMyInfoListsUserModes verifies the 004 numeric lists +// the user modes o and w as its third parameter. +func TestMyInfoListsUserModes(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" { + t.Fatalf( + "004 params %v, want four with user modes ow third", + params, + ) + } +} + // TestNonOpCannotSetModes verifies non-operators // cannot set +i, +s, +k, +l, +b. func TestNonOpCannotSetModes(t *testing.T) { diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index 3dcca86..83f6cbe 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -605,7 +605,7 @@ func (c *Conn) deliverWelcome() { ) c.sendNumeric( irc.RplMyInfo, - c.serverSfx, "neoirc", "", "mnst", + c.serverSfx, "neoirc", service.UserModes, "mnst", ) c.sendNumeric( irc.RplIsupport, diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 1cc4316..229b43c 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -486,6 +486,22 @@ func TestWelcomeContainsNick(t *testing.T) { } } +// TestMyInfoListsUserModes checks that RPL_MYINFO lists the +// user modes o and w, with no empty parameter before the +// channel modes. +func TestMyInfoListsUserModes(t *testing.T) { + t.Parallel() + + env := newTestEnv(t) + lines := env.dial(t).register("carol") + + assertContains( + t, lines, + ":test.irc 004 carol test.irc neoirc ow mnst", + "RPL_MYINFO with user modes", + ) +} + func TestPingPong(t *testing.T) { t.Parallel() 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/schema/README.md b/schema/README.md index 6d66f1c..fce0bd8 100644 --- a/schema/README.md +++ b/schema/README.md @@ -144,8 +144,8 @@ Where a schema file and the HTTP API disagree, the HTTP API is right: with `ERR_NEEDMOREPARAMS` (`461`). A `body` of `[""]` clears the topic. - `003.json` describes the server's creation date. The HTTP API sends the date the server last started. -- `004.json` has example mode lists. The HTTP API sends an empty string as the - user modes and `ikmnostl` as the channel modes. +- `004.json` has example mode lists. The HTTP API sends `ow` as the user modes + and `ikmnostl` as the channel modes. - `322.json` says `LIST` gets one reply per channel. The server leaves out a secret channel, one with mode `+s`, unless the user is on it. - `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such -- 2.54.0 From 6a7989e08d063f84f96bd2f982a1c3688ec1ef6f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 04:48:18 +0100 Subject: [PATCH 21/37] List the missing channel modes in the 004 reply (closes #238) The channel-modes parameter of RPL_MYINFO left out modes that each transport's MODE accepts. The IRC listener now sends Himnostv, adding H, i, o and v. The HTTP API now sends Hbiklmnostv, adding H, b and v. Each transport keeps its own list, because they accept different modes. The tests compare the whole parameter, so a missing or extra mode fails them. The README and the schema notes show the new HTTP API list. Model: opus-5-5 --- README.md | 2 +- internal/handlers/api.go | 2 +- internal/handlers/api_test.go | 12 +++++++----- internal/ircserver/conn.go | 2 +- internal/ircserver/server_test.go | 19 ++++++++++--------- schema/README.md | 2 +- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0474e54..d9cef51 100644 --- a/README.md +++ b/README.md @@ -1236,7 +1236,7 @@ IRC listener sends the usual 3-digit code. Over the HTTP API, 005 arrives named | `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","ow","ikmnostl"]}` | +| `004` | RPL_MYINFO | After session creation or login, on MOTD | `{"command":"RPL_MYINFO","code":4,"to":"alice","params":["neoirc","0.1","ow","Hbiklmnostv"]}` | | `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"]}` | diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 7c0574c..1ec917b 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -412,7 +412,7 @@ func (hdlr *Handlers) deliverWelcome( // 004 RPL_MYINFO hdlr.enqueueNumeric( ctx, clientID, irc.RplMyInfo, nick, - []string{srvName, version, service.UserModes, "ikmnostl"}, + []string{srvName, version, service.UserModes, "Hbiklmnostv"}, "", ) diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index d72d6de..f3642ca 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -6797,9 +6797,10 @@ func TestISUPPORT(t *testing.T) { } } -// TestMyInfoListsUserModes verifies the 004 numeric lists -// the user modes o and w as its third parameter. -func TestMyInfoListsUserModes(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) @@ -6813,9 +6814,10 @@ func TestMyInfoListsUserModes(t *testing.T) { } params := getNumericParams(myInfo) - if len(params) != 4 || params[2] != "ow" { + if len(params) != 4 || params[2] != "ow" || + params[3] != "Hbiklmnostv" { t.Fatalf( - "004 params %v, want four with user modes ow third", + "004 params %v, want four ending ow Hbiklmnostv", params, ) } diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index 83f6cbe..edcc6f7 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -605,7 +605,7 @@ func (c *Conn) deliverWelcome() { ) c.sendNumeric( irc.RplMyInfo, - c.serverSfx, "neoirc", service.UserModes, "mnst", + c.serverSfx, "neoirc", service.UserModes, "Himnostv", ) c.sendNumeric( irc.RplIsupport, diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 229b43c..17a142d 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -10,6 +10,7 @@ import ( "net" "os" "runtime" + "slices" "strings" "sync/atomic" "testing" @@ -486,20 +487,20 @@ func TestWelcomeContainsNick(t *testing.T) { } } -// TestMyInfoListsUserModes checks that RPL_MYINFO lists the -// user modes o and w, with no empty parameter before the -// channel modes. -func TestMyInfoListsUserModes(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") - assertContains( - t, lines, - ":test.irc 004 carol test.irc neoirc ow mnst", - "RPL_MYINFO with user modes", - ) + 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) { diff --git a/schema/README.md b/schema/README.md index fce0bd8..e5c9409 100644 --- a/schema/README.md +++ b/schema/README.md @@ -145,7 +145,7 @@ Where a schema file and the HTTP API disagree, the HTTP API is right: - `003.json` describes the server's creation date. The HTTP API sends the date the server last started. - `004.json` has example mode lists. The HTTP API sends `ow` as the user modes - and `ikmnostl` as the channel modes. + and `Hbiklmnostv` as the channel modes. - `322.json` says `LIST` gets one reply per channel. The server leaves out a secret channel, one with mode `+s`, unless the user is on it. - `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such -- 2.54.0 From e7ae675155f2f47d8b8e44da1c80db2a6c046d90 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 05:02:33 +0100 Subject: [PATCH 22/37] Send peers the IRC listener user's QUIT reason (closes #216) handleQuit now records the reason on the connection, and cleanup passes it to BroadcastQuit, the same service call the HTTP API's QUIT uses. A QUIT without a reason still gives Client quit. A connection that ends without a QUIT still gives Connection closed. The relay test that expected Connection closed for a bare QUIT now expects Client quit. Model: opus-5-5 --- internal/ircserver/commands.go | 1 + internal/ircserver/conn.go | 27 +++++--- internal/ircserver/relay_test.go | 2 +- internal/ircserver/server_test.go | 108 ++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 12 deletions(-) diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 43e00e5..2a8b75a 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() } diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index edcc6f7..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) } } diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index a40c281..1cf28e1 100644 --- a/internal/ircserver/relay_test.go +++ b/internal/ircserver/relay_test.go @@ -316,7 +316,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) diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index 17a142d..7c397f7 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "database/sql" + "encoding/json" "errors" "fmt" "log/slog" @@ -24,6 +25,7 @@ import ( "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" ) @@ -873,6 +875,112 @@ 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) + } + + channelID, err := env.database.GetChannelByName(ctx, "#bye") + if err != nil { + t.Fatalf("get channel: %v", err) + } + + err = env.database.JoinChannel(ctx, channelID, 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 -- 2.54.0 From acb952794479c17110b3de2e9b83f0546c989cd1 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 05:27:27 +0100 Subject: [PATCH 23/37] Make the PASS notice name the HTTP API login (closes #217) After PASS on the IRC listener, the confirming NOTICE told the user to reconnect with PASS and their nick. The IRC listener cannot add a connection to an existing session, so that step does not work. The notice now says the password is set and that another client can log in to the session over the HTTP API with POST /api/v1/login, as the README describes. The test now checks the whole notice text. Model: opus-5-5 --- internal/ircserver/commands.go | 7 +++++-- internal/ircserver/server_test.go | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index 2a8b75a..c2b4e16 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -1227,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/server_test.go b/internal/ircserver/server_test.go index 7c397f7..bff359c 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -1250,6 +1250,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() @@ -1262,7 +1265,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", ) } -- 2.54.0 From 7968be00e740f0d4f03851b5119ea9a94661d17f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 05:38:07 +0100 Subject: [PATCH 24/37] Stop the earlier poll loop on a second /connect (closes #228) A second /connect started a new poll loop without stopping the first. The first loop then polled with the new client too, so each message could be shown twice. It also read stopPoll without a.mu while cmdConnect wrote it. cmdConnect now closes the earlier loop's channel in the same locked step that replaces the client and the cursor, and hands the new channel to the new loop. The loop checks its channel under a.mu before each poll and again before it moves the cursor, so a stopped loop never polls with the new client or moves the new cursor. Model: opus-5-5 --- internal/cli/app.go | 47 ++++-- internal/cli/app_internal_test.go | 241 +++++++++++++++++++++++++++++- 2 files changed, 274 insertions(+), 14 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 3e0bb80..207048b 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -199,11 +199,21 @@ func (a *App) cmdConnect(serverURL string) { 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( @@ -212,9 +222,7 @@ func (a *App) cmdConnect(serverURL string) { )) a.ui.SetStatus(resp.Nick, "", "connected") - a.stopPoll = make(chan struct{}) - - go a.pollLoop() + go a.pollLoop(stop) } func (a *App) cmdNick(nick string) { @@ -702,16 +710,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() @@ -729,12 +744,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]) } diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 9d097fa..0e3203f 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -24,12 +24,13 @@ const ( ) // eventLoopTimeout is how long a test waits for a typed line to -// be handled, for the welcome lines, or for the UI to stop, before -// it fails. +// 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 TestLinesKeepOrder holds the first line, -// which gives the second line time to start too early. +// 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 @@ -422,6 +423,191 @@ func TestLinesKeepOrder(t *testing.T) { 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 @@ -706,6 +892,53 @@ func newServer(t *testing.T, body string) *httptest.Server { 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. func runUI(t *testing.T, ui *UI) { t.Helper() -- 2.54.0 From 2b1a49e59bd2f8549b044e49dda1118dacf1953d Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 06:03:53 +0100 Subject: [PATCH 25/37] Redraw the status bar when a line arrives in another window (closes #232) A line that arrives in a window that is not shown is counted as unread, but refreshStatusBar did nothing, so the window joined the status bar's unread list only at the next SetStatus. UI now keeps the nick, target and connection state last passed to SetStatus, and refreshStatusBar redraws the status bar with them. Model: opus-5-5 --- internal/cli/app_internal_test.go | 25 +++++++++++++++++++++++++ internal/cli/ui.go | 16 ++++++++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 0e3203f..679e67e 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -752,6 +752,31 @@ func TestStatusBar(t *testing.T) { } } +// 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. diff --git a/internal/cli/ui.go b/internal/cli/ui.go index f702a8d..6f229df 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -30,6 +30,12 @@ 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 @@ -179,7 +185,11 @@ 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() }) } @@ -300,8 +310,10 @@ func (ui *UI) callOnInput(text string) { }() } +// 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( -- 2.54.0 From 1e2f0de877ff5654322e28071c84881e4507a28f Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 06:24:58 +0100 Subject: [PATCH 26/37] Make the schema files match the server (closes #218) Each file in schema/ now describes what the HTTP API on next sends and accepts. A numeric reply has its name in command and its number in code. body is always an array of strings. MODE, KICK and JOIN carry their values in body, and TOPIC needs a non-empty body. PING takes no token, and its PONG is the HTTP response. A channel name is limited to 63 characters, and the leading # is optional from a client. PUBKEY.json is deleted, since the server has no such command. The section of schema/README.md that listed the differences is removed, and the sentences it made false are corrected. Model: opus-5-5 --- schema/README.md | 99 +++++++----------------------------- schema/commands/JOIN.json | 18 +++++-- schema/commands/KICK.json | 19 ++++--- schema/commands/MODE.json | 19 +++---- schema/commands/NICK.json | 10 ++-- schema/commands/NOTICE.json | 11 ++-- schema/commands/PART.json | 13 +++-- schema/commands/PING.json | 12 ++--- schema/commands/PONG.json | 14 ++--- schema/commands/PRIVMSG.json | 10 ++-- schema/commands/PUBKEY.json | 37 -------------- schema/commands/QUIT.json | 12 ++--- schema/commands/TOPIC.json | 16 +++--- schema/message.json | 52 ++++++++----------- schema/numerics/001.json | 23 +++++++-- schema/numerics/002.json | 26 +++++++--- schema/numerics/003.json | 24 +++++++-- schema/numerics/004.json | 47 +++++++++++++---- schema/numerics/322.json | 31 ++++++++--- schema/numerics/323.json | 33 ++++++++++-- schema/numerics/332.json | 28 +++++++--- schema/numerics/353.json | 30 ++++++++--- schema/numerics/366.json | 40 ++++++++++++--- schema/numerics/372.json | 24 +++++++-- schema/numerics/375.json | 34 +++++++++++-- schema/numerics/376.json | 33 ++++++++++-- schema/numerics/401.json | 32 +++++++++--- schema/numerics/403.json | 30 ++++++++--- schema/numerics/433.json | 34 +++++++++---- schema/numerics/442.json | 40 ++++++++++++--- schema/numerics/482.json | 40 ++++++++++++--- 31 files changed, 561 insertions(+), 330 deletions(-) delete mode 100644 schema/commands/PUBKEY.json diff --git a/schema/README.md b/schema/README.md index e5c9409..995e9e7 100644 --- a/schema/README.md +++ b/schema/README.md @@ -20,7 +20,7 @@ are the fields it can have: - `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 server message. +- `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` @@ -60,29 +60,32 @@ A `PRIVMSG` whose `body` has two strings is a two-line message: ## Files +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`. + Commands, in `commands/`: - `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`: a user joined a channel. -- `PART.json`: a user left a channel. -- `QUIT.json`: a user left the server. -- `NICK.json`: a user changed nick. -- `TOPIC.json`: a channel topic was set. -- `MODE.json`: a channel mode was changed. -- `KICK.json`: a channel operator removed a user from a channel. +- `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`. -- `PUBKEY.json`: an announcement of a user's public signing key. The server has - no such command. It answers a `PUBKEY` with `ERR_UNKNOWNCOMMAND` (`421`). +- `PONG.json`: the answer to a `PING`, which is the HTTP response to it. Numeric replies, in `numerics/`: -- `001.json`: `RPL_WELCOME`, which welcomes a new user. +- `001.json`: `RPL_WELCOME`, which welcomes the user. - `002.json`: `RPL_YOURHOST`, the server's name and version. -- `003.json`: `RPL_CREATED`, which says when the server was created. -- `004.json`: `RPL_MYINFO`, the server's version and its lists of modes. +- `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. @@ -96,71 +99,3 @@ Numeric replies, in `numerics/`: - `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. - -## Where the files differ from the HTTP API - -Where a schema file and the HTTP API disagree, the HTTP API is right: - -- `message.json` and the files in `numerics/` put the three-digit code in - `command`. They have no `code` field. The HTTP API puts the reply's name in - `command` and its number in `code`. -- `message.json` allows an object as `body`. The HTTP API reads only arrays of - strings. A `PRIVMSG` with an object as `body` gets `ERR_NOTEXTTOSEND` (`412`). -- `message.json` says `to` is a channel or a nick. A `WALLOPS` message has `*` - there. -- `message.json` allows `meta` on every message. The HTTP API keeps a client's - `meta` only on a `PRIVMSG` or `NOTICE`. -- `message.json` describes signature fields in `meta`. The server never checks a - signature. -- `message.json` does not describe `meta.hashcash`. The HTTP API reads it in a - `PRIVMSG` to a channel with mode `+H`. -- `JOIN.json` has no `body`. A client's `JOIN` can carry the channel key in - `body`. When a user joins, the `JOIN` event repeats the channel name in - `body`. -- `JOIN.json` allows a channel name of any length. The server allows at most 63 - characters after the `#`. It answers a longer name with `ERR_NOSUCHCHANNEL` - (`403`). -- `KICK.json` puts the kicked nick in `params`. The HTTP API reads a client's - `KICK` from `body`: the nick, then an optional reason. -- `MODE.json` puts the mode change in `params`. The HTTP API uses `body` - instead. A client sends the change and any value as separate strings, such as - `"+o"` and `"bob"`. In a `MODE` event, `body` holds the change as one string, - such as `"+o bob"`, or as two strings, such as `"+k"` and `"secret"`. -- `NOTICE.json` allows `*` as `to`. The server answers a `NOTICE` to `*` with - `ERR_NOSUCHNICK` (`401`). -- `NOTICE.json` has an example notice from the server to `*`. The server sends - no such notice. -- `PING.json` and `PONG.json` describe a token that the `PONG` repeats. The HTTP - API ignores the `body` of a `PING`. Its answer is the HTTP response - `{"command": "PONG", "from": ""}`. -- `PING.json` and `PONG.json` also describe messages between servers. The server - does not connect to other IRC servers. -- `PUBKEY.json` describes a command the server does not have. -- `QUIT.json` says that `QUIT` comes only from the server. A client can send it - too, to end its session. A client's `QUIT` can carry a reason in `body`. -- `TOPIC.json` says a `TOPIC` without a `body` queries the topic. The HTTP API - answers such a `TOPIC` with `ERR_NEEDMOREPARAMS` (`461`). -- `TOPIC.json` says an empty array clears the topic. The HTTP API answers `[]` - with `ERR_NEEDMOREPARAMS` (`461`). A `body` of `[""]` clears the topic. -- `003.json` describes the server's creation date. The HTTP API sends the date - the server last started. -- `004.json` has example mode lists. The HTTP API sends `ow` as the user modes - and `Hbiklmnostv` as the channel modes. -- `322.json` says `LIST` gets one reply per channel. The server leaves out a - secret channel, one with mode `+s`, unless the user is on it. -- `332.json` says `RPL_TOPIC` answers a `TOPIC` query. The HTTP API has no such - query. It sends `RPL_TOPIC` after a client sets the topic, and after a `JOIN` - to a channel that has one. -- `353.json` says `body` lists nicks. The HTTP API lists `nick!user@host` in - place of each nick, such as `@op1!op1@host1`. -- `353.json` says the first parameter marks a channel as public, private or - secret. The server always sends `=`, the mark for a public channel. -- `376.json` gives the text as `End of /MOTD command`. The HTTP API adds a full - stop. -- `401.json` gives the text as `No such nick/channel`. The server sends - `No such nick` in answer to a `PRIVMSG` or `NOTICE` to an unknown nick. -- `433.json` has an example with `*` as `to`. The HTTP API sends `433` only in - answer to a `NICK`, with the user's nick as `to`. - -The HTTP API also accepts some messages that the files do not allow, such as a -`JOIN` whose `to` has no `#`. 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" + ] + } ] } -- 2.54.0 From 562ce8724549737cb8b25b05dacfdbd108a4ccb2 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 07:10:08 +0100 Subject: [PATCH 27/37] Keep the connection state in the status bar on /query (closes #235) /query set the status bar to [connected] whatever the connection state. Before /connect, it made the status bar say [connected] while the client had no session. /query now reads the connection state with the nick, as /window does, and shows [connected] only while the client is connected, [disconnected] otherwise. TestQueryConnectionState types /query bob, connected and not, and checks the status bar. It names the two status texts as constants, because the linter asks for one once a third copy appears. Model: opus-5-5 --- internal/cli/app.go | 8 +++++- internal/cli/app_internal_test.go | 47 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 207048b..659c9e2 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -402,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) { diff --git a/internal/cli/app_internal_test.go b/internal/cli/app_internal_test.go index 679e67e..a6bf8af 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -23,6 +23,13 @@ const ( 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. @@ -361,6 +368,46 @@ func TestWindowConnectionState(t *testing.T) { } } +// 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. -- 2.54.0 From 24e8b4d027a0be168b5d974764e6afb43e513252 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 07:39:17 +0100 Subject: [PATCH 28/37] Refuse a PRIVMSG or NOTICE whose body or meta has the wrong type (closes #245) Over the HTTP API, a PRIVMSG or NOTICE body was checked only by decoding it into a list of strings. JSON null decodes into an empty string there, and meta was not checked at all, so a null line, or a meta that is not an object, reached every recipient. handlePrivmsg now answers 400 invalid request body, before anything else, when body is present and is not an array of strings, or meta is present and is not an object. The check returns the lines it accepted, and the empty-body check counts those, so body is decoded once. A missing or empty body still gets ERR_NOTEXTTOSEND. README.md says so. Model: opus-5-5 --- README.md | 21 +++-- internal/handlers/api.go | 53 +++++++++++- internal/handlers/api_test.go | 159 +++++++++++++++++++++++++++++++--- 3 files changed, 209 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index d9cef51..f1b0de3 100644 --- a/README.md +++ b/README.md @@ -643,8 +643,10 @@ Send a message to a channel or user. This is the primary messaging command. 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). +- A `body` that is not an array of strings, or a `meta` that is not an object, + gets HTTP 400 `invalid request body`, and nothing is relayed. A missing or + empty `body` gets 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). @@ -1749,17 +1751,18 @@ 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), +(4xx/5xx) are reserved for transport-level problems: a malformed request (400), missing/invalid auth cookies (401), and server errors (500). **HTTP errors (transport-level only):** -| 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 | +| Status | Error | When | +| ------ | ---------------------- | ------------------------------------------------------------------------------------------------- | +| 400 | `invalid request body` | Malformed JSON, or a body over `MAX_MESSAGE_SIZE` bytes | +| 400 | `invalid request body` | A `PRIVMSG` or `NOTICE` whose `body` is not an array of strings, or whose `meta` is not an object | +| 400 | `command required` | Empty command | +| 401 | `not registered` | Missing or invalid auth cookie | +| 500 | `internal error` | Server-side failure | **IRC numeric error replies (delivered via message queue):** diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 1ec917b..0924d7a 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -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( diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index f3642ca..728f8c9 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -8,10 +8,12 @@ import ( "errors" "fmt" "io" + "maps" "net/http" "net/http/httptest" "net/url" "os" + "reflect" "slices" "strconv" "strings" @@ -999,6 +1001,8 @@ 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() @@ -1013,20 +1017,25 @@ 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, + ) + } } } @@ -1056,6 +1065,132 @@ 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() -- 2.54.0 From 5fe325399b14008de4db1d47ea897767fe5615af Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Mon, 2 Nov 2026 08:30:56 +0100 Subject: [PATCH 29/37] Show square brackets in the neoirc-cli message window as written (closes #233) The message window has colour tags on, so tview read square-bracket text in nicks, channel names, reasons, topics and message text as tags. It dropped that text or changed the colour of the rest of the line. A new escape in internal/cli/ui.go writes each "[" as "[[:]", and app.go applies it to every part of a line that comes from the user or the server. tview.Escape is not used: it misses bracket pairs holding symbols such as "|", and tags with a URL. Lines for other server commands now show the command, such as "[MODE]", which tview had read as a tag. Model: opus-5-5 --- internal/cli/app.go | 106 +++++----- internal/cli/app_internal_test.go | 319 +++++++++++++++++++++++++++++- internal/cli/ui.go | 10 + 3 files changed, 380 insertions(+), 55 deletions(-) diff --git a/internal/cli/app.go b/internal/cli/app.go index 659c9e2..c6926ec 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -108,7 +108,7 @@ func (a *App) handleInput(text string) { }) if err != nil { a.ui.AddStatus( - "[red]Send error: " + err.Error(), + "[red]Send error: " + escape(err.Error()), ) } } @@ -166,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), ) } } @@ -182,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 @@ -192,9 +192,9 @@ 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 } @@ -218,7 +218,7 @@ func (a *App) cmdConnect(serverURL string) { 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") @@ -244,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)", ) @@ -256,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 } @@ -269,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) { @@ -297,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 } @@ -337,9 +337,9 @@ 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 } @@ -384,9 +384,9 @@ func (a *App) cmdMsg(args string) { Body: []string{text}, }) if err != nil { - a.ui.AddStatus(fmt.Sprintf( - "[red]Send failed: %v", err, - )) + a.ui.AddStatus( + "[red]Send failed: " + escape(err.Error()), + ) } } @@ -445,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()), + ) } } @@ -471,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 } @@ -486,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, " ")), )) } @@ -517,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 } @@ -528,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), )) } @@ -550,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()), + ) } } @@ -588,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()), + ) } } @@ -619,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()), + ) } } @@ -824,7 +824,7 @@ func (a *App) handlePrivmsgEvent( a.ui.AddLine(target, fmt.Sprintf( "[gray]%s [green]<%s>[white] %s", - timestamp, msg.From, text, + timestamp, escape(msg.From), escape(text), )) } @@ -837,7 +837,7 @@ 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), )) } @@ -856,11 +856,11 @@ func (a *App) handlePartEvent( line := fmt.Sprintf( "[gray]%s [yellow]*** %s has left %s", - timestamp, msg.From, msg.To, + timestamp, escape(msg.From), escape(msg.To), ) if reason != "" { - line += " (" + reason + ")" + line += " (" + escape(reason) + ")" } if msg.From == myNick { @@ -881,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), )) } } @@ -913,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), )) } @@ -925,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), )) } @@ -941,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), )) } @@ -951,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 a6bf8af..79571b1 100644 --- a/internal/cli/app_internal_test.go +++ b/internal/cli/app_internal_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" "time" + "unicode/utf8" "github.com/gdamore/tcell/v2" api "sneak.berlin/go/neoirc/internal/cli/api" @@ -892,6 +893,260 @@ func TestOwnEventFromOtherClient(t *testing.T) { } } +// 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) { @@ -919,6 +1174,41 @@ func statusBarText(ui *UI) string { 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. @@ -964,6 +1254,23 @@ func newServer(t *testing.T, body string) *httptest.Server { 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. @@ -1011,11 +1318,15 @@ func waitPoll(t *testing.T, polled <-chan struct{}, server string) { } } -// runUI runs ui on a simulated screen until the test ends. -func runUI(t *testing.T, ui *UI) { +// 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() - ui.app.SetScreen(tcell.NewSimulationScreen("")) + 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. @@ -1035,4 +1346,6 @@ func runUI(t *testing.T, ui *UI) { 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 6f229df..80708a6 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -235,6 +235,16 @@ 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() { // Colour tags stay off. With them on, tview reads text in // square brackets, such as "[connected]" or part of a nick, -- 2.54.0 From c4fc156a8c8d4ebcbfef54767ec876f8e9858aa7 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 3 Nov 2026 06:59:36 +0100 Subject: [PATCH 30/37] Rewrite AGENTS.md sentence by sentence Rewrite the agent rules from scratch, with one idea per sentence and every file name and command in backticks. Drop the rule that models embed `models.Base`, which no package has. Say that the linter keeps tests in external test packages, and that an `export_test.go` file gives them access to unexported code. Limit the rules on doc comments and blank imports to files outside the tests. State magic numbers as a preference, and correct the blank-line rule for `return`. State the branch workflow: pull requests target `next` and only the owner merges `next` into `main`. Record the three snake_case JSON tags the HTTP API keeps. Part of https://git.eeqj.de/sneak/neoirc/issues/190. Model: fable-5-1 (writing, rework); opus-5-5 (rework) --- AGENTS.md | 94 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 56 insertions(+), 38 deletions(-) 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. -- 2.54.0 From 0b6338442347ef85ef19aa761c360729338847b9 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 3 Nov 2026 07:38:43 +0100 Subject: [PATCH 31/37] Rewrite README.md sentence by sentence README.md is rewritten from scratch against the 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, puts every endpoint, command, numeric reply, mode and setting in reference sections, and ends with development and testing. Where the old text disagreed with the code, the code decides; the PR body lists each case. The hashcash examples are real 20-bit stamps. REPO_POLICIES.md and CONVENTIONS.md are copies of shared documents and are untouched. Model: fable-5-1 (writing, rework) --- README.md | 4957 +++++++++++++++++++---------------------------------- 1 file changed, 1753 insertions(+), 3204 deletions(-) diff --git a/README.md b/README.md index f1b0de3..305f6aa 100644 --- a/README.md +++ b/README.md @@ -1,1664 +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). -- A `body` that is not an array of strings, or a `meta` that is not an object, - gets HTTP 400 `invalid request body`, and nothing is relayed. A missing or - empty `body` gets 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","ow","Hbiklmnostv"]}` | -| `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", @@ -1669,261 +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: a malformed request (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 | `invalid request body` | A `PRIVMSG` or `NOTICE` whose `body` is not an array of strings, or whose `meta` is not an object | -| 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 [ @@ -1939,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, @@ -2051,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 a password of the wrong length with a + `NOTICE`, not `ERR_NEEDMOREPARAMS` (`461`), and a stored password with a + `NOTICE` too. -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`. It must not set `foreign_keys`. 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 { @@ -2832,7 +1820,7 @@ neoirc.example.com { } ``` -**nginx:** +nginx: ```nginx server { @@ -2847,650 +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 -│ ├── dbtest/ # Gives each test its own database; only tests import it -│ ├── 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 -- 2.54.0 From 254873a656648cb47d02e2a6348105d4fc0f441c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 3 Nov 2026 08:20:55 +0100 Subject: [PATCH 32/37] Fix the sentences a fresh read of README.md failed (closes #190) A fresh read of README.md on next, sentence by sentence against the rules on https://git.eeqj.de/sneak/neoirc/issues/190 and the code, failed one sentence. The IRC listener bullet on 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") needs a second reading to see that "a stored password" means the case where the password is accepted. It is now two short sentences: the NOTICE on success first, then the NOTICE instead of 461 on a wrong length. Both facts match handlePassPostReg and checkPasswordLength in internal/ircserver. Every other sentence passed; nothing else is changed. Model: fable-5-1 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 305f6aa..d05ce68 100644 --- a/README.md +++ b/README.md @@ -1637,9 +1637,9 @@ They behave as over the HTTP API, with the IRC syntax and these differences: - `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. +- `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`). ### What IRC clients see -- 2.54.0 From 4221f71920c5a13ab1305e528760f8f1ca00a2ff Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 3 Nov 2026 08:38:44 +0100 Subject: [PATCH 33/37] Add the # to RPL_ENDOFWHO for an unknown channel over HTTP (closes #249) WHO over the HTTP API adds a missing # to the channel name, but the unknown-channel path sent RPL_ENDOFWHO with the name as sent. A client keyed on the channel name saw two spellings. Both paths now send the name with the #. Model: opus-5-5 --- internal/handlers/api.go | 2 +- internal/handlers/api_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 0924d7a..3ad7068 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -3238,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 728f8c9..7ed1aac 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -4039,6 +4039,31 @@ 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() -- 2.54.0 From c12668226f9ec3f967fa62368ba2014c7e1f9d13 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 3 Nov 2026 09:20:42 +0100 Subject: [PATCH 34/37] Apply MAX_MESSAGE_SIZE to the whole request body (closes #250) Creating a session, logging in and posting a message stopped reading after the first JSON value, so the limit never saw the rest of the body and trailing bytes were ignored. All three now read the whole limited body and decode it with json.Unmarshal. A body over the limit, or anything but whitespace after the value, gets 400 invalid request body. Model: opus-5-5 --- internal/handlers/api.go | 4 +- internal/handlers/api_test.go | 101 ++++++++++++++++++++++++++++++++++ internal/handlers/auth.go | 3 +- internal/handlers/handlers.go | 13 +++++ 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 3ad7068..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, @@ -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, diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 7ed1aac..305be3f 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -797,6 +797,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.sendCommand(token, map[string]any{ + commandKey: irc.CmdPass, + bodyKey: []string{testPassword}, + }) + + 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() 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 == "" { -- 2.54.0 From 8afd873813422aa5ec1b9acbc243457fe13eddb5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 3 Nov 2026 11:59:17 +0100 Subject: [PATCH 35/37] Move the test-only helpers out of the shipped db package (closes #248) The server binary carried exported names that only tests call, including raw SQL access and a way to weaken the password hash. Delete testing.go, GetDB and SetBcryptCost from the shipped code, so passwords always hash at bcrypt.DefaultCost. Move GetDB, GetOrCreateChannel, JoinChannel and HasChannelInvite into export_test.go. Delete GetClientHostInfo, whose remaining check GetLatestClientForSession covers. Tests in other packages now build the database with db.New and run raw SQL through dbtest.Exec. Handler tests and the relay tests' aliceWithHTTPClient helper need a password only to log in, so they store one hashed at bcrypt.MinCost. Tests of PASS itself keep the real cost. Model: opus-5-5 --- internal/db/auth.go | 9 +-- internal/db/db.go | 5 -- internal/db/export_test.go | 76 +++++++++++++++++++++++++ internal/db/main_test.go | 14 ----- internal/db/queries.go | 93 ------------------------------- internal/db/queries_test.go | 19 +------ internal/db/testing.go | 25 --------- internal/dbtest/dbtest.go | 24 +++++++- internal/handlers/api_test.go | 86 ++++++++++++++-------------- internal/ircserver/relay_test.go | 21 +++++-- internal/ircserver/server_test.go | 82 +++++++++++---------------- internal/service/service_test.go | 21 ++----- 12 files changed, 199 insertions(+), 276 deletions(-) delete mode 100644 internal/db/main_test.go delete mode 100644 internal/db/testing.go 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 b669061..922ee2e 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -85,11 +85,6 @@ 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 == "" { diff --git a/internal/db/export_test.go b/internal/db/export_test.go index 5dd9384..f24537c 100644 --- a/internal/db/export_test.go +++ b/internal/db/export_test.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "testing" + "time" "sneak.berlin/go/neoirc/internal/dbtest" ) @@ -67,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..f50839d 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") @@ -2460,28 +2389,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 a39f88a..c44ea2a 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -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 index add7f03..8346bf1 100644 --- a/internal/dbtest/dbtest.go +++ b/internal/dbtest/dbtest.go @@ -1,5 +1,5 @@ -// Package dbtest gives each test a database of its own. Only tests -// may import it. +// Package dbtest gives each test a database of its own and runs raw +// SQL on it. Only tests may import it. package dbtest import ( @@ -37,3 +37,23 @@ func NewURL(tb testing.TB) string { 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_test.go b/internal/handlers/api_test.go index 305be3f..00c6be4 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -47,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) } @@ -137,6 +140,7 @@ type testServer struct { t *testing.T fxApp *fxtest.App broker *broker.Broker + dbURL string database *db.Database handlers *handlers.Handlers } @@ -231,6 +235,7 @@ func newTestServerWith( t: t, fxApp: app, broker: brk, + dbURL: dbURL, database: database, handlers: hdlr, } @@ -858,10 +863,7 @@ func TestRequestBodyLimit(t *testing.T) { tserver := newTestServer(t) token := tserver.createSession(nick) - tserver.sendCommand(token, map[string]any{ - commandKey: irc.CmdPass, - bodyKey: []string{testPassword}, - }) + tserver.setPassword(nick) bytesToLimit := maxMessageSize - len(endpoint.value) @@ -1763,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)} @@ -1815,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) @@ -1887,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, @@ -2129,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 { @@ -3736,13 +3749,8 @@ func TestLoginValid(t *testing.T) { 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{ @@ -3806,13 +3814,8 @@ func TestLoginWrongPassword(t *testing.T) { 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", @@ -4283,13 +4286,8 @@ func TestLoginRateLimitAllowsNormalUse(t *testing.T) { 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{ diff --git a/internal/ircserver/relay_test.go b/internal/ircserver/relay_test.go index 1cf28e1..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" ) @@ -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 bff359c..2ca7d02 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -32,14 +32,9 @@ import ( 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 @@ -110,34 +105,27 @@ func newTestEnvWithResolver( ) *testEnv { t.Helper() - conn, err := sql.Open("sqlite", dbtest.NewURL(t)) - if err != nil { - t.Fatalf("open db: %v", err) - } + dbURL := dbtest.NewURL(t) + lifecycle := fxtest.NewLifecycle(t) - 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() log := slog.New(slog.NewTextHandler( @@ -161,6 +149,7 @@ func newTestEnvWithResolver( t.Cleanup(srv.Stop) return &testEnv{ + dbURL: dbURL, database: database, brk: brk, cfg: cfg, @@ -929,12 +918,7 @@ func quitSeenByPeers(t *testing.T, quit string) (string, []string) { t.Fatalf("create peer session: %v", err) } - channelID, err := env.database.GetChannelByName(ctx, "#bye") - if err != nil { - t.Fatalf("get channel: %v", err) - } - - err = env.database.JoinChannel(ctx, channelID, apiPeerID) + _, _, err = env.database.CreateOrJoinChannel(ctx, "#bye", apiPeerID, "") if err != nil { t.Fatalf("join peer: %v", err) } @@ -1005,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) } @@ -1068,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{}) @@ -1088,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) } } @@ -1370,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/service/service_test.go b/internal/service/service_test.go index a51710a..f27eff8 100644 --- a/internal/service/service_test.go +++ b/internal/service/service_test.go @@ -9,7 +9,6 @@ import ( "encoding/json" "errors" "fmt" - "os" "slices" "strings" "sync" @@ -18,7 +17,6 @@ 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" @@ -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 @@ -111,6 +105,7 @@ func newTestEnv(t *testing.T) *testEnv { return &testEnv{ svc: svc, + dbURL: dbURL, db: database, broker: brk, app: app, @@ -940,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, ) @@ -978,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 -- 2.54.0 From bc7ef8240bbbf70416795bd135e23c1a051c4ae5 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 3 Nov 2026 12:20:48 +0100 Subject: [PATCH 36/37] Keep Go's caches out of the test stage's image (closes #256) The test stage ran go mod download in its own layer, and go test left Go's build cache in another. Exporting them took about 20 seconds of every make test, and nothing reads either one afterwards. Removing the build cache alone saved almost nothing, because the module cache is the larger layer. So the download now runs in the same RUN as go test, and go clean removes both caches once the tests pass. A failing run still reruns with -v and exits 1. Model: opus-5-5 --- Dockerfile | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 -- 2.54.0 From bc8aa6bb96fb4a830526e14e6b474624ad683c2a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 3 Nov 2026 12:38:42 +0100 Subject: [PATCH 37/37] Remove the unused GetSessionChannelIDs from the db package (closes #254) Neither the server nor any test calls this method. Model: opus-5-5 --- internal/db/queries.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/internal/db/queries.go b/internal/db/queries.go index f50839d..1f5799c 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1056,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; -- 2.54.0