Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Data flow: `transport.ts` (`WsTransport`: one line per frame, PING/PONG, reconne
- Event names, payload shapes and store expectations are fixed by `docs/resources/bus-contract.md`; when adding a new event, extend `shared/types/socket-events.ts` and a `socket-events/*` consumer together.
- Replies, reactions, deletion and edits (bus-contract §1.4): `Channel.idByMsgid` / `idOf()` is the msgid → id map; `client.react()` / `redact()` / `editMessage()` send, `handlers/tagmsg.ts` / `handlers/redact.ts` / `privmsg.ts` (`+seance/edit`) dispatch `msg:react` / `msg:redact` / `msg:edit`. Anything that refers to another message by msgid must go through `client.afterReplay(fn)` so that inside a chathistory replay it runs after the batch's messages have ids. Wire constants (`REPLY_TAG`, `EDIT_TAG`, `REACT_TAG`, `UNREACT_TAG`, `REDACTION_CAP`, `TYPING_TAG`) live in `wire.ts`.
- **Reconnects are quiet, by content not by context.** A reattach can bring the server's join burst several times over (session restore, our re-JOIN, the bouncer's alias attach), so: `topic.ts` prints a 332/333 only when the topic differs from the one the channel shows (`Channel.topicAsked` is the `/topic` escape hatch), and a JOIN for a channel already JOINED produces no line and no second `CHATHISTORY`/`MARKREAD`/`MODE` (`Channel.rejoining`, `client.restoring` and the state check in `handleMessage`). The catch-up (`CHATHISTORY AFTER`) is what shows what actually happened. Keep new join-burst handlers consistent with this.
- **A SASL login that was asked for must succeed.** When `options.sasl` is set and the exchange does not end in 903, `IrcClient.saslFailed()` reports why in the lobby and — unless the deploy set `features.saslDisconnectOnFail: false` in `config.json` (`saslRequired`) — QUITs instead of registering unauthenticated. That covers missing credentials (caught in `onOpen`, before `CAP LS`), a server with no usable `sasl` cap (`startSasl`), a `CAP NAK`, the 902/904-907 numerics and the 12 s timeout. `handlers/cap.ts` drops whatever the negotiator had queued once `client.isQuitting`, so no `CAP END` follows the `QUIT`. See `docs/resources/nefarious2-websocket.md` § SASL for what the server does.
- **The catch-up cursor is offered exactly once per connection, and only then.** `IrcClient.cursor` (the newest msgid shown on the network, by `@time`, persisted in `saved-networks.ts`) goes out as `PERSISTENCE ATTACH default <msgid>` from `saslProgress` on SASL _success_, before `CAP END` in the same flush — never without SASL (the server answers `FAIL PERSISTENCE ACCOUNT_REQUIRED`), never after 001, never without the `attach-cursor` token in the `draft/persistence` CAP 302 value, and never with a msgid of 64 bytes or more. While the server accepts it (`client.serverReplay`) the restoration JOINs must not schedule a `CHATHISTORY AFTER` of their own (`persistence.ts` `serverReplayCovers`) — the server's replay is that catch-up, and it arrives as an appended `chathistory` batch, not as `more`.
- Typing notifications (bus-contract §1.5): `client.typing(chan, state)` sends `@+typing=<state> TAGMSG` with the spec's 3 s per-target throttle (`TYPING_INTERVAL_MS`; session reset by `sendMessage`/`onClose`), the `typing` bus emit routes to it in `bus.ts`, and `handlers/tagmsg.ts` dispatches `typing {chan, nick, state}` for other users' tags (never own echo or replay). Tests: `test/irc/typing.ts` (sinon fake timers).

Expand Down
16 changes: 15 additions & 1 deletion client/js/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export interface BrandingFeatures {
* `false` hides the host/port/TLS fields and pins them to the default.
*/
allowCustomServer?: boolean;
/**
* When a network is set to log in with SASL, drop the connection unless
* that login succeeds, instead of registering unauthenticated. Default
* true. `false` restores the old behaviour: the failure is still
* reported in the lobby, but the connection goes ahead as a stranger.
*/
saslDisconnectOnFail?: boolean;
}

/**
Expand Down Expand Up @@ -187,6 +194,7 @@ export const DEFAULT_BRANDING: BrandingConfig = {
multiNetwork: true,
saveNetworks: true,
allowCustomServer: true,
saslDisconnectOnFail: true,
},
strings: {},
};
Expand Down Expand Up @@ -233,6 +241,7 @@ export function brandingFeatures(config: BrandingConfig = current): Required<Bra
multiNetwork: config.features?.multiNetwork !== false,
saveNetworks: config.features?.saveNetworks !== false,
allowCustomServer: config.features?.allowCustomServer !== false,
saslDisconnectOnFail: config.features?.saslDisconnectOnFail !== false,
};
}

Expand Down Expand Up @@ -518,7 +527,12 @@ export function normalizeBranding(

const features: BrandingFeatures = {};

for (const key of ["multiNetwork", "saveNetworks", "allowCustomServer"] as const) {
for (const key of [
"multiNetwork",
"saveNetworks",
"allowCustomServer",
"saslDisconnectOnFail",
] as const) {
features[key] = optionalBoolean(rawFeatures[key]) ?? defaults.features?.[key] ?? true;
}

Expand Down
90 changes: 77 additions & 13 deletions client/js/irc/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import socket, {EventBus} from "../socket";
import {brandingFeatures} from "../branding";
import {isHighlight} from "../highlight";
import {ChanState, ChanType} from "../../../shared/types/chan";
import {MessageType, SharedMsg, TypingState} from "../../../shared/types/msg";
Expand Down Expand Up @@ -171,6 +172,11 @@ export interface IrcClientOptions extends ConnectOptions {
export const NOT_CONNECTED_TEXT =
"You are not connected to the IRC network, unable to send your command.";

/** What to try after a SASL login the deploy insists on did not happen. */
export const SASL_REQUIRED_HINT =
"Check the account name and password in this network's settings, or pick " +
'"No authentication" there to connect without logging in.';

/** Prefix characters a channel name may start with when the user omits one. */
const CHANNEL_PREFIXES = "#&!+";

Expand Down Expand Up @@ -622,7 +628,6 @@ export class IrcClient {
this._state = "registering";
this.connected = false;
this.closeHintShown = false;
this.caps = this.createCaps();
this.isupport.reset();
this.motdBuffer = null;
this.host = "";
Expand All @@ -632,6 +637,18 @@ export class IrcClient {
this.serverReplay = false;
this.endSasl();

// SASL was picked for this network but there is nothing to log in
// with: say so before registering as a stranger.
if (
this.options.sasl &&
!this.saslMechanism &&
this.saslFailed("no account name or password is configured")
) {
return;
}

this.caps = this.createCaps();

for (const line of this.caps.start()) {
this.send(line);
}
Expand All @@ -654,6 +671,60 @@ export class IrcClient {
}
}

/**
* Deploy policy: when the user picked SASL, does the login have to
* succeed for the connection to go ahead? `config.json`'s
* `features.saslDisconnectOnFail` (default true) decides; a caller may
* override it per network through {@link ConnectOptions}.
*/
private get saslRequired(): boolean {
return this.options.saslDisconnectOnFail ?? brandingFeatures().saslDisconnectOnFail;
}

/**
* SASL was asked for and did not succeed. Always reported — silently
* registering as a stranger when the user asked to log in hides
* everything from a typo in the password to services being down — and,
* unless the deploy turned {@link saslRequired} off, fatal: QUIT and stay
* down instead of connecting unauthenticated.
*
* Returns true when it dropped the connection, so callers stop there.
*/
private saslFailed(reason: string): boolean {
this.pushMessage(
this.lobby,
{type: MessageType.ERROR, text: `SASL authentication failed: ${reason}`},
true
);

if (!this.saslRequired) {
return false;
}

this.pushMessage(
this.lobby,
{
type: MessageType.ERROR,
text: `Not connecting to ${this.options.host} without the login you asked for.`,
},
true
);
this.pushMessage(this.lobby, {text: SASL_REQUIRED_HINT}, true);
this.disconnect("SASL authentication failed");
return true;
}

/** Why the server's `sasl` cap cannot carry `mechanism`, for the report. */
private saslUnavailable(mechanism: SaslMechanism): string {
const offered = this.caps.value("sasl");

if (offered === undefined || offered === "") {
return "the server does not offer SASL";
}

return `the server offers SASL ${offered}, not ${mechanism}`;
}

/** A negotiator that also asks for `sasl` (when usable) and runs SASL before `CAP END`. */
private createCaps(): CapNegotiator {
const mechanism = this.saslMechanism;
Expand All @@ -674,10 +745,13 @@ export class IrcClient {
/**
* `beforeEnd` hook: open the exchange if the server enabled `sasl` — or is
* about to (the opener is pipelined right behind the `CAP REQ`, which the
* server has answered by the time it reads AUTHENTICATE) — else nothing.
* server has answered by the time it reads AUTHENTICATE). A server that
* offers no usable `sasl` is a SASL failure like any other: the user
* asked to log in and will not be logged in.
*/
private startSasl(mechanism: SaslMechanism): string[] {
if (!this.caps.hasCapability("sasl") && !this.caps.isRequesting("sasl")) {
this.saslFailed(this.saslUnavailable(mechanism));
return [];
}

Expand Down Expand Up @@ -707,17 +781,7 @@ export class IrcClient {
this.endSasl();

if (!result.ok) {
this.pushMessage(
this.lobby,
{
type: MessageType.ERROR,
text: `SASL authentication failed: ${result.error ?? "unknown error"}`,
},
true
);

if (this.options.saslDisconnectOnFail) {
this.disconnect("SASL authentication failed");
if (this.saslFailed(result.error ?? "unknown error")) {
return;
}
} else {
Expand Down
7 changes: 7 additions & 0 deletions client/js/irc/handlers/cap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const cap: Handler = (client, msg) => {
const result = client.caps.handle(msg);

for (const line of result.send) {
if (client.isQuitting) {
// A required SASL login failed while the negotiator ran its
// `beforeEnd` hook (client.ts): the `CAP END` it queued behind
// that would only go out after our QUIT.
break;
}

client.send(line);
}

Expand Down
6 changes: 4 additions & 2 deletions client/js/irc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ export type ConnectOptions = {
saslAccount: string;
saslPassword: string;
/**
* Drop the connection when SASL fails instead of carrying on unauthenticated
* (irc-framework's `sasl_disconnect_on_fail`; the old server left it off).
* Drop the connection when a configured SASL login does not succeed,
* instead of carrying on unauthenticated. Unset means "ask the deploy":
* `config.json`'s `features.saslDisconnectOnFail`, which defaults to
* true (see `client/js/branding.ts` and client.ts `saslRequired`).
*/
saslDisconnectOnFail?: boolean;
};
Expand Down
17 changes: 17 additions & 0 deletions docs/resources/branding.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Every field is optional; `{"appName": "Seance"}` (the shipped default) is a comp
| `features.multiNetwork` | boolean | `true` | `false` hides the sidebar "connect" button once one network exists. |
| `features.saveNetworks` | boolean | `true` | `false` hides the saved-networks picker, "remember password" and "connect automatically" on the connect form (see follow-ups). |
| `features.allowCustomServer` | boolean | `true` | `false` behaves like `lockHost` and also ignores hosts from saved networks and `?host=` URL parameters. Requires `defaultNetwork`. |
| `features.saslDisconnectOnFail` | boolean | `true` | Drop the connection when a network set to log in with SASL does not manage to; see [Failed SASL logins](#failed-sasl-logins). |
| `strings.<key>` | string | built-in copy | Keys: `connect.title`, `connect.savedNetworks`, `connect.savedNetworksEmpty`, `connect.submit`, `help.about`, `help.website`, `help.documentation`, `help.privacy`. |
| `uploads` | object | none (uploads off) | Network-provided file uploader; see [Uploads](#uploads). Dropped unless `endpoint` is an `https:` URL. |
| `uploads.endpoint` | `https` URL | — | Receives a multipart `POST` per file. |
Expand All @@ -81,6 +82,22 @@ Every field is optional; `{"appName": "Seance"}` (the shipped default) is a comp

URL parameters (`?host=…&port=…&nick=…&join=…&autoconnect=1`, `?uri=web+irc://…`) still pre-fill the form and beat `defaultNetwork`, except for host/port/TLS when the host is locked.

## Failed SASL logins

When a network is configured with "Username + password (SASL PLAIN)" and the login does not succeed, Seance reports why and **drops the connection** — it does not quietly register you as an unauthenticated stranger. `features.saslDisconnectOnFail: false` restores the old behaviour: the same report, but the connection carries on.

"Does not succeed" is deliberately wide, because every one of these leaves the user logged out when they asked to be logged in:

- no account name or password is configured (caught before `CAP LS`, so nothing is sent);
- the server does not offer the `sasl` capability, or offers it without `PLAIN` (the message names what it does offer);
- the server `NAK`s `sasl`;
- the server answers `902`, `904`, `905`, `906` or `907` — its text is quoted verbatim, so "invalid credentials", "service unavailable" or nefarious2's `FAIL AUTHENTICATE VERIFICATION_REQUIRED` reach the user;
- nothing arrives within 12 s, or the mechanism gets a challenge it cannot answer.

The lobby then shows the reason, `Not connecting to <host> without the login you asked for.` and what to try; the client sends `QUIT` and does not reconnect, so credentials can be fixed in the network's settings without a reconnect loop. Nothing about this reaches the wire beyond the `QUIT`: the ircd decides on its own whether an unauthenticated client may register at all.

Leave it on for a network whose users expect an account (channel access, host masks, a bouncer session keyed to the account); turn it off for a public deploy where connecting anyway is more useful than not connecting at all.

## Uploads

Seance has no server of its own, so the file goes straight from the browser to an uploader the network runs. Files reach it by drag & drop anywhere on the page, by pasting an image into the input, or from the paperclip button. Running that service is the network's responsibility; Seance only needs it to honour this contract:
Expand Down
10 changes: 10 additions & 0 deletions docs/resources/nefarious2-websocket.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,16 @@ lcarol << @batch=hist7AAF;time=2026-08-26T03:38:23.204Z;msgid=ABAAAAAaA8Iz[n :la

ircu-family throttling applies to WebSocket clients exactly as to TCP ones. `ircd/parse.c` (~L1620) charges every command from a non-oper `lag = lagmin + len / lagfactor` seconds onto `cli_since` (defaults `2 + len/120`; `MFLG_SLOW` commands always), and `ircd/s_bsd.c` (~L1339) stops reading the socket while `cli_since - CurrentTime >= 10` (opers / `IsTrusted` exempt). Multiline batches are charged once at `BATCH -` instead of per line. The clock is reset when registration completes (`ircd/s_auth.c:1483`), so the CAP/NICK/USER/SASL exchange itself is free and only costs round trips — Seance pipelines it (`caps.ts`). So a client gets roughly five commands "for free" and is then held to one every 2 s; a burst of 65 commands (what Seance used to send for 15 autojoined channels) silenced the server for ~90 s and every later line, including the user's first message, waited behind it. Exceeding the recvq while held is "Excess Flood". Seance now sends one multi-target `JOIN`, no `MODE` on join, and paces history/marker fetches one channel per 4 s (`client/js/irc/catchup.ts`; `docs/projects/connect-burst.md`). Worth asking upstream whether read-only fetches (`CHATHISTORY`, `MARKREAD` query) could carry a smaller charge.

## SASL, read from source 2026-08-29

What the server does with `AUTHENTICATE`, and why Seance treats "SASL did not succeed" as a reason not to register at all (`features.saslDisconnectOnFail`, see [branding.md](branding.md#failed-sasl-logins)).

- **`CAP END` does not skip the exchange.** `cap_end()` (`ircd/m_cap.c:798`) only clears `AR_CAP_PENDING` via `auth_cap_done()`; `AR_SASL_PENDING`, set by `auth_sasl_start()` on the first `AUTHENTICATE`, is cleared only by `auth_sasl_done()` from the success, failure, abort and timeout paths (`ircd/s_auth.c:1786-1830`). `check_auth_finished()` scans every `AR_*` flag, so a client that sends `CAP END` mid-exchange still waits for the numeric — and a client that quits mid-exchange is fine, `QUIT` is `MFLG_UNREG` (`ircd/parse.c:212`) and handled by `m_quit` before registration.
- **Three routes, tried in order** (`ircd/m_authenticate.c`): local Keycloak SASL (`sasl_auth.c`, `--enable-keycloak`, `SASL_LOCAL`), IAuth, then the P10 relay to `FEAT_SASL_SERVER` (X3). With none of them reachable the answer is `904 :SASL authentication failed: service unavailable` (plus `FAIL AUTHENTICATE SASL_FAIL` under `standard-replies`). The dev ircd has no services at all and does not even list `sasl` in `CAP LS` (`sasl_server_available()` gates the advertisement, `m_cap.c:569`).
- **Failure numerics carry text worth showing.** `ERR_SASLFAIL` is `":SASL authentication failed%s"` (`ircd/s_err.c:1843`), and the Keycloak path fills that `%s` in: `: malformed PLAIN data`, `: empty username or password`, `: username too long`, `: authzid too long`, `: no client certificate`, `: token rejected by server policy`, `: introspection unavailable`, `: request timed out`, plus the SCRAM ones. Wrong credentials give the bare form. Some paths also send a standard reply first — `FAIL AUTHENTICATE VERIFICATION_REQUIRED` for an unverified account (`sasl_auth.c:746`, `:1381`), `TOO_LONG` for a payload over 400 bytes, `ALREADY_AUTHENTICATED` for a second login. Seance quotes all of it (`handlers/sasl.ts`, `handlers/standard-replies.ts`).
- **Timeouts and aborts.** `FEAT_SASL_TIMEOUT` (default 10 s, `ircd_features.c:1056`) arms a timer on the first `AUTHENTICATE`; on expiry `abort_sasl(cptr, 1)` sends `904 :SASL authentication failed: request timed out` (`ircd/m_sasl.c:278`). `AUTHENTICATE *` from the client gets `906` (or `904` after a failed local session, RFC 7628 §3.2.3). If the services server disconnects mid-exchange, every pending session is aborted at once rather than left to time out (`ircd/list.c:354-372`). Seance's own backstop is 12 s (`sasl.ts SASL_TIMEOUT_MS`) so the server's more descriptive numeric wins.
- **Success is `900` then `903`** (`sasl_complete_login()`, `sasl_auth.c:578-614`). Only `903` ends the exchange; `900` alone means the login is not finished. `908 RPL_SASLMECHS` is informational (`m_sasl.c:273`), so a mechanism list on its own is not an outcome.

## Session persistence (`draft/persistence`), read from source 2026-08-27

The branch has a built-in bouncer (`ircd/bouncer_session.c`, `m_bouncer.c`, `m_persistence.c`): with `BOUNCER_ENABLE` (default off; AfterNET has it on) a logged-in user's session is _held_ when the connection drops (`BOUNCER_DEFAULT_HOLD`, `BOUNCER_SESSION_HOLD` 4 h) and the next connection with the same account resumes it (`BOUNCER_AUTO_RESUME`). Accounts only, so nothing of this can be exercised against the dev ircd (no services); the behaviour below was seen live on AfterNET and read from the code.
Expand Down
Loading