diff --git a/.dockerignore b/.dockerignore index 3850038..83fe15e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,80 @@ -# .git is sent so the build can stamp the version, without its config. -.git/config -*.md -!README.md -neoircd -neoirc-cli -data.db -data.db-wal -data.db-shm -.env +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`, or `config/.env` and +# `certs/server.key` still ship while this file reads as solved. Only +# genuinely root-anchored entries go unprefixed. Never transplant these +# into .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. +# +# Extend with this repo's own host-built artifacts, written anchored: +# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and +# deletes the package directory from the context. + +# .git is sent without its config. Without a VERSION build argument the +# stage that compiles runs `git describe --tags --always` on .git, which +# does not need .git/config; that file can hold a credential, such as a +# password in a remote URL or the token the CI checkout step stores there. +# Each submodule keeps a config with the same exposure in its git directory +# under .git/modules/, nested again for a submodule's own submodules, or in +# its own .git directory when it keeps one. +# KNOWN GAP: a submodule whose name has a `config` segment (`config`, +# `deploy/config`, `config/lib`) loses its whole git directory, because +# `**/.git/modules/**/config` also matches that segment's directory +# under .git/modules/. Go's version stamping then fails the build; +# nothing leaks. Name such a submodule without that segment: +# `git submodule add --name`. +**/.git/config +**/.git/modules/**/config + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because it occurs once where agents run at the repo root. +# KNOWN GAP: a repo running agents in subdirectories still ships +# `services/api/.claude/` and must add its own anchored entry. +.claude + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Re-include a committed template with a negation if the +# build needs one: `!docs/example.env`. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. Public certificates +# (*.crt, *.cer) are deliberately absent: they are legitimate inputs. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +**/[iI][dD]_[eE][dD]25519 +**/[iI][dD]_[eE][dD]25519_[sS][kK] + +# Dependencies: restored inside the image, never copied in. +**/node_modules + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state: never a build input, and it churns COPY. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* + +# This repository's host-built artifacts: the binaries, and the database +# a local run writes. +/neoircd +/neoirc-cli +/data.db +/data.db-wal +/data.db-shm diff --git a/.editorconfig b/.editorconfig index 2fe0ce0..b8d5a99 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,3 +10,8 @@ insert_final_newline = true [Makefile] indent_style = tab + +# This repository's own entries, after the shared content above. + +[*.go] +indent_style = tab diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index aca7a51..ee73864 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -6,4 +6,4 @@ jobs: steps: # actions/checkout v4.2.2, 2026-02-22 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - run: docker build . + - run: script/cibuild diff --git a/.gitignore b/.gitignore index 745e210..823d722 100644 --- a/.gitignore +++ b/.gitignore @@ -11,19 +11,48 @@ Thumbs.db .vscode/ *.sublime-* +# Agent scratch (worktrees of this repo, created and destroyed by +# in-flight tooling). Unanchored: .gitignore patterns already match at +# every depth, so no prefix is wanted here. This is not a .dockerignore +# entry and must not be given a `**/` prefix on the way into one. +.claude/ + # Node node_modules/ -# Environment / secrets -.env -.env.* -*.pem -*.key +# Secrets. Unanchored like every entry above, so each matches at every +# depth. Matching is case-sensitive on Linux, so names use character +# ranges rather than a lowercase form that misses `Server.Key`. + +# Environment files. `*.env` covers bare `.env` and the `prod.env` +# convention. Only the templates `example.env` and `sample.env` are +# re-included below. A repository that commits any other template adds +# its own negation after these lines, for example `!.env.example`. +*.[eE][nN][vV] +.[eE][nN][vV].* +.[eE][nN][vV][rR][cC] +!example.env +!sample.env + +# Private keys and the bundles carrying them. +*.[pP][eE][mM] +*.[kK][eE][yY] +*.[pP]12 +*.[pP][fF][xX] +[iI][dD]_[rR][sS][aA] +[iI][dD]_[dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA] +[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK] +[iI][dD]_[eE][dD]25519 +[iI][dD]_[eE][dD]25519_[sS][kK] + +# This repository's own entries, after the shared content above. # Build artifacts web/dist/ -/neoircd /bin/ +/neoircd +/neoirc-cli *.exe *.dll *.so @@ -32,8 +61,6 @@ web/dist/ *.out vendor/ -# Project +# Local database and logs data.db -debug.log -/neoirc-cli -web/node_modules/ +*.log diff --git a/.golangci.yml b/.golangci.yml index 2698d2d..1b73eb9 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,13 +1,30 @@ version: "2" +# Config schema uses the golangci-lint v2 layout (settings live under +# linters.settings, not top-level linters-settings) so that the +# thresholds below are actually applied by golangci-lint >= v2. + run: timeout: 5m modules-download-mode: readonly linters: default: all + enable: + # Successor to the deprecated gomodguard. Named explicitly, rather than + # left to `default: all`, because it carries the module policy below. + - gomodguard_v2 disable: - - wsl # Deprecated in v2, replaced by wsl_v5 + # Genuinely incompatible with project patterns + - exhaustruct # Requires all struct fields + - exhaustruct_v5 # Requires all struct fields (successor to exhaustruct) + - godot # Requires comments to end with periods + - wrapcheck # Too verbose for internal packages + - varnamelen # Short names like db, id are idiomatic Go + # Deprecated: the warning is attached to the old name, so it is + # silenced by disabling that name, not by enabling the successor. + - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 settings: lll: line-length: 88 @@ -18,19 +35,65 @@ linters: max-complexity: 15 dupl: threshold: 100 - gosec: - excludes: - - G704 depguard: + # Test-support code must not be compiled into the shipped binary. A + # test-support package exists to hand a test privileges the program + # itself must never have, so a file that is not a test must not import + # one. Test files, and the files inside a package whose directory name + # ends in `test`, are where that code belongs, and are exempt. + # + # The deny list below is the one part of this file a repository is + # expected to extend, and the only part it may. depguard matches an + # import path against a list of prefixes, so it cannot be told "any path + # whose last segment ends in test"; a repository's own test-support + # packages have to be named here one at a time, by full import path, + # under a module path that differs from repository to repository. Add + # them; change nothing else. rules: - all: + test-support: + list-mode: lax + files: + - "$all" + - "!$test" + - "!**/*test/**" deny: - - pkg: "io/ioutil" - desc: "Deprecated; use io and os packages." - - pkg: "math/rand$" - desc: "Use crypto/rand for security-sensitive code." + - pkg: net/http/httptest + 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: + blocked: + - module: github.com/rs/zerolog + recommendations: + - log/slog + reason: "Structured logging is stdlib log/slog." + # One entry per pre-fork module path, because the later releases + # are separate paths. A prefix match would be shorter but would + # also reach github.com/go-redis/redismock, the test double for + # the successor these entries recommend. + - module: github.com/go-redis/redis + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v7 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v8 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/sergi/go-diff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "No unified diff output; use go-udiff." + - module: github.com/hexops/gotextdiff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "Unmaintained fork; use go-udiff." issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..23d67fc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +node_modules/ +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/AGENTS.md b/AGENTS.md index 69ff0a4..d189eb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,12 @@ ## Before Every Commit -1. **Format**: `gofmt -s -w .` and `goimports -w .` -2. **Lint**: `golangci-lint run --config .golangci.yml ./...` — zero issues -3. **Test**: `go test -race ./...` — all passing -4. **Build**: `go build ./cmd/neoircd` — compiles clean +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 + +Never run `go test`, `golangci-lint` or `gofmt` directly; use the `make` +targets. No commit lands on main with lint errors, test failures, or formatting issues. diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 8885442..05d6b19 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -1,6 +1,8 @@ # Go HTTP Server Conventions -This document defines the architectural patterns, design decisions, and conventions for building Go HTTP servers. All new projects must follow these standards. +This document defines the architectural patterns, design decisions, and +conventions for building Go HTTP servers. All new projects must follow these +standards. ## Table of Contents @@ -84,10 +86,11 @@ project-root/ ### Key Principles -- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping. -- **`internal/`**: All application packages. Not importable by external projects. -- **One package per concern**: config, database, handlers, middleware, etc. -- **Flat handler files**: One file per handler or logical group of handlers. +- **`cmd/{appname}/`**: Only the entry point. Minimal logic, just bootstrapping. +- **`internal/`**: All application packages. Not importable by external + projects. +- **One package per concern**: config, database, handlers, middleware, etc. +- **Flat handler files**: One file per handler or logical group of handlers. --- @@ -188,7 +191,8 @@ Providers are resolved automatically by fx, but conceptually follow this order: 2. `logger.New` - Logger (depends on Globals) 3. `config.New` - Configuration (depends on Globals, Logger) 4. `database.New` - Database (depends on Logger, Config) -5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, Database) +5. `healthcheck.New` - Health check (depends on Globals, Config, Logger, + Database) 6. `middleware.New` - Middleware (depends on Logger, Globals, Config) 7. `handlers.New` - Handlers (depends on Logger, Globals, Database, Healthcheck) 8. `server.New` - Server (depends on all above) @@ -452,11 +456,11 @@ func New(lc fx.Lifecycle, params HandlersParams) (*Handlers, error) { ### Closure-Based Handler Pattern For JSON route handlers, both the request and the response structures are -defined in the scope of the method that returns the HandlerFunc. They can -be called simply `Request` and `Response` or slightly more descriptive -names. +defined in the scope of the method that returns the HandlerFunc. They can be +called simply `Request` and `Response` or slightly more descriptive names. -All handlers return `http.HandlerFunc` using the closure pattern. This allows initialization logic to run once when the handler is created: +All handlers return `http.HandlerFunc` using the closure pattern. This allows +initialization logic to run once when the handler is created: ```go // internal/handlers/index.go @@ -517,10 +521,11 @@ func (s *Handlers) decodeJSON(w http.ResponseWriter, r *http.Request, v interfac ### Handler Naming Convention -- `HandleIndex()` - Main page -- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method suffix -- `HandleNow()` - API endpoints -- `HandleHealthCheck()` - System endpoints +- `HandleIndex()` - Main page +- `HandleLoginGET()` / `HandleLoginPOST()` - Form handlers with HTTP method + suffix +- `HandleNow()` - API endpoints +- `HandleHealthCheck()` - System endpoints --- @@ -741,7 +746,8 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) { 1. **Environment variables** (highest priority via `AutomaticEnv()`) 2. **`.env` file** (loaded via `godotenv/autoload` import) -3. **Config files**: `/etc/{appname}/{appname}.yaml`, `~/.config/{appname}/{appname}.yaml` +3. **Config files**: `/etc/{appname}/{appname}.yaml`, + `~/.config/{appname}/{appname}.yaml` 4. **Defaults** (lowest priority) ### Environment Loading diff --git a/Dockerfile b/Dockerfile index b90d0ca..ace81b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,41 +8,57 @@ COPY web/src/ src/ COPY web/build.sh build.sh RUN sh build.sh -# Lint stage — fast feedback on formatting and lint issues -# golangci/golangci-lint:v2.1.6, 2026-03-02 -FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint +# Lint phase, built alone by script/lint. The linter is invoked directly +# rather than through `make lint`, which is itself a docker build and +# would recurse into a daemon that does not exist in a build step. +# golangci/golangci-lint:v2.14.0, 2026-10-06 +FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -# Create placeholder files so //go:embed dist/* in web/embed.go resolves -# without depending on the web-builder stage (lint should fail fast) +# Placeholder files so //go:embed dist/* in web/embed.go resolves +# without waiting for the web-builder stage. The test phase does the same. RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js -RUN make fmt-check -RUN make lint +RUN golangci-lint run --config .golangci.yml ./... -# Build stage +# Test phase, built alone by script/test. -race needs cgo and so a C +# compiler, which the Debian Go image ships and the alpine one does not. +# -p 4 runs at most four test binaries at once: under -race each one +# costs a few hundred MB, and the default is one per core. +# 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 +RUN go test -p 4 -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -p 4 -timeout 90s -race -v ./...; exit 1; } + +# Build stage. Nothing is wanted from either phase above; the copies +# are what make BuildKit build them first, so this stage cannot run +# unless lint and test passed. # golang:1.24-alpine, 2026-02-26 FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder -WORKDIR /src -RUN apk add --no-cache git build-base make - -# Force BuildKit to run the lint stage before proceeding COPY --from=lint /src/go.sum /dev/null - +COPY --from=test /src/go.sum /dev/null +RUN apk add --no-cache git +# A tar-stream context keeps the sender's file owners, which git refuses. +RUN git config --system --add safe.directory /src +WORKDIR /src COPY go.mod go.sum ./ RUN go mod download - COPY . . COPY --from=web-builder /web/dist/ web/dist/ -RUN make test - # Build static binaries (no cgo needed at runtime — modernc.org/sqlite is pure Go) # # neoircd is stamped with the VERSION build arg when one is given, otherwise -# with the tag or short commit from the .git in the build context. With .git -# present, a version that is still empty, dev or unknown fails the build. +# with `git describe --tags --always` on the .git in the build context. With +# .git present, a version that is still empty, dev or unknown fails the build: +# git is missing or could not read the checkout. ARG VERSION RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ if [ -e .git ]; then \ @@ -54,7 +70,8 @@ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.Version=${VERSION}" -o /neoircd ./cmd/neoircd/ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /neoirc-cli ./cmd/neoirc-cli/ -# Runtime stage +# Runtime stage, and the last one: a plain `docker build .` builds this +# stage's chain and nothing else. # alpine:3.21, 2026-02-26 FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 RUN apk add --no-cache ca-certificates \ diff --git a/Makefile b/Makefile index 2aac9ec..d08cc41 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,25 @@ -.PHONY: all build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist +.PHONY: all bootstrap setup build lint fmt fmt-check test check clean run debug docker hooks ensure-web-dist BINARY := neoircd VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") LDFLAGS := -X main.Version=$(VERSION) +# The standard targets are thin shims; the implementations live in +# script/ per the scripts-to-rule-them-all pattern (see the Entrypoints +# section of README.md). + all: check build +bootstrap: + @script/bootstrap + +setup: + @script/setup + # ensure-web-dist creates placeholder files so //go:embed dist/* in # web/embed.go resolves without a full Node.js build. The real SPA is # built by the web-builder Docker stage; these placeholders let -# "make test" and "make build" work outside Docker. +# "make build" work outside Docker. ensure-web-dist: @if [ ! -d web/dist ]; then \ mkdir -p web/dist && \ @@ -20,25 +30,20 @@ ensure-web-dist: build: ensure-web-dist go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/neoircd -lint: ensure-web-dist - golangci-lint run --config .golangci.yml ./... +test: + @script/test + +lint: + @script/lint fmt: - gofmt -s -w . - goimports -w . + @script/fmt fmt-check: - @test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) + @script/fmt-check -test: ensure-web-dist - go test -timeout 120s -race -cover ./... - -# check runs all validation without making changes -# Used by CI and Docker build — fails if anything is wrong -check: test lint fmt-check - @echo "==> Building..." - go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/neoircd - @echo "==> All checks passed!" +check: + @script/check run: build ./bin/$(BINARY) @@ -50,10 +55,7 @@ clean: rm -rf bin/ neoircd docker: - docker build -t neoirc . + @script/docker hooks: - @printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit - @printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit - @printf 'make check\n' >> .git/hooks/pre-commit - @chmod +x .git/hooks/pre-commit + @script/install-precommit diff --git a/README.md b/README.md index fb97988..d2f1723 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ 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. +`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. --- @@ -28,6 +28,7 @@ web client as a convenience/reference implementation, but the API comes first. - [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) @@ -42,8 +43,8 @@ web client as a convenience/reference implementation, but the API comes first. ## Motivation IRC is in decline because session state is tied to the TCP connection. In a -mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer -or pay for IRCCloud. +mobile-first world, that's a nonstarter. Not everyone wants to run a bouncer or +pay for IRCCloud. This project builds a server that: @@ -64,8 +65,8 @@ is too complex. ## Why Not Just Use IRC / XMPP / Matrix? This isn't a new protocol that borrows IRC terminology for familiarity. This -**is** IRC — the same command model, the same semantics, the same numeric -reply codes from RFC 1459/2812 — carried over HTTP+JSON instead of raw TCP. +**is** IRC — the same command model, the same semantics, the same numeric reply +codes from RFC 1459/2812 — carried over HTTP+JSON instead of raw TCP. The question isn't "why build something new?" It's "what's the minimum set of changes to make IRC work on modern devices?" The answer turned out to be four @@ -83,13 +84,13 @@ 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. +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. +memberships, and message queue survive disconnects. Multiple devices can share a +session simultaneously, each with its own delivery queue. ### 3. Structured message bodies @@ -105,9 +106,9 @@ 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. +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 @@ -115,22 +116,22 @@ 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. +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. +`{"command": "PRIVMSG", "to": "#channel", "body": ["hello"]}` — encoded as JSON +rather than space-delimited text. The command vocabulary is IRC's, not an +invention. -The message envelope is deliberately identical for C2S and S2C. A `PRIVMSG` is -a `PRIVMSG` regardless of direction. A `JOIN` from a client is the same shape -as the `JOIN` relayed to channel members. This keeps the protocol simple and -makes signing consistent — you sign the same structure you send. +The message envelope is deliberately identical for C2S and S2C. A `PRIVMSG` is a +`PRIVMSG` regardless of direction. A `JOIN` from a client is the same shape as +the `JOIN` relayed to channel members. This keeps the protocol simple and makes +signing consistent — you sign the same structure you send. ### Why not XMPP or Matrix? @@ -158,26 +159,26 @@ for multi-client access. #### Session Creation -- **Session creation**: client sends `POST /api/v1/session` with a desired - nick → 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 when behind TLS. - 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. +- **Session creation**: client sends `POST /api/v1/session` with a desired nick + → 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. 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 at least 8 characters. + `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 at least 8 characters. - **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 and message queues are shared. @@ -195,17 +196,16 @@ For users who want to access the same session from multiple devices: 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 the last client disconnects -(QUIT or logout). Identity verification at the message layer via cryptographic -signatures (see [Security Model](#security-model)) remains independent of -password status. +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 +the last client disconnects (QUIT or logout). Identity verification at the +message layer via cryptographic signatures (see +[Security Model](#security-model)) remains independent of password status. ### Hostmask (nick!user@host) @@ -219,19 +219,19 @@ Each session has an IRC-style hostmask composed of three parts: - **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**, allowing the server to track the -network origin of each individual client independently from the session. -Client-level IP and hostname are **not displayed to regular users**. They are -only visible to **server operators** (o-line) via `RPL_WHOISACTUALLY` (338) -when the oper performs a WHOIS on a user. +Each **client connection** (created at session creation or login) also stores +its own **ip** and **hostname**, allowing the server to track the network origin +of each individual client independently from the session. Client-level IP and +hostname are **not displayed to regular users**. They are only visible to +**server operators** (o-line) via `RPL_WHOISACTUALLY` (338) when the oper +performs a WHOIS on a user. 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 target's current client IP and hostname +- **WHOIS (oper-only)** (`338 RPL_WHOISACTUALLY`) — when the querier is a server + operator, includes the target's current client IP and hostname - **WHO** (`352 RPL_WHOREPLY`) — `params` contains `[channel, username, hostname, server, nick, flags]` @@ -242,14 +242,14 @@ The hostmask format (`nick!user@host`) is stored for future use in ban matching - 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. No further character restrictions in the - current implementation. +- Nicks are **case-sensitive** (unlike traditional IRC). `Alice` and `alice` are + different nicks. +- Nick length: 1–32 characters. No further character restrictions in the current + implementation. - 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. +- 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 @@ -274,11 +274,10 @@ User Session └── Client C (cookie_c, queue_c) ``` -**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. +**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. **Rationale:** The fundamental IRC mobile problem is that you can't have your phone and laptop connected simultaneously without a bouncer. Server-side @@ -326,9 +325,9 @@ The server implements HTTP long-polling for real-time message delivery: 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) + - 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-client notification channels. When a message is enqueued for a client, the @@ -337,8 +336,8 @@ long-poll handlers. 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. +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 @@ -355,18 +354,18 @@ balancer, and CDN handles it correctly. - **Ephemeral** — channels disappear when the last member leaves. There is no persistent channel registration. - **No channel size limits** in the current implementation. -- **Channel names** must start with `#`. If a client sends a `JOIN` without - the `#` prefix, the server adds it. +- **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 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 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 nick as the `msg_to` field. This means DM history is queryable per-nick, but if a user changes their nick, old DMs are associated with the old nick. @@ -379,28 +378,29 @@ 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 +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. +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 + +- 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) +- Browsers and HTTP cookie jars manage cookies automatically; CLI clients must + explicitly save and resend cookies (e.g., curl `-c`/`-b` flags) --- @@ -509,16 +509,16 @@ Each message is stored ONCE. One queue entry per recipient client. ``` 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. +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. ### In-Memory Broker -The server maintains an in-memory notification broker to avoid database -polling. The broker is a map of `client_id → []chan struct{}`. When a message -is enqueued for a client: +The server maintains an in-memory notification broker to avoid database polling. +The broker is a map of `client_id → []chan struct{}`. When a message is enqueued +for a client: 1. The handler calls `broker.Notify(clientID)` 2. The broker closes all waiting channels for that client @@ -554,41 +554,41 @@ the same JSON envelope: #### 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 3-digit numeric reply code (`001`, `433`, etc.). Case-insensitive on input; server normalizes to uppercase. | -| `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 | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). | -| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** | -| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. 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. | +| 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 3-digit numeric reply code (`001`, `433`, etc.). Case-insensitive on input; server normalizes to uppercase. | +| `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 | Sometimes | Sometimes | Additional IRC-style positional parameters. Used by commands like `MODE`, `KICK`, and numeric replies like `353` (NAMES). | +| `body` | array or object | Usually | Usually | Structured message body. For text messages: array of strings (one per line). For structured data (e.g., `PUBKEY`): JSON object. **Never a raw string.** | +| `ts` | string (ISO 8601) | Ignored | Always | Server-assigned timestamp in RFC 3339 / ISO 8601 format with nanosecond precision. Example: `"2026-02-10T20:00:00.000000000Z"`. 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. +- `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, validated, or interpreted by the - server. +- `meta` is **relayed verbatim**. The server stores it as-is and includes it in + S2C messages. It is never modified, validated, or interpreted by the server. ### 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`). +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"]} @@ -598,22 +598,23 @@ Send a message to a channel or user. This is the primary messaging command. ``` **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.000000000Z", - "meta": {} + "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello world"], + "ts": "2026-02-10T20:00:00.000000000Z", + "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 +- 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). @@ -622,24 +623,30 @@ Send a message to a channel or user. This is the primary messaging command. - If the DM target nick doesn't exist, the server returns HTTP 404. **Response:** `201 Created` + ```json -{"id": "uuid-string", "status": "sent"} +{ "id": "uuid-string", "status": "sent" } ``` **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. +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"]} +{ + "command": "NOTICE", + "to": "#general", + "body": ["server maintenance in 5 min"] +} ``` -**Behavior:** Same as PRIVMSG in all respects, except clients receiving a -NOTICE must not send an automatic reply. +**Behavior:** Same as PRIVMSG in all respects, except clients receiving a NOTICE +must not send an automatic reply. **IRC reference:** RFC 1459 §4.4.2 @@ -648,6 +655,7 @@ NOTICE must not send an automatic reply. Join a channel. If the channel doesn't exist, it is created. **C2S:** + ```json {"command": "JOIN", "to": "#general"} {"command": "JOIN", "to": "general"} @@ -656,15 +664,16 @@ Join a channel. If the channel doesn't exist, it is created. If the `#` prefix is omitted, the server adds it. **S2C (broadcast to all channel members, including the joiner):** + ```json { - "id": "...", - "command": "JOIN", - "from": "alice", - "to": "#general", - "body": [], - "ts": "2026-02-10T20:00:00.000000000Z", - "meta": {} + "id": "...", + "command": "JOIN", + "from": "alice", + "to": "#general", + "body": [], + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": {} } ``` @@ -673,15 +682,16 @@ If the `#` prefix is omitted, the server adds it. - If the channel doesn't exist, it is created with no topic and no modes. - If the user is already in the channel, the JOIN is a no-op (no error, no duplicate broadcast). -- 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 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 implicit operator (not yet enforced in current implementation). **Response:** `200 OK` + ```json -{"status": "joined", "channel": "#general"} +{ "status": "joined", "channel": "#general" } ``` **IRC reference:** RFC 1459 §4.2.1 @@ -691,36 +701,39 @@ If the `#` prefix is omitted, the server adds it. Leave a channel. **C2S:** + ```json {"command": "PART", "to": "#general"} {"command": "PART", "to": "#general", "body": ["goodbye"]} ``` **S2C (broadcast to all channel members, including the leaver):** + ```json { - "id": "...", - "command": "PART", - "from": "alice", - "to": "#general", - "body": ["goodbye"], - "ts": "...", - "meta": {} + "id": "...", + "command": "PART", + "from": "alice", + "to": "#general", + "body": ["goodbye"], + "ts": "...", + "meta": {} } ``` **Behavior:** -- The PART event is broadcast **before** the member is removed, so the - departing user receives their own PART event. +- The PART event is broadcast **before** the member is removed, so the departing + user receives their own PART event. - 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"} +{ "status": "parted", "channel": "#general" } ``` **IRC reference:** RFC 1459 §4.2.2 @@ -730,20 +743,22 @@ Leave a channel. Change the user's nickname. **C2S:** + ```json -{"command": "NICK", "body": ["newnick"]} +{ "command": "NICK", "body": ["newnick"] } ``` **S2C (broadcast to all users sharing a channel with the changer):** + ```json { - "id": "...", - "command": "NICK", - "from": "oldnick", - "to": "", - "body": ["newnick"], - "ts": "...", - "meta": {} + "id": "...", + "command": "NICK", + "from": "oldnick", + "to": "", + "body": ["newnick"], + "ts": "...", + "meta": {} } ``` @@ -752,19 +767,21 @@ Change the user's nickname. - `body[0]` is the new nick. Must be 1–32 characters. - 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. +- 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 returns HTTP 409 Conflict. **Response:** `200 OK` + ```json -{"status": "ok", "nick": "newnick"} +{ "status": "ok", "nick": "newnick" } ``` **Error (nick taken):** `409 Conflict` + ```json -{"error": "nick already in use"} +{ "error": "nick already in use" } ``` **IRC reference:** RFC 1459 §4.1.2 @@ -772,51 +789,54 @@ Change the user's nickname. #### 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. +`POST /api/v1/login`. The password is hashed with bcrypt and stored server-side. **C2S:** + ```json -{"command": "PASS", "body": ["mypassword"]} +{ "command": "PASS", "body": ["mypassword"] } ``` **Behavior:** - `body[0]` is the password. Must be at least 8 characters. - On success, the server responds with `{"status": "ok"}`. -- If the password is too short or missing, the server sends - ERR_NEEDMOREPARAMS (461) via the message queue. +- If the password is too short 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. +- 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"} +{ "status": "ok" } ``` -**IRC reference:** Inspired by RFC 1459 §4.1.1 (PASS), repurposed for -session password management. +**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"]} +{ "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": {} + "id": "...", + "command": "TOPIC", + "from": "alice", + "to": "#general", + "body": ["Welcome to #general"], + "ts": "...", + "meta": {} } ``` @@ -825,13 +845,14 @@ Set or change a channel's topic. - Updates the channel's topic in the database. - The TOPIC event is broadcast to all channel members. - 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 +- 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"} +{ "status": "ok", "topic": "Welcome to #general" } ``` **IRC reference:** RFC 1459 §4.2.4 @@ -841,37 +862,40 @@ Set or change a channel's topic. 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", - "to": "", - "body": ["leaving"], - "ts": "...", - "meta": {} + "id": "...", + "command": "QUIT", + "from": "alice", + "to": "", + "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 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 cookie is invalidated, the nick - is released. +- The user's session is destroyed — the auth cookie is invalidated, the nick is + released. - Subsequent requests with the old auth cookie return HTTP 401. **Response:** `200 OK` + ```json -{"status": "quit"} +{ "status": "quit" } ``` **IRC reference:** RFC 1459 §4.1.6 @@ -881,27 +905,30 @@ Destroy the session and disconnect from the server. Client keepalive. Server responds synchronously with PONG. **C2S:** + ```json -{"command": "PING"} +{ "command": "PING" } ``` **Response (synchronous, not via the queue):** `200 OK` + ```json -{"command": "PONG", "from": "servername"} +{ "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. +**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 Modes -Query channel or user modes. Returns the current mode string and, for -channels, the creation timestamp. +Query channel or user modes. Returns the current mode string and, for channels, +the creation timestamp. **C2S:** + ```json {"command": "MODE", "to": "#general"} {"command": "MODE", "to": "alice"} @@ -909,16 +936,18 @@ channels, the creation timestamp. **S2C (via message queue):** -For channels, the server sends RPL_CHANNELMODEIS (324) and -RPL_CREATIONTIME (329): +For channels, the server sends RPL_CHANNELMODEIS (324) and RPL_CREATIONTIME +(329): + ```json {"command": "324", "to": "alice", "params": ["#general", "+n"]} {"command": "329", "to": "alice", "params": ["#general", "1709251200"]} ``` For users, the server sends RPL_UMODEIS (221): + ```json -{"command": "221", "to": "alice", "body": ["+"]} +{ "command": "221", "to": "alice", "body": ["+"] } ``` **Note:** Mode changes (setting/unsetting modes) are not yet implemented. @@ -932,49 +961,53 @@ Request the member list for a channel. Returns RPL_NAMREPLY (353) and RPL_ENDOFNAMES (366). **C2S:** + ```json -{"command": "NAMES", "to": "#general"} +{ "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). +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"} +{ "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). +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 additionally includes RPL_WHOISACTUALLY (338) with the -target's current client IP address and hostname. +If the querying user is a **server operator** (authenticated via `OPER`), the +response additionally includes RPL_WHOISACTUALLY (338) with the target's current +client IP address and hostname. **C2S:** + ```json -{"command": "WHOIS", "to": "alice"} +{ "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). +Query users in a channel. Returns RPL_WHOREPLY (352) for each user followed by +RPL_ENDOFWHO (315). **C2S:** + ```json -{"command": "WHO", "to": "#general"} +{ "command": "WHO", "to": "#general" } ``` **IRC reference:** RFC 1459 §4.5.1 @@ -985,8 +1018,9 @@ Request server user/channel statistics. Returns RPL_LUSERCLIENT (251), RPL_LUSEROP (252), RPL_LUSERCHANNELS (254), and RPL_LUSERME (255). **C2S:** + ```json -{"command": "LUSERS"} +{ "command": "LUSERS" } ``` LUSERS replies are also sent automatically during connection registration. @@ -995,18 +1029,20 @@ LUSERS replies are also sent automatically during connection registration. #### OPER — Gain Server Operator Status -Authenticate as a server operator (o-line). On success, the session gains -oper privileges, which currently means additional information is visible in -WHOIS responses (e.g., target user's current client IP and hostname). +Authenticate as a server operator (o-line). On success, the session gains oper +privileges, which currently means additional information is visible in WHOIS +responses (e.g., target user's current client IP and hostname). **C2S:** + ```json -{"command": "OPER", "body": ["opername", "operpassword"]} +{ "command": "OPER", "body": ["opername", "operpassword"] } ``` **S2C (via message queue on success):** + ```json -{"command": "381", "to": "alice", "body": ["You are now an IRC operator"]} +{ "command": "381", "to": "alice", "body": ["You are now an IRC operator"] } ``` **Behavior:** @@ -1014,8 +1050,8 @@ WHOIS responses (e.g., target user's current client IP and hostname). - `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 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 persists for the session lifetime. There is no de-oper command. @@ -1028,14 +1064,16 @@ 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"]} +{ "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. **Errors:** + - `482` (ERR_CHANOPRIVSNEEDED) — kicker is not a channel operator - `441` (ERR_USERNOTINCHANNEL) — target is not in the channel - `403` (ERR_NOSUCHCHANNEL) — channel does not exist @@ -1047,25 +1085,30 @@ reason. If no reason is provided, the kicker's nick is used as the default. Distribute a public signing key to channel members. **C2S:** + ```json -{"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64-encoded-pubkey"}} +{ + "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": {} + "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. +store, or interpret the key material. See [Security Model](#security-model) for +the full key distribution protocol. **Status:** Not yet implemented. @@ -1075,47 +1118,47 @@ Numeric replies follow IRC conventions from RFC 1459/2812. They are sent from the server to the client (never C2S) and use 3-digit string codes in the `command` field. -| Code | Name | When Sent | Example | -|------|----------------------|-----------|---------| -| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | -| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` | -| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | -| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","ikmnostl"]}` | -| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc"],"body":["are supported by this server"]}` | -| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` | -| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | -| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` | -| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` | -| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` | -| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` | -| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | -| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"313","to":"alice","params":["bob"],"body":["is an IRC operator"]}` | -| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` | -| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | -| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` | -| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"338","to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` | -| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` | -| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | -| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` | -| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` | -| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` | -| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | -| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | -| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | -| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | -| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | -| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` | -| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` | -| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"381","to":"alice","body":["You are now an IRC operator"]}` | -| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | -| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | -| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` | -| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` | -| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` | -| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | -| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | -| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | -| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"491","to":"alice","body":["No O-lines for your host"]}` | +| Code | Name | When Sent | Example | +| ----- | -------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `001` | RPL_WELCOME | After session creation | `{"command":"001","to":"alice","body":["Welcome to the network, alice"]}` | +| `002` | RPL_YOURHOST | After session creation | `{"command":"002","to":"alice","body":["Your host is neoirc, running version 0.1"]}` | +| `003` | RPL_CREATED | After session creation | `{"command":"003","to":"alice","body":["This server was created 2026-02-10"]}` | +| `004` | RPL_MYINFO | After session creation | `{"command":"004","to":"alice","params":["neoirc","0.1","","ikmnostl"]}` | +| `005` | RPL_ISUPPORT | After session creation | `{"command":"005","to":"alice","params":["CHANTYPES=#","NICKLEN=32","PREFIX=(ov)@+","CHANMODES=b,k,Hl,imnst","NETWORK=neoirc"],"body":["are supported by this server"]}` | +| `221` | RPL_UMODEIS | In response to user MODE query | `{"command":"221","to":"alice","body":["+"]}` | +| `251` | RPL_LUSERCLIENT | On connect or LUSERS command | `{"command":"251","to":"alice","body":["There are 5 users and 0 invisible on 1 servers"]}` | +| `252` | RPL_LUSEROP | On connect or LUSERS command | `{"command":"252","to":"alice","params":["0"],"body":["operator(s) online"]}` | +| `254` | RPL_LUSERCHANNELS | On connect or LUSERS command | `{"command":"254","to":"alice","params":["3"],"body":["channels formed"]}` | +| `255` | RPL_LUSERME | On connect or LUSERS command | `{"command":"255","to":"alice","body":["I have 5 clients and 1 servers"]}` | +| `311` | RPL_WHOISUSER | In response to WHOIS | `{"command":"311","to":"alice","params":["bob","bobident","host.example.com","*"],"body":["bob"]}` | +| `312` | RPL_WHOISSERVER | In response to WHOIS | `{"command":"312","to":"alice","params":["bob","neoirc"],"body":["neoirc server"]}` | +| `313` | RPL_WHOISOPERATOR | In WHOIS if target is oper | `{"command":"313","to":"alice","params":["bob"],"body":["is an IRC operator"]}` | +| `315` | RPL_ENDOFWHO | End of WHO response | `{"command":"315","to":"alice","params":["#general"],"body":["End of /WHO list"]}` | +| `318` | RPL_ENDOFWHOIS | End of WHOIS response | `{"command":"318","to":"alice","params":["bob"],"body":["End of /WHOIS list"]}` | +| `319` | RPL_WHOISCHANNELS | In response to WHOIS | `{"command":"319","to":"alice","params":["bob"],"body":["#general #dev"]}` | +| `338` | RPL_WHOISACTUALLY | In WHOIS when querier is oper | `{"command":"338","to":"alice","params":["bob","192.168.1.1"],"body":["is actually using host client.example.com"]}` | +| `322` | RPL_LIST | In response to LIST | `{"command":"322","to":"alice","params":["#general","5"],"body":["General discussion"]}` | +| `323` | RPL_LISTEND | End of LIST response | `{"command":"323","to":"alice","body":["End of /LIST"]}` | +| `324` | RPL_CHANNELMODEIS | In response to channel MODE query | `{"command":"324","to":"alice","params":["#general","+n"]}` | +| `329` | RPL_CREATIONTIME | After channel MODE query | `{"command":"329","to":"alice","params":["#general","1709251200"]}` | +| `331` | RPL_NOTOPIC | Channel has no topic (on JOIN) | `{"command":"331","to":"alice","params":["#general"],"body":["No topic is set"]}` | +| `332` | RPL_TOPIC | On JOIN or TOPIC query | `{"command":"332","to":"alice","params":["#general"],"body":["Welcome!"]}` | +| `352` | RPL_WHOREPLY | In response to WHO | `{"command":"352","to":"alice","params":["#general","bobident","host.example.com","neoirc","bob","H"],"body":["0 bob"]}` | +| `353` | RPL_NAMREPLY | On JOIN or NAMES query | `{"command":"353","to":"alice","params":["=","#general"],"body":["op1!op1@host1 alice!alice@host2 bob!bob@host3"]}` | +| `366` | RPL_ENDOFNAMES | End of NAMES response | `{"command":"366","to":"alice","params":["#general"],"body":["End of /NAMES list"]}` | +| `372` | RPL_MOTD | MOTD line | `{"command":"372","to":"alice","body":["Welcome to the server"]}` | +| `375` | RPL_MOTDSTART | Start of MOTD | `{"command":"375","to":"alice","body":["- neoirc-server Message of the Day -"]}` | +| `376` | RPL_ENDOFMOTD | End of MOTD | `{"command":"376","to":"alice","body":["End of /MOTD command"]}` | +| `381` | RPL_YOUREOPER | Successful OPER auth | `{"command":"381","to":"alice","body":["You are now an IRC operator"]}` | +| `401` | ERR_NOSUCHNICK | DM to nonexistent nick | `{"command":"401","to":"alice","params":["bob"],"body":["No such nick/channel"]}` | +| `403` | ERR_NOSUCHCHANNEL | Action on nonexistent channel | `{"command":"403","to":"alice","params":["#nope"],"body":["No such channel"]}` | +| `421` | ERR_UNKNOWNCOMMAND | Unrecognized command | `{"command":"421","to":"alice","params":["FOO"],"body":["Unknown command"]}` | +| `432` | ERR_ERRONEUSNICKNAME | Invalid nick format | `{"command":"432","to":"alice","params":["bad nick!"],"body":["Erroneous nickname"]}` | +| `433` | ERR_NICKNAMEINUSE | NICK to taken nick | `{"command":"433","to":"*","params":["alice"],"body":["Nickname is already in use"]}` | +| `442` | ERR_NOTONCHANNEL | Action on unjoined channel | `{"command":"442","to":"alice","params":["#general"],"body":["You're not on that channel"]}` | +| `461` | ERR_NEEDMOREPARAMS | Missing required fields | `{"command":"461","to":"alice","params":["JOIN"],"body":["Not enough parameters"]}` | +| `482` | ERR_CHANOPRIVSNEEDED | Non-op tries op action | `{"command":"482","to":"alice","params":["#general"],"body":["You're not channel operator"]}` | +| `491` | ERR_NOOPERHOST | Failed OPER auth | `{"command":"491","to":"alice","body":["No O-lines for your host"]}` | **Note:** Numeric replies are now implemented. All IRC command responses (success and error) are delivered as numeric replies through the message queue. @@ -1127,22 +1170,22 @@ carries IRC-style parameters (e.g., channel name, target nick). Inspired by IRC, simplified: -| 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 nick #channel` 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 | **Enforced** | -| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** | -| `+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** | +| 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 nick #channel` 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 | **Enforced** | +| `+s` | Secret | Channel hidden from LIST and WHOIS for non-members | **Enforced** | +| `+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** | **User channel modes (set per-user per-channel):** -| Mode | Meaning | Display prefix | Status | -|------|---------|----------------|--------| +| Mode | Meaning | Display prefix | Status | +| ---- | -------- | ------------------ | ------------ | | `+o` | Operator | `@` in NAMES reply | **Enforced** | | `+v` | Voice | `+` in NAMES reply | **Enforced** | @@ -1182,18 +1225,19 @@ MODE #channel +l 50 — set limit to 50 members MODE #channel -l — remove the limit ``` -**Secret (+s):** Hides the channel from `LIST` for non-members and from -`WHOIS` channel lists when the querier is not in the same channel. +**Secret (+s):** Hides the channel from `LIST` for non-members and from `WHOIS` +channel lists when the querier is not in the same channel. -**KICK command:** Channel operators can remove users with `KICK #channel nick -[:reason]`. The kicked user and all channel members receive the KICK message. +**KICK command:** Channel operators can remove users with +`KICK #channel nick [:reason]`. The kicked user and all channel members receive +the KICK message. **NOTICE:** Follows RFC 2812 — NOTICE never triggers auto-replies (including RPL_AWAY), and skips hashcash validation on +H channels (servers and services use NOTICE). -**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and -`CHANMODES=b,k,Hl,imnst` in RPL_ISUPPORT (005). +**ISUPPORT:** The server advertises `PREFIX=(ov)@+` and `CHANMODES=b,k,Hl,imnst` +in RPL_ISUPPORT (005). ### Per-Channel Hashcash (Anti-Spam) @@ -1223,12 +1267,12 @@ 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" - } + "command": "PRIVMSG", + "to": "#general", + "body": ["hello world"], + "meta": { + "hashcash": "1:20:260317:#general:a1b2c3...bodyhash:1f4a" + } } ``` @@ -1256,7 +1300,7 @@ All API responses include appropriate HTTP status codes. Error responses have the format: ```json -{"error": "human-readable error message"} +{ "error": "human-readable error message" } ``` ### POST /api/v1/session — Create Session @@ -1269,15 +1313,20 @@ 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:** + ```json -{"nick": "alice", "username": "alice", "pow_token": "1:20:260310:neoirc::3a2f1"} +{ + "nick": "alice", + "username": "alice", + "pow_token": "1:20:260310:neoirc::3a2f1" +} ``` -| Field | Type | Required | Constraints | -|------------|--------|-------------|-------------| -| `nick` | string | Yes | 1–32 characters, must be 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) | +| Field | Type | Required | Constraints | +| ----------- | ------ | ----------- | -------------------------------------------------------------- | +| `nick` | string | Yes | 1–32 characters, must be 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) | The `username` field sets the user portion of the IRC hostmask (`nick!user@host`). The hostname is automatically resolved via reverse DNS of @@ -1295,37 +1344,38 @@ Set-Cookie: neoirc_auth=494ba9fc...e3; Path=/; HttpOnly; SameSite=Strict ```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) | +| Field | Type | Description | +| ------ | ------- | -------------------------------------------------- | +| `id` | integer | Server-assigned user ID | +| `nick` | string | Confirmed nick (always matches request on success) | **Cookie properties:** -| Property | Value | -|------------|-------| -| `Name` | `neoirc_auth` | +| Property | Value | +| ---------- | --------------------------------------- | +| `Name` | `neoirc_auth` | | `HttpOnly` | `true` (not accessible from JavaScript) | -| `SameSite` | `Strict` (prevents CSRF) | -| `Secure` | `true` when behind TLS | -| `Path` | `/` | +| `SameSite` | `Strict` (prevents CSRF) | +| `Secure` | `true` (sent only over HTTPS) | +| `Path` | `/` | **Errors:** -| Status | Error | When | -|--------|-------|------| -| 400 | `nick must be 1-32 characters` | Empty or too-long nick | -| 400 | `invalid username format` | Username doesn't match allowed format | +| Status | Error | When | +| ------ | --------------------------------- | ------------------------------------------------------------------ | +| 400 | `nick must be 1-32 characters` | Empty or too-long 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 | +| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) | +| 409 | `nick already taken` | Another active session holds this nick | **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 \ @@ -1345,14 +1395,15 @@ state (JOIN + TOPIC + NAMES for each channel the session belongs to) into the new client's queue, so the client can immediately restore its UI state. **Request Body:** + ```json -{"nick": "alice", "password": "mypassword"} +{ "nick": "alice", "password": "mypassword" } ``` -| Field | Type | Required | Constraints | -|------------|--------|----------|-------------| +| 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 | +| `password` | string | Yes | Must match the session's password | **Response:** `200 OK` @@ -1360,24 +1411,25 @@ The response sets an `neoirc_auth` HttpOnly 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 | +| Field | Type | Description | +| ------ | ------- | ------------ | +| `id` | integer | Session ID | +| `nick` | string | Current nick | **Errors:** -| Status | Error | When | -|--------|-------|------| -| 400 | `nick and password required` | Missing nick or password | -| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set | +| Status | Error | When | +| ------ | ---------------------------- | -------------------------------------------------------------- | +| 400 | `nick and password required` | Missing nick or password | +| 401 | `invalid credentials` | Wrong password, nick not found, or session has no password set | **curl example:** + ```bash curl -s -c cookies.txt -X POST http://localhost:8080/api/v1/login \ -H 'Content-Type: application/json' \ @@ -1392,43 +1444,46 @@ Return the current user's session state. **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. | +| 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` + ```json { - "id": 1, - "nick": "alice", - "channels": [ - {"id": 1, "name": "#general", "topic": "Welcome!"}, - {"id": 2, "name": "#dev", "topic": ""} - ] + "id": 1, + "nick": "alice", + "channels": [ + { "id": 1, "name": "#general", "topic": "Welcome!" }, + { "id": 2, "name": "#dev", "topic": "" } + ] } ``` -| Field | Type | Description | -|------------|--------|-------------| -| `id` | integer | User ID | -| `nick` | string | Current nick | +| Field | Type | Description | +| ---------- | ------- | -------------------------------- | +| `id` | integer | User ID | +| `nick` | string | Current nick | | `channels` | array | Channels the user is a member of | Each channel object: -| Field | Type | Description | -|---------|---------|-------------| -| `id` | integer | Channel ID | -| `name` | string | Channel name (e.g., `#general`) | +| Field | Type | Description | +| ------- | ------- | ------------------------------------- | +| `id` | integer | Channel ID | +| `name` | string | Channel name (e.g., `#general`) | | `topic` | string | Channel topic (empty string if unset) | **curl example:** + ```bash curl -s http://localhost:8080/api/v1/state \ -b cookies.txt | jq . ``` **Reconnect with channel state initialization:** + ```bash curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \ -b cookies.txt | jq . @@ -1441,60 +1496,63 @@ real-time endpoint — clients call it in a loop. **Query Parameters:** -| Param | Type | Default | Description | -|-----------|---------|---------|-------------| +| 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`. | +| `timeout` | integer | `0` | Long-poll timeout in seconds. `0` = return immediately. Max `30`. Recommended: `15`. | **Response:** `200 OK` + ```json { - "messages": [ - { - "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "command": "JOIN", - "from": "bob", - "to": "#general", - "body": [], - "ts": "2026-02-10T20:00:00.000000000Z", - "meta": {} - }, - { - "id": "b7c8210f-849c-4b90-9ee8-d99c8889358e", - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["hello world"], - "ts": "2026-02-10T20:00:01.000000000Z", - "meta": {} - } - ], - "last_id": 42 + "messages": [ + { + "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "command": "JOIN", + "from": "bob", + "to": "#general", + "body": [], + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": {} + }, + { + "id": "b7c8210f-849c-4b90-9ee8-d99c8889358e", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["hello world"], + "ts": "2026-02-10T20:00:01.000000000Z", + "meta": {} + } + ], + "last_id": 42 } ``` -| Field | Type | Description | -|------------|---------|-------------| +| Field | Type | Description | +| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------- | | `messages` | array | Array of IRC message envelopes (see [Protocol Specification](#protocol-specification)). Empty array if no messages. | -| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. | +| `last_id` | integer | Queue cursor. Pass this as `after` in the next request. | **Long-poll behavior:** -1. If messages are immediately available (queue entries with ID > `after`), - the server responds instantly. +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 message arrives for this user (instantly via in-memory broker) + - The timeout expires (returns `{"messages":[], "last_id": }`) + - The client disconnects (no response) **curl example (immediate):** + ```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 . ``` @@ -1508,7 +1566,7 @@ part, nick, etc. **Request body:** An IRC message envelope with `command` and relevant fields: ```json -{"command": "PRIVMSG", "to": "#general", "body": ["hello world"]} +{ "command": "PRIVMSG", "to": "#general", "body": ["hello world"] } ``` See [Commands (C2S and S2C)](#commands-c2s-and-s2c) for the full command @@ -1516,121 +1574,122 @@ reference with all required and optional fields. **Command dispatch table:** -| Command | Required Fields | Optional | Response Status | -|-----------|---------------------|---------------|-----------------| -| `PRIVMSG` | `to`, `body` | `meta` | 200 OK | -| `NOTICE` | `to`, `body` | `meta` | 200 OK | -| `JOIN` | `to` | | 200 OK | -| `PART` | `to` | `body` | 200 OK | -| `NICK` | `body` | | 200 OK | -| `PASS` | `body` | | 200 OK | -| `TOPIC` | `to`, `body` | | 200 OK | -| `MODE` | `to` | | 200 OK | -| `NAMES` | `to` | | 200 OK | -| `LIST` | | | 200 OK | -| `WHOIS` | `to` or `body` | | 200 OK | -| `WHO` | `to` | | 200 OK | -| `LUSERS` | | | 200 OK | -| `OPER` | `body` | | 200 OK | -| `QUIT` | | `body` | 200 OK | -| `PING` | | | 200 OK | +| Command | Required Fields | Optional | Response Status | +| --------- | --------------- | -------- | --------------- | +| `PRIVMSG` | `to`, `body` | `meta` | 200 OK | +| `NOTICE` | `to`, `body` | `meta` | 200 OK | +| `JOIN` | `to` | | 200 OK | +| `PART` | `to` | `body` | 200 OK | +| `NICK` | `body` | | 200 OK | +| `PASS` | `body` | | 200 OK | +| `TOPIC` | `to`, `body` | | 200 OK | +| `MODE` | `to` | | 200 OK | +| `NAMES` | `to` | | 200 OK | +| `LIST` | | | 200 OK | +| `WHOIS` | `to` or `body` | | 200 OK | +| `WHO` | `to` | | 200 OK | +| `LUSERS` | | | 200 OK | +| `OPER` | `body` | | 200 OK | +| `QUIT` | | `body` | 200 OK | +| `PING` | | | 200 OK | -All IRC commands return HTTP 200 OK. IRC-level success and error responses -are delivered as **numeric replies** through the message queue (see +All IRC commands return HTTP 200 OK. IRC-level success and error responses are +delivered as **numeric replies** through the message queue (see [Numeric Replies](#numeric-replies) below). HTTP error codes (4xx/5xx) are reserved for transport-level problems: malformed JSON (400), missing/invalid auth cookies (401), and server errors (500). **HTTP errors (transport-level only):** -| Status | Error | When | -|--------|-------|------| +| Status | Error | When | +| ------ | ----------------- | ------------------------------- | | 400 | `invalid request` | Malformed JSON or empty command | -| 401 | `unauthorized` | Missing or invalid auth cookie | -| 500 | `internal error` | Server-side failure | +| 401 | `unauthorized` | Missing or invalid auth cookie | +| 500 | `internal error` | Server-side failure | **IRC numeric error replies (delivered via message queue):** -| Numeric | Name | When | -|---------|------|------| -| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist | -| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name | -| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command | -| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format | -| 433 | ERR_NICKNAMEINUSE | NICK target is taken | -| 442 | ERR_NOTONCHANNEL | Not a member of the target channel | -| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | -| 491 | ERR_NOOPERHOST | Failed OPER authentication | +| Numeric | Name | When | +| ------- | -------------------- | -------------------------------------------- | +| 401 | ERR_NOSUCHNICK | DM target nick doesn't exist | +| 403 | ERR_NOSUCHCHANNEL | Target channel doesn't exist or invalid name | +| 421 | ERR_UNKNOWNCOMMAND | Unrecognized command | +| 432 | ERR_ERRONEUSNICKNAME | Invalid nickname format | +| 433 | ERR_NICKNAMEINUSE | NICK target is taken | +| 442 | ERR_NOTONCHANNEL | Not a member of the target channel | +| 461 | ERR_NEEDMOREPARAMS | Missing required fields (to, body) | +| 491 | ERR_NOOPERHOST | Failed OPER authentication | **IRC numeric success replies (delivered via message queue):** -| Numeric | Name | When | -|---------|------|------| -| 001 | RPL_WELCOME | Sent on session creation/login | -| 002 | RPL_YOURHOST | Sent on session creation/login | -| 003 | RPL_CREATED | Sent on session creation/login | -| 004 | RPL_MYINFO | Sent on session creation/login | -| 005 | RPL_ISUPPORT | Sent on session creation/login | -| 221 | RPL_UMODEIS | In response to user MODE query | -| 251 | RPL_LUSERCLIENT | On connect or LUSERS command | -| 252 | RPL_LUSEROP | On connect or LUSERS command | -| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command | -| 255 | RPL_LUSERME | On connect or LUSERS command | -| 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 | -| 318 | RPL_ENDOFWHOIS | End of WHOIS list | -| 319 | RPL_WHOISCHANNELS | WHOIS channels list | -| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | -| 322 | RPL_LIST | Channel in LIST response | -| 323 | RPL_LISTEND | End of LIST | -| 324 | RPL_CHANNELMODEIS | Channel mode query response | -| 329 | RPL_CREATIONTIME | Channel creation timestamp | -| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) | -| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) | -| 352 | RPL_WHOREPLY | User in WHO response | -| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) | -| 366 | RPL_ENDOFNAMES | End of NAMES list | -| 375 | RPL_MOTDSTART | Start of MOTD | -| 372 | RPL_MOTD | MOTD line | -| 376 | RPL_ENDOFMOTD | End of MOTD | -| 381 | RPL_YOUREOPER | Successful OPER authentication | +| Numeric | Name | When | +| ------- | ----------------- | ------------------------------------ | +| 001 | RPL_WELCOME | Sent on session creation/login | +| 002 | RPL_YOURHOST | Sent on session creation/login | +| 003 | RPL_CREATED | Sent on session creation/login | +| 004 | RPL_MYINFO | Sent on session creation/login | +| 005 | RPL_ISUPPORT | Sent on session creation/login | +| 221 | RPL_UMODEIS | In response to user MODE query | +| 251 | RPL_LUSERCLIENT | On connect or LUSERS command | +| 252 | RPL_LUSEROP | On connect or LUSERS command | +| 254 | RPL_LUSERCHANNELS | On connect or LUSERS command | +| 255 | RPL_LUSERME | On connect or LUSERS command | +| 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 | +| 318 | RPL_ENDOFWHOIS | End of WHOIS list | +| 319 | RPL_WHOISCHANNELS | WHOIS channels list | +| 338 | RPL_WHOISACTUALLY | WHOIS client IP (oper-only) | +| 322 | RPL_LIST | Channel in LIST response | +| 323 | RPL_LISTEND | End of LIST | +| 324 | RPL_CHANNELMODEIS | Channel mode query response | +| 329 | RPL_CREATIONTIME | Channel creation timestamp | +| 331 | RPL_NOTOPIC | Channel has no topic (on JOIN) | +| 332 | RPL_TOPIC | Channel topic (on JOIN, TOPIC set) | +| 352 | RPL_WHOREPLY | User in WHO response | +| 353 | RPL_NAMREPLY | Channel member list (on JOIN, NAMES) | +| 366 | RPL_ENDOFNAMES | End of NAMES list | +| 375 | RPL_MOTDSTART | Start of MOTD | +| 372 | RPL_MOTD | MOTD line | +| 376 | RPL_ENDOFMOTD | End of MOTD | +| 381 | RPL_YOUREOPER | Successful OPER authentication | ### GET /api/v1/history — Message History -Fetch historical messages for a channel. Returns messages in chronological -order (oldest first). +Fetch historical messages for a channel. Returns messages in chronological order +(oldest first). **Query Parameters:** -| Param | Type | Default | Description | -|----------|---------|---------|-------------| -| `target` | string | (required) | Channel name (e.g., `#general`) | -| `before` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. | -| `limit` | integer | `50` | Maximum messages to return. | +| Param | Type | Default | Description | +| -------- | ------- | ---------- | -------------------------------------------------------------------------------- | +| `target` | string | (required) | Channel name (e.g., `#general`) | +| `before` | integer | `0` | Return only messages with DB ID < this value (for pagination). `0` means latest. | +| `limit` | integer | `50` | Maximum messages to return. | **Response:** `200 OK` + ```json [ - { - "id": "uuid-1", - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["first message"], - "ts": "2026-02-10T19:00:00.000000000Z", - "meta": {} - }, - { - "id": "uuid-2", - "command": "PRIVMSG", - "from": "bob", - "to": "#general", - "body": ["second message"], - "ts": "2026-02-10T19:01:00.000000000Z", - "meta": {} - } + { + "id": "uuid-1", + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["first message"], + "ts": "2026-02-10T19:00:00.000000000Z", + "meta": {} + }, + { + "id": "uuid-2", + "command": "PRIVMSG", + "from": "bob", + "to": "#general", + "body": ["second message"], + "ts": "2026-02-10T19:01:00.000000000Z", + "meta": {} + } ] ``` @@ -1638,6 +1697,7 @@ order (oldest first). events). Event messages are delivered via the live queue only. **curl example:** + ```bash # Latest 50 messages in #general curl -s "http://localhost:8080/api/v1/history?target=%23general&limit=50" \ @@ -1653,10 +1713,11 @@ curl -s "http://localhost:8080/api/v1/history?target=%23general&before=100&limit List all channels on the server. **Response:** `200 OK` + ```json [ - {"id": 1, "name": "#general", "topic": "Welcome!"}, - {"id": 2, "name": "#dev", "topic": "Development discussion"} + { "id": 1, "name": "#general", "topic": "Welcome!" }, + { "id": 2, "name": "#dev", "topic": "Development discussion" } ] ``` @@ -1666,14 +1727,16 @@ List members of a channel. The `{name}` parameter is the channel name **without** the `#` prefix (it's added by the server). **Response:** `200 OK` + ```json [ - {"id": 1, "nick": "alice", "lastSeen": "2026-02-10T20:00:00Z"}, - {"id": 2, "nick": "bob", "lastSeen": "2026-02-10T19:55:00Z"} + { "id": 1, "nick": "alice", "lastSeen": "2026-02-10T20:00:00Z" }, + { "id": 2, "nick": "bob", "lastSeen": "2026-02-10T19:55:00Z" } ] ``` **curl example:** + ```bash curl -s http://localhost:8080/api/v1/channels/general/members \ -b cookies.txt | jq . @@ -1681,10 +1744,10 @@ curl -s http://localhost:8080/api/v1/channels/general/members \ ### POST /api/v1/logout — Logout -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. +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. **Request:** No body. Requires auth cookie. @@ -1693,16 +1756,17 @@ nick released. The auth cookie is cleared in the response. The response clears the `neoirc_auth` cookie. ```json -{"status": "ok"} +{ "status": "ok" } ``` **Errors:** -| Status | Error | When | -|--------|-------|------| +| Status | Error | When | +| ------ | -------------- | ------------------------------ | | 401 | `unauthorized` | 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 . ``` @@ -1715,17 +1779,17 @@ Return the current user's session state. This is an alias for **Request:** No body. Requires auth. **Response:** `200 OK` + ```json { - "id": 1, - "nick": "alice", - "channels": [ - {"id": 1, "name": "#general", "topic": "Welcome!"} - ] + "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 . @@ -1736,22 +1800,23 @@ curl -s http://localhost:8080/api/v1/users/me \ Return server metadata. No authentication required. **Response:** `200 OK` + ```json { - "name": "My NeoIRC Server", - "version": "0.1.0", - "motd": "Welcome! Be nice.", - "users": 42, - "hashcash_bits": 20 + "name": "My NeoIRC Server", + "version": "0.1.0", + "motd": "Welcome! Be nice.", + "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 | +| 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). | ### GET /.well-known/healthcheck.json — Health Check @@ -1763,31 +1828,31 @@ health status and runtime statistics. ```json { - "status": "ok", - "now": "2024-01-15T12:00:00.000000000Z", - "uptimeSeconds": 3600, - "uptimeHuman": "1h0m0s", - "version": "0.1.0", - "appname": "neoirc", - "maintenanceMode": false, - "sessions": 42, - "clients": 85, - "queuedLines": 128, - "channels": 7, - "connectionsSinceBoot": 200, - "sessionsSinceBoot": 150, - "messagesSinceBoot": 5000 + "status": "ok", + "now": "2024-01-15T12:00:00.000000000Z", + "uptimeSeconds": 3600, + "uptimeHuman": "1h0m0s", + "version": "0.1.0", + "appname": "neoirc", + "maintenanceMode": false, + "sessions": 42, + "clients": 85, + "queuedLines": 128, + "channels": 7, + "connectionsSinceBoot": 200, + "sessionsSinceBoot": 150, + "messagesSinceBoot": 5000 } ``` -| 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` | Total client connections since server start | -| `sessionsSinceBoot` | Total sessions created since server start | +| 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` | Total client connections since server start | +| `sessionsSinceBoot` | Total sessions created since server start | | `messagesSinceBoot` | Total PRIVMSG/NOTICE messages sent since server start | --- @@ -1876,69 +1941,76 @@ 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 +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: + ```json { - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["hello"], - "id": "abc-123", - "ts": "2026-02-10T20:00:00Z", - "meta": {"alg": "ed25519"} + "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). +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"}} - ``` + ```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") +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 +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"}} +{ + "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 +- 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 @@ -1946,16 +2018,16 @@ under canonicalization (a bare string `hello` is not valid JSON, and ```json { - "command": "PRIVMSG", - "from": "alice", - "to": "#general", - "body": ["this message is signed"], - "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", - "ts": "2026-02-10T20:00:00.000000000Z", - "meta": { - "alg": "ed25519", - "sig": "base64url-encoded-64-byte-signature" - } + "command": "PRIVMSG", + "from": "alice", + "to": "#general", + "body": ["this message is signed"], + "id": "7f5a04f8-eab4-4d2e-be55-f5cfcfaf43c5", + "ts": "2026-02-10T20:00:00.000000000Z", + "meta": { + "alg": "ed25519", + "sig": "base64url-encoded-64-byte-signature" + } } ``` @@ -1967,24 +2039,23 @@ under canonicalization (a bare string `hello` is not valid JSON, and 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. +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. ### Authentication - **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 when behind TLS. + (CSRF protection), and Secure (sent only over HTTPS). - **Anonymous sessions**: `POST /api/v1/session` requires only a nick. No password, instant access. 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 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`. + 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. @@ -2002,22 +2073,22 @@ authenticity. ### Key Management -- **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. +- **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. ### DM Privacy -- **DMs are not end-to-end encrypted** in the current implementation. The - server can read DM content. E2E encryption for DMs is planned (see +- **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. +- **DMs are stored** in the messages table, subject to the same rotation policy + as channel messages. ### Transport Security @@ -2026,12 +2097,12 @@ authenticity. termination. - **CORS**: The server allows all origins with credentials (`Access-Control-Allow-Credentials: true`), reflecting the request Origin. - This enables cookie-based auth from cross-origin clients. Restrict origins - in production via reverse proxy configuration if needed. + This enables cookie-based auth from cross-origin clients. 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. + 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. --- @@ -2077,12 +2148,12 @@ POST /api/v1/federation/relay Key properties: -- **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. +- **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. + (`nick@server`) to prevent collisions. Within a single server, bare nicks are + used. ### State Synchronization @@ -2097,14 +2168,14 @@ This mirrors IRC's server burst protocol. ### S2S Commands -| Command | Description | -|----------|-------------| +| Command | Description | +| -------- | ---------------------------------- | | `RELAY` | Relay a message from a remote user | -| `LINK` | Establish server link | -| `UNLINK` | Tear down server link | +| `LINK` | Establish server link | +| `UNLINK` | Tear down server link | | `SYNC` | Request full state synchronization | -| `PING` | Inter-server keepalive | -| `PONG` | Inter-server keepalive response | +| `PING` | Inter-server keepalive | +| `PONG` | Inter-server keepalive response | ### Federation Endpoints @@ -2135,140 +2206,145 @@ The database schema is managed via embedded SQL migration files in **Current tables:** #### `sessions` -| 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 hostname of the connecting client IP | -| `ip` | TEXT | Real IP address of the session creator | -| `is_oper` | INTEGER | Server operator (o-line) status (0 = no, 1 = yes) | -| `password_hash`| TEXT | bcrypt hash (empty string for anonymous sessions) | -| `signing_key` | TEXT | Public signing key (empty string if unset) | -| `away_message` | TEXT | Away message (empty string if not away) | -| `created_at` | DATETIME | Session creation time | -| `last_seen` | DATETIME | Last API request time | + +| 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 hostname of the connecting client IP | +| `ip` | TEXT | Real IP address of the session creator | +| `is_oper` | INTEGER | Server operator (o-line) status (0 = no, 1 = yes) | +| `password_hash` | TEXT | bcrypt hash (empty string for anonymous sessions) | +| `signing_key` | TEXT | Public signing key (empty string if unset) | +| `away_message` | TEXT | Away message (empty string if not away) | +| `created_at` | DATETIME | Session creation time | +| `last_seen` | DATETIME | Last API request time | 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) | + +| 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 hostname of this client connection | -| `created_at` | DATETIME | Client creation time | -| `last_seen` | DATETIME | Last API request time | +| `ip` | TEXT | Real IP address of this client connection | +| `hostname` | TEXT | Reverse DNS hostname of this client connection | +| `created_at` | DATETIME | Client creation time | +| `last_seen` | DATETIME | Last API request time | 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 | -| `created_at` | DATETIME | Channel creation time | -| `updated_at` | DATETIME | Last modification time | + +| 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 | +| `created_at` | DATETIME | Channel creation time | +| `updated_at` | DATETIME | Last modification time | #### `channel_members` -| Column | Type | Description | -|--------------|----------|-------------| -| `id` | INTEGER | Primary key (auto-increment) | + +| 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) | -| `joined_at` | DATETIME | When the user joined | +| `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) | -| `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 | + +| 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) | +| `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 `(msg_to, id)` and `(created_at)`. #### `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 | + +| 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). +`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). +- **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 — `handleQuit` and `cleanupUser` both call - `DeleteSession` unconditionally. Idle sessions are automatically expired - after `SESSION_IDLE_TIMEOUT` - (default 30 days) — the server runs a background cleanup loop that parts - idle users from all channels, broadcasts QUIT, and releases their nicks. -- **Clients**: Individual client 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. +- **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 — `handleQuit` and `cleanupUser` both call + `DeleteSession` unconditionally. Idle sessions are automatically expired after + `SESSION_IDLE_TIMEOUT` (default 30 days) — the server runs a background + cleanup loop that parts idle users from all channels, broadcasts QUIT, and + releases their nicks. +- **Clients**: Individual client 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. --- ## Configuration All configuration is via 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). +[Viper](https://github.com/spf13/viper). A `.env` file in the working directory +is also loaded automatically via [godotenv](https://github.com/joho/godotenv). -| Variable | Type | Default | Description | -|--------------------|---------|--------------------------------------|-------------| -| `PORT` | int | `8080` | HTTP listen port | -| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | -| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | -| `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` | Session idle timeout as a Go duration string (e.g. `720h`, `24h`). Sessions with no activity for this long are expired and the nick is released. 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 message body size in bytes (planned enforcement) | -| `LONG_POLL_TIMEOUT`| int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | -| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | -| `SERVER_NAME` | string | `""` | Server display name. Defaults to hostname if 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 to empty string to disable. | -| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | +| Variable | Type | Default | Description | +| ---------------------- | ------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | int | `8080` | HTTP listen port | +| `DBURL` | string | `file:///var/lib/neoirc/state.db?_journal_mode=WAL` | SQLite connection string. For file-based: `file:///path/to/db.db?_journal_mode=WAL`. For in-memory (testing): `file::memory:?cache=shared`. | +| `DEBUG` | bool | `false` | Enable debug logging (verbose request/response logging) | +| `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` | Session idle timeout as a Go duration string (e.g. `720h`, `24h`). Sessions with no activity for this long are expired and the nick is released. 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 message body size in bytes (planned enforcement) | +| `LONG_POLL_TIMEOUT` | int | `15` | Default long-poll timeout in seconds (client can override via query param, server caps at 30) | +| `MOTD` | string | `""` | Message of the day, shown to clients via `GET /api/v1/server` | +| `SERVER_NAME` | string | `""` | Server display name. Defaults to hostname if 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 to empty string to disable. | +| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) | ### Example `.env` file @@ -2302,13 +2378,13 @@ IRC_LISTEN_ADDR= ### Supported Commands -| Category | Commands | -|------------|------------------------------------------------------| -| Connection | `NICK`, `USER`, `PASS`, `QUIT`, `PING`/`PONG`, `CAP` | +| 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` | -| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | +| Messaging | `PRIVMSG`, `NOTICE` | +| Info | `WHO`, `WHOIS`, `LUSERS`, `MOTD`, `AWAY` | +| Operator | `OPER` (requires `NEOIRC_OPER_NAME` and `NEOIRC_OPER_PASSWORD`) | ### Protocol Details @@ -2322,15 +2398,15 @@ IRC_LISTEN_ADDR= automatically prepended. - **First joiner**: The first user to join a channel is automatically granted operator status (`@`). -- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), - `+v` (voice) +- **Channel modes**: `+m` (moderated), `+t` (topic lock), `+o` (operator), `+v` + (voice) ### 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. A user connected via IRC and a user -connected via the HTTP API can communicate in the same channels seamlessly. +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. A user connected via IRC and a user connected via +the HTTP API can communicate in the same channels seamlessly. ### Docker Usage @@ -2347,6 +2423,34 @@ docker run -d \ --- +## 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`. + +- `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) @@ -2355,7 +2459,7 @@ The Docker image contains a single static binary (`neoircd`) and nothing else. ```bash # Build -docker build -t neoirc . +make docker # Run docker run -p 8080:8080 \ @@ -2365,16 +2469,19 @@ docker run -p 8080:8080 \ neoirc ``` -The Dockerfile is a four-stage build: +The Dockerfile is a five-stage build: + 1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX → bundled JS via esbuild) into `web/dist/` -2. **lint**: Runs formatting checks and golangci-lint against the Go source - (uses empty placeholder files for `web/dist/` so it runs independently of - web-builder for fast feedback) -3. **builder**: Runs tests and 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) -4. **final**: Minimal Alpine image with only the `neoircd` binary +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 only the `neoircd` binary ### Binary @@ -2393,6 +2500,7 @@ make build For production, run behind a TLS-terminating reverse proxy. **Caddy:** + ``` neoirc.example.com { reverse_proxy localhost:8080 @@ -2400,6 +2508,7 @@ neoirc.example.com { ``` **nginx:** + ```nginx server { listen 443 ssl; @@ -2426,9 +2535,11 @@ seconds to accommodate long-poll connections. string). This allows concurrent reads during writes. - **Single writer**: SQLite allows only one writer at a time. For high-traffic servers, Postgres support is planned. -- **Backup**: The database is a single file. Back it up with `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file (safe with WAL mode). -- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. - Use the `DBURL` env var to place it elsewhere. +- **Backup**: The database is a single file. Back it up with + `sqlite3 /var/lib/neoirc/state.db ".backup backup.db"` or just copy the file + (safe with WAL mode). +- **Location**: By default, `state.db` is created in `/var/lib/neoirc/`. Use the + `DBURL` env var to place it elsewhere. --- @@ -2551,23 +2662,25 @@ while True: ```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 + 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 + } } - } } ``` @@ -2575,21 +2688,21 @@ async function pollLoop() { 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` | +| 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 401**: Auth cookie expired or invalid. Re-create session or re-login + (if a password was set). - **HTTP 404**: Channel or user not found. - **HTTP 409**: Nick already taken (on session creation or NICK change). - **HTTP 400**: Malformed request. Check the `error` field in the response. @@ -2602,17 +2715,17 @@ Clients should handle these message commands from the queue: 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`. +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. + 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. --- @@ -2629,18 +2742,17 @@ account registration, no IP-based rate limits that punish shared networks. 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`. +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 within 48 hours (not expired, not too far in the future) - - SHA-256 hash has the required leading zero bits - - Stamp has not been used before (replay prevention) + - Version is `1` + - Claimed bits ≥ required bits + - Resource matches the server name + - Date is within 48 hours (not expired, not too far in the future) + - SHA-256 hash has the required leading zero bits + - Stamp has not been used before (replay prevention) ### Stamp Format @@ -2650,14 +2762,14 @@ 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) | +| 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 | +| (empty) | Extension field (unused) | +| `counter` | Hex counter value found by the client to satisfy the PoW | **Example stamp:** `1:20:260310:neoirc::3a2f1b` @@ -2689,7 +2801,8 @@ 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` +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`. @@ -2698,13 +2811,13 @@ computation with batched parallelism. The CLI client uses Go's `crypto/sha256`. 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 | +| 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 @@ -2712,17 +2825,17 @@ creating one session pays once and keeps their session. ### Why Hashcash and Not Rate Limits? -- **No state to track**: No IP tables, no token buckets, no sliding windows. - The server only needs to verify a single hash. +- **No state to track**: No IP tables, no token buckets, no sliding windows. The + server only needs to verify a single hash. - **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 (one SHA-256 hash) regardless of difficulty. 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. +- **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 @@ -2730,14 +2843,14 @@ 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 | +| 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. +`Retry-After: 1` header. Stale per-IP entries are automatically cleaned up every +10 minutes. > **⚠️ Security: Reverse Proxy Required for Production Use** > @@ -2796,10 +2909,13 @@ guess is borne by the server (bcrypt), not the client. ### 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] **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), `+n` (no external) -- [x] **Channel modes (tier 2)** — enforce `+i` (invite-only), `+s` (secret), `+b` (ban), `+k` (key), `+l` (limit) +- [x] **Channel modes** — enforce `+m` (moderated), `+t` (topic lock), `+n` (no + external) +- [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 @@ -2812,32 +2928,32 @@ guess is borne by the server (bcrypt), not the client. - [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-461 errors) + (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-461 errors) - [ ] **Max message size enforcement** — reject oversized messages - [ ] **NOTICE command** — distinct from PRIVMSG (no auto-reply flag) -- [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] **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 ### 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 +- [ ] **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 + 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 +- [ ] **Connection flood protection** — per-IP connection limits as a complement + to hashcash - [ ] **Invite system** — `INVITE` command for `+i` channels - [ ] **Ban system** — channel-level bans by nick pattern @@ -2845,7 +2961,8 @@ guess is borne by the server (bcrypt), not the client. ## Project Structure -Following [gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md): +Following +[gohttpserver CONVENTIONS.md](https://git.eeqj.de/sneak/gohttpserver/src/branch/main/CONVENTIONS.md): ``` neoirc/ @@ -2900,8 +3017,10 @@ neoirc/ │ │ └── style.css │ └── dist/ # Generated at Docker build time (not committed) ├── schema/ # JSON Schema definitions (planned) +├── 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 @@ -2910,18 +3029,18 @@ neoirc/ ### Required Libraries -| Purpose | Library | -|------------|---------| -| DI | `go.uber.org/fx` | -| Router | `github.com/go-chi/chi` | -| Logging | `log/slog` (stdlib) | -| Config | `github.com/spf13/viper` | -| Env | `github.com/joho/godotenv/autoload` | -| CORS | `github.com/go-chi/cors` | -| Metrics | `github.com/prometheus/client_golang` | -| DB | `modernc.org/sqlite` + `database/sql` | -| UUIDs | `github.com/google/uuid` | -| Errors | `github.com/getsentry/sentry-go` (optional) | +| Purpose | Library | +| ---------- | ------------------------------------------------------- | +| DI | `go.uber.org/fx` | +| Router | `github.com/go-chi/chi` | +| Logging | `log/slog` (stdlib) | +| Config | `github.com/spf13/viper` | +| Env | `github.com/joho/godotenv/autoload` | +| CORS | `github.com/go-chi/cors` | +| Metrics | `github.com/prometheus/client_golang` | +| DB | `modernc.org/sqlite` + `database/sql` | +| UUIDs | `github.com/google/uuid` | +| Errors | `github.com/getsentry/sentry-go` (optional) | | TUI Client | `github.com/rivo/tview` + `github.com/gdamore/tcell/v2` | --- @@ -2933,16 +3052,15 @@ neoirc/ 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. + 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. +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 raw TCP, no protocol negotiation. HTTP is universal, proxy-friendly, CDN-friendly, and works on @@ -2951,8 +3069,8 @@ neoirc/ 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. + 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 @@ -2964,25 +3082,25 @@ neoirc/ 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. + 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. + 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. + 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. + 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. --- @@ -3010,4 +3128,3 @@ MIT ## Author [@sneak](https://sneak.berlin) - diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md index 8478553..20382d1 100644 --- a/REPO_POLICIES.md +++ b/REPO_POLICIES.md @@ -1,6 +1,6 @@ --- title: Repository Policies -last_modified: 2026-03-09 +last_modified: 2026-10-04 --- This document covers repository structure, tooling, and workflow standards. Code @@ -34,10 +34,57 @@ style conventions are in separate documents: every file before committing. There are zero exceptions to this rule. - Every repo with software must have a root `Makefile` with these targets: - `make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only), - `make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and - `make hooks` (installs pre-commit hook). A model Makefile is at - `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. + `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), + `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`), + `make docker`, and `make hooks` (installs pre-commit hook). A model Makefile + is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. + +- Repos follow the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + pattern: the implementation of each Makefile target lives in an executable + script in `script/` (`script/bootstrap`, `script/setup`, `script/test`, + `script/lint`, `script/fmt`, `script/fmt-check`, `script/check`, + `script/docker`), and the Makefile targets are thin shims that call them. The + scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in + minimal containers (e.g. alpine images have no bash); locate the repo root + with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From + the standard's canonical set we use `bootstrap`, `setup` (make the repo ready + for development after a fresh clone: runs `bootstrap`, then + `install-precommit`, plus any repo-specific initialization), `test`, and + `cibuild`. `script/bootstrap` installs all dependencies idempotently and + assumes nothing is present: base tools come from nix, apt, brew, or apk + (detected in that order; apt runs noninteractive). For node it uses the + installed node if present; otherwise it installs a PINNED node version via + nvm, first installing nvm itself if missing — from a hash-verified GitHub + release archive (never `curl | sh`), with bash installed as an explicit + prerequisite since nvm requires bash. yarn is then pinned via + `corepack prepare yarn@ --activate`. Never install "latest" or "lts"; + always exact versions. `script/cibuild` runs the CI build: it changes to the + repo root, runs `script/bootstrap`, runs `script/check`, and builds the image + with the version; the Gitea workflow calls it. **`script/cibuild` runs + `script/bootstrap` first**, because the workflow checks out the repo and runs + nothing else, while `script/fmt-check` runs the formatter on the host: on a + pristine checkout with nothing installed the run dies there, after the + containerised gates have passed. **The bootstrap alone is not enough**: + `script/bootstrap` installs node and yarn under nvm and leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host + entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore + source nvm for the pinned node version before invoking it, exactly as + `script/bootstrap`'s own install step does. A runner carrying nothing but + docker and git then gets through `script/check`. Four further scripts are our + own extensions to the standard: `script/check` runs `script/test`, + `script/lint` and `script/fmt-check`; `script/precommit` is what the git + pre-commit hook runs, and it calls `script/check`; `script/install-precommit` + installs the git pre-commit hook (the `make hooks` target shims to it); and + `script/projectname` (literally that filename) simply outputs the project's + name. Scripts that need the name call `script/projectname` — e.g. + `script/docker` assembles its image tag from it — so those scripts stay + byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. + `go mod tidy` verification in Go repos) belong in `script/precommit`, not in + the hook itself. Model scripts are at + `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/`. The README + must document the provided scripts in an **Entrypoints** section (see the + README requirements below). - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) instead of invoking the underlying tools directly. The Makefile is the single @@ -53,15 +100,198 @@ style conventions are in separate documents: contributor should be able to understand the entire development workflow by reading the Makefile. -- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check` - as a build step so the build fails if the branch is not green. For non-server - repos, the Dockerfile should bring up a development environment and run - `make check`. For server repos, `make check` should run as an early build - stage before the final image is assembled. +- Every repo should have a `Dockerfile`, and it carries the repo's gates: a + `lint` phase and a `test` phase, with the final stage depending on both so the + image cannot be built unless they pass. For non-server repos the final stage + brings up a development environment; for server repos it is the runtime image. + The gate phases and the build stage start from their pinned base images and + install what those images lack either inline, as the canonical Go `Dockerfile` + below does for `git`, or by running `script/bootstrap`, as the `prompts` + repo's own `Dockerfile` does for its yarn packages. The development + environment stage installs development prerequisites by running + `script/bootstrap` rather than duplicating its installs inline. A stage that + runs `script/bootstrap` COPYs `script/` and the dependency manifests + (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it. + +- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is + no separate lint file. `script/lint` and `script/test` each build one phase + and nothing else: + + ```sh + docker build --no-cache --target lint -t "$(script/projectname)-lint" . + docker build --no-cache --target test -t "$(script/projectname)-test" . + ``` + + **A stage that is not the last one in the file is built only when the final + stage's chain depends on it, or when `--target` names it.** That is why the + two gates are always invoked by name here, and why the final stage carries a + `COPY --from=` of a harmless file from each of them: without that edge a + plain `docker build .` builds the last stage alone and exits 0 having linted + and tested nothing. + + **Every `docker build` in `script/` is tagged**, here and in + `script/cibuild` and `script/docker`. An untagged build leaves a dangling + image behind on every invocation, on every developer host and every CI + runner; a tagged one replaces the previous image. + + Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, + `eslint`, `prettier` — never through `make lint` or `script/test`, which are + themselves a `docker build` and would recurse into a daemon that does not + exist in a build step. Formatting is the exception and stays on the host: + `script/fmt` writes the working tree, and `script/fmt-check` is its + read-only twin. + + **No lint verdict may come from a host invocation of the linter.** On a + shared host golangci-lint reads a result cache keyed on file content rather + than location, so a second checkout of the same content is served the first + one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs + exit non-zero with `parallel golangci-lint is running` — a status a caller + cannot tell from real findings. Both have produced wrong verdicts in this + org, in both directions. A container has its own cache, its own `TMPDIR` and + a digest-pinned binary, so neither is reachable. + +- **Any build that runs checks is built with `--no-cache`.** Docker invalidates + a `COPY` layer only when the copied content changes, so on an unchanged tree + the check `RUN` is served from cache, nothing executes, and the build still + exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: + `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the + four, and there is no fifth — `script/check` runs the two gate phases and + `script/fmt-check`, and builds no image of its own. A bare `docker build .` is + not evidence that anything ran: a sub-second build reporting success is a + cache hit, not a result. Never invalidate by pruning — `docker builder prune` + and friends destroy a build cache shared with every other build on the host. + When a check is added or changed, prove it works by planting a defect it must + catch and watching the run fail on it, then revert the defect. A green run + alone shows neither that the check ran nor that it covers what it should. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Debian Go image. The canonical Go repo + `Dockerfile`: + + ```dockerfile + # Lint phase + # golangci/golangci-lint:v2.x.x, YYYY-MM-DD + FROM golangci/golangci-lint@sha256:... AS lint + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN golangci-lint run --config .golangci.yml ./... + + # Test phase. -race needs cgo and so a C compiler, which the Debian Go + # image ships and the alpine one does not. + # golang:1.x, YYYY-MM-DD + FROM golang@sha256:... AS test + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } + + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so this stage cannot run + # unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + RUN apk add --no-cache git + # A tar-stream context keeps the sender's file owners, which git refuses. + RUN git config --system --add safe.directory /src + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + + # The VERSION build arg when one is given, otherwise + # `git describe --tags --always` on the .git in the build context. With + # .git present, a version that is still empty, dev or unknown fails the + # build: git is missing or could not read the checkout. + ARG VERSION + RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ + if [ -e .git ]; then \ + case "$VERSION" in ""|dev|unknown) \ + echo "version is '$VERSION' although .git is present" >&2; \ + exit 1 ;; \ + esac; \ + fi; \ + CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage, and the last one + FROM alpine@sha256:... + COPY --from=builder /app /usr/local/bin/app + ENTRYPOINT ["app"] + ``` + + Key points: + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only + purpose is the ordering edge. BuildKit runs stages in parallel by default, + and a stage nothing depends on is not built at all, so without these two + lines a red gate would not fail the build. + - Keep the runtime stage last, and if you add a stage after it, give it the + same two copies. A plain `docker build .` builds the last stage's chain + and nothing else. + - If the project uses `//go:embed` directives that reference build artifacts + (e.g. a web frontend compiled in a separate stage), the lint phase must + create placeholder files so the embed directives resolve. Example: + `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. + - If the project requires CGO or system libraries for linting, install them + in the lint phase. The `golangci/golangci-lint` image is Debian-based and + has no `apk`, so install with `apt-get` under the Debian package name + (`libvips-dev`, where alpine says `vips-dev`), and delete the package + lists in the same `RUN`, so the layer does not keep them: + + ```dockerfile + RUN apt-get update \ + && apt-get install -y --no-install-recommends libvips-dev \ + && rm -rf /var/lib/apt/lists/* + ``` + + - `.dockerignore` lets `.git` into the build context. It keeps out every git + `config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the + repository's own, each submodule's under `.git/modules/`, and that of a + submodule keeping its own `.git` directory. `git describe` does not need + them, and each can hold a credential: a password in a remote URL, or the + token the CI checkout step stores there. A submodule whose name has a + `config` segment (`config`, `deploy/config`, `config/lib`) loses its whole + git directory to `**/.git/modules/**/config`, and Go's version stamping + then fails the build: give it a name without that segment + (`git submodule add --name`). The stage that compiles has `git` (the + Debian Go image has it; an alpine one needs `apk add --no-cache git`) and + takes the version from the `VERSION` build argument when one is given, + otherwise from `git describe --tags --always`. That gives the tag on a + tagged commit; on a later commit, the tag, the number of commits since it + and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no + tag is reachable. The stage that compiles also marks its working directory + safe for git (`git config --system --add safe.directory /src`): a context + sent as a tar stream keeps the sender's file owners, and git refuses a + checkout owned by another user, so the version would come out empty. + `ARG VERSION` has no default, and the build fails if the context carries + `.git` and the version still comes out empty, `dev` or `unknown`. A plain + `docker build .` with no build arguments must succeed; a Dockerfile that + refuses an empty build argument drops that refusal and keeps the argument. - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - runs `docker build .` on push. Since the Dockerfile already runs `make check`, - a successful build implies all checks pass. + runs `script/cibuild` on push, and checks out the repo as its only other step. + That script bootstraps, runs the gate phases, and then builds the image, so a + successful run means every check passed; a bare `docker build .` does not + carry the same guarantee, because its gate phases may come from the cache. The + image build is uncached and so runs the gate phases a second time. That is the + price of the rule above, and it is worth paying: the image that ships is built + from a run of its own gates rather than from a cache entry. A separate + workflow limited to `main` by a `branches` list under `on: push` cannot be + checked by review: to try a change to it, add the feature branch to that list + and push, then remove the branch from the list again before merging. Keep any + job in it that publishes behind `if: github.ref_name == 'main'`, so the run + from the feature branch publishes nothing. - Use platform-standard formatters: `black` for Python, `prettier` for JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with @@ -69,9 +299,11 @@ style conventions are in separate documents: Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, HTML, CSS) should also have `.prettierrc` and `.prettierignore`. -- Pre-commit hook: `make check` if local testing is possible, otherwise - `make lint && make fmt-check`. The Makefile should provide a `make hooks` - target to install the pre-commit hook. +- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local + testing is not possible in the repo, `script/precommit` may skip `script/test` + and run only `script/lint` and `script/fmt-check`. The hook is installed by + `script/install-precommit`; the Makefile must provide a `make hooks` target + that shims to it. - All repos with software must have tests that run via the platform-standard test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful @@ -79,8 +311,66 @@ style conventions are in separate documents: module under test to verify it compiles/parses. There is no excuse for `make test` to be a no-op. -- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - Makefile. +- `make test` must complete in under 60 seconds. That is the hard cap, and a + suite that exceeds it fails. Under 20 seconds is the target. A suite between + 20 and 60 seconds is still green, but the overage must be filed as an + improvement bug against that repo. Add a 90-second timeout to the test + invocation (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely slow + one. + +- **The test command should use the conditional verbose rerun pattern.** Run + tests without `-v` (verbose) first. If tests fail, automatically rerun with + `-v` to show full output. This keeps CI logs and `docker build` output clean + on success (just package/suite summaries) while providing full diagnostic + detail on failure (every test case, every assertion). The command lives in the + `test` phase of the `Dockerfile`, since `script/test` builds that phase; the + Makefile form below is the same pattern for any repo-local invocation: + + ```makefile + test: + @ || \ + { echo "--- Rerunning with -v for details ---"; \ + ; exit 1; } + ``` + + Go example: + + ```makefile + test: + @go test -count=1 -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -count=1 -timeout 90s -race -v ./...; exit 1; } + ``` + + `-count=1` is required on both invocations: it defeats Go's test _result_ + cache, so neither run can report a stored pass in place of running the + tests. It leaves the build cache alone, so it costs the runtime of the suite + and no recompilation. + + That cache is Go's own, separate from Docker's layer cache. Go stores a + passing result in its cache directory (`GOCACHE`), and when the same tests + run again on unchanged code it prints that result, marked `(cached)`, + without running them. That matters on a developer's machine, where this + target runs and the directory lasts from one run to the next. The `test` + phase of the `Dockerfile` needs no `-count=1`: its base image holds no + result for this repo's tests and nothing before its `go test` step runs a + test, so there is nothing to replay. `--no-cache` (above) is what makes that + step run on an unchanged tree. + + Python example: + + ```makefile + test: + @python -m pytest || \ + { echo "--- Rerunning with -v for details ---"; \ + python -m pytest -v; exit 1; } + ``` + + The `exit 1` ensures the target always fails after a rerun — the first run + already proved the tests are broken, so the build must not pass even if a + flaky test happens to succeed on the second attempt. The rerun exists solely + for diagnostic output. - Docker builds must complete in under 5 minutes. @@ -93,10 +383,84 @@ style conventions are in separate documents: must be in `.gitignore`. No exceptions. - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`. - Fetch the standard `.gitignore` from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up - a new repo. + editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), + language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` + from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when + setting up a new repo. These patterns are written to `.gitignore`'s own + semantics, in which an unanchored pattern already matches at every depth; they + are not a `.dockerignore` and must not be transplanted into one unmodified. + +- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns + across unmodified leaves secrets in the build context.** Docker matches with + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so + `*` does not cross `/` and a pattern without a leading `**/` is anchored at + the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` + therefore excludes only the copies at the repository root, while `config/.env` + and `certs/server.key` still reach the context and can land in an image layer + — which is more dangerous than a short file with no secret patterns at all, + because it reads as solved and stops anyone looking. Give every + depth-independent pattern the `**/` prefix and leave only genuinely + root-anchored entries unprefixed: `.claude`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, + and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern + also catches something the build needs, re-include it with a negation + (`!docs/example.env`); deleting the pattern reopens the exposure for every + other file it covers. Fetch the standard `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend + it with the repo's own artifacts. + +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so under `COPY . .` the build context + inflates by a multiple of the repo and another session's unreviewed work can + be copied into an image layer. In `.gitignore` the entry is `.claude/`, + unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` + prefix, because the prefixed form would also delete any nested directory of + that name from the build. Anchoring carries a known gap that the canonical + `.dockerignore` states in its own comment, since consuming repos receive the + file and not the tracker: the directory is created in the agent's working + directory, so a repo running agents in subdirectories still ships + `services/api/.claude/` and must add its own anchored entry there. + +- **A plain `docker build .` of a clone stamps the version that + `git describe --tags --always` gives**, derived from the `.git` in the build + context as the canonical `Dockerfile` above shows. Without its failure check, + a missing `git` or an unreadable checkout would leave `-X main.Version=` empty + and the build would still exit 0. `script/docker` and `script/cibuild` pass + the version they compute on the host; it takes precedence. They do this + byte-identically across repos: + + ```sh + # Own line: a failing command substitution inside an argument does not + # trip `set -e`, so the inline form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$(script/projectname)" . + ``` + + `--always` makes an untagged repo yield an abbreviated commit hash rather + than failing, and the `[ -n "$version" ]` line is the single place the + fallback is applied — a live check that fires on a build from an export with + no `.git` and on a repository with no commits yet. Do not fold it into the + substitution as `|| echo unknown`, which makes the guard unreachable. The + Dockerfile's side is `ARG VERSION` in the stage that compiles, declared + there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + Dockerfile declares no such `ARG` is ignored and costs nothing, which is why + the scripts stay byte-identical. One consequence for CI: the standard + checkout action clones shallow and fetches no tags, so a repo that embeds a + tag-derived version must set `fetch-depth: 0` on its checkout step. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + patterns.** Plant files at the root _and_ at least two directories deep, build + a probe image that does `COPY . .`, and list what actually landed + (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` + size is not a substitute: a nested secret is a few bytes, and BuildKit + transfers only the delta from the previous build. - **No build artifacts in version control.** Code-derived data (compiled bundles, minified output, generated assets) must never be committed to the @@ -112,9 +476,56 @@ style conventions are in separate documents: - Make all changes on a feature branch. You can do whatever you want on a feature branch. -- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - manually by the user. Fetch from - `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. +- `.golangci.yml` is standardized. The vendored copy in a consuming repo must + _NEVER_ be modified by an agent: fetch it from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it + byte-identical, so that no repo can quietly loosen its own linting. Linter + configuration changes are made to the canonical copy in the `prompts` repo and + reach consuming repos by re-vendoring; an agent may open a PR against + canonical, which only the user merges. One list is exempt from byte-identity, + because it cannot be written once for every repo: the `deny` list of the + `test-support` depguard rule, where a repo names its own test-support packages + by full import path. A repo adds entries there and changes nothing else, and a + re-vendor carries its entries forward. The canonical golangci-lint version is + v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`, + which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go` + directive must not name a newer Go minor version than the one golangci-lint + was built with, or golangci-lint refuses to lint it: this release lints + `go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo + installs golangci-lint on the host. A repo sets the lint phase digest to the + one named here and re-vendors `.golangci.yml` in the same commit, whichever of + the two prompted the change: the canonical copy can name linters that an older + golangci-lint rejects, and a newer golangci-lint can add linters that + `default: all` switches on until the canonical copy disables them. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `PATH` only, so on an already-provisioned machine the pin is inert and a + version bump is a silent no-op — while the Dockerfile, installing into a clean + image, gets the pinned version, so a local `make check` and `make docker` can + disagree about what the tool even is. The canonical form: + - compares the installed version against the pin over the **whole** version + token; a parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install; + - treats absent, non-zero, empty or unrecognised `--version` output as a + mismatch, so the failure direction is a redundant install and never a + skipped one; + - after installing, re-resolves the binary the way callers do — `hash -r`, + then through `PATH`, not through the directory the installer wrote to — + and fails naming the resolved path, since an install that a shadowing + binary hides succeeds while changing nothing any caller sees; + - is actually called, and prints the version on both success paths: a + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + + A Go tool a repo needs on the host is installed with `go install` pinned to + a commit hash (`go install @`). It is never tracked as + a `go.mod` tool dependency or through a `tools.go` file, either of which + pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`. - When pinning images or packages by hash, add a comment above the reference with the version and date (YYYY-MM-DD). @@ -128,12 +539,76 @@ style conventions are in separate documents: - Dockerized web services listen on port 8080 by default, overridable with `PORT`. +- **HTTP/web services must be hardened for production internet exposure before + tagging 1.0.** This means full compliance with security best practices + including, without limitation, all of the following: + - **Security headers** on every response: + - `Strict-Transport-Security` (HSTS) with `max-age` of at least one year + and `includeSubDomains`. + - `Content-Security-Policy` (CSP) with a restrictive default policy + (`default-src 'self'` as a baseline, tightened per-resource as + needed). Never use `unsafe-inline` or `unsafe-eval` unless + unavoidable, and document the reason. + - `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required). + Prefer the `frame-ancestors` CSP directive as the primary control. + - `X-Content-Type-Options: nosniff`. + - `Referrer-Policy: strict-origin-when-cross-origin` (or stricter). + - `Permissions-Policy` restricting access to browser features the + application does not use (camera, microphone, geolocation, etc.). + - **Request and response limits:** + - Maximum request body size enforced on all endpoints (e.g. Go + `http.MaxBytesReader`). Choose a sane default per-route; never accept + unbounded input. + - Maximum response body size where applicable (e.g. paginated APIs). + - `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend + against slowloris attacks. + - `WriteTimeout` on the `http.Server`. + - `IdleTimeout` on the `http.Server`. + - Per-handler execution time limits via `context.WithTimeout` or + chi/stdlib `middleware.Timeout`. + - **Authentication and session security:** + - Rate limiting on password-based authentication endpoints. API keys are + high-entropy and not susceptible to brute force, so they are exempt. + - CSRF tokens on all state-mutating HTML forms. API endpoints + authenticated via `Authorization` header (Bearer token, API key) are + exempt because the browser does not attach these automatically. + - Passwords stored using bcrypt, scrypt, or argon2 — never plain-text, + MD5, or SHA. + - Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or + `Strict`) attributes. + - **Reverse proxy awareness:** + - True client IP detection when behind a reverse proxy + (`X-Forwarded-For`, `X-Real-IP`). The application must accept + forwarded headers only from a configured set of trusted proxy + addresses — never trust `X-Forwarded-For` unconditionally. + - **CORS:** + - Authenticated endpoints must restrict `Access-Control-Allow-Origin` to + an explicit allowlist of known origins. Wildcard (`*`) is acceptable + only for public, unauthenticated read-only APIs. + - **Error handling:** + - Internal errors must never leak stack traces, SQL queries, file paths, + or other implementation details to the client. Return generic error + messages in production; detailed errors only when `DEBUG` is enabled. + - **TLS:** + - Services never terminate TLS directly. They are always deployed behind + a TLS-terminating reverse proxy. The service itself listens on plain + HTTP. However, HSTS headers and `Secure` cookie flags must still be + set by the application so that the browser enforces HTTPS end-to-end. + + This list is non-exhaustive. Apply defense-in-depth: if a standard security + hardening measure exists for HTTP services and is not listed here, it is + still expected. When in doubt, harden. + - `README.md` is the primary documentation. Required sections: - **Description**: First line must include the project name, purpose, category (web server, SPA, CLI tool, etc.), license, and author. Example: "µPaaS is an MIT-licensed Go web application by @sneak that receives git-frontend webhooks and deploys applications via Docker in realtime." - **Getting Started**: Copy-pasteable install/usage code block. + - **Entrypoints**: Opens by stating that the repo adheres to the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + standard (with that link), then documents each provided `script/` + entrypoint and its purpose. - **Rationale**: Why does this exist? - **Design**: How is the program structured? - **TODO**: Update meticulously, even between commits. When planning, put @@ -164,12 +639,14 @@ style conventions are in separate documents: settings. - Avoid putting files in the repo root unless necessary. Root should contain - only project-level config files (`README.md`, `Makefile`, `Dockerfile`, - `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and - language-specific config). Everything else goes in a subdirectory. Canonical - subdirectory names: + only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, + `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, + and language-specific config). Everything else goes in a subdirectory. + Canonical subdirectory names: - `bin/` — executable scripts and tools - - `cmd/` — Go command entrypoints + - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose + body is a single call into `internal/` or `pkg/`, no project logic in + `cmd/` - `configs/` — configuration templates and examples - `deploy/` — deployment manifests (k8s, compose, terraform) - `docs/` — documentation and markdown (README.md stays in root) @@ -188,8 +665,15 @@ style conventions are in separate documents: - `README.md`, `.git`, `.gitignore`, `.editorconfig` - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) - `Makefile` + - `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`, + `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`, + `install-precommit`) - `Dockerfile`, `.dockerignore` - `.gitea/workflows/check.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - Python: `pyproject.toml` + +- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It + is never committed under a file or directory named after one agent tool, such + as `CLAUDE.md` or `.claude/`, and never split into separate memory files. diff --git a/internal/db/errors.go b/internal/db/errors.go index 7f5ad49..de7768c 100644 --- a/internal/db/errors.go +++ b/internal/db/errors.go @@ -1,4 +1,3 @@ -// Package db provides database access and migration management. package db import ( diff --git a/internal/db/queries.go b/internal/db/queries.go index a74a94d..d840773 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -1150,7 +1150,8 @@ func scanMessages( code, _ := strconv.Atoi(msg.Command) msg.Code = code - if mt, err := irc.FromInt(code); err == nil { + mt, lookupErr := irc.FromInt(code) + if lookupErr == nil { msg.Command = mt.Name() } } @@ -1373,7 +1374,9 @@ func (database *Database) GetStaleOrphanSessions( for rows.Next() { var stale StaleSession - if err := rows.Scan(&stale.ID, &stale.Nick); err != nil { + + err = rows.Scan(&stale.ID, &stale.Nick) + if err != nil { return nil, fmt.Errorf( "scan stale session: %w", err, ) @@ -1382,7 +1385,8 @@ func (database *Database) GetStaleOrphanSessions( result = append(result, stale) } - if err := rows.Err(); err != nil { + err = rows.Err() + if err != nil { return nil, fmt.Errorf( "iterate stale sessions: %w", err, ) @@ -1905,9 +1909,10 @@ func (database *Database) ListChannelBans( for rows.Next() { var ban BanInfo - if scanErr := rows.Scan( + scanErr := rows.Scan( &ban.Mask, &ban.SetBy, &ban.CreatedAt, - ); scanErr != nil { + ) + if scanErr != nil { return nil, fmt.Errorf( "scan channel ban: %w", scanErr, ) @@ -1916,7 +1921,8 @@ func (database *Database) ListChannelBans( bans = append(bans, ban) } - if rowErr := rows.Err(); rowErr != nil { + rowErr := rows.Err() + if rowErr != nil { return nil, fmt.Errorf( "iterate channel bans: %w", rowErr, ) @@ -2247,11 +2253,12 @@ func (database *Database) ListAllChannelsWithCountsFiltered( for rows.Next() { var chanInfo ChannelInfoFull - if scanErr := rows.Scan( + scanErr := rows.Scan( &chanInfo.Name, &chanInfo.MemberCount, &chanInfo.Topic, - ); scanErr != nil { + ) + if scanErr != nil { return nil, fmt.Errorf( "scan channel: %w", scanErr, ) @@ -2260,7 +2267,8 @@ func (database *Database) ListAllChannelsWithCountsFiltered( channels = append(channels, chanInfo) } - if rowErr := rows.Err(); rowErr != nil { + rowErr := rows.Err() + if rowErr != nil { return nil, fmt.Errorf( "iterate channels: %w", rowErr, ) @@ -2308,11 +2316,12 @@ func (database *Database) GetSessionChannelsFiltered( for rows.Next() { var chanInfo ChannelInfo - if scanErr := rows.Scan( + scanErr := rows.Scan( &chanInfo.ID, &chanInfo.Name, &chanInfo.Topic, - ); scanErr != nil { + ) + if scanErr != nil { return nil, fmt.Errorf( "scan channel: %w", scanErr, ) @@ -2321,7 +2330,8 @@ func (database *Database) GetSessionChannelsFiltered( channels = append(channels, chanInfo) } - if rowErr := rows.Err(); rowErr != nil { + rowErr := rows.Err() + if rowErr != nil { return nil, fmt.Errorf( "iterate channels: %w", rowErr, ) diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index 459dc04..9c201d2 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -987,11 +987,12 @@ func TestGetOperCount(t *testing.T) { sid2, _, _, err := database.CreateSession( ctx, "user2", "", "", "", ) - _ = sid2 if err != nil { t.Fatal(err) } + _ = sid2 + // Initially zero opers. count, err := database.GetOperCount(ctx) if err != nil { @@ -1023,23 +1024,25 @@ func TestGetOperCount(t *testing.T) { func TestWildcardMatch(t *testing.T) { t.Parallel() + const hostmask = "nick!user@host" + tests := []struct { pattern string input string match bool }{ - {"*!*@*", "nick!user@host", true}, + {"*!*@*", hostmask, true}, {"*!*@*.example.com", "nick!user@foo.example.com", true}, {"*!*@*.example.com", "nick!user@other.net", false}, {"badnick!*@*", "badnick!user@host", true}, {"badnick!*@*", "goodnick!user@host", false}, - {"nick!user@host", "nick!user@host", true}, - {"nick!user@host", "nick!user@other", false}, + {hostmask, hostmask, true}, + {hostmask, "nick!user@other", false}, {"*", "anything", true}, - {"?ick!*@*", "nick!user@host", true}, + {"?ick!*@*", hostmask, true}, {"?ick!*@*", "nn!user@host", false}, // Case-insensitive. - {"Nick!*@*", "nick!user@host", true}, + {"Nick!*@*", hostmask, true}, } for _, tc := range tests { diff --git a/internal/handlers/api.go b/internal/handlers/api.go index a27afdc..3da43f3 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -125,21 +125,18 @@ func (hdlr *Handlers) authSession( } // setAuthCookie sets the authentication cookie on the -// response. +// response. It is always Secure: the server runs behind a +// TLS-terminating reverse proxy. func (hdlr *Handlers) setAuthCookie( writer http.ResponseWriter, - request *http.Request, token string, ) { - secure := request.TLS != nil || - request.Header.Get("X-Forwarded-Proto") == "https" - http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields Name: authCookieName, Value: token, Path: "/", HttpOnly: true, - Secure: secure, + Secure: true, SameSite: http.SameSiteStrictMode, }) } @@ -148,17 +145,13 @@ func (hdlr *Handlers) setAuthCookie( // the client. func (hdlr *Handlers) clearAuthCookie( writer http.ResponseWriter, - request *http.Request, ) { - secure := request.TLS != nil || - request.Header.Get("X-Forwarded-Proto") == "https" - http.SetCookie(writer, &http.Cookie{ //nolint:exhaustruct // optional fields Name: authCookieName, Value: "", Path: "/", HttpOnly: true, - Secure: secure, + Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: -1, }) @@ -172,7 +165,7 @@ func (hdlr *Handlers) requireAuth( hdlr.authSession(request) if err != nil { hdlr.respondJSON(writer, request, map[string]any{ - "error": "not registered", + errorKey: "not registered", "numeric": irc.ErrNotRegistered, }, http.StatusUnauthorized) @@ -285,11 +278,11 @@ func (hdlr *Handlers) executeCreateSession( hdlr.deliverMOTD(request, clientID, sessionID, nick) - hdlr.setAuthCookie(writer, request, token) + hdlr.setAuthCookie(writer, token) hdlr.respondJSON(writer, request, map[string]any{ - "id": sessionID, - "nick": nick, + "id": sessionID, + nickKey: nick, }, http.StatusCreated) } @@ -643,7 +636,7 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc { hdlr.respondJSON(writer, request, map[string]any{ "id": sessionID, - "nick": nick, + nickKey: nick, "channels": channels, }, http.StatusOK) } @@ -1090,7 +1083,7 @@ func (hdlr *Handlers) dispatchQueryCommand( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, + map[string]string{statusKey: statusError}, http.StatusOK) } } @@ -1112,7 +1105,7 @@ func (hdlr *Handlers) handlePrivmsg( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, + map[string]string{statusKey: statusError}, http.StatusOK) return @@ -1127,7 +1120,7 @@ func (hdlr *Handlers) handlePrivmsg( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, + map[string]string{statusKey: statusError}, http.StatusOK) return @@ -1169,7 +1162,7 @@ func (hdlr *Handlers) respondIRCError( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, + map[string]string{statusKey: statusError}, http.StatusOK) } @@ -1257,7 +1250,7 @@ func (hdlr *Handlers) handleChannelMsg( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"id": uuid, "status": "sent"}, + map[string]string{"id": uuid, statusKey: "sent"}, http.StatusOK) } @@ -1447,7 +1440,7 @@ func (hdlr *Handlers) handleDirectMsg( hdlr.respondJSON(writer, request, map[string]string{ - "id": result.UUID, "status": "sent", + "id": result.UUID, statusKey: "sent", }, http.StatusOK) } @@ -1522,7 +1515,7 @@ func (hdlr *Handlers) executeJoin( hdlr.respondJSON(writer, request, map[string]string{ - "status": "joined", + statusKey: "joined", "channel": channel, }, http.StatusOK) @@ -1679,6 +1672,7 @@ func (hdlr *Handlers) handlePart( // Extract reason from body for the service call. reason := "" + if body != nil { var lines []string if json.Unmarshal(body, &lines) == nil && @@ -1700,7 +1694,7 @@ func (hdlr *Handlers) handlePart( hdlr.respondJSON(writer, request, map[string]string{ - "status": "parted", + statusKey: "parted", "channel": channel, }, http.StatusOK) @@ -1739,7 +1733,7 @@ func (hdlr *Handlers) handleNick( if newNick == nick { hdlr.respondJSON(writer, request, map[string]string{ - "status": "ok", "nick": newNick, + statusKey: "ok", nickKey: newNick, }, http.StatusOK) @@ -1770,7 +1764,7 @@ func (hdlr *Handlers) executeNickChange( hdlr.respondJSON(writer, request, map[string]string{ - "status": "ok", "nick": newNick, + statusKey: "ok", nickKey: newNick, }, http.StatusOK) } @@ -1831,7 +1825,7 @@ func (hdlr *Handlers) handleTopic( hdlr.respondJSON(writer, request, map[string]string{ - "status": "ok", "topic": topic, + statusKey: "ok", "topic": topic, }, http.StatusOK) } @@ -1885,7 +1879,7 @@ func (hdlr *Handlers) dispatchInfoCommand( _ = target _ = bodyLines - okResp := map[string]string{"status": "ok"} + okResp := map[string]string{statusKey: "ok"} switch command { case irc.CmdMotd: @@ -1916,6 +1910,7 @@ func (hdlr *Handlers) handleQuit( body json.RawMessage, ) { reason := "Client quit" + if body != nil { var lines []string if json.Unmarshal(body, &lines) == nil && @@ -1928,10 +1923,10 @@ func (hdlr *Handlers) handleQuit( request.Context(), sessionID, nick, reason, ) - hdlr.clearAuthCookie(writer, request) + hdlr.clearAuthCookie(writer) hdlr.respondJSON(writer, request, - map[string]string{"status": "quit"}, + map[string]string{statusKey: "quit"}, http.StatusOK) } @@ -1963,7 +1958,7 @@ func (hdlr *Handlers) handleMode( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) return @@ -2064,7 +2059,7 @@ func (hdlr *Handlers) queryChannelMode( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2241,7 +2236,7 @@ func (hdlr *Handlers) applyParameterizedMode( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "error"}, + map[string]string{statusKey: statusError}, http.StatusOK) } } @@ -2307,7 +2302,7 @@ func (hdlr *Handlers) applyUserMode( ) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2353,7 +2348,7 @@ func (hdlr *Handlers) setChannelFlag( ) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2437,7 +2432,7 @@ func (hdlr *Handlers) setHashcashMode( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2486,7 +2481,7 @@ func (hdlr *Handlers) clearHashcashMode( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2591,7 +2586,7 @@ func (hdlr *Handlers) executeBanChange( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2645,7 +2640,7 @@ func (hdlr *Handlers) listBans( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2709,7 +2704,7 @@ func (hdlr *Handlers) setChannelKeyMode( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2758,7 +2753,7 @@ func (hdlr *Handlers) clearChannelKeyMode( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2833,7 +2828,7 @@ func (hdlr *Handlers) setChannelLimitMode( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -2883,7 +2878,7 @@ func (hdlr *Handlers) clearChannelLimitMode( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3050,7 +3045,7 @@ func (hdlr *Handlers) executeInvite( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3119,7 +3114,7 @@ func (hdlr *Handlers) handleNames( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3172,7 +3167,7 @@ func (hdlr *Handlers) handleList( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3262,7 +3257,7 @@ func (hdlr *Handlers) executeWhois( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3287,7 +3282,7 @@ func (hdlr *Handlers) whoisNotFound( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3454,7 +3449,7 @@ func (hdlr *Handlers) handleWho( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) return @@ -3496,7 +3491,7 @@ func (hdlr *Handlers) handleWho( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3512,7 +3507,7 @@ func (hdlr *Handlers) handleLusers( ) hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3703,10 +3698,10 @@ func (hdlr *Handlers) HandleLogout() http.HandlerFunc { ) } - hdlr.clearAuthCookie(writer, request) + hdlr.clearAuthCookie(writer) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } } @@ -3809,7 +3804,7 @@ func (hdlr *Handlers) handleOper( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3857,7 +3852,7 @@ func (hdlr *Handlers) handleAway( hdlr.broker.Notify(sessionID) hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3919,7 +3914,7 @@ func (hdlr *Handlers) handleKick( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } @@ -3943,10 +3938,7 @@ func (hdlr *Handlers) deliverWhoisIdle( return } - idleSeconds := int64(time.Since(lastSeen).Seconds()) - if idleSeconds < 0 { - idleSeconds = 0 - } + idleSeconds := max(int64(time.Since(lastSeen).Seconds()), 0) signonUnix := strconv.FormatInt( createdAt.Unix(), 10, diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index bdfabd5..b6635a8 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/httptest" "os" + "slices" "strconv" "strings" "sync" @@ -33,6 +34,7 @@ import ( "sneak.berlin/go/neoirc/internal/server" "sneak.berlin/go/neoirc/internal/service" "sneak.berlin/go/neoirc/internal/stats" + "sneak.berlin/go/neoirc/pkg/irc" ) func TestMain(m *testing.M) { @@ -45,6 +47,9 @@ const ( bodyKey = "body" toKey = "to" statusKey = "status" + nickKey = "nick" + passwordKey = "password" + testPassword = "password123" privmsgCmd = "PRIVMSG" joinCmd = "JOIN" apiMessages = "/api/v1/messages" @@ -308,7 +313,9 @@ func doRequestAuth( } if cookie != "" { - request.AddCookie(&http.Cookie{ //nolint:exhaustruct // only name+value needed + // A request cookie carries only its name and value, so + // gosec's G124 attribute check does not apply. + request.AddCookie(&http.Cookie{ //nolint:exhaustruct,gosec // only name+value Name: authCookieName, Value: cookie, }) @@ -328,7 +335,7 @@ func (tserver *testServer) createSession( tserver.t.Helper() body, err := json.Marshal( - map[string]string{"nick": nick}, + map[string]string{nickKey: nick}, ) if err != nil { tserver.t.Fatalf("marshal session: %v", err) @@ -470,7 +477,7 @@ func postSession( t.Helper() body, err := json.Marshal( - map[string]string{"nick": nick}, + map[string]string{nickKey: nick}, ) if err != nil { t.Fatalf("marshal: %v", err) @@ -677,22 +684,24 @@ func TestAuthValidToken(t *testing.T) { t.Fatalf("expected 200, got %d", status) } - if result["nick"] != "authtest" { + if result[nickKey] != "authtest" { t.Fatalf( "expected nick authtest, got %v", - result["nick"], + result[nickKey], ) } } func TestJoinChannel(t *testing.T) { + const channel = "#test" + tserver := newTestServer(t) token := tserver.createSession("joiner") status, result := tserver.sendCommand( token, map[string]any{ - commandKey: joinCmd, toKey: "#test", + commandKey: joinCmd, toKey: channel, }, ) if status != http.StatusOK { @@ -701,7 +710,7 @@ func TestJoinChannel(t *testing.T) { ) } - if result["channel"] != "#test" { + if result["channel"] != channel { t.Fatalf( "expected #test, got %v", result["channel"], ) @@ -733,20 +742,22 @@ func TestJoinWithoutHash(t *testing.T) { } func TestPartChannel(t *testing.T) { + const channel = "#test" + tserver := newTestServer(t) token := tserver.createSession("parter") tserver.sendCommand( token, map[string]any{ - commandKey: joinCmd, toKey: "#test", + commandKey: joinCmd, toKey: channel, }, ) status, result := tserver.sendCommand( token, map[string]any{ - commandKey: "PART", toKey: "#test", + commandKey: "PART", toKey: channel, }, ) if status != http.StatusOK { @@ -755,7 +766,7 @@ func TestPartChannel(t *testing.T) { ) } - if result["channel"] != "#test" { + if result["channel"] != channel { t.Fatalf( "expected #test, got %v", result["channel"], ) @@ -787,15 +798,17 @@ func TestJoinMissingTo(t *testing.T) { } func TestChannelMessage(t *testing.T) { + const channel = "#test" + tserver := newTestServer(t) aliceToken := tserver.createSession("alice_msg") bobToken := tserver.createSession("bob_msg") tserver.sendCommand(aliceToken, map[string]any{ - commandKey: joinCmd, toKey: "#test", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(bobToken, map[string]any{ - commandKey: joinCmd, toKey: "#test", + commandKey: joinCmd, toKey: channel, }) _, _ = tserver.pollMessages(aliceToken, 0) @@ -805,7 +818,7 @@ func TestChannelMessage(t *testing.T) { aliceToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#test", + toKey: channel, bodyKey: []string{"hello world"}, }, ) @@ -831,17 +844,19 @@ func TestChannelMessage(t *testing.T) { } func TestMessageMissingBody(t *testing.T) { + const channel = "#test" + tserver := newTestServer(t) token := tserver.createSession("nobody") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#test", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: privmsgCmd, toKey: "#test", + commandKey: privmsgCmd, toKey: channel, }) if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) @@ -989,7 +1004,7 @@ func TestNickChange(t *testing.T) { status, result := tserver.sendCommand( token, map[string]any{ - commandKey: "NICK", + commandKey: irc.CmdNick, bodyKey: []string{"newnick"}, }, ) @@ -999,9 +1014,9 @@ func TestNickChange(t *testing.T) { ) } - if result["nick"] != "newnick" { + if result[nickKey] != "newnick" { t.Fatalf( - "expected newnick, got %v", result["nick"], + "expected newnick, got %v", result[nickKey], ) } } @@ -1011,7 +1026,7 @@ func TestNickSameAsCurrent(t *testing.T) { token := tserver.createSession("same_nick") status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: "NICK", + commandKey: irc.CmdNick, bodyKey: []string{"same_nick"}, }) if status != http.StatusOK { @@ -1028,7 +1043,7 @@ func TestNickCollision(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: "NICK", + commandKey: irc.CmdNick, bodyKey: []string{"taken_nick"}, }) if status != http.StatusOK { @@ -1052,7 +1067,7 @@ func TestNickInvalid(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: "NICK", + commandKey: irc.CmdNick, bodyKey: []string{"bad nick!"}, }) if status != http.StatusOK { @@ -1076,7 +1091,7 @@ func TestNickEmptyBody(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand( - token, map[string]any{commandKey: "NICK"}, + token, map[string]any{commandKey: irc.CmdNick}, ) if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) @@ -1093,18 +1108,20 @@ func TestNickEmptyBody(t *testing.T) { } func TestTopic(t *testing.T) { + const channel = "#topictest" + tserver := newTestServer(t) token := tserver.createSession("topic_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#topictest", + commandKey: joinCmd, toKey: channel, }) status, result := tserver.sendCommand( token, map[string]any{ - commandKey: "TOPIC", - toKey: "#topictest", + commandKey: irc.CmdTopic, + toKey: channel, bodyKey: []string{"Hello World Topic"}, }, ) @@ -1128,7 +1145,7 @@ func TestTopicMissingTo(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: "TOPIC", + commandKey: irc.CmdTopic, bodyKey: []string{"topic"}, }) if status != http.StatusOK { @@ -1146,17 +1163,19 @@ func TestTopicMissingTo(t *testing.T) { } func TestTopicMissingBody(t *testing.T) { + const channel = "#topictest" + tserver := newTestServer(t) token := tserver.createSession("topicnobody") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#topictest", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(token, 0) status, _ := tserver.sendCommand(token, map[string]any{ - commandKey: "TOPIC", toKey: "#topictest", + commandKey: irc.CmdTopic, toKey: channel, }) if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) @@ -1189,7 +1208,7 @@ func TestTopicNonMember(t *testing.T) { status, _ := tserver.sendCommand( bobToken, map[string]any{ - commandKey: "TOPIC", + commandKey: irc.CmdTopic, toKey: "#topicpriv", bodyKey: []string{"Hijacked topic"}, }, @@ -1351,17 +1370,19 @@ func TestHistory(t *testing.T) { } func TestHistoryNonMember(t *testing.T) { + const channel = "#secret" + tserver := newTestServer(t) aliceToken := tserver.createSession("alice_hist") bobToken := tserver.createSession("bob_hist") // Alice creates and joins a channel. tserver.sendCommand(aliceToken, map[string]any{ - commandKey: joinCmd, toKey: "#secret", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(aliceToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#secret", + toKey: channel, bodyKey: []string{"secret stuff"}, }) @@ -1466,15 +1487,17 @@ func TestChannelMembers(t *testing.T) { } func TestLongPoll(t *testing.T) { + const channel = "#longpoll" + tserver := newTestServer(t) aliceToken := tserver.createSession("lp_alice") bobToken := tserver.createSession("lp_bob") tserver.sendCommand(aliceToken, map[string]any{ - commandKey: joinCmd, toKey: "#longpoll", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(bobToken, map[string]any{ - commandKey: joinCmd, toKey: "#longpoll", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(bobToken, 0) @@ -1516,7 +1539,7 @@ func TestLongPoll(t *testing.T) { tserver.sendCommand(aliceToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#longpoll", + toKey: channel, bodyKey: []string{"wake up!"}, }) @@ -1571,14 +1594,16 @@ func TestLongPollTimeout(t *testing.T) { } func TestEphemeralChannelCleanup(t *testing.T) { + const channel = "#ephemeral" + tserver := newTestServer(t) token := tserver.createSession("ephemeral") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#ephemeral", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(token, map[string]any{ - commandKey: "PART", toKey: "#ephemeral", + commandKey: "PART", toKey: channel, }) resp, err := doRequestAuth( @@ -1603,8 +1628,8 @@ func TestEphemeralChannelCleanup(t *testing.T) { t.Fatalf("decode channels: %v", decErr) } - for _, channel := range channels { - if channel["name"] == "#ephemeral" { + for _, listed := range channels { + if listed["name"] == channel { t.Fatal( "ephemeral channel should be cleaned up", ) @@ -1630,7 +1655,7 @@ func TestConcurrentSessions(t *testing.T) { nick := fmt.Sprintf("conc_%d", index) body, err := json.Marshal( - map[string]string{"nick": nick}, + map[string]string{nickKey: nick}, ) if err != nil { errs <- fmt.Errorf( @@ -1903,7 +1928,7 @@ func TestPassCommand(t *testing.T) { status, result := tserver.sendCommand( token, map[string]any{ - commandKey: "PASS", + commandKey: irc.CmdPass, bodyKey: []string{"s3cure_pass"}, }, ) @@ -1931,7 +1956,7 @@ func TestPassCommandShortPassword(t *testing.T) { status, _ := tserver.sendCommand( token, map[string]any{ - commandKey: "PASS", + commandKey: irc.CmdPass, bodyKey: []string{"short"}, }, ) @@ -1959,7 +1984,7 @@ func TestPassCommandEmpty(t *testing.T) { // Try empty password — should fail. status, _ := tserver.sendCommand( token, - map[string]any{commandKey: "PASS"}, + map[string]any{commandKey: irc.CmdPass}, ) if status != http.StatusOK { t.Fatalf("expected 200, got %d", status) @@ -1982,13 +2007,13 @@ func TestLoginValid(t *testing.T) { token := tserver.createSession("loginuser") tserver.sendCommand(token, map[string]any{ - commandKey: "PASS", - bodyKey: []string{"password123"}, + commandKey: irc.CmdPass, + bodyKey: []string{testPassword}, }) // Login with nick + password. loginBody, err := json.Marshal(map[string]string{ - "nick": "loginuser", "password": "password123", + nickKey: "loginuser", passwordKey: testPassword, }) if err != nil { t.Fatal(err) @@ -2035,10 +2060,10 @@ func TestLoginValid(t *testing.T) { t.Fatalf("expected 200, got %d", status) } - if state["nick"] != "loginuser" { + if state[nickKey] != "loginuser" { t.Fatalf( "expected loginuser, got %v", - state["nick"], + state[nickKey], ) } } @@ -2050,15 +2075,15 @@ func TestLoginWrongPassword(t *testing.T) { token := tserver.createSession("wrongpwuser") tserver.sendCommand(token, map[string]any{ - commandKey: "PASS", + commandKey: irc.CmdPass, bodyKey: []string{"correctpass1"}, }) postJSONExpectStatus( t, tserver, "/api/v1/login", map[string]string{ - "nick": "wrongpwuser", - "password": "wrongpass12", + nickKey: "wrongpwuser", + passwordKey: "wrongpass12", }, http.StatusUnauthorized, ) @@ -2070,8 +2095,8 @@ func TestLoginNonexistentUser(t *testing.T) { postJSONExpectStatus( t, tserver, "/api/v1/login", map[string]string{ - "nick": "ghostuser", - "password": "password123", + nickKey: "ghostuser", + passwordKey: testPassword, }, http.StatusUnauthorized, ) @@ -2081,7 +2106,7 @@ func TestSessionCookie(t *testing.T) { tserver := newTestServer(t) body, err := json.Marshal( - map[string]string{"nick": "cookietest"}, + map[string]string{nickKey: "cookietest"}, ) if err != nil { t.Fatal(err) @@ -2128,6 +2153,11 @@ func TestSessionCookie(t *testing.T) { t.Fatal("cookie should be SameSite=Strict") } + // Secure even over plain HTTP, as this test server is. + if !authCookie.Secure { + t.Fatal("cookie should be Secure") + } + // Verify JSON body does NOT contain token. var result map[string]any @@ -2152,10 +2182,10 @@ func TestSessionStillWorks(t *testing.T) { t.Fatalf("expected 200, got %d", status) } - if state["nick"] != "anon_user" { + if state[nickKey] != "anon_user" { t.Fatalf( "expected anon_user, got %v", - state["nick"], + state[nickKey], ) } } @@ -2217,7 +2247,7 @@ func TestWhoisShowsHostInfo(t *testing.T) { _, lastID := tserver.pollMessages(queryToken, 0) tserver.sendCommand(queryToken, map[string]any{ - commandKey: "WHOIS", + commandKey: irc.CmdWhois, toKey: "whoisuser", }) @@ -2258,7 +2288,7 @@ func (tserver *testServer) createSessionWithUsername( tserver.t.Helper() body, err := json.Marshal(map[string]string{ - "nick": nick, + nickKey: nick, "username": username, }) if err != nil { @@ -2301,6 +2331,8 @@ func (tserver *testServer) createSessionWithUsername( } func TestWhoShowsHostInfo(t *testing.T) { + const channel = "#whotest" + tserver := newTestServer(t) whoToken := tserver.createSessionWithUsername( @@ -2308,20 +2340,20 @@ func TestWhoShowsHostInfo(t *testing.T) { ) tserver.sendCommand(whoToken, map[string]any{ - commandKey: joinCmd, toKey: "#whotest", + commandKey: joinCmd, toKey: channel, }) queryToken := tserver.createSession("whoquerier") tserver.sendCommand(queryToken, map[string]any{ - commandKey: joinCmd, toKey: "#whotest", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(queryToken, 0) tserver.sendCommand(queryToken, map[string]any{ commandKey: "WHO", - toKey: "#whotest", + toKey: channel, }) msgs, _ := tserver.pollMessages(queryToken, lastID) @@ -2375,7 +2407,7 @@ func TestSessionUsernameDefault(t *testing.T) { // WHOIS should show the nick as the username. tserver.sendCommand(queryToken, map[string]any{ - commandKey: "WHOIS", + commandKey: irc.CmdWhois, toKey: "defaultusr", }) @@ -2418,8 +2450,8 @@ func TestLoginRateLimitExceeded(t *testing.T) { for range 5 { loginBody, mErr := json.Marshal( map[string]string{ - "nick": "nosuchuser", - "password": "doesnotmatter", + nickKey: "nosuchuser", + passwordKey: "doesnotmatter", }, ) if mErr != nil { @@ -2441,7 +2473,7 @@ func TestLoginRateLimitExceeded(t *testing.T) { // The next request should be rate-limited. loginBody, err := json.Marshal(map[string]string{ - "nick": "nosuchuser", "password": "doesnotmatter", + nickKey: "nosuchuser", passwordKey: "doesnotmatter", }) if err != nil { t.Fatal(err) @@ -2479,13 +2511,13 @@ func TestLoginRateLimitAllowsNormalUse(t *testing.T) { token := tserver.createSession("normaluser") tserver.sendCommand(token, map[string]any{ - commandKey: "PASS", - bodyKey: []string{"password123"}, + commandKey: irc.CmdPass, + bodyKey: []string{testPassword}, }) // A single login should succeed without rate limiting. loginBody, err := json.Marshal(map[string]string{ - "nick": "normaluser", "password": "password123", + nickKey: "normaluser", passwordKey: testPassword, }) if err != nil { t.Fatal(err) @@ -2527,13 +2559,13 @@ func TestNickBroadcastToChannels(t *testing.T) { _, lastID := tserver.pollMessages(bobToken, 0) tserver.sendCommand(aliceToken, map[string]any{ - commandKey: "NICK", + commandKey: irc.CmdNick, bodyKey: []string{"nick_a_new"}, }) msgs, _ := tserver.pollMessages(bobToken, lastID) - if !findMessage(msgs, "NICK", "nick_a") { + if !findMessage(msgs, irc.CmdNick, "nick_a") { t.Fatalf( "bob didn't get nick change: %v", msgs, ) @@ -2596,17 +2628,19 @@ func TestChannelHashcashSetMode(t *testing.T) { } func TestChannelHashcashQueryMode(t *testing.T) { + const channel = "#hcquery" + tserver := newTestServer(t) token := tserver.createSession("hcquery_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#hcquery", + commandKey: joinCmd, toKey: channel, }) // Set hashcash bits. tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcquery", + toKey: channel, bodyKey: []string{"+H", "5"}, }) @@ -2615,7 +2649,7 @@ func TestChannelHashcashQueryMode(t *testing.T) { // Query mode — should show +nH. tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcquery", + toKey: channel, }) msgs, _ := tserver.pollMessages(token, lastID) @@ -2638,24 +2672,26 @@ func TestChannelHashcashQueryMode(t *testing.T) { } func TestChannelHashcashClearMode(t *testing.T) { + const channel = "#hcclear" + tserver := newTestServer(t) token := tserver.createSession("hcclear_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#hcclear", + commandKey: joinCmd, toKey: channel, }) // Set hashcash bits. tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcclear", + toKey: channel, bodyKey: []string{"+H", "5"}, }) // Clear hashcash bits. status, _ := tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcclear", + toKey: channel, bodyKey: []string{"-H"}, }) if status != http.StatusOK { @@ -2667,7 +2703,7 @@ func TestChannelHashcashClearMode(t *testing.T) { token, map[string]any{ commandKey: privmsgCmd, - toKey: "#hcclear", + toKey: channel, bodyKey: []string{"test message"}, }, ) @@ -2679,17 +2715,19 @@ func TestChannelHashcashClearMode(t *testing.T) { } func TestChannelHashcashRejectNoStamp(t *testing.T) { + const channel = "#hcreject" + tserver := newTestServer(t) token := tserver.createSession("hcreject_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#hcreject", + commandKey: joinCmd, toKey: channel, }) // Set hashcash requirement. tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcreject", + toKey: channel, bodyKey: []string{"+H", "2"}, }) @@ -2700,7 +2738,7 @@ func TestChannelHashcashRejectNoStamp(t *testing.T) { token, map[string]any{ commandKey: privmsgCmd, - toKey: "#hcreject", + toKey: channel, bodyKey: []string{"spam message"}, }, ) @@ -2720,17 +2758,22 @@ func TestChannelHashcashRejectNoStamp(t *testing.T) { } func TestChannelHashcashAcceptValidStamp(t *testing.T) { + const ( + channel = "#hcaccept" + text = "hello world" + ) + tserver := newTestServer(t) token := tserver.createSession("hcaccept_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#hcaccept", + commandKey: joinCmd, toKey: channel, }) // Set hashcash requirement (2 bits = fast to mint). tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcaccept", + toKey: channel, bodyKey: []string{"+H", "2"}, }) @@ -2738,14 +2781,14 @@ func TestChannelHashcashAcceptValidStamp(t *testing.T) { // Mint a valid hashcash stamp. msgBody, marshalErr := json.Marshal( - []string{"hello world"}, + []string{text}, ) if marshalErr != nil { t.Fatal(marshalErr) } stamp := mintTestChannelHashcash( - t, 2, "#hcaccept", msgBody, + t, 2, channel, msgBody, ) // Send message with valid hashcash. @@ -2753,8 +2796,8 @@ func TestChannelHashcashAcceptValidStamp(t *testing.T) { token, map[string]any{ commandKey: privmsgCmd, - toKey: "#hcaccept", - bodyKey: []string{"hello world"}, + toKey: channel, + bodyKey: []string{text}, metaKey: map[string]any{ hashcashKey: stamp, }, @@ -2780,17 +2823,22 @@ func TestChannelHashcashAcceptValidStamp(t *testing.T) { } func TestChannelHashcashRejectReplayedStamp(t *testing.T) { + const ( + channel = "#hcreplay" + text = "unique msg" + ) + tserver := newTestServer(t) token := tserver.createSession("hcreplay_user") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#hcreplay", + commandKey: joinCmd, toKey: channel, }) // Set hashcash requirement. tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#hcreplay", + toKey: channel, bodyKey: []string{"+H", "2"}, }) @@ -2798,22 +2846,22 @@ func TestChannelHashcashRejectReplayedStamp(t *testing.T) { // Mint and send once — should succeed. msgBody, marshalErr := json.Marshal( - []string{"unique msg"}, + []string{text}, ) if marshalErr != nil { t.Fatal(marshalErr) } stamp := mintTestChannelHashcash( - t, 2, "#hcreplay", msgBody, + t, 2, channel, msgBody, ) status, _ := tserver.sendCommand( token, map[string]any{ commandKey: privmsgCmd, - toKey: "#hcreplay", - bodyKey: []string{"unique msg"}, + toKey: channel, + bodyKey: []string{text}, metaKey: map[string]any{ hashcashKey: stamp, }, @@ -2830,8 +2878,8 @@ func TestChannelHashcashRejectReplayedStamp(t *testing.T) { token, map[string]any{ commandKey: privmsgCmd, - toKey: "#hcreplay", - bodyKey: []string{"unique msg"}, + toKey: channel, + bodyKey: []string{text}, metaKey: map[string]any{ hashcashKey: stamp, }, @@ -2944,7 +2992,7 @@ func TestNamesShowsHostmask(t *testing.T) { // Issue an explicit NAMES command. tserver.sendCommand(queryToken, map[string]any{ - commandKey: "NAMES", + commandKey: irc.CmdNames, toKey: "#namestest", }) @@ -3089,7 +3137,7 @@ func TestOperCommandSuccess(t *testing.T) { // Send OPER command. tserver.sendCommand(token, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName, testOperPassword}, }) @@ -3112,7 +3160,7 @@ func TestOperCommandFailure(t *testing.T) { // Send OPER with wrong password. tserver.sendCommand(token, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName, "wrongpass"}, }) @@ -3135,7 +3183,7 @@ func TestOperCommandNeedMoreParams(t *testing.T) { // Send OPER with only one parameter. tserver.sendCommand(token, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName}, }) @@ -3162,7 +3210,7 @@ func TestOperWhoisShowsClientInfo(t *testing.T) { // Authenticate as oper. tserver.sendCommand(operToken, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName, testOperPassword}, }) @@ -3179,7 +3227,7 @@ func TestOperWhoisShowsClientInfo(t *testing.T) { // Now WHOIS the target. tserver.sendCommand(operToken, map[string]any{ - commandKey: "WHOIS", + commandKey: irc.CmdWhois, toKey: "target", }) @@ -3230,7 +3278,7 @@ func TestNonOperWhoisHidesClientInfo(t *testing.T) { // WHOIS the target without oper status. tserver.sendCommand(regToken, map[string]any{ - commandKey: "WHOIS", + commandKey: irc.CmdWhois, toKey: "hidden", }) @@ -3262,7 +3310,7 @@ func TestWhoisShowsOperatorStatus(t *testing.T) { _, lastID := tserver.pollMessages(operToken, 0) tserver.sendCommand(operToken, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName, testOperPassword}, }) @@ -3277,7 +3325,7 @@ func TestWhoisShowsOperatorStatus(t *testing.T) { _, queryLastID := tserver.pollMessages(queryToken, 0) tserver.sendCommand(queryToken, map[string]any{ - commandKey: "WHOIS", + commandKey: irc.CmdWhois, toKey: "iamoper", }) @@ -3301,7 +3349,7 @@ func TestOperNoOlineConfigured(t *testing.T) { _, lastID := tserver.pollMessages(token, 0) tserver.sendCommand(token, map[string]any{ - commandKey: "OPER", + commandKey: irc.CmdOper, bodyKey: []string{testOperName, "password"}, }) @@ -3335,7 +3383,7 @@ func TestOperatorAutoGrantOnCreate(t *testing.T) { // Issue NAMES — the creator should have @prefix. tserver.sendCommand(token, map[string]any{ - commandKey: "NAMES", toKey: "#opcreate", + commandKey: irc.CmdNames, toKey: "#opcreate", }) msgs, _ := tserver.pollMessages(token, lastID) @@ -3374,22 +3422,24 @@ func TestOperatorAutoGrantOnCreate(t *testing.T) { // TestSecondJoinerNotOperator verifies that subsequent // joiners do NOT get +o. func TestSecondJoinerNotOperator(t *testing.T) { + const channel = "#optest2" + tserver := newTestServer(t) creatorToken := tserver.createSession("op_first") joinerToken := tserver.createSession("op_second") tserver.sendCommand(creatorToken, map[string]any{ - commandKey: joinCmd, toKey: "#optest2", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(joinerToken, map[string]any{ - commandKey: joinCmd, toKey: "#optest2", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(joinerToken, 0) tserver.sendCommand(joinerToken, map[string]any{ - commandKey: "NAMES", toKey: "#optest2", + commandKey: irc.CmdNames, toKey: channel, }) msgs, _ := tserver.pollMessages(joinerToken, lastID) @@ -3426,15 +3476,17 @@ func TestSecondJoinerNotOperator(t *testing.T) { // TestModeGrantOperator tests MODE +o. func TestModeGrantOperator(t *testing.T) { + const channel = "#modeop" + tserver := newTestServer(t) opToken := tserver.createSession("granter") targetToken := tserver.createSession("grantee") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modeop", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#modeop", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(targetToken, 0) @@ -3442,7 +3494,7 @@ func TestModeGrantOperator(t *testing.T) { // granter (creator = +o) grants +o to grantee. status, _ := tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modeop", + toKey: channel, bodyKey: []string{"+o", "grantee"}, }) if status != http.StatusOK { @@ -3463,7 +3515,7 @@ func TestModeGrantOperator(t *testing.T) { _, lastID = tserver.pollMessages(targetToken, 0) tserver.sendCommand(targetToken, map[string]any{ - commandKey: "NAMES", toKey: "#modeop", + commandKey: irc.CmdNames, toKey: channel, }) msgs, _ = tserver.pollMessages(targetToken, lastID) @@ -3487,27 +3539,29 @@ func TestModeGrantOperator(t *testing.T) { // TestModeRevokeOperator tests MODE -o. func TestModeRevokeOperator(t *testing.T) { + const channel = "#revoke" + tserver := newTestServer(t) opToken := tserver.createSession("revoker") targetToken := tserver.createSession("revokee") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#revoke", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#revoke", + commandKey: joinCmd, toKey: channel, }) // Grant +o, then revoke it. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#revoke", + toKey: channel, bodyKey: []string{"+o", "revokee"}, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#revoke", + toKey: channel, bodyKey: []string{"-o", "revokee"}, }) @@ -3515,7 +3569,7 @@ func TestModeRevokeOperator(t *testing.T) { _, lastID := tserver.pollMessages(opToken, 0) tserver.sendCommand(opToken, map[string]any{ - commandKey: "NAMES", toKey: "#revoke", + commandKey: irc.CmdNames, toKey: channel, }) msgs, _ := tserver.pollMessages(opToken, lastID) @@ -3539,21 +3593,23 @@ func TestModeRevokeOperator(t *testing.T) { // TestModeVoice tests MODE +v/-v. func TestModeVoice(t *testing.T) { + const channel = "#voice" + tserver := newTestServer(t) opToken := tserver.createSession("voicer") targetToken := tserver.createSession("voiced") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#voice", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#voice", + commandKey: joinCmd, toKey: channel, }) // Grant +v. status, _ := tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#voice", + toKey: channel, bodyKey: []string{"+v", "voiced"}, }) if status != http.StatusOK { @@ -3564,7 +3620,7 @@ func TestModeVoice(t *testing.T) { _, lastID := tserver.pollMessages(opToken, 0) tserver.sendCommand(opToken, map[string]any{ - commandKey: "NAMES", toKey: "#voice", + commandKey: irc.CmdNames, toKey: channel, }) msgs, _ := tserver.pollMessages(opToken, lastID) @@ -3584,14 +3640,14 @@ func TestModeVoice(t *testing.T) { // Revoke -v. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#voice", + toKey: channel, bodyKey: []string{"-v", "voiced"}, }) _, lastID = tserver.pollMessages(opToken, 0) tserver.sendCommand(opToken, map[string]any{ - commandKey: "NAMES", toKey: "#voice", + commandKey: irc.CmdNames, toKey: channel, }) msgs, _ = tserver.pollMessages(opToken, lastID) @@ -3612,19 +3668,21 @@ func TestModeVoice(t *testing.T) { // TestModeNonOpCannotGrant verifies non-operators // cannot grant +o or +v. func TestModeNonOpCannotGrant(t *testing.T) { + const channel = "#noperm" + tserver := newTestServer(t) opToken := tserver.createSession("realop") nonOpToken := tserver.createSession("nonop") targetToken := tserver.createSession("target3") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#noperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(nonOpToken, map[string]any{ - commandKey: joinCmd, toKey: "#noperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#noperm", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(nonOpToken, 0) @@ -3632,7 +3690,7 @@ func TestModeNonOpCannotGrant(t *testing.T) { // Non-op tries +o. tserver.sendCommand(nonOpToken, map[string]any{ commandKey: modeCmd, - toKey: "#noperm", + toKey: channel, bodyKey: []string{"+o", "target3"}, }) @@ -3652,7 +3710,7 @@ func TestModeNonOpCannotGrant(t *testing.T) { tserver.sendCommand(nonOpToken, map[string]any{ commandKey: modeCmd, - toKey: "#noperm", + toKey: channel, bodyKey: []string{"+v", "target3"}, }) @@ -3669,21 +3727,23 @@ func TestModeNonOpCannotGrant(t *testing.T) { // TestModeratedChannelBlocksNonVoiced tests +m mode. func TestModeratedChannelBlocksNonVoiced(t *testing.T) { + const channel = "#moderated" + tserver := newTestServer(t) opToken := tserver.createSession("modop") regularToken := tserver.createSession("regular2") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#moderated", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(regularToken, map[string]any{ - commandKey: joinCmd, toKey: "#moderated", + commandKey: joinCmd, toKey: channel, }) // Set +m. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#moderated", + toKey: channel, bodyKey: []string{"+m"}, }) @@ -3692,7 +3752,7 @@ func TestModeratedChannelBlocksNonVoiced(t *testing.T) { // Regular user tries to send — should be blocked. tserver.sendCommand(regularToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#moderated", + toKey: channel, bodyKey: []string{"blocked message"}, }) @@ -3710,21 +3770,23 @@ func TestModeratedChannelBlocksNonVoiced(t *testing.T) { // TestModeratedChannelAllowsOp tests that operators can // send in +m channels. func TestModeratedChannelAllowsOp(t *testing.T) { + const channel = "#modop" + tserver := newTestServer(t) opToken := tserver.createSession("modop2") observerToken := tserver.createSession("observer2") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modop", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(observerToken, map[string]any{ - commandKey: joinCmd, toKey: "#modop", + commandKey: joinCmd, toKey: channel, }) // Set +m. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modop", + toKey: channel, bodyKey: []string{"+m"}, }) @@ -3733,7 +3795,7 @@ func TestModeratedChannelAllowsOp(t *testing.T) { // Op sends — should work. status, result := tserver.sendCommand(opToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#modop", + toKey: channel, bodyKey: []string{"op message"}, }) if status != http.StatusOK { @@ -3755,26 +3817,28 @@ func TestModeratedChannelAllowsOp(t *testing.T) { // TestModeratedChannelAllowsVoiced tests that voiced // users can send in +m channels. func TestModeratedChannelAllowsVoiced(t *testing.T) { + const channel = "#modvoice" + tserver := newTestServer(t) opToken := tserver.createSession("modop3") voicedToken := tserver.createSession("modvoiced") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modvoice", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(voicedToken, map[string]any{ - commandKey: joinCmd, toKey: "#modvoice", + commandKey: joinCmd, toKey: channel, }) // Set +m and +v on voiced user. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modvoice", + toKey: channel, bodyKey: []string{"+m"}, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modvoice", + toKey: channel, bodyKey: []string{"+v", "modvoiced"}, }) @@ -3784,7 +3848,7 @@ func TestModeratedChannelAllowsVoiced(t *testing.T) { status, result := tserver.sendCommand( voicedToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#modvoice", + toKey: channel, bodyKey: []string{"voiced message"}, }, ) @@ -3849,23 +3913,25 @@ func TestTopicLockDefaultOn(t *testing.T) { // TestTopicLockEnforced verifies non-operators cannot // change topic when +t is active. func TestTopicLockEnforced(t *testing.T) { + const channel = "#tlock" + tserver := newTestServer(t) opToken := tserver.createSession("topicop") regularToken := tserver.createSession("topicuser") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#tlock", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(regularToken, map[string]any{ - commandKey: joinCmd, toKey: "#tlock", + commandKey: joinCmd, toKey: channel, }) // +t is on by default. Non-op tries to set topic. _, lastID := tserver.pollMessages(regularToken, 0) tserver.sendCommand(regularToken, map[string]any{ - commandKey: "TOPIC", - toKey: "#tlock", + commandKey: irc.CmdTopic, + toKey: channel, bodyKey: []string{"unauthorized topic"}, }) @@ -3893,7 +3959,7 @@ func TestTopicLockOpCanChange(t *testing.T) { // Op sets topic — should succeed (creator has +o). status, result := tserver.sendCommand( opToken, map[string]any{ - commandKey: "TOPIC", + commandKey: irc.CmdTopic, toKey: "#tlock2", bodyKey: []string{"op topic"}, }, @@ -3915,29 +3981,31 @@ func TestTopicLockOpCanChange(t *testing.T) { // TestTopicLockDisabled verifies that -t allows anyone // to change topic. func TestTopicLockDisabled(t *testing.T) { + const channel = "#tloff" + tserver := newTestServer(t) opToken := tserver.createSession("tloff_op") regularToken := tserver.createSession("tloff_user") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#tloff", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(regularToken, map[string]any{ - commandKey: joinCmd, toKey: "#tloff", + commandKey: joinCmd, toKey: channel, }) // Disable +t. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#tloff", + toKey: channel, bodyKey: []string{"-t"}, }) // Now regular user sets topic — should succeed. status, result := tserver.sendCommand( regularToken, map[string]any{ - commandKey: "TOPIC", - toKey: "#tloff", + commandKey: irc.CmdTopic, + toKey: channel, bodyKey: []string{"user topic"}, }, ) @@ -3958,15 +4026,17 @@ func TestTopicLockDisabled(t *testing.T) { // TestKickByOperator tests that an operator can kick // a user. func TestKickByOperator(t *testing.T) { + const channel = "#kicktest" + tserver := newTestServer(t) opToken := tserver.createSession("kicker") targetToken := tserver.createSession("kicked") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#kicktest", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#kicktest", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(targetToken, 0) @@ -3974,7 +4044,7 @@ func TestKickByOperator(t *testing.T) { // Kick the target. status, _ := tserver.sendCommand(opToken, map[string]any{ commandKey: kickCmd, - toKey: "#kicktest", + toKey: channel, bodyKey: []string{"kicked", "misbehaving"}, }) if status != http.StatusOK { @@ -3997,7 +4067,7 @@ func TestKickByOperator(t *testing.T) { tserver.sendCommand(targetToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#kicktest", + toKey: channel, bodyKey: []string{"still here?"}, }) @@ -4013,19 +4083,21 @@ func TestKickByOperator(t *testing.T) { // TestKickByNonOperator tests that non-operators cannot // kick. func TestKickByNonOperator(t *testing.T) { + const channel = "#kickperm" + tserver := newTestServer(t) opToken := tserver.createSession("op_kick2") nonOpToken := tserver.createSession("nonop_kick") targetToken := tserver.createSession("target_kick") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(nonOpToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickperm", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(nonOpToken, 0) @@ -4033,7 +4105,7 @@ func TestKickByNonOperator(t *testing.T) { // Non-op tries to kick. tserver.sendCommand(nonOpToken, map[string]any{ commandKey: kickCmd, - toKey: "#kickperm", + toKey: channel, bodyKey: []string{"target_kick", "nope"}, }) @@ -4081,26 +4153,28 @@ func TestKickTargetNotInChannel(t *testing.T) { // TestKickBroadcastToChannel verifies the KICK message // is broadcast to all channel members. func TestKickBroadcastToChannel(t *testing.T) { + const channel = "#kickbc" + tserver := newTestServer(t) opToken := tserver.createSession("op_kb") targetToken := tserver.createSession("target_kb") observerToken := tserver.createSession("obs_kb") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickbc", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickbc", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(observerToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickbc", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(observerToken, 0) tserver.sendCommand(opToken, map[string]any{ commandKey: kickCmd, - toKey: "#kickbc", + toKey: channel, bodyKey: []string{"target_kb", "reason"}, }) @@ -4130,7 +4204,7 @@ func TestNoticeNoAwayReply(t *testing.T) { // Send NOTICE — should NOT trigger RPL_AWAY. tserver.sendCommand(senderToken, map[string]any{ - commandKey: "NOTICE", + commandKey: irc.CmdNotice, toKey: "noticeaway", bodyKey: []string{"notice message"}, }) @@ -4182,25 +4256,27 @@ func TestPrivmsgTriggersAway(t *testing.T) { // TestNoticeSkipsHashcash verifies NOTICE bypasses // hashcash on +H channels. func TestNoticeSkipsHashcash(t *testing.T) { + const channel = "#hcnotice" + tserver := newTestServer(t) opToken := tserver.createSession("hcnotice_op") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#hcnotice", + commandKey: joinCmd, toKey: channel, }) // Set hashcash requirement. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#hcnotice", + toKey: channel, bodyKey: []string{"+H", "2"}, }) // Send NOTICE without hashcash — should succeed. status, result := tserver.sendCommand( opToken, map[string]any{ - commandKey: "NOTICE", - toKey: "#hcnotice", + commandKey: irc.CmdNotice, + toKey: channel, bodyKey: []string{"server notice"}, }, ) @@ -4218,28 +4294,30 @@ func TestNoticeSkipsHashcash(t *testing.T) { // TestModeratedNoticeBlocked verifies +m blocks NOTICE // from non-voiced/non-op users too. func TestModeratedNoticeBlocked(t *testing.T) { + const channel = "#modnotice" + tserver := newTestServer(t) opToken := tserver.createSession("modnotop") regularToken := tserver.createSession("modnotice") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modnotice", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(regularToken, map[string]any{ - commandKey: joinCmd, toKey: "#modnotice", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modnotice", + toKey: channel, bodyKey: []string{"+m"}, }) _, lastID := tserver.pollMessages(regularToken, 0) tserver.sendCommand(regularToken, map[string]any{ - commandKey: "NOTICE", - toKey: "#modnotice", + commandKey: irc.CmdNotice, + toKey: channel, bodyKey: []string{"blocked notice"}, }) @@ -4256,22 +4334,24 @@ func TestModeratedNoticeBlocked(t *testing.T) { // TestNonOpCannotSetModerated verifies non-operators // cannot set +m. func TestNonOpCannotSetModerated(t *testing.T) { + const channel = "#modperm" + tserver := newTestServer(t) opToken := tserver.createSession("setmod_op") regularToken := tserver.createSession("setmod_reg") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(regularToken, map[string]any{ - commandKey: joinCmd, toKey: "#modperm", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(regularToken, 0) tserver.sendCommand(regularToken, map[string]any{ commandKey: modeCmd, - toKey: "#modperm", + toKey: channel, bodyKey: []string{"+m"}, }) @@ -4301,17 +4381,7 @@ func TestISupportPrefix(t *testing.T) { params := getNumericParams(isupportMsg) - found := false - - for _, param := range params { - if param == "PREFIX=(ov)@+" { - found = true - - break - } - } - - if !found { + if !slices.Contains(params, "PREFIX=(ov)@+") { t.Fatalf( "expected PREFIX=(ov)@+ in ISUPPORT, "+ "got %v", @@ -4323,23 +4393,25 @@ func TestISupportPrefix(t *testing.T) { // TestModeQueryShowsModerated verifies MODE query shows // +m when set. func TestModeQueryShowsModerated(t *testing.T) { + const channel = "#mqtest" + tserver := newTestServer(t) token := tserver.createSession("mquery") tserver.sendCommand(token, map[string]any{ - commandKey: joinCmd, toKey: "#mqtest", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(token, map[string]any{ commandKey: modeCmd, - toKey: "#mqtest", + toKey: channel, bodyKey: []string{"+m"}, }) _, lastID := tserver.pollMessages(token, 0) tserver.sendCommand(token, map[string]any{ - commandKey: modeCmd, toKey: "#mqtest", + commandKey: modeCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(token, lastID) @@ -4362,15 +4434,17 @@ func TestModeQueryShowsModerated(t *testing.T) { // TestKickDefaultReason verifies KICK uses kicker's // nick as default reason. func TestKickDefaultReason(t *testing.T) { + const channel = "#kickdef" + tserver := newTestServer(t) opToken := tserver.createSession("kickdefop") targetToken := tserver.createSession("kickdeftg") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickdef", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(targetToken, map[string]any{ - commandKey: joinCmd, toKey: "#kickdef", + commandKey: joinCmd, toKey: channel, }) _, lastID := tserver.pollMessages(targetToken, 0) @@ -4378,7 +4452,7 @@ func TestKickDefaultReason(t *testing.T) { // Kick with only nick, no reason. tserver.sendCommand(opToken, map[string]any{ commandKey: kickCmd, - toKey: "#kickdef", + toKey: channel, bodyKey: []string{"kickdeftg"}, }) @@ -4401,17 +4475,19 @@ const ( // TestBanAddRemoveList verifies +b add, list, and -b // remove via MODE commands. func TestBanAddRemoveList(t *testing.T) { + const channel = "#bans" + tserver := newTestServer(t) opToken := tserver.createSession("banop") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#bans", + commandKey: joinCmd, toKey: channel, }) // Add a ban. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#bans", + toKey: channel, bodyKey: []string{"+b", "*!*@evil.com"}, }) @@ -4420,7 +4496,7 @@ func TestBanAddRemoveList(t *testing.T) { // List bans (+b with no argument). tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#bans", + toKey: channel, bodyKey: []string{"+b"}, }) @@ -4440,7 +4516,7 @@ func TestBanAddRemoveList(t *testing.T) { // Remove the ban. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#bans", + toKey: channel, bodyKey: []string{"-b", "*!*@evil.com"}, }) @@ -4449,11 +4525,12 @@ func TestBanAddRemoveList(t *testing.T) { // List again — should be empty (just end-of-list). tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#bans", + toKey: channel, bodyKey: []string{"+b"}, }) msgs, _ = tserver.pollMessages(opToken, lastID) + banMsg = findNumericWithParams(msgs, "367") if banMsg != nil { t.Fatal("expected no 367 after ban removal") @@ -4467,24 +4544,26 @@ func TestBanAddRemoveList(t *testing.T) { // TestBanBlocksJoin verifies that a banned user cannot // join a channel. func TestBanBlocksJoin(t *testing.T) { + const channel = "#banjoin" + tserver := newTestServer(t) opToken := tserver.createSession("banop2") userToken := tserver.createSession("banned2") // Op creates channel and sets a ban. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#banjoin", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#banjoin", + toKey: channel, bodyKey: []string{"+b", "banned2!*@*"}, }) // Banned user tries to join. _, lastID := tserver.pollMessages(userToken, 0) tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#banjoin", + commandKey: joinCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(userToken, lastID) @@ -4498,22 +4577,24 @@ 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) { + const channel = "#banmsg" + tserver := newTestServer(t) opToken := tserver.createSession("banmsgop") userToken := tserver.createSession("banmsgusr") // Both join. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#banmsg", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#banmsg", + commandKey: joinCmd, toKey: channel, }) // Op bans the user. tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#banmsg", + toKey: channel, bodyKey: []string{"+b", "banmsgusr!*@*"}, }) @@ -4521,7 +4602,7 @@ func TestBanBlocksPrivmsg(t *testing.T) { _, lastID := tserver.pollMessages(userToken, 0) tserver.sendCommand(userToken, map[string]any{ commandKey: privmsgCmd, - toKey: "#banmsg", + toKey: channel, bodyKey: []string{"hello"}, }) @@ -4536,24 +4617,26 @@ func TestBanBlocksPrivmsg(t *testing.T) { // TestInviteOnlyJoin verifies +i behavior: join rejected // without invite, accepted with invite. func TestInviteOnlyJoin(t *testing.T) { + const channel = "#invonly" + tserver := newTestServer(t) opToken := tserver.createSession("invop") userToken := tserver.createSession("invusr") // Op creates channel and sets +i. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#invonly", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#invonly", + toKey: channel, bodyKey: []string{"+i"}, }) // User tries to join without invite. _, lastID := tserver.pollMessages(userToken, 0) tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#invonly", + commandKey: joinCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(userToken, lastID) @@ -4568,12 +4651,12 @@ func TestInviteOnlyJoin(t *testing.T) { // Op invites user. tserver.sendCommand(opToken, map[string]any{ commandKey: inviteCmd, - bodyKey: []string{"invusr", "#invonly"}, + bodyKey: []string{"invusr", channel}, }) // User tries again — should succeed with invite. _, result := tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#invonly", + commandKey: joinCmd, toKey: channel, }) if result[statusKey] != joinedStatus { @@ -4587,17 +4670,19 @@ func TestInviteOnlyJoin(t *testing.T) { // TestSecretChannelHiddenFromList verifies +s hides a // channel from LIST for non-members. func TestSecretChannelHiddenFromList(t *testing.T) { + const channel = "#secret" + tserver := newTestServer(t) opToken := tserver.createSession("secop") outsiderToken := tserver.createSession("secout") // Op creates secret channel. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#secret", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#secret", + toKey: channel, bodyKey: []string{"+s"}, }) @@ -4618,7 +4703,7 @@ func TestSecretChannelHiddenFromList(t *testing.T) { params := getNumericParams(msg) for _, p := range params { - if p == "#secret" { + if p == channel { t.Fatal("outsider should not see #secret in LIST") } } @@ -4642,7 +4727,7 @@ func TestSecretChannelHiddenFromList(t *testing.T) { params := getNumericParams(msg) for _, p := range params { - if p == "#secret" { + if p == channel { found = true } } @@ -4656,24 +4741,26 @@ func TestSecretChannelHiddenFromList(t *testing.T) { // TestChannelKeyJoin verifies +k behavior: wrong/missing // key is rejected, correct key allows join. func TestChannelKeyJoin(t *testing.T) { + const channel = "#keyed" + tserver := newTestServer(t) opToken := tserver.createSession("keyop") userToken := tserver.createSession("keyusr") // Op creates keyed channel. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#keyed", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#keyed", + toKey: channel, bodyKey: []string{"+k", "mykey"}, }) // User tries without key. _, lastID := tserver.pollMessages(userToken, 0) tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#keyed", + commandKey: joinCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(userToken, lastID) @@ -4688,7 +4775,7 @@ func TestChannelKeyJoin(t *testing.T) { // User tries with wrong key. tserver.sendCommand(userToken, map[string]any{ commandKey: joinCmd, - toKey: "#keyed", + toKey: channel, bodyKey: []string{"wrongkey"}, }) @@ -4703,7 +4790,7 @@ func TestChannelKeyJoin(t *testing.T) { // User tries with correct key. _, result := tserver.sendCommand(userToken, map[string]any{ commandKey: joinCmd, - toKey: "#keyed", + toKey: channel, bodyKey: []string{"mykey"}, }) @@ -4718,6 +4805,8 @@ func TestChannelKeyJoin(t *testing.T) { // TestUserLimitEnforcement verifies +l behavior: blocks // join when at capacity. func TestUserLimitEnforcement(t *testing.T) { + const channel = "#limited" + tserver := newTestServer(t) opToken := tserver.createSession("limop") user1Token := tserver.createSession("limusr1") @@ -4725,17 +4814,17 @@ func TestUserLimitEnforcement(t *testing.T) { // Op creates channel with limit 2. tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#limited", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#limited", + toKey: channel, bodyKey: []string{"+l", "2"}, }) // User1 joins — should succeed (2 members now: op + user1). _, result := tserver.sendCommand(user1Token, map[string]any{ - commandKey: joinCmd, toKey: "#limited", + commandKey: joinCmd, toKey: channel, }) if result[statusKey] != joinedStatus { t.Fatalf("user1 should join, got %v", result) @@ -4744,7 +4833,7 @@ func TestUserLimitEnforcement(t *testing.T) { // User2 tries to join — should fail (at limit: 2/2). _, lastID := tserver.pollMessages(user2Token, 0) tserver.sendCommand(user2Token, map[string]any{ - commandKey: joinCmd, toKey: "#limited", + commandKey: joinCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(user2Token, lastID) @@ -4760,11 +4849,13 @@ func TestUserLimitEnforcement(t *testing.T) { // TestModeStringIncludesNewModes verifies that querying // channel mode returns the new modes (+i, +s, +k, +l). func TestModeStringIncludesNewModes(t *testing.T) { + const channel = "#modestr" + tserver := newTestServer(t) opToken := tserver.createSession("modestrop") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#modestr", + commandKey: joinCmd, toKey: channel, }) // Set all tier 2 modes. @@ -4773,7 +4864,7 @@ func TestModeStringIncludesNewModes(t *testing.T) { } { tserver.sendCommand(opToken, map[string]any{ commandKey: modeCmd, - toKey: "#modestr", + toKey: channel, bodyKey: modeChange, }) } @@ -4782,7 +4873,7 @@ func TestModeStringIncludesNewModes(t *testing.T) { // Query mode. tserver.sendCommand(opToken, map[string]any{ - commandKey: modeCmd, toKey: "#modestr", + commandKey: modeCmd, toKey: channel, }) msgs, _ := tserver.pollMessages(opToken, lastID) @@ -4838,15 +4929,17 @@ func TestISUPPORT(t *testing.T) { // TestNonOpCannotSetModes verifies non-operators // cannot set +i, +s, +k, +l, +b. func TestNonOpCannotSetModes(t *testing.T) { + const channel = "#noperm" + tserver := newTestServer(t) opToken := tserver.createSession("modeopx") userToken := tserver.createSession("modeusrx") tserver.sendCommand(opToken, map[string]any{ - commandKey: joinCmd, toKey: "#noperm", + commandKey: joinCmd, toKey: channel, }) tserver.sendCommand(userToken, map[string]any{ - commandKey: joinCmd, toKey: "#noperm", + commandKey: joinCmd, toKey: channel, }) modes := [][]string{ @@ -4858,7 +4951,7 @@ func TestNonOpCannotSetModes(t *testing.T) { _, lastID := tserver.pollMessages(userToken, 0) tserver.sendCommand(userToken, map[string]any{ commandKey: modeCmd, - toKey: "#noperm", + toKey: channel, bodyKey: modeChange, }) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 56033be..7104c13 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -117,11 +117,11 @@ func (hdlr *Handlers) executeLogin( request, clientID, sessionID, nick, ) - hdlr.setAuthCookie(writer, request, token) + hdlr.setAuthCookie(writer, token) hdlr.respondJSON(writer, request, map[string]any{ - "id": sessionID, - "nick": nick, + "id": sessionID, + nickKey: nick, }, http.StatusOK) } @@ -177,6 +177,6 @@ func (hdlr *Handlers) handlePass( } hdlr.respondJSON(writer, request, - map[string]string{"status": "ok"}, + map[string]string{statusKey: "ok"}, http.StatusOK) } diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 81d1acb..a150cc8 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -24,6 +24,14 @@ import ( var errUnauthorized = errors.New("unauthorized") +// Field names and values used in many JSON responses. +const ( + statusKey = "status" + statusError = "error" + errorKey = "error" + nickKey = "nick" +) + // Params defines the dependencies for creating Handlers. type Params struct { fx.In @@ -137,7 +145,7 @@ func (hdlr *Handlers) respondError( ) { hdlr.respondJSON( writer, request, - map[string]string{"error": msg}, + map[string]string{errorKey: msg}, status, ) } diff --git a/internal/hashcash/hashcash.go b/internal/hashcash/hashcash.go index 345c20b..2c00725 100644 --- a/internal/hashcash/hashcash.go +++ b/internal/hashcash/hashcash.go @@ -100,9 +100,10 @@ func (v *Validator) Validate( dateStr := parts[2] resource := parts[3] - if err := v.validateHeader( + err := v.validateHeader( version, bitsStr, resource, requiredBits, - ); err != nil { + ) + if err != nil { return err } @@ -111,13 +112,13 @@ func (v *Validator) Validate( return err } - if err := validateTime(stampTime); err != nil { + err = validateTime(stampTime) + if err != nil { return err } - if err := validateProof( - stamp, requiredBits, - ); err != nil { + err = validateProof(stamp, requiredBits) + if err != nil { return err } diff --git a/internal/ircserver/commands.go b/internal/ircserver/commands.go index a336ec1..e6c3356 100644 --- a/internal/ircserver/commands.go +++ b/internal/ircserver/commands.go @@ -159,9 +159,7 @@ func (c *Conn) handleJoin( return } - channels := strings.Split(msg.Params[0], ",") - - for _, chanName := range channels { + for chanName := range strings.SplitSeq(msg.Params[0], ",") { chanName = strings.TrimSpace(chanName) if !strings.HasPrefix(chanName, "#") { @@ -305,9 +303,7 @@ func (c *Conn) handlePart( reason = msg.Params[1] } - channels := strings.Split(msg.Params[0], ",") - - for _, ch := range channels { + for ch := range strings.SplitSeq(msg.Params[0], ",") { ch = strings.TrimSpace(ch) c.partChannel(ctx, ch, reason) } @@ -619,8 +615,8 @@ func (c *Conn) applyChannelModes( ) { adding := true argIdx := 0 - applied := "" - appliedArgs := "" + + var applied, appliedArgs strings.Builder for _, modeChar := range modeStr { var res modeResult @@ -672,16 +668,13 @@ func (c *Conn) applyChannelModes( argIdx += res.consumed if !res.skip { - applied += res.applied - appliedArgs += res.appliedArgs + applied.WriteString(res.applied) + appliedArgs.WriteString(res.appliedArgs) } } - if applied != "" { - modeReply := applied - if appliedArgs != "" { - modeReply += appliedArgs - } + if applied.Len() > 0 { + modeReply := applied.String() + appliedArgs.String() c.send(FormatMessage( c.hostmask(), "MODE", channel, modeReply, diff --git a/internal/ircserver/conn.go b/internal/ircserver/conn.go index bd4fbb7..fdd6b35 100644 --- a/internal/ircserver/conn.go +++ b/internal/ircserver/conn.go @@ -62,7 +62,6 @@ type Conn struct { lastQueueID int64 closed bool - cancel context.CancelFunc } func newConn( @@ -151,8 +150,10 @@ func resolveHost(ctx context.Context, addr string) string { } // serve is the main loop for a single IRC client connection. +// Cancelling ctx when it returns stops the relay goroutine. func (c *Conn) serve(ctx context.Context) { - ctx, c.cancel = context.WithCancel(ctx) + ctx, cancel := context.WithCancel(ctx) + defer cancel() defer c.cleanup(ctx) scanner := bufio.NewScanner(c.conn) @@ -481,7 +482,7 @@ func (c *Conn) deliverMOTD() { "- %s Message of the Day -", c.serverSfx, )) - for _, line := range strings.Split(motd, "\n") { + for line := range strings.SplitSeq(motd, "\n") { c.sendNumeric(irc.RplMotd, "- "+line) } diff --git a/internal/ircserver/integration_test.go b/internal/ircserver/integration_test.go index d6da16b..57cd413 100644 --- a/internal/ircserver/integration_test.go +++ b/internal/ircserver/integration_test.go @@ -282,6 +282,7 @@ func TestIntegrationTwoClients(t *testing.T) { // Both nicks should appear in the name list. foundBothNames := false + for _, line := range aliceNames { if strings.Contains(line, " 353 ") && strings.Contains(line, "alice") && @@ -671,6 +672,7 @@ func TestIntegrationTwoClients(t *testing.T) { }) foundPartErr := false + for _, line := range bobPartFail { if strings.Contains(line, " 403 ") || strings.Contains(line, " 442 ") { @@ -833,6 +835,7 @@ func TestIntegrationModeModerated(t *testing.T) { }) foundModErr := false + for _, line := range bobLines { if strings.Contains(line, " 404 ") || strings.Contains(line, " 482 ") { @@ -859,6 +862,7 @@ func TestIntegrationModeModerated(t *testing.T) { }) bob.send("PRIVMSG #modtest :voiced message") + aliceLines := alice.readUntil(func(l string) bool { return strings.Contains(l, "voiced message") }) diff --git a/internal/ircserver/parser_test.go b/internal/ircserver/parser_test.go index 23fdd5f..936e811 100644 --- a/internal/ircserver/parser_test.go +++ b/internal/ircserver/parser_test.go @@ -4,12 +4,18 @@ import ( "testing" "sneak.berlin/go/neoirc/internal/ircserver" + "sneak.berlin/go/neoirc/pkg/irc" ) //nolint:funlen // table-driven test func TestParseMessage(t *testing.T) { t.Parallel() + const ( + nick = "alice" + channel = "#general" + ) + tests := []struct { name string input string @@ -24,10 +30,10 @@ func TestParseMessage(t *testing.T) { }, { name: "simple command", - input: "PING", + input: irc.CmdPing, want: &ircserver.Message{ Prefix: "", - Command: "PING", + Command: irc.CmdPing, Params: nil, }, wantNil: false, @@ -38,7 +44,7 @@ func TestParseMessage(t *testing.T) { want: &ircserver.Message{ Prefix: "", Command: "NICK", - Params: []string{"alice"}, + Params: []string{nick}, }, wantNil: false, }, @@ -57,8 +63,8 @@ func TestParseMessage(t *testing.T) { input: "PRIVMSG #general :hello world", want: &ircserver.Message{ Prefix: "", - Command: "PRIVMSG", - Params: []string{"#general", "hello world"}, + Command: irc.CmdPrivmsg, + Params: []string{channel, "hello world"}, }, wantNil: false, }, @@ -68,7 +74,7 @@ func TestParseMessage(t *testing.T) { want: &ircserver.Message{ Prefix: "server.example.com", Command: "001", - Params: []string{"alice", "Welcome to IRC"}, + Params: []string{nick, "Welcome to IRC"}, }, wantNil: false, }, @@ -79,7 +85,7 @@ func TestParseMessage(t *testing.T) { Prefix: "", Command: "USER", Params: []string{ - "alice", "0", "*", "Alice Smith", + nick, "0", "*", "Alice Smith", }, }, wantNil: false, @@ -90,7 +96,7 @@ func TestParseMessage(t *testing.T) { want: &ircserver.Message{ Prefix: "", Command: "JOIN", - Params: []string{"#general"}, + Params: []string{channel}, }, wantNil: false, }, @@ -99,17 +105,17 @@ func TestParseMessage(t *testing.T) { input: "QUIT :leaving now", want: &ircserver.Message{ Prefix: "", - Command: "QUIT", + Command: irc.CmdQuit, Params: []string{"leaving now"}, }, wantNil: false, }, { name: "quit without reason", - input: "QUIT", + input: irc.CmdQuit, want: &ircserver.Message{ Prefix: "", - Command: "QUIT", + Command: irc.CmdQuit, Params: nil, }, wantNil: false, @@ -120,7 +126,7 @@ func TestParseMessage(t *testing.T) { want: &ircserver.Message{ Prefix: "", Command: "MODE", - Params: []string{"#general"}, + Params: []string{channel}, }, wantNil: false, }, @@ -131,7 +137,7 @@ func TestParseMessage(t *testing.T) { Prefix: "", Command: "KICK", Params: []string{ - "#general", "bob", "misbehaving", + channel, "bob", "misbehaving", }, }, wantNil: false, @@ -141,8 +147,8 @@ func TestParseMessage(t *testing.T) { input: "PRIVMSG #general :", want: &ircserver.Message{ Prefix: "", - Command: "PRIVMSG", - Params: []string{"#general", ""}, + Command: irc.CmdPrivmsg, + Params: []string{channel, ""}, }, wantNil: false, }, @@ -161,7 +167,7 @@ func TestParseMessage(t *testing.T) { input: "PING :irc.example.com", want: &ircserver.Message{ Prefix: "", - Command: "PING", + Command: irc.CmdPing, Params: []string{"irc.example.com"}, }, wantNil: false, @@ -173,7 +179,7 @@ func TestParseMessage(t *testing.T) { Prefix: "", Command: "TOPIC", Params: []string{ - "#general", + channel, "Welcome to the channel!", }, }, @@ -237,6 +243,12 @@ func TestParseMessage(t *testing.T) { func TestFormatMessage(t *testing.T) { t.Parallel() + const ( + nick = "alice" + channel = "#general" + serverName = "server" + ) + tests := []struct { name string prefix string @@ -247,35 +259,35 @@ func TestFormatMessage(t *testing.T) { { name: "simple command", prefix: "", - command: "PING", + command: irc.CmdPing, params: nil, - want: "PING", + want: irc.CmdPing, }, { name: "with prefix", - prefix: "server", + prefix: serverName, command: "PONG", - params: []string{"server"}, + params: []string{serverName}, want: ":server PONG server", }, { name: "privmsg with trailing", prefix: "alice!alice@host", - command: "PRIVMSG", - params: []string{"#general", "hello world"}, + command: irc.CmdPrivmsg, + params: []string{channel, "hello world"}, want: ":alice!alice@host PRIVMSG #general :hello world", }, { name: "numeric reply", - prefix: "server", + prefix: serverName, command: "001", - params: []string{"alice", "Welcome to IRC"}, + params: []string{nick, "Welcome to IRC"}, want: ":server 001 alice :Welcome to IRC", }, { name: "empty trailing", - prefix: "server", - command: "PRIVMSG", + prefix: serverName, + command: irc.CmdPrivmsg, params: []string{"#chan", ""}, want: ":server PRIVMSG #chan :", }, @@ -302,7 +314,7 @@ func TestParseFormatRoundTrip(t *testing.T) { // parameter either contains a space (gets ':' prefix // on format) or is a non-trailing single token. lines := []string{ - "PING", + irc.CmdPing, "NICK alice", "PRIVMSG #general :hello world", "JOIN #general", diff --git a/internal/ircserver/server.go b/internal/ircserver/server.go index 5aeeab0..e3b6d2e 100644 --- a/internal/ircserver/server.go +++ b/internal/ircserver/server.go @@ -81,22 +81,24 @@ func New( // start begins listening for TCP connections. // //nolint:contextcheck // long-lived server ctx, not the short Fx one -func (s *Server) start(_ context.Context, addr string) error { - ln, err := net.Listen("tcp", addr) +func (s *Server) start(ctx context.Context, addr string) error { + var listenConfig net.ListenConfig + + ln, err := listenConfig.Listen(ctx, "tcp", addr) if err != nil { return fmt.Errorf("irc listen: %w", err) } s.listener = ln - ctx, cancel := context.WithCancel(context.Background()) + serverCtx, cancel := context.WithCancel(context.Background()) s.cancel = cancel s.log.Info( "irc server listening", "addr", addr, ) - go s.acceptLoop(ctx) + go s.acceptLoop(serverCtx) return nil } diff --git a/internal/ircserver/server_test.go b/internal/ircserver/server_test.go index ead0963..476e244 100644 --- a/internal/ircserver/server_test.go +++ b/internal/ircserver/server_test.go @@ -71,7 +71,9 @@ func newTestEnv(t *testing.T) *testEnv { MOTD: "Welcome to test IRC", } - listener, err := net.Listen("tcp", "127.0.0.1:0") + var listenConfig net.ListenConfig + + listener, err := listenConfig.Listen(t.Context(), "tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } @@ -116,10 +118,12 @@ func newTestEnv(t *testing.T) *testEnv { func (env *testEnv) dial(t *testing.T) *testClient { t.Helper() - conn, err := net.DialTimeout( + dialer := net.Dialer{Timeout: testTimeout} + + conn, err := dialer.DialContext( + t.Context(), "tcp", env.srv.Listener().Addr().String(), - testTimeout, ) if err != nil { t.Fatalf("dial: %v", err) @@ -323,6 +327,7 @@ func TestPrivmsgBetweenClients(t *testing.T) { bob.joinAndDrain("#chat") alice.send("PRIVMSG #chat :hello bob!") + lines := bob.sendAndExpect("PING :sync", "hello bob!") assertContains(t, lines, "hello bob!", "channel PRIVMSG") } diff --git a/internal/server/server.go b/internal/server/server.go index 1006342..d19f869 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -78,7 +78,9 @@ func New( srv.enableSentry() srv.SetupRoutes() - go srv.serve() //nolint:contextcheck + // The start hook's context ends when the hook + // returns; serving must outlive it. + go srv.serve() //nolint:contextcheck,gosec // G118 return nil }, diff --git a/internal/service/service.go b/internal/service/service.go index bc6b09c..690ce08 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -19,6 +19,12 @@ import ( "sneak.berlin/go/neoirc/pkg/irc" ) +// Error texts that several commands reply with. +const ( + msgNoSuchChannel = "No such channel" + msgNotChannelOp = "You're not channel operator" +) + // Params defines the dependencies for creating a Service. type Params struct { fx.In @@ -142,7 +148,7 @@ func (s *Service) SendChannelMessage( return 0, "", &IRCError{ irc.ErrNoSuchChannel, []string{channel}, - "No such channel", + msgNoSuchChannel, } } @@ -256,10 +262,11 @@ func (s *Service) JoinChannel( isCreator := countErr == nil && memberCount == 0 if !isCreator { - if joinErr := checkJoinRestrictions( + joinErr := checkJoinRestrictions( ctx, s.db, chID, sessionID, channel, suppliedKey, memberCount, - ); joinErr != nil { + ) + if joinErr != nil { return nil, joinErr } } @@ -306,7 +313,7 @@ func (s *Service) PartChannel( return &IRCError{ irc.ErrNoSuchChannel, []string{channel}, - "No such channel", + msgNoSuchChannel, } } @@ -348,7 +355,7 @@ func (s *Service) SetTopic( return &IRCError{ irc.ErrNoSuchChannel, []string{channel}, - "No such channel", + msgNoSuchChannel, } } @@ -372,14 +379,13 @@ func (s *Service) SetTopic( return &IRCError{ irc.ErrChanOpPrivsNeeded, []string{channel}, - "You're not channel operator", + msgNotChannelOp, } } } - if setErr := s.db.SetTopic( - ctx, channel, topic, - ); setErr != nil { + setErr := s.db.SetTopic(ctx, channel, topic) + if setErr != nil { return fmt.Errorf("set topic: %w", setErr) } @@ -409,7 +415,7 @@ func (s *Service) KickUser( return &IRCError{ irc.ErrNoSuchChannel, []string{channel}, - "No such channel", + msgNoSuchChannel, } } @@ -420,7 +426,7 @@ func (s *Service) KickUser( return &IRCError{ irc.ErrChanOpPrivsNeeded, []string{channel}, - "You're not channel operator", + msgNotChannelOp, } } @@ -609,7 +615,7 @@ func (s *Service) ValidateChannelOp( return 0, &IRCError{ irc.ErrNoSuchChannel, []string{channel}, - "No such channel", + msgNoSuchChannel, } } @@ -620,7 +626,7 @@ func (s *Service) ValidateChannelOp( return 0, &IRCError{ irc.ErrChanOpPrivsNeeded, []string{channel}, - "You're not channel operator", + msgNotChannelOp, } } @@ -682,33 +688,28 @@ func (s *Service) SetChannelFlag( ) error { switch flag { case 'm': - if err := s.db.SetChannelModerated( - ctx, chID, setting, - ); err != nil { + err := s.db.SetChannelModerated(ctx, chID, setting) + if err != nil { return fmt.Errorf("set moderated: %w", err) } case 't': - if err := s.db.SetChannelTopicLocked( - ctx, chID, setting, - ); err != nil { + err := s.db.SetChannelTopicLocked(ctx, chID, setting) + if err != nil { return fmt.Errorf("set topic locked: %w", err) } case 'i': - if err := s.db.SetChannelInviteOnly( - ctx, chID, setting, - ); err != nil { + err := s.db.SetChannelInviteOnly(ctx, chID, setting) + if err != nil { return fmt.Errorf("set invite only: %w", err) } case 's': - if err := s.db.SetChannelSecret( - ctx, chID, setting, - ); err != nil { + err := s.db.SetChannelSecret(ctx, chID, setting) + if err != nil { return fmt.Errorf("set secret: %w", err) } case 'n': - if err := s.db.SetChannelNoExternal( - ctx, chID, setting, - ); err != nil { + err := s.db.SetChannelNoExternal(ctx, chID, setting) + if err != nil { return fmt.Errorf( "set no external: %w", err, ) diff --git a/package.json b/package.json new file mode 100644 index 0000000..514f67b --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "devDependencies": { + "prettier": "3.8.1" + } +} diff --git a/pkg/irc/numerics.go b/pkg/irc/numerics.go index b7bba22..9146608 100644 --- a/pkg/irc/numerics.go +++ b/pkg/irc/numerics.go @@ -68,6 +68,7 @@ const ( // Command responses (200-399). const ( // RFC 2812 trace/stats/links replies (200-219). + RplTraceLink IRCMessageType = 200 RplTraceConnecting IRCMessageType = 201 RplTraceHandshake IRCMessageType = 202 @@ -224,7 +225,7 @@ const ( // names maps numeric codes to their standard IRC names. // -//nolint:gochecknoglobals +//nolint:gochecknoglobals,gosec // G101: IRC numeric names, not credentials var names = map[IRCMessageType]string{ RplWelcome: "RPL_WELCOME", RplYourHost: "RPL_YOURHOST", diff --git a/schema/README.md b/schema/README.md index 6aaa5fb..4a91ae6 100644 --- a/schema/README.md +++ b/schema/README.md @@ -22,19 +22,20 @@ Structured: {"command": "PUBKEY", "body": {"alg": "ed25519", "key": "base64..."} Common fields (see `message.json` for full schema): -| 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.) | +| 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.) | **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: + - Multiline messages without escape sequences - Deterministic canonicalization via RFC 8785 JCS for signing - Structured data where needed @@ -43,20 +44,20 @@ object (for structured data like PUBKEY). Never a raw string. This enables: IRC commands used for client↔server and server↔server communication. -| 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 | +| 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 | ## Numeric Replies @@ -64,30 +65,30 @@ Three-digit codes for server responses, per IRC convention. ### Success / Informational (0xx–3xx) -| Code | Name | File | Description | -|-------|-------------------|-----------------------|--------------------------------| -| `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation | -| `002` | RPL_YOURHOST | `numerics/002.json` | Server host info | -| `003` | RPL_CREATED | `numerics/003.json` | Server creation date | -| `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes | -| `322` | RPL_LIST | `numerics/322.json` | Channel list entry | -| `323` | RPL_LISTEND | `numerics/323.json` | End of channel list | -| `332` | RPL_TOPIC | `numerics/332.json` | Channel topic | -| `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list | -| `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list | -| `372` | RPL_MOTD | `numerics/372.json` | MOTD line | -| `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD | -| `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD | +| Code | Name | File | Description | +| ----- | -------------- | ------------------- | ------------------------------ | +| `001` | RPL_WELCOME | `numerics/001.json` | Welcome after session creation | +| `002` | RPL_YOURHOST | `numerics/002.json` | Server host info | +| `003` | RPL_CREATED | `numerics/003.json` | Server creation date | +| `004` | RPL_MYINFO | `numerics/004.json` | Server info and modes | +| `322` | RPL_LIST | `numerics/322.json` | Channel list entry | +| `323` | RPL_LISTEND | `numerics/323.json` | End of channel list | +| `332` | RPL_TOPIC | `numerics/332.json` | Channel topic | +| `353` | RPL_NAMREPLY | `numerics/353.json` | Channel member list | +| `366` | RPL_ENDOFNAMES | `numerics/366.json` | End of NAMES list | +| `372` | RPL_MOTD | `numerics/372.json` | MOTD line | +| `375` | RPL_MOTDSTART | `numerics/375.json` | Start of MOTD | +| `376` | RPL_ENDOFMOTD | `numerics/376.json` | End of MOTD | ### Errors (4xx) -| Code | Name | File | Description | -|-------|----------------------|-----------------------|--------------------------------| -| `401` | ERR_NOSUCHNICK | `numerics/401.json` | No such nick/channel | -| `403` | ERR_NOSUCHCHANNEL | `numerics/403.json` | No such channel | -| `433` | ERR_NICKNAMEINUSE | `numerics/433.json` | Nickname already in use | -| `442` | ERR_NOTONCHANNEL | `numerics/442.json` | Not on that channel | -| `482` | ERR_CHANOPRIVSNEEDED | `numerics/482.json` | Not channel operator | +| 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) diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..2ee1e02 --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,143 @@ +#!/bin/sh +# script/bootstrap: install all dependencies needed to build and develop +# this repo. Idempotent: every install is guarded by a check so already +# installed tools are skipped. Base tooling comes from nix, apt, brew, +# or apk (detected in that order); assumes nothing is present. Node is +# used directly if installed; otherwise it is installed at a pinned +# version via nvm (installing nvm itself first, from a hash-verified +# release archive, never curl | sh). +# +# The Go install at the end of main() is this repo's addition. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Pinned versions, 2026-07-06 +NODE_VERSION="22.17.0" +NVM_VERSION="0.40.3" +# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz +NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0" +YARN_VERSION="1.22.22" + +PKGMGR="" +SUDO="" + +detect_pkgmgr() { + [ -n "$PKGMGR" ] && return 0 + if command -v nix-env >/dev/null 2>&1; then + PKGMGR="nix" + elif command -v apt-get >/dev/null 2>&1; then + PKGMGR="apt" + elif command -v brew >/dev/null 2>&1; then + PKGMGR="brew" + elif command -v apk >/dev/null 2>&1; then + PKGMGR="apk" + else + echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2 + exit 1 + fi + if [ "$PKGMGR" = "apt" ]; then + export DEBIAN_FRONTEND=noninteractive + if [ "$(id -u)" != "0" ]; then + SUDO="sudo" + fi + fi +} + +# pkg_install +pkg_install() { + detect_pkgmgr + case "$PKGMGR" in + nix) nix-env -iA "nixpkgs.$1" ;; + apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;; + brew) brew install "$3" ;; + apk) apk add --no-cache "$4" ;; + esac +} + +missing() { + ! command -v "$1" >/dev/null 2>&1 +} + +# verify_sha256 +verify_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$1" | cut -d' ' -f1)" + else + actual="$(shasum -a 256 "$1" | cut -d' ' -f1)" + fi + if [ "$actual" != "$2" ]; then + echo "bootstrap: sha256 mismatch for $1" >&2 + echo " expected: $2" >&2 + echo " actual: $actual" >&2 + exit 1 + fi +} + +# nvm is a bash script; run a command in a bash with nvm loaded +nvm_sh() { + bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*" +} + +ensure_nvm() { + [ -s "$HOME/.nvm/nvm.sh" ] && return 0 + # nvm prerequisites; nvm itself requires bash + if missing bash; then pkg_install bash bash bash bash; fi + if missing curl; then pkg_install curl curl curl curl; fi + if missing git; then pkg_install git git git git; fi + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/nvm.tar.gz" \ + "https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz" + verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256" + mkdir -p "$HOME/.nvm" + tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1 + rm -rf "$tmp" +} + +ensure_node() { + if ! missing node; then return 0; fi + ensure_nvm + nvm_sh "nvm install $NODE_VERSION" +} + +ensure_yarn() { + if ! missing yarn; then return 0; fi + if ! missing corepack; then + corepack enable + corepack prepare "yarn@$YARN_VERSION" --activate + elif [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \ + corepack prepare yarn@$YARN_VERSION --activate" + else + npm install -g "yarn@$YARN_VERSION" + fi +} + +install_js_deps() { + if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ + yarn install --frozen-lockfile" + else + yarn install --frozen-lockfile + fi +} + +main() { + cd "$ROOT" + + if missing make; then pkg_install gnumake make make make; fi + if missing git; then pkg_install git git git git; fi + + ensure_node + ensure_yarn + install_js_deps + + # Go, for make build, script/precommit and the gofmt step of + # script/fmt and script/fmt-check. golangci-lint is not installed: it + # runs only in the Dockerfile's lint phase. + if missing go; then pkg_install go golang go go; fi + + echo "bootstrap complete" +} + +main "$@" diff --git a/script/check b/script/check new file mode 100755 index 0000000..92875f7 --- /dev/null +++ b/script/check @@ -0,0 +1,16 @@ +#!/bin/sh +# script/check: run all checks (test, lint, fmt-check). Our own +# extension to scripts-to-rule-them-all. test and lint are Docker +# phases; fmt-check is native, because a formatter writes the working +# tree. Must not modify any files. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/test" + "$SCRIPT_DIR/lint" + "$SCRIPT_DIR/fmt-check" +} + +main "$@" diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..d8d3200 --- /dev/null +++ b/script/cibuild @@ -0,0 +1,28 @@ +#!/bin/sh +# script/cibuild: run the CI build. It bootstraps first: a CI runner +# checks out and runs this and nothing else, and script/fmt-check runs +# the formatter on the host, which a pristine checkout cannot do. +# --no-cache for the same reason as script/docker: the gate phases the +# final stage depends on are RUN steps, and a cached one is a check that +# did not run. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/docker b/script/docker new file mode 100755 index 0000000..07b626c --- /dev/null +++ b/script/docker @@ -0,0 +1,24 @@ +#!/bin/sh +# script/docker: build the Docker image tagged with the project name. +# Identical in all repos; the tag comes from script/projectname. +# --no-cache because the gate phases the final stage depends on are RUN +# steps, and a cached one is a check that did not run. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + # Own line: a failing command substitution inside an argument does + # not trip `set -e`, so the inline form degrades silently to an + # empty constant. The VERSION build argument takes precedence over + # the version a build stage derives from the .git in the context. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/fmt b/script/fmt new file mode 100755 index 0000000..9fa3468 --- /dev/null +++ b/script/fmt @@ -0,0 +1,35 @@ +#!/bin/sh +# script/fmt: format all files (writes). +# +# The gofmt step in main() is this repo's addition. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + +main() { + cd "$ROOT" + # Before prettier, because run_yarn replaces this shell with yarn. + gofmt -s -w . + run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always +} + +main "$@" diff --git a/script/fmt-check b/script/fmt-check new file mode 100755 index 0000000..5ffa381 --- /dev/null +++ b/script/fmt-check @@ -0,0 +1,40 @@ +#!/bin/sh +# script/fmt-check: check formatting (read-only). +# +# The gofmt check in main() is this repo's addition. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt-check: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + +main() { + cd "$ROOT" + # Before prettier, because run_yarn replaces this shell with yarn. + unformatted="$(gofmt -s -l .)" + if [ -n "$unformatted" ]; then + echo "fmt-check: run make fmt; gofmt would change:" >&2 + echo "$unformatted" >&2 + exit 1 + fi + run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always +} + +main "$@" diff --git a/script/install-precommit b/script/install-precommit new file mode 100755 index 0000000..bef6406 --- /dev/null +++ b/script/install-precommit @@ -0,0 +1,16 @@ +#!/bin/sh +# script/install-precommit: install the git pre-commit hook that runs +# script/precommit. Our own extension to scripts-to-rule-them-all. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + hook=".git/hooks/pre-commit" + printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + echo "pre-commit hook installed: runs script/precommit" +} + +main "$@" diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..2d8b075 --- /dev/null +++ b/script/lint @@ -0,0 +1,23 @@ +#!/bin/sh +# script/lint: run the linter. Linting is a phase of the Dockerfile and +# this builds that phase alone; the linter is never installed or run on +# a developer host, where a shared result cache and a host-global lock +# make its answer untrustworthy. +# +# The phase is not the last stage in the file, so it is built only when +# --target names it. --no-cache because a cached lint layer is a lint +# that did not run. The tag makes each build replace the previous image +# instead of leaving a dangling one behind. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache \ + --target lint \ + -t "$("$SCRIPT_DIR/projectname")-lint" . +} + +main "$@" diff --git a/script/precommit b/script/precommit new file mode 100755 index 0000000..1e8d377 --- /dev/null +++ b/script/precommit @@ -0,0 +1,23 @@ +#!/bin/sh +# script/precommit: run by the git pre-commit hook; fails the commit if +# checks fail. Our own extension to scripts-to-rule-them-all. +# +# This repo's addition: go mod tidy runs first, failing the commit if it +# changes go.mod or go.sum. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + go mod tidy + git diff --exit-code -- go.mod go.sum || { + echo "precommit: go mod tidy changed go.mod or go.sum;" \ + "stage the changes and retry" >&2 + exit 1 + } + "$SCRIPT_DIR/check" +} + +main "$@" diff --git a/script/projectname b/script/projectname new file mode 100755 index 0000000..fe4df79 --- /dev/null +++ b/script/projectname @@ -0,0 +1,12 @@ +#!/bin/sh +# script/projectname: output the name of this project. Our own +# extension to scripts-to-rule-them-all. Other scripts that need the +# name (e.g. script/docker) call this, so they can stay identical +# across all repos. +set -eu + +main() { + echo "neoirc" +} + +main "$@" diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..4cc5b6b --- /dev/null +++ b/script/setup @@ -0,0 +1,13 @@ +#!/bin/sh +# script/setup: set up the repo for development after a fresh clone: +# installs dependencies and the git pre-commit hook. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/install-precommit" +} + +main "$@" diff --git a/script/test b/script/test new file mode 100755 index 0000000..cd239f2 --- /dev/null +++ b/script/test @@ -0,0 +1,19 @@ +#!/bin/sh +# script/test: run the test suite. Testing is a phase of the Dockerfile +# and this builds that phase alone, on the same terms as script/lint: +# --target because a phase that is not the last stage is built only when +# named, --no-cache because a cached test layer is a test that did not +# run, and a tag so each build replaces the previous image. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache \ + --target test \ + -t "$("$SCRIPT_DIR/projectname")-test" . +} + +main "$@" diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..d846639 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +prettier@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" + integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==