Select the types of activity you want to include in your feed.
docs(leveva): record P11 slice 53 (WebSocket transport)
PLAN.md P11 row 52->53; full slice entry in docs/progress-log/p11.md; the plan file; and the websocket.md IRCv3 spec this implements (previously untracked).
···9090| **P8 — Delete the C + retire the oracle harness ✅ DONE** (per-sub-phase: [`PLAN-P8-progress.md`](PLAN-P8-progress.md) + [`docs/progress-log/p8.md`](docs/progress-log/p8.md)) | remaining C (the ~25 variadic sender trampolines + residual data globals) + `ircd-sys` C build + `ircd-golden` (L2) + `ircd-testkit`/`cref_*` archives (L1) + the frozen reference‑C tree | With all C _logic_ already Rust (P7 exit), delete the last C: replace the variadic sender trampolines with a non‑variadic Rust sender API at every call site (faithful `wire!`/`reply_one!`/`format!` first; leveva typed messages adopted alongside the IRC senders), drop the residual data globals via the data‑symbol seam (bindgen `extern` decls resolve to `#[no_mangle]` Rust defs at link), then drop **all** C compilation from `ircd-sys` (no more `cc::Build`), retire the differential harness (`cref_*`/L1 `ircd-testkit`/`ircd-golden` L2 can no longer be built once the oracle is gone), and **migrate the load‑bearing tests into `ircd-common`** as self‑contained Rust tests (own fixtures, no `cref_` oracle). **Progress (P8a–P8r — full entries in the progress logs):** **every variadic sender trampoline is now Rust** (P8a–P8m, incl. the keystones `sendto_one`/`sendto_flag`/`esendto_*`), and the five data‑global `.o`s are dropped outright (`s_auth.o` iauth globals P8n, `send.o` P8o, `s_bsd.o` `local[]`/`highest_fd`/… P8p, `ircd.o` `me`/`client`/timers/… P8q). **P8r dropped the last C‑logic TU `support.o`** — `dgets`/`make_isupport` ported to Rust + the `ipv6string`/`minus_one` data globals defined as `#[no_mangle]` statics (`snprintf_append` is dead in Rust — WHOX is field‑by‑field — so it died with the `.o`; `irc_sprintf` is deleted not ported, in P11). **Every ircd C TU is now Rust:** the link set holds only the generated `version.o` + the L1 harness's `ctruth.o`. **P8s migrated all 115 L1 `cref_` differentials into self‑contained `ircd-common` `insta` snapshot tests** (drive only the Rust port via `link_anchor()`, no oracle; soundness via the capture chain) — the suite is green + 0 warnings. **P8t ported the last *generated* C TU `version.c` to Rust** (`ircd-common/src/version.rs`): the data globals `generation`/`creation`/`pass_version`/`infotext`/`isupport` are now `#[no_mangle]` statics via the data‑symbol seam — `generation`/`creation` sourced from crate metadata at build time (`CARGO_PKG_VERSION` + an `ircd-common/build.rs` UTC build stamp), `pass_version`/`infotext` copied verbatim. `version.c.SH` generation + the `version.o` compile/archive are gone from `ircd-sys/build.rs`; `libircd_c.a` now holds **only** the L1 harness `ctruth.o`. (`creation` is now genuinely build‑time volatile, so the golden canonicalizer masks RPL_CREATED 003, matching the existing 371 Birth‑Date rule.) **P8u (FINAL) retired the oracle.** Ran the differential suite a last time green — L1 `ircd-testkit` all pass; L2 `ircd-golden` 86 pass + only the documented reference‑C‑garbage `s_serv_stats` flake (where Rust is the *correct* side) — then **dropped all C compilation** from `ircd-sys/build.rs`: the `make` object build, the `cref_*`/`ctruth.o`/`res.o` recompiles, the `libircd_c.a` archive, and the whole‑archive link + `-lz`/`-lm`/`-lcrypt` (none needed by Rust — `pow` resolves from glibc 2.29+). `build.rs` now only runs `configure` + bindgen over the C *headers* (the struct view `ircd-common` still uses until P9–P12) and expands the install‑path Makefile vars. `ircd-testkit` (L1) + `ircd-golden` (L2) are `exclude`d from the workspace (mothballed, git‑recoverable); the `ctruth.c`/`layout.rs` drift‑net is deleted. **The workspace is now 100% Rust — zero C TUs compiled.** **P8v (literal end of C) deleted the C *source tree* itself + retired `ircd-sys`:** froze the generated `bindings.rs` into a committed `ircd-common/src/bindings.rs` (a self‑alias `extern crate self as ircd_sys;` keeps every `ircd_sys::bindings::*` path resolving, zero src churn) + the install‑path consts as literals, then deleted `ircd-sys`, the mothballed `ircd-testkit`/`ircd-golden` oracle crates, the `iauth-rs` C‑iauth differential, and **all** C source (`common/ ircd/ iauth/ support/ contrib/ cbuild/ configure .clang-format* clangformat.yml`). `ircd-rs` calls `ircd_common::ircd::c_ircd_main` directly. **Not one line of C remains in the repo.** | **MET. Final differential run green; oracle mothballed; `cargo build --workspace` 0 warnings links pure Rust; `cargo test --workspace` 695 pass / 0 fail (P8v; was 698 before deleting the C‑oracle tests); the `ircd-common` snapshot tests carry the reference‑C correctness forward. C source tree deleted — repo is 100% Rust at the file level.** |
9191| **P9 — std‑library cleanup ❌ RETIRED (2026‑06‑08)** | — (was: whole Rust tree) | **Skipped by decision — superseded by the `leveva` greenfield.** P9 assumed an *in‑place* idiomatic transformation of the mechanical port (`MyMalloc`/`MyFree`→`Box`/`Vec`, `dbuf` freelist→owned, raw `libc`→`std`/`nix`, `[c_char;N]`→`[u8;N]`). Because the idiomatic end‑state is now a separate greenfield crate (`leveva`), `ircd-common` is throwaway code (the oracle, deleted at parity), so polishing it earns nothing. Nothing was implemented; the row is kept for provenance. | n/a — retired. |
9292| **P10 — `leveva` greenfield foundations 🔶 IN PROGRESS** ([`docs/progress-log/p10.md`](docs/progress-log/p10.md)) | new `leveva` crate (lib) + `leveva-integration` differential tests | The idiomatic‑Rust product, built from scratch (not transformed from the port). **Done so far:** RFC 1459 case‑folding (`casemap`); typed identifier strings (`IrcStr`/`IrcString` + `Nick`/`ChanName`/`Uid`/`Sid`/`Cid`/`ServerName`/`UserName`/`HostName` validating newtypes via `ident_newtype!`); `Message`/`MessageBuilder`; the full `Numeric` reply/error enum (184 codes, discriminant = wire code); a generic safe `patricia` trie; glob `matching`; `mode`; KDL `config` (model/parse/password/privilege); `rustls`/`tokio` `tls` endpoint with hot reload; `ident`. The async boot path (`main.rs`) binds listeners + watches certs but currently drops accepted connections — no protocol layer yet. Idiomatic Rust allowed everywhere; pulls real crates (tokio/rustls/kdl/nom/clap). | `cargo test -p leveva` green + clippy clean per module; `leveva-integration` pins each greenfield reimplementation against `ircd-common` (the oracle) within their shared domain (documented divergences asserted explicitly). |
9393-| **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 52 slices (45–47 = the parity‑matrix slices: channel‑lifecycle inverses, burst/netsplit teardown inverses, then WHO/WHOIS/WHOX; 48 = the first IRCv3 slice — `CAP` capability negotiation framework + `cap-notify`; 49 = registration edge cases — `PASS` command + the connection‑block password gate against `allow` rules; 50 = STATS completion — `STATS k`/`K` active k‑line reporting (`216`) from the live `KlineStore`; 51 = the first IRCv3 behavior cap — `multi-prefix` (all `@+` status sigils in NAMES/WHO/WHOIS when negotiated); 52 = the first **live two‑node link integration** — two real `leveva` subprocesses linked by a real operator `CONNECT`, asserting `PRIVMSG`/`TOPIC`/`MODE` relay in both directions, exercising the outbound dial path (`command::connect`→`dial_server`→`build_link_greeting`) no hand‑coded `Peer` test reaches) — full per‑slice detail in [`docs/progress-log/p11.md`](docs/progress-log/p11.md).** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. |
9393+| **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 52 slices (45–47 = the parity‑matrix slices: channel‑lifecycle inverses, burst/netsplit teardown inverses, then WHO/WHOIS/WHOX; 48 = the first IRCv3 slice — `CAP` capability negotiation framework + `cap-notify`; 49 = registration edge cases — `PASS` command + the connection‑block password gate against `allow` rules; 50 = STATS completion — `STATS k`/`K` active k‑line reporting (`216`) from the live `KlineStore`; 51 = the first IRCv3 behavior cap — `multi-prefix` (all `@+` status sigils in NAMES/WHO/WHOIS when negotiated); 52 = the first **live two‑node link integration** — two real `leveva` subprocesses linked by a real operator `CONNECT`, asserting `PRIVMSG`/`TOPIC`/`MODE` relay in both directions, exercising the outbound dial path (`command::connect`→`dial_server`→`build_link_greeting`) no hand‑coded `Peer` test reaches; 53 = the first **transport** slice — **WebSocket support** (IRCv3 `websocket`) served by **axum** at `/socket`, a nested `websocket{}` block on a `listen{}` port, the leveva‑native `+W` umode + WHOIS `320` for ws clients (propagated network‑wide via `SEND_UMODES`/`UNICK`), pure framing/negotiation helpers in `leveva::websocket` + a real‑client end‑to‑end golden, plus `HELP USER_MODES`/`CHANNEL_MODES` reference pages) — full per‑slice detail in [`docs/progress-log/p11.md`](docs/progress-log/p11.md).** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. |
9494| **P12 — Retire the mechanical port; `leveva` is the product** | delete `ircd-common`/`ircd-rs` + C‑era `iauth-rs` scaffolding; workspace = `leveva` | Once `leveva` is at parity (P11 green across the matrix), run the differential suite a final time, then **delete the mechanical port** (`ircd-common`, `ircd-rs`) and fold/replace `iauth-rs` with leveva‑native auth. The `leveva-integration` oracle tests retire with their oracle; the load‑bearing characterizations carry forward as self‑contained `leveva` tests. Workspace becomes just `leveva` (+ any sub‑crates it wants). This is the literal end of the strangler — same lifecycle the C tree had at P8. | Final differential run green; `ircd-common`/`ircd-rs` deleted; `cargo test -p leveva` carries the behavioral guarantees forward; minimal `unsafe`; clean clippy. |
95959696---
···33593359**Gate:** `cargo build --workspace` 0 warnings; `cargo clippy -p leveva --tests` clean;
33603360`integration_two_node` (1) + `link_greeting_proptest` (3) green; siblings `golden_s2s_relay`
33613361/ `relay_proptest` / `s2s_proptest` still green.
33623362+33633363+## 2026-06-10 — Slice 53: WebSocket transport (axum) + `+W` umode + WHOIS 320
33643364+33653365+The first **transport** slice (every prior slice spoke raw TCP/TLS): browser/web IRC
33663366+clients connect over WebSocket per the IRCv3 `websocket` spec, served by **axum** at the
33673367+HTTP path `/socket`. A `listen{}` port becomes a WebSocket port via a nested `websocket{}`
33683368+block; clients that arrive over it carry a leveva-native `+W` user mode, surfaced in WHOIS
33693369+as a `320` line.
33703370+33713371+- **Division of labour (the user's instruction).** axum / tokio-tungstenite own the
33723372+ WebSocket protocol — the HTTP upgrade handshake, frame masking, fragmentation, ping/pong,
33733373+ close codes. leveva implements only the IRC-over-WS *semantics*, faithful to InspIRCd's
33743374+ `m_websocket.cpp` (insp4): one inbound frame = one IRC line (strip embedded CR/LF, append
33753375+ `\r\n`); one **outbound frame per line** (split on `\n`, drop **all** CR, Text frame with
33763376+ `U+FFFD` repair or Binary frame); subprotocol negotiation (`binary.ircv3.net` /
33773377+ `text.ircv3.net` by client preference, else `default-mode`); the `Origin` allow-list and
33783378+ the `X-Real-IP`/`X-Forwarded-For` proxy-trust check.
33793379+- **`leveva::websocket` (pure, `websocket/mod.rs`).** `negotiate` / `decode_incoming` /
33803380+ `frame_lines` / `origin_allowed` / `proxy_trusted` / `cidr_contains` — no axum, no socket,
33813381+ so they are exhaustively unit- and proptest-tested. `FrameMode`/`Negotiation`/`OutFrame`.
33823382+- **`leveva::websocket::server` (`websocket/server.rs`).** The axum/hyper glue: a per-listener
33833383+ `Router` with `GET /socket` → `ws_handler` (origin 403, negotiation 400-on-reject, real-host
33843384+ resolution, `WebSocketUpgrade` echoing the agreed subprotocol, 16 KiB max message), each
33853385+ connection served via `hyper_util`'s `serve_connection_with_upgrades` over the existing
33863386+ stream (the hot-reloadable `TlsEndpoint` for `wss://`). `serve_ws` drives the upgraded
33873387+ socket through the **same `Session` state machine** as the TCP/TLS path — a dedicated
33883388+ `select!` over socket / mailbox / keepalive / auth — flagged `set_websocket(true)` so
33893389+ registration adds `+W`; one frame per outbound line via `SinkExt::send` (correct flush,
33903390+ unlike a hand-rolled `AsyncWrite`-over-`Sink` adapter). To keep the server a clean lib
33913391+ submodule, `tls.rs` was promoted to `leveva::tls` (it reached for no binary types); the
33923392+ binary's accept loop now branches on the listener's `websocket` config.
33933393+- **`+W` user mode (`mode.rs`).** A leveva-native `UserMode::Websocket` (`'W'`, bit `0x0040`),
33943394+ appended last so the oracle modes' bits/order are untouched; no C oracle (the oracle
33953395+ `user_modes[]` has no `W`). Seeded at registration for WebSocket sessions and mirrored to
33963396+ the registry. **Per the user's call it propagates network-wide**: it joins `SEND_UMODES`
33973397+ (oracle 61 + `0x40` = 125) and the introduction burst already renders it (`UNICK`), so a
33983398+ remote WebSocket user's WHOIS shows the same `320` a local one does. Server-set-only — a
33993399+ client `+W`/`-W` is silently ignored (no `501`), like `+o`/`+r`/`+a`. It also joins the
34003400+ server's advertised umode list (`RPL_MYINFO` 004 + ISUPPORT → `oOiwraW`).
34013401+- **WHOIS `320`.** `320 RPL_WHOISEXTRA :is connected via WebSocket`, emitted after the `313`
34023402+ oper block when the target carries `+W` (the catch-all WHOIS slot the RPL_WHOISTLS alias
34033403+ uses). Fires for remote WebSocket users too.
34043404+- **Config (`websocket{}` block).** `origins` (Origin globs; empty ⇒ any, warned),
34053405+ `proxy-ranges` (peer IP glob/CIDR trusted for the forwarded-IP headers; a single
34063406+ space-delimited string also accepted, à la InspIRCd `proxyranges`), `default-mode`
34073407+ (`text`|`binary`|`reject`, default `text`). Bare `websocket` enables with defaults;
34083408+ `websocket #false` disables. `wss://` reuses the existing `tls`/`tls-cert`/`tls-key`.
34093409+- **Tests.** Unit: mode round-trips/render, WHOIS ordering (`311 312 301 313 320 318`),
34103410+ registration `+W` seeding (+ inverse), MODE server-only guard, config parse edge cases,
34113411+ the pure helpers. **Fuzz** (`websocket_proptest.rs`): one-frame↔one-line roundtrip, frames
34123412+ never carry CR/LF, frame count = non-empty lines, negotiate only echoes an offered
34133413+ subprotocol, origin/proxy totality + CIDR boundaries. **Integration golden**
34143414+ (`golden_websocket.rs`): a real `tokio-tungstenite` client against a booted leveva drives
34153415+ the whole `/socket` stack end to end — text subprotocol negotiated+echoed, `001` as text
34163416+ frames, WHOIS `320`, `MODE` shows `+W`; a second test drives the binary subprotocol and
34173417+ asserts binary frames.
34183418+- **HELP reference pages.** `HELP USER_MODES` / `HELP CHANNEL_MODES` — table-formatted mode
34193419+ catalogues `MODE.md` now delegates to (readable on markdown-rendering web clients). The
34203420+ HELP system gained topic pages (allow `_` in lookups, an allowlist keeping the orphan-page
34213421+ invariant honest).
34223422+- **Out of scope:** native WS ping keepalive (`nativeping`) — leveva keeps IRC-level PING;
34233423+ per-message-deflate; legacy no-subprotocol autodetect beyond `default-mode`; a single
34243424+ `/socket` path only.
34253425+- **Plan:** `docs/superpowers/plans/2026-06-10-p11-slice53-websocket.md`.
34263426+34273427+**Gate:** `cargo build --workspace` 0 warnings; `cargo clippy -p leveva --tests` clean;
34283428+`cargo test -p leveva` green (unit + `websocket_proptest` + `golden_websocket` + all prior
34293429+slices, with the `004` umode-list snapshots updated to `oOiwraW`).
···11+---
22+title: WebSocket support
33+layout: spec
44+copyrights:
55+ -
66+ name: Shivaram Lingamneni
77+ period: 2021
88+ email: slingamn@cs.stanford.edu
99+1010+---
1111+1212+## Introduction
1313+1414+This specification describes a mechanism for WebSocket-based clients (in particular, web-based IRC clients running in the browser) to connect to IRC networks.
1515+1616+## WebSocket features and encoding
1717+1818+WebSocket is a message-based protocol, i.e., it handles message fragmentation and termination internally. It offers two message types, binary and text; each has advantages and disadvantages as a transport for IRC. Binary messages can transport arbitrary bytes and therefore any valid IRC line, while text messages must consist entirely of UTF-8 data. Consequently, binary frames are necessary to fully support IRC communities that use encodings other than UTF-8. On the other hand, given that web browsers are pushing increasingly towards UTF-8 as the default encoding for HTML5 documents and scripting languages, text messages make it simpler to achieve a correct implementation on the client side.
1919+2020+We define two [WebSocket subprotocols](https://tools.ietf.org/html/rfc6455#section-1.9): `binary.ircv3.net` and `text.ircv3.net`. Servers implementing this specification MUST support both subprotocols in standard configurations. Clients implementing this specification MUST initially request one or both of these subprotocols, in order of preference (most preferred first). The server MUST accept any subprotocol it supports, respecting the client's order of preference. If no subprotocol can be agreed on, the server MAY continue connection establishment without sending the `Sec-WebSocket-Protocol` header.
2121+2222+If `binary.ircv3.net` is negotiated, then client and server will exchange binary messages; if `text.ircv3.net` is negotiated, then they will exchange text messages. If no subprotocol is successfully negotiated, server and client MAY implement fallbacks designed for compatibility with legacy software; the exact behavior is left implementation-defined.
2323+2424+In all cases, the message format is the same: each message MUST consist of a single IRC line, except that servers and clients MUST NOT include trailing `\r` or `\n` characters in the message.
2525+2626+Maximum message size is reduced by two bytes accordingly (ie. to 510 bytes by default for the non-tags part of a message), to account for the server automatically inserting `\r\n`.
2727+2828+Servers MUST NOT relay non-UTF-8 content to clients using text messages, since this may result in those clients being disconnected by the browser. Servers MAY replace non-UTF-8 bytes with the UTF-8 encoding of the Unicode replacement character `�` (`U+FFFD`).
2929+3030+## Client example
3131+~~~
3232+let socket = new WebSocket('wss://ws.example.org', ['text.ircv3.net']);
3333+socket.addEventListener('open', () => {
3434+ socket.send('USER 0 0 0 0');
3535+ socket.send('NICK bob');
3636+});
3737+socket.addEventListener('message', event => processLine(event.data));
3838+~~~
3939+4040+## Security considerations
4141+4242+Clients and servers SHOULD impose limits on the maximum size of messages they will accept, in order to prevent denial-of-service attacks. The limits SHOULD reflect the increased total line lengths allowed by the [`message-tags`](../extensions/message-tags.html) specification.
4343+4444+## Other implementation considerations
4545+4646+This section is non-normative.
4747+4848+Given the numerous implementations in the wild predating this specification, servers and clients should strive for maximum compatibility.
4949+5050+Servers may wish to support legacy clients that do not negotiate a subprotocol. For example, when no subprotocol is negotiated, servers could accept either incoming message type, while using a fixed (or operator-configurable) message type for outgoing messages. Alternatively, they could autodetect the outgoing message type based on the type of the first incoming message sent by the client.
5151+5252+Similarly, clients may wish to support legacy servers that are unaware of subprotocols. The [standard JavaScript API](https://fetch.spec.whatwg.org/#websocket-opening-handshake) for WebSockets will [fail the WebSocket connection](https://tools.ietf.org/html/rfc6455#section-7.1.7) if none of the client's desired subprotocols could be negotiated. In this case, the client could attempt another connection without subprotocol negotiation.
5353+5454+Some servers might support non-standard configurations where only one subprotocol is accepted. Clients can request both subprotocols to ensure compatibility with these server configurations.
···11+# P11 slice 53 — WebSocket transport (axum) + `+W` umode + WHOIS `320`
22+33+## Goal
44+55+Let browser/web IRC clients connect to leveva over WebSocket, per
66+`docs/rfcs/ircv3/websocket.md` (IRCv3 `binary.ircv3.net` / `text.ircv3.net`
77+subprotocols), served by **axum** at the HTTP path `/socket`. A WebSocket
88+listener is a `listen{}` port carrying a nested `websocket{}` block. Clients
99+that arrive over a WebSocket get a leveva-native `+W` user mode, and WHOIS shows
1010+a dedicated `320 RPL_WHOISEXTRA :is connected via WebSocket` line.
1111+1212+## Locked decisions (from the user)
1313+1414+- Config: a **`listen{}` flag** — a nested `websocket{}` sub-block. Its presence
1515+ enables WS on that port. Optional children:
1616+ - `origins "<glob>" ...` — allowed `Origin:` header globs (glob-matched).
1717+ - `proxy-ranges "<glob-or-CIDR>" ..." — peer IPs trusted to supply
1818+ `X-Real-IP` / `X-Forwarded-For` (so a reverse proxy can pass the real client
1919+ IP). Each entry is a glob **or** a CIDR.
2020+ - `default-mode "text"|"binary"|"reject"` — frame mode when the client
2121+ negotiates no subprotocol. Default `text`.
2222+- WHOIS numeric: **`320` RPL_WHOISEXTRA**, `:is connected via WebSocket`.
2323+- **axum holds the brunt** of the WS protocol (handshake/masking/framing/ping/
2424+ pong/close). We implement only the IRC-over-WS *semantics*.
2525+- TLS (`wss://`) reuses the existing per-listener `tls`/`tls-cert`/`tls-key`
2626+ fields and the hot-reloadable `TlsEndpoint`.
2727+2828+## Reference behaviour (InspIRCd `m_websocket.cpp`, insp4)
2929+3030+Faithful to the parts that define IRC-over-WS semantics (NOT the byte-level WS
3131+machinery — axum does that):
3232+3333+- **Incoming** (ws.cpp:359-368): each complete frame (text or binary) is one IRC
3434+ line; strip any embedded `\r`/`\n`, then terminate with `\r\n` before the
3535+ protocol core sees it.
3636+- **Outgoing** (ws.cpp:588-637): split the core's wire bytes on `\n`; for each
3737+ line drop a trailing `\r`; emit **one frame per line** — a **Text** frame
3838+ (re-encoded so it is valid UTF-8: invalid bytes → `U+FFFD`, ws.cpp:608) when
3939+ sending-as-text, else a **Binary** frame with the raw bytes.
4040+- **Subprotocol negotiation** (ws.cpp:506-534): scan `Sec-WebSocket-Protocol`
4141+ for `binary.ircv3.net` / `text.ircv3.net` in client-preference order; first
4242+ match wins and decides text-vs-binary. None matched + `default-mode=reject` →
4343+ fail the handshake (400); else fall back to `default-mode`.
4444+- **Origin** (ws.cpp:434-463): glob-match the `Origin:` header against the
4545+ allowed list; no list configured ⇒ allow any (we warn). Forbidden → 403.
4646+- **proxy-ranges** (ws.cpp:465-504): only if the peer IP matches a configured
4747+ range do we trust `X-Real-IP` / `X-Forwarded-For` and use it as the client's
4848+ display host.
4949+5050+## Where it plugs in (verified seams)
5151+5252+- `leveva/src/main.rs`
5353+ - `serve_client<S: AsyncRead+AsyncWrite>` (375) — **untouched**; TCP/TLS only.
5454+ - `serve(Bound)` (296) accept loop — a WS listener instead runs the new
5555+ `ws_server::serve_websocket(Bound, WebsocketConfig)`.
5656+ - `Bound` (72) — boot wiring in `main()` (158-205) decides per-listener whether
5757+ to spawn `serve` or `serve_websocket`.
5858+- `leveva/src/session.rs`
5959+ - `Session` (36) gains a `websocket: bool` + `set_websocket`.
6060+ - `finalize_registration` (404): seed `ctx.default_user_modes | +W` when
6161+ `self.websocket`, into both `registry.set_modes` (533) and `Registered.modes`
6262+ (556). `+W` thus mirrors to the registry → WHOIS sees it.
6363+- `leveva/src/mode.rs` — add `UserMode::Websocket` (`'W'`, bit `0x0040`).
6464+ leveva-native: **no C oracle** (the C `user_modes[]` has no `W`); documented as
6565+ an extension, appended last so render order of existing modes is unchanged.
6666+- `leveva/src/command/mode.rs:447` — add `'W'` to the silently-ignored
6767+ server-only set (a client may not toggle `+W`; no `501`).
6868+- `leveva/src/command/whois.rs` — after the `313` block (~110), emit `320` when
6969+ `rec.modes & UserMode::Websocket.bit() != 0`.
7070+- `leveva/src/config/{model,parse,mod}.rs` + `examples/simple.kdl` —
7171+ `Listener.websocket: Option<WebsocketConfig>`; `WebsocketConfig`/`DefaultMode`.
7272+7373+## New code
7474+7575+### `leveva/src/websocket.rs` (LIB — pure, no axum) — `pub mod websocket;`
7676+7777+Pure, fully unit/proptest-testable helpers:
7878+7979+- `pub enum DefaultMode { Text, Binary, Reject }` (config `default-mode`).
8080+- `pub enum FrameMode { Text, Binary }` (actual outgoing frame type).
8181+- `pub enum Negotiation { Reject, Accept { subprotocol: Option<&'static str>, frame: FrameMode } }`
8282+- `pub fn negotiate(requested: &[&str], default: DefaultMode) -> Negotiation` —
8383+ first `binary.ircv3.net`/`text.ircv3.net` in `requested` wins; else apply
8484+ `default` (Reject → `Negotiation::Reject`).
8585+- `pub fn decode_incoming(payload: &[u8], out: &mut Vec<u8>)` — strip CR/LF,
8686+ append `\r\n`. (One frame = one line.)
8787+- `pub fn frame_lines(mode: FrameMode, wire: &[u8]) -> Vec<OutFrame>` where
8888+ `OutFrame { Text(String), Binary(Vec<u8>) }` — split `wire` on `\n`, drop a
8989+ trailing `\r`, skip empty lines; Text → `String::from_utf8_lossy` (gives the
9090+ `U+FFFD` replacement); Binary → raw bytes.
9191+- `pub fn origin_allowed(origins: &[String], origin: Option<&str>) -> bool` —
9292+ empty list ⇒ true; else any glob match (`crate::matching::matches`).
9393+- `pub fn proxy_trusted(ranges: &[String], peer: IpAddr) -> bool` — glob match
9494+ on the IP string OR CIDR containment (`cidr_contains`).
9595+- `fn cidr_contains(cidr: &str, ip: IpAddr) -> Option<bool>` — parse `base/len`
9696+ for v4/v6 with std only (mask the high `len` bits); `None` if not CIDR syntax.
9797+9898+### `leveva/src/ws_server.rs` (BINARY module — axum/hyper glue) — `mod ws_server;` in main.rs
9999+100100+- `pub async fn serve_websocket(b: Bound, cfg: WebsocketConfig)` — accept loop:
101101+ per connection, optional TLS via `b.tls`, then serve ONE HTTP connection with
102102+ `hyper_util::server::conn::auto::Builder::serve_connection_with_upgrades`
103103+ (websockets need upgrades), the service being a per-connection
104104+ `axum::Router` (peer baked into state) adapted by
105105+ `hyper_util::service::TowerToHyperService`.
106106+- `GET /socket` → `ws_handler`:
107107+ - origin check (`origin_allowed`) → 403 on fail;
108108+ - `negotiate(requested, cfg.default_mode)` → 400 on `Reject`;
109109+ - resolve display host (proxy-ranges + X-Real-IP/X-Forwarded-For, else peer IP);
110110+ - `WebSocketUpgrade::protocols([chosen])` (echo the chosen subprotocol) then
111111+ `.on_upgrade(move |socket| serve_ws(socket, host, ctx, ping_freq, auth_ctx, frame))`.
112112+- `async fn serve_ws(socket: WebSocket, host, ctx, ping_freq, auth_ctx, frame)` —
113113+ the dedicated session loop (mirrors `serve_client`'s `select!` over socket /
114114+ mailbox / keepalive / auth), but:
115115+ - read: `socket.next()` → on Text/Binary `decode_incoming` → `session.feed`;
116116+ Close/None/Err → break; Ping/Pong → ignore (tungstenite auto-pongs).
117117+ - write: every `out`/mailbox `Wire` is `frame_lines(frame, &bytes)` then
118118+ `socket.send(Message::Text|Binary).await` per frame (`send` flushes).
119119+ - sets `session.set_websocket(true)` before the loop (→ `+W` at registration).
120120+ Duplicating the orchestration is deliberate: the byte-stream `serve_client`
121121+ stays untouched, and the per-line `SinkExt::send` gives correct flush semantics
122122+ that a hand-rolled `AsyncWrite`-over-`Sink` adapter would not.
123123+124124+## Dependencies (leveva/Cargo.toml)
125125+126126+- `axum = { version = "0.8", default-features = false, features = ["ws", "http1"] }`
127127+- `hyper-util = { version = "0.1", features = ["server", "server-auto", "tokio", "service", "http1"] }`
128128+- `futures-util` (for `StreamExt`/`SinkExt` on the WebSocket) — or use axum's
129129+ `WebSocket` `recv`/`send` inherent methods to avoid the dep if sufficient.
130130+- dev: `tokio-tungstenite` (real WS client for the integration golden).
131131+132132+## Tests (TDD — failing test first each unit)
133133+134134+1. **`mode.rs`** unit: `+W` round-trips (char/bit/from_*), `render` appends `W`
135135+ last and leaves existing order intact; bit is `0x0040`.
136136+2. **`command/mode.rs`** unit: a client `MODE me +W` is a no-op (no `501`, `+W`
137137+ not set); a `+W` seeded at registration shows in the `221` query string.
138138+3. **`command/whois.rs`** unit: a `+W` target → codes `["311","312","320","318"]`
139139+ with `320` trailing `is connected via WebSocket`; **inverse** non-WS → no
140140+ `320`; ordering when also away+oper (`311 312 301 313 320 318`).
141141+4. **`session.rs`** unit: a `set_websocket(true)` session registers with `+W` in
142142+ its modes (and mirrored to the registry / visible to WHOIS `320`); **inverse**
143143+ default session has no `+W`.
144144+5. **`config`** unit (`config/tests.rs`): a `listen{ websocket{ origins ...;
145145+ proxy-ranges ...; default-mode "binary" } }` parses to the expected
146146+ `WebsocketConfig`; bare `websocket` enables with defaults (text, no origins);
147147+ `websocket #false` / absent → `None`; bad `default-mode` → `BadField`; empty
148148+ origins → a `warnings()` advisory.
149149+6. **`websocket.rs`** unit: `decode_incoming` strips CR/LF + appends `\r\n`;
150150+ `frame_lines` splits multi-line wire into per-line frames (text vs binary);
151151+ `negotiate` preference order + reject; `origin_allowed`; `cidr_contains` v4/v6.
152152+7. **Fuzz — `tests/websocket_proptest.rs`** (proptest):
153153+ - **roundtrip**: for arbitrary line bytes (no `\n`), `decode_incoming` then
154154+ `frame_lines(Binary, ..)` reproduces the original line (modulo CR/LF strip).
155155+ - `frame_lines` never emits a frame containing `\n` or `\r`; text frames are
156156+ always valid UTF-8.
157157+ - `negotiate` never panics and only ever returns a subprotocol it was offered.
158158+ - `origin_allowed`/`proxy_trusted` never panic on arbitrary input; `*` origin
159159+ matches anything; a `/32`(`/128`) CIDR matches only its exact IP.
160160+8. **Integration golden — `tests/golden_websocket.rs`** (real WS client via
161161+ `tokio-tungstenite`): boot leveva with a `websocket{}` listener, connect to
162162+ `ws://…/socket` requesting `text.ircv3.net`, send `NICK`/`USER` as frames,
163163+ assert the `001` welcome arrives as a **text frame**, then `WHOIS` self shows
164164+ `320 … :is connected via WebSocket` and `MODE` query shows `W`. Reuse the
165165+ existing leveva boot harness used by `tests/integration_two_node.rs`.
166166+167167+## Gate
168168+169169+`cargo build --workspace` 0 warnings; `cargo clippy -p leveva --tests` clean;
170170+all units + `websocket_proptest` + `golden_websocket` green; existing
171171+`whois_proptest` / `usermode_proptest` / config tests still green.
172172+173173+## Out of scope (note explicitly)
174174+175175+- No native WS `PING` keepalive (`nativeping`) — leveva keeps IRC-level PING
176176+ (the existing heartbeat); browser pings are auto-ponged by tungstenite.
177177+- No per-message-deflate, no legacy no-subprotocol autodetect beyond
178178+ `default-mode`, no `allowmissingorigin` knob (empty origins already = allow).
179179+- Single `/socket` path only.