serverless-ircd 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +34 -2
- package/.github/workflows/deploy-aws.yml +1 -2
- package/.github/workflows/deploy-cf-tcp.yml +1 -5
- package/.github/workflows/deploy-cf.yml +1 -2
- package/CHANGELOG.md +435 -0
- package/README.md +206 -27
- package/apps/aws-stack/README.md +37 -3
- package/apps/aws-stack/package.json +1 -1
- package/apps/aws-stack/src/aws-stack.ts +106 -13
- package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
- package/apps/aws-stack/tests/stack.test.ts +49 -3
- package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
- package/apps/aws-stack/tests/vitest.setup.ts +18 -0
- package/apps/aws-stack/vitest.config.ts +3 -0
- package/apps/cf-tcp-container/package.json +1 -1
- package/apps/cf-tcp-container/src/config-loader.ts +6 -7
- package/apps/cf-tcp-container/src/container-server.ts +3 -0
- package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
- package/apps/cf-tcp-container/wrangler.toml +1 -1
- package/apps/cf-worker/package.json +2 -1
- package/apps/cf-worker/vitest.config.ts +1 -1
- package/apps/cf-worker/wrangler.toml +5 -3
- package/apps/local-cli/package.json +1 -1
- package/apps/local-cli/src/config-loader.ts +6 -6
- package/apps/local-cli/src/main.ts +20 -0
- package/apps/local-cli/src/server.ts +138 -28
- package/apps/local-cli/tests/e2e.test.ts +113 -29
- package/apps/local-cli/tests/tcp.test.ts +71 -19
- package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
- package/biome.json +1 -0
- package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
- package/docs/ADR-002-location-of-authority.md +82 -0
- package/docs/ADR-003-durable-object-sharding.md +110 -0
- package/docs/ADR-004-dynamodb-schema.md +96 -0
- package/docs/ADR-005-wss-only-transport-v1.md +83 -0
- package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
- package/docs/ADR-007-deterministic-ports.md +82 -0
- package/docs/ADR-008-monorepo-tooling.md +60 -0
- package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
- package/docs/ADR-Index.md +15 -0
- package/docs/AWS-Adapter-Architecture.md +496 -0
- package/docs/AWS-Deployment.md +1275 -0
- package/docs/AWS-TCP-Deployment.md +410 -0
- package/docs/Cloudflare-Deployment-Guide.md +719 -0
- package/docs/Cloudflare-TCP-Deployment.md +510 -0
- package/docs/Home.md +15 -0
- package/docs/Observability.md +87 -0
- package/docs/PlanExtensions.md +812 -0
- package/docs/PlanIRCv3Websocket.md +503 -0
- package/docs/PlanWebClient.md +451 -0
- package/docs/Release-Process.md +443 -0
- package/package.json +2 -2
- package/packages/aws-adapter/package.json +1 -1
- package/packages/aws-adapter/src/aws-runtime.ts +69 -0
- package/packages/aws-adapter/src/handlers/connect.ts +36 -5
- package/packages/aws-adapter/src/handlers/default.ts +66 -5
- package/packages/aws-adapter/src/handlers/index.ts +41 -2
- package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
- package/packages/aws-adapter/src/index.ts +2 -0
- package/packages/aws-adapter/src/serialize.ts +40 -2
- package/packages/aws-adapter/src/stats.ts +80 -0
- package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
- package/packages/aws-adapter/tests/account-store.test.ts +4 -7
- package/packages/aws-adapter/tests/aws-harness.ts +5 -2
- package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
- package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
- package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
- package/packages/aws-adapter/tests/connect.test.ts +100 -4
- package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
- package/packages/aws-adapter/tests/global-setup.ts +55 -30
- package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
- package/packages/aws-adapter/tests/handlers.test.ts +238 -4
- package/packages/aws-adapter/tests/message-store.test.ts +6 -5
- package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
- package/packages/aws-adapter/tests/stats.test.ts +317 -0
- package/packages/cf-adapter/package.json +6 -1
- package/packages/cf-adapter/src/cf-runtime.ts +66 -1
- package/packages/cf-adapter/src/channel-do.ts +2 -2
- package/packages/cf-adapter/src/connection-do.ts +185 -54
- package/packages/cf-adapter/src/env.ts +25 -6
- package/packages/cf-adapter/src/index.ts +2 -0
- package/packages/cf-adapter/src/registry-do.ts +22 -3
- package/packages/cf-adapter/src/serialize.ts +25 -4
- package/packages/cf-adapter/src/sharding.ts +1 -2
- package/packages/cf-adapter/src/stats.ts +65 -0
- package/packages/cf-adapter/tests/cf-harness.ts +1 -1
- package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
- package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
- package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
- package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
- package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
- package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
- package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
- package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
- package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
- package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
- package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
- package/packages/cf-adapter/tests/serialize.test.ts +308 -0
- package/packages/cf-adapter/tests/sharding.test.ts +1 -1
- package/packages/cf-adapter/tests/stats.test.ts +120 -0
- package/packages/cf-adapter/tests/worker/main.ts +7 -7
- package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
- package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
- package/packages/cf-adapter/vitest.config.ts +1 -1
- package/packages/cf-adapter/wrangler.test.toml +7 -0
- package/packages/in-memory-runtime/package.json +1 -1
- package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
- package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
- package/packages/irc-core/package.json +1 -1
- package/packages/irc-core/src/admission.ts +16 -15
- package/packages/irc-core/src/caps/capabilities.ts +38 -4
- package/packages/irc-core/src/caps/index.ts +1 -0
- package/packages/irc-core/src/caps/sts.ts +84 -0
- package/packages/irc-core/src/commands/account-notify.ts +53 -0
- package/packages/irc-core/src/commands/away.ts +9 -3
- package/packages/irc-core/src/commands/cap.ts +23 -5
- package/packages/irc-core/src/commands/chathistory.ts +40 -11
- package/packages/irc-core/src/commands/index.ts +42 -0
- package/packages/irc-core/src/commands/invite.ts +2 -4
- package/packages/irc-core/src/commands/isupport.ts +59 -2
- package/packages/irc-core/src/commands/kick.ts +2 -4
- package/packages/irc-core/src/commands/kill.ts +127 -0
- package/packages/irc-core/src/commands/list.ts +1 -1
- package/packages/irc-core/src/commands/lusers.ts +204 -0
- package/packages/irc-core/src/commands/mode.ts +12 -9
- package/packages/irc-core/src/commands/monitor.ts +327 -0
- package/packages/irc-core/src/commands/multiline.ts +256 -0
- package/packages/irc-core/src/commands/names.ts +3 -5
- package/packages/irc-core/src/commands/part.ts +2 -4
- package/packages/irc-core/src/commands/pre-away.ts +112 -0
- package/packages/irc-core/src/commands/privmsg.ts +19 -12
- package/packages/irc-core/src/commands/read-marker.ts +108 -0
- package/packages/irc-core/src/commands/registration.ts +8 -0
- package/packages/irc-core/src/commands/rehash.ts +119 -0
- package/packages/irc-core/src/commands/sasl.ts +24 -1
- package/packages/irc-core/src/commands/setname.ts +109 -0
- package/packages/irc-core/src/commands/stats.ts +152 -0
- package/packages/irc-core/src/commands/tagmsg.ts +71 -1
- package/packages/irc-core/src/commands/topic.ts +2 -4
- package/packages/irc-core/src/commands/trace.ts +137 -0
- package/packages/irc-core/src/commands/wallops.ts +118 -0
- package/packages/irc-core/src/commands/whois.ts +5 -0
- package/packages/irc-core/src/config.ts +72 -10
- package/packages/irc-core/src/effects.ts +41 -1
- package/packages/irc-core/src/index.ts +2 -0
- package/packages/irc-core/src/ports.ts +568 -0
- package/packages/irc-core/src/protocol/index.ts +14 -0
- package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
- package/packages/irc-core/src/protocol/numerics.ts +57 -11
- package/packages/irc-core/src/protocol/outbound.ts +36 -4
- package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
- package/packages/irc-core/src/state/connection.ts +32 -1
- package/packages/irc-core/src/types.ts +120 -1
- package/packages/irc-core/src/ws-framing.ts +132 -0
- package/packages/irc-core/src/ws-subprotocol.ts +66 -0
- package/packages/irc-core/stryker.commands.conf.json +1 -2
- package/packages/irc-core/tests/admission.test.ts +18 -0
- package/packages/irc-core/tests/away-store.test.ts +73 -0
- package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
- package/packages/irc-core/tests/caps/sts.test.ts +123 -0
- package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
- package/packages/irc-core/tests/commands/away.test.ts +85 -2
- package/packages/irc-core/tests/commands/cap.test.ts +165 -0
- package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
- package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
- package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
- package/packages/irc-core/tests/commands/kill.test.ts +243 -0
- package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
- package/packages/irc-core/tests/commands/mode.test.ts +129 -0
- package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
- package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
- package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
- package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
- package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
- package/packages/irc-core/tests/commands/registration.test.ts +64 -0
- package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
- package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
- package/packages/irc-core/tests/commands/setname.test.ts +225 -0
- package/packages/irc-core/tests/commands/stats.test.ts +294 -0
- package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
- package/packages/irc-core/tests/commands/trace.test.ts +282 -0
- package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
- package/packages/irc-core/tests/commands/whois.test.ts +34 -0
- package/packages/irc-core/tests/config.test.ts +103 -13
- package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
- package/packages/irc-core/tests/effects.test.ts +14 -0
- package/packages/irc-core/tests/labeled-response.test.ts +181 -0
- package/packages/irc-core/tests/message-tags.test.ts +114 -0
- package/packages/irc-core/tests/monitor-store.test.ts +200 -0
- package/packages/irc-core/tests/numerics.test.ts +90 -0
- package/packages/irc-core/tests/outbound.test.ts +51 -0
- package/packages/irc-core/tests/ports.test.ts +22 -0
- package/packages/irc-core/tests/raw-modules.d.ts +11 -0
- package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
- package/packages/irc-core/tests/standard-replies.test.ts +412 -0
- package/packages/irc-core/tests/stats-store.test.ts +222 -0
- package/packages/irc-core/tests/types.test.ts +27 -0
- package/packages/irc-core/tests/ws-framing.test.ts +213 -0
- package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
- package/packages/irc-server/package.json +1 -1
- package/packages/irc-server/src/actor.ts +699 -19
- package/packages/irc-server/src/dispatch.ts +109 -16
- package/packages/irc-server/src/routing.ts +3 -0
- package/packages/irc-server/src/runtime.ts +31 -0
- package/packages/irc-server/src/transport.ts +10 -7
- package/packages/irc-server/tests/actor.test.ts +2523 -42
- package/packages/irc-server/tests/dispatch.test.ts +300 -2
- package/packages/irc-server/tests/raw-modules.d.ts +11 -0
- package/packages/irc-server/tests/routing.test.ts +1 -0
- package/packages/irc-server/tests/runtime.test.ts +7 -0
- package/packages/irc-test-support/package.json +1 -1
- package/packages/irc-test-support/src/index.ts +6 -0
- package/packages/irc-test-support/src/scenarios.ts +9 -1
- package/packages/irc-test-support/src/test-config.ts +54 -0
- package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
- package/packages/irc-test-support/tests/test-config.test.ts +51 -0
- package/pnpm-workspace.yaml +1 -0
- package/tools/ci-hardening/package.json +1 -1
- package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
- package/tools/load-test/package.json +33 -0
- package/tools/load-test/src/client.ts +351 -0
- package/tools/load-test/src/config.ts +313 -0
- package/tools/load-test/src/harness.ts +116 -0
- package/tools/load-test/src/main.ts +120 -0
- package/tools/load-test/src/metrics.ts +168 -0
- package/tools/load-test/src/report.ts +106 -0
- package/tools/load-test/tests/client.test.ts +212 -0
- package/tools/load-test/tests/config.test.ts +152 -0
- package/tools/load-test/tests/framing.test.ts +37 -0
- package/tools/load-test/tests/harness.test.ts +165 -0
- package/tools/load-test/tests/metrics.test.ts +174 -0
- package/tools/load-test/tests/report.test.ts +161 -0
- package/tools/load-test/tests/smoke.test.ts +67 -0
- package/tools/load-test/tsconfig.build.json +12 -0
- package/tools/load-test/tsconfig.test.json +10 -0
- package/tools/load-test/vitest.config.ts +29 -0
- package/tools/package.json +6 -1
- package/tools/seed-cf-accounts.ts +4 -1
- package/tools/tcp-ws-forwarder/package.json +1 -1
- package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
- package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
- package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `REHASH` command (oper-gated config reload).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: REHASH runs in the Connection entity. The
|
|
5
|
+
* reducer is pure — it only enforces the oper gate and emits the
|
|
6
|
+
* `382 RPL_REHASHING` reply. The actual config re-fetch is a request/response
|
|
7
|
+
* side effect (re-invoke the adapter's bound config loader and swap the live
|
|
8
|
+
* {@link ServerConfig} reference on the actor), so — exactly like
|
|
9
|
+
* `lookupNick` / `getConnection` — it is NOT modelled as a fire-and-forget
|
|
10
|
+
* `Effect`; the actor layer performs it and surfaces success/failure back to
|
|
11
|
+
* the oper via a second `382` line on failure.
|
|
12
|
+
*
|
|
13
|
+
* Wire format:
|
|
14
|
+
* - `REHASH` (no params).
|
|
15
|
+
* - `382 RPL_REHASHING`:
|
|
16
|
+
* `:<server> 382 <nick> :Rehashing from <source>`
|
|
17
|
+
* - `481 ERR_NOPRIVILEGES`:
|
|
18
|
+
* `:<server> 481 <nick> :Permission Denied - You're not an IRC operator`
|
|
19
|
+
*
|
|
20
|
+
* Behaviour:
|
|
21
|
+
* - Non-oper → `481 ERR_NOPRIVILEGES`; no reload is signalled.
|
|
22
|
+
* - Oper → emits `382` noting the bound config source and signals
|
|
23
|
+
* `shouldReload: true`. The actor re-invokes its loader and swaps the
|
|
24
|
+
* live config; on failure the previous config stays in effect and a
|
|
25
|
+
* graceful error-suffixed `382` follows (handled by the actor).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Effect } from '../effects.js';
|
|
29
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
30
|
+
import type { IrcMessage } from '../protocol/messages.js';
|
|
31
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
32
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
33
|
+
import type { Ctx } from '../types.js';
|
|
34
|
+
|
|
35
|
+
/** Fixed text emitted for `481 ERR_NOPRIVILEGES` (matches `mode.ts`). */
|
|
36
|
+
const NOPRIVILEGES_TRAILING_TEXT = "Permission Denied - You're not an IRC operator";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Result of {@link rehashReducer}. Adds a `shouldReload` intent flag on top of
|
|
40
|
+
* the standard reducer shape so the actor layer knows whether to perform the
|
|
41
|
+
* async config re-fetch (oper gate passed) without re-deriving the gate.
|
|
42
|
+
*/
|
|
43
|
+
export interface RehashResult {
|
|
44
|
+
state: ConnectionState;
|
|
45
|
+
effects: EffectType[];
|
|
46
|
+
shouldReload: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Composes the trailing text for the `382 RPL_REHASHING` line. The success
|
|
51
|
+
* form notes the bound config source; the failure form (used by the actor on
|
|
52
|
+
* reload rejection) appends the error so the oper sees what went wrong
|
|
53
|
+
* without being disconnected.
|
|
54
|
+
*/
|
|
55
|
+
export function buildRehashTrailing(configSource: string, error?: string): string {
|
|
56
|
+
const base = `Rehashing from ${configSource}`;
|
|
57
|
+
return error === undefined ? base : `${base} failed: ${error}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Renders the full `:<server> 382 <nick> :<trailing>` wire line. Shared by
|
|
62
|
+
* the reducer (optimistic success line) and the actor (error-suffixed line
|
|
63
|
+
* on reload failure) so the two paths cannot drift in formatting.
|
|
64
|
+
*/
|
|
65
|
+
export function rehashLine(
|
|
66
|
+
serverName: string,
|
|
67
|
+
nick: string,
|
|
68
|
+
configSource: string,
|
|
69
|
+
error?: string,
|
|
70
|
+
): RawLine {
|
|
71
|
+
const code = Numerics.RPL_REHASHING.toString().padStart(3, '0');
|
|
72
|
+
return { text: `:${serverName} ${code} ${nick} :${buildRehashTrailing(configSource, error)}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handles `REHASH`.
|
|
77
|
+
*
|
|
78
|
+
* Enforces the oper gate, stamps `lastSeen`, and — for an oper — emits the
|
|
79
|
+
* `382 RPL_REHASHING` reply and signals the actor to perform the reload via
|
|
80
|
+
* `shouldReload`. The reducer never performs IO.
|
|
81
|
+
*/
|
|
82
|
+
export function rehashReducer(
|
|
83
|
+
state: ConnectionState,
|
|
84
|
+
_msg: IrcMessage,
|
|
85
|
+
ctx: Ctx,
|
|
86
|
+
configSource: string,
|
|
87
|
+
): RehashResult {
|
|
88
|
+
state.lastSeen = ctx.clock.now();
|
|
89
|
+
|
|
90
|
+
if (!state.userModes.oper) {
|
|
91
|
+
return {
|
|
92
|
+
state,
|
|
93
|
+
effects: [
|
|
94
|
+
Effect.send(ctx.connId, [
|
|
95
|
+
numericLine(ctx, Numerics.ERR_NOPRIVILEGES, NOPRIVILEGES_TRAILING_TEXT),
|
|
96
|
+
]),
|
|
97
|
+
],
|
|
98
|
+
shouldReload: false,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const nick = ctx.connection.nick ?? '*';
|
|
103
|
+
return {
|
|
104
|
+
state,
|
|
105
|
+
effects: [Effect.send(ctx.connId, [rehashLine(ctx.serverName, nick, configSource)])],
|
|
106
|
+
shouldReload: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Builds a `:<server> <code> <nick> :<trailing>` line. The nick falls back to
|
|
112
|
+
* `*` for an unregistered connection, matching the rest of the codebase's
|
|
113
|
+
* numeric formatting.
|
|
114
|
+
*/
|
|
115
|
+
function numericLine(ctx: Ctx, code: number, trailing: string): RawLine {
|
|
116
|
+
const nick = ctx.connection.nick ?? '*';
|
|
117
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
118
|
+
return { text: [`:${ctx.serverName}`, codeStr, nick, `:${trailing}`].join(' ') };
|
|
119
|
+
}
|
|
@@ -35,6 +35,9 @@ import { Numerics } from '../protocol/numerics.js';
|
|
|
35
35
|
import { hostmaskOf } from '../state/connection.js';
|
|
36
36
|
import type { ConnectionState } from '../state/connection.js';
|
|
37
37
|
import type { Ctx, Reducer } from '../types.js';
|
|
38
|
+
import { emitAccountNotify } from './account-notify.js';
|
|
39
|
+
import { nowAwayLine, replayPersistedAway } from './pre-away.js';
|
|
40
|
+
import { seedReadMarkers } from './read-marker.js';
|
|
38
41
|
|
|
39
42
|
/** Per-ircdocs.horse: a single `AUTHENTICATE` payload chunk is at most 400 bytes. */
|
|
40
43
|
const SASL_CHUNK_BYTES = 400;
|
|
@@ -218,9 +221,29 @@ function applyResult(
|
|
|
218
221
|
|
|
219
222
|
if (result.ok) {
|
|
220
223
|
state.account = result.account;
|
|
224
|
+
// IRCv3 draft/read-marker: restore the user's per-channel last-read
|
|
225
|
+
// markers from the persisted store so a reconnect resumes at their
|
|
226
|
+
// last read position. No-op when no ReadMarkerStore is bound.
|
|
227
|
+
if (ctx.readMarkers !== undefined) {
|
|
228
|
+
seedReadMarkers(state, ctx.readMarkers, result.account);
|
|
229
|
+
}
|
|
230
|
+
// IRCv3 draft/pre-away: replay the user's persisted away reason onto
|
|
231
|
+
// the fresh connection so a reconnect restores the away state, and
|
|
232
|
+
// emit the 306 numeric so the user sees they are still away. No-op
|
|
233
|
+
// when no AwayStore is bound or no reason is stored.
|
|
234
|
+
const replayedAway =
|
|
235
|
+
ctx.away !== undefined ? replayPersistedAway(state, ctx.away, result.account) : undefined;
|
|
236
|
+
const awayEffects: EffectType[] =
|
|
237
|
+
replayedAway !== undefined
|
|
238
|
+
? [Effect.send(ctx.connId, [nowAwayLine(state, ctx.serverName)])]
|
|
239
|
+
: [];
|
|
221
240
|
return {
|
|
222
241
|
state,
|
|
223
|
-
effects: [
|
|
242
|
+
effects: [
|
|
243
|
+
Effect.send(ctx.connId, [loggedIn(ctx, result.account), saslSuccess(ctx)]),
|
|
244
|
+
...awayEffects,
|
|
245
|
+
...emitAccountNotify({ conn: state, account: result.account }),
|
|
246
|
+
],
|
|
224
247
|
};
|
|
225
248
|
}
|
|
226
249
|
return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `SETNAME` command (RFC 2812 §4.1.7).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: SETNAME runs in the Connection entity.
|
|
5
|
+
* The reducer mutates `state.realname` (the GECOS field originally set by
|
|
6
|
+
* the `USER` reducer) and performs no IO.
|
|
7
|
+
*
|
|
8
|
+
* Wire format:
|
|
9
|
+
* - `SETNAME :<realname>` — replaces `state.realname`.
|
|
10
|
+
* - `461 ERR_NEEDMOREPARAMS` when the realname is missing/empty.
|
|
11
|
+
* - `451 ERR_NOTREGISTERED` when issued before registration completes.
|
|
12
|
+
*
|
|
13
|
+
* Broadcast decision: there is no standard IRCv3 cap for relaying a
|
|
14
|
+
* realname change (the `chghost` cap covers user/host, not realname), so
|
|
15
|
+
* v1 updates `state.realname` silently. A subsequent `WHOIS` reflects the
|
|
16
|
+
* new value in `311 RPL_WHOISUSER`. A `draft/setname` relay cap is a
|
|
17
|
+
* documented follow-up.
|
|
18
|
+
*
|
|
19
|
+
* Length cap: registration-time `USER` does not enforce a realname length
|
|
20
|
+
* cap, so this command applies its own via `serverConfig.realnameLen`
|
|
21
|
+
* (falling back to {@link DEFAULT_REALNAME_LEN}). An over-cap value is
|
|
22
|
+
* rejected with `432 ERR_ERRONEUSNICKNAME` — the same numeric registration
|
|
23
|
+
* uses for an over-length `NICK` (the codebase's only registration-time
|
|
24
|
+
* "value rejected for length" precedent). The offending value is NOT
|
|
25
|
+
* echoed back so a client cannot amplify a multi-kilobyte payload via the
|
|
26
|
+
* error reply. Truncation (as `TOPIC` does) is deliberately not used: the
|
|
27
|
+
* spec frames an over-long realname as a rejection, not a silent trim.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Effect } from '../effects.js';
|
|
31
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
32
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
33
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
34
|
+
import type { Ctx, Reducer } from '../types.js';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Built-in realname length cap when `serverConfig.realnameLen` is unset.
|
|
38
|
+
* Matches the schema default and the common ircd-seven GECOS ceiling.
|
|
39
|
+
*/
|
|
40
|
+
const DEFAULT_REALNAME_LEN = 50;
|
|
41
|
+
|
|
42
|
+
/** Fixed trailing text for `461 ERR_NEEDMOREPARAMS` (matches `USER`/`PASS`). */
|
|
43
|
+
const NEEDMOREPARAMS_TRAILING = 'Not enough parameters';
|
|
44
|
+
|
|
45
|
+
/** Fixed trailing text for `451 ERR_NOTREGISTERED`. */
|
|
46
|
+
const NOTREGISTERED_TRAILING = 'You have not registered';
|
|
47
|
+
|
|
48
|
+
/** Fixed trailing text for the over-cap `432` rejection. */
|
|
49
|
+
const ERRONEOUS_REALNAME_TRAILING = 'Erroneous realname';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Handles `SETNAME :<realname>`.
|
|
53
|
+
*
|
|
54
|
+
* Stamps `lastSeen`, enforces the registration + length gates, and on
|
|
55
|
+
* success replaces `state.realname` with no broadcast. The reducer never
|
|
56
|
+
* performs IO.
|
|
57
|
+
*/
|
|
58
|
+
export const setnameReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
|
|
59
|
+
state.lastSeen = ctx.clock.now();
|
|
60
|
+
|
|
61
|
+
const effects: EffectType[] = [];
|
|
62
|
+
|
|
63
|
+
if (state.registration !== 'registered') {
|
|
64
|
+
effects.push(
|
|
65
|
+
Effect.send(ctx.connId, [
|
|
66
|
+
numericLine(ctx, Numerics.ERR_NOTREGISTERED, NOTREGISTERED_TRAILING),
|
|
67
|
+
]),
|
|
68
|
+
);
|
|
69
|
+
return { state, effects };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const realname = msg.params[0];
|
|
73
|
+
if (realname === undefined || realname.length === 0) {
|
|
74
|
+
effects.push(
|
|
75
|
+
Effect.send(ctx.connId, [
|
|
76
|
+
numericLine(ctx, Numerics.ERR_NEEDMOREPARAMS, NEEDMOREPARAMS_TRAILING, 'SETNAME'),
|
|
77
|
+
]),
|
|
78
|
+
);
|
|
79
|
+
return { state, effects };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const cap = ctx.serverConfig.realnameLen ?? DEFAULT_REALNAME_LEN;
|
|
83
|
+
if (realname.length > cap) {
|
|
84
|
+
effects.push(
|
|
85
|
+
Effect.send(ctx.connId, [
|
|
86
|
+
numericLine(ctx, Numerics.ERR_ERRONEUSNICKNAME, ERRONEOUS_REALNAME_TRAILING),
|
|
87
|
+
]),
|
|
88
|
+
);
|
|
89
|
+
return { state, effects };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
state.realname = realname;
|
|
93
|
+
return { state, effects };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Builds a `:<server> <code> <nick> [:<middle>] :<trailing>` line. The nick
|
|
98
|
+
* falls back to `*` when the connection has not yet registered one, and the
|
|
99
|
+
* optional `middle` is omitted when not supplied — matching the numeric
|
|
100
|
+
* formatting used by the registration reducers.
|
|
101
|
+
*/
|
|
102
|
+
function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
|
|
103
|
+
const nick = ctx.connection.nick ?? '*';
|
|
104
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
105
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
106
|
+
if (middle !== undefined) parts.push(middle);
|
|
107
|
+
parts.push(`:${trailing}`);
|
|
108
|
+
return { text: parts.join(' ') };
|
|
109
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `STATS` command (RFC 2812 §4.6.3).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: STATS is a read-only diagnostic that
|
|
5
|
+
* reports server statistics keyed by a single-letter query. The reducer
|
|
6
|
+
* takes an already-aggregated {@link ServerStatsSnapshot} (the actor
|
|
7
|
+
* awaits a {@link ServerStats} backend before calling here). The reducer
|
|
8
|
+
* never mutates anything but the requester's `lastSeen`.
|
|
9
|
+
*
|
|
10
|
+
* Wire format (RFC 2812 §5.1 + modern practice):
|
|
11
|
+
* - `STATS <query> [<server>]`
|
|
12
|
+
* - `211 RPL_STATSLINKINFO` (the `l` query): per-link sendq/byte/msg
|
|
13
|
+
* counters. Single-server deployment collapses this to one line for
|
|
14
|
+
* the bound server.
|
|
15
|
+
* - `242 RPL_STATSUPTIME` (the `u` query):
|
|
16
|
+
* `:<server> 242 <nick> :Server Up <d> days, <h>:<mm>:<ss>`
|
|
17
|
+
* - `219 RPL_ENDOFSTATS`:
|
|
18
|
+
* `:<server> 219 <nick> <query> :End of /STATS report`
|
|
19
|
+
* - `402 ERR_NOSUCHSERVER`:
|
|
20
|
+
* `:<server> 402 <nick> <server> :No such server`
|
|
21
|
+
*
|
|
22
|
+
* Implemented query letters: `u` (uptime), `l` (link info). Unknown letters
|
|
23
|
+
* follow charybdis semantics: the reducer emits ONLY the `219` terminator
|
|
24
|
+
* with an empty body (no 461/401/etc). The remaining RFC letters
|
|
25
|
+
* (`k`/`K`/`q`/`g`/`x`/`z`/…) are deferred behind per-letter tests; the
|
|
26
|
+
* actor routes them here regardless so a future implementation is just
|
|
27
|
+
* another case branch.
|
|
28
|
+
*
|
|
29
|
+
* `<server>` resolves at the actor layer: a non-local name yields
|
|
30
|
+
* `402 ERR_NOSUCHSERVER` (single-server network — S2S routing is a PLAN
|
|
31
|
+
* non-goal). When the actor passes `serverTarget`, the reducer emits ONLY
|
|
32
|
+
* the 402 and nothing else.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { Effect } from '../effects.js';
|
|
36
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
37
|
+
import type { ServerStatsSnapshot } from '../ports.js';
|
|
38
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
39
|
+
import type { Ctx } from '../types.js';
|
|
40
|
+
|
|
41
|
+
/** Fixed text emitted for `402 ERR_NOSUCHSERVER`. */
|
|
42
|
+
const NOSUCHSERVER_TRAILING_TEXT = 'No such server';
|
|
43
|
+
|
|
44
|
+
/** Fixed text emitted for `219 RPL_ENDOFSTATS`. */
|
|
45
|
+
const ENDOFSTATS_TRAILING_TEXT = 'End of /STATS report';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Builds a `:<server> <code> <nick> [<middle>] :<trailing>` numeric line
|
|
49
|
+
* addressed to the requester. When `middle` is empty the middle field is
|
|
50
|
+
* still emitted as a bare space-prefixed token (matches charybdis output
|
|
51
|
+
* for STATS with no query letter).
|
|
52
|
+
*/
|
|
53
|
+
function numericLine(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
54
|
+
const nick = ctx.connection.nick ?? '*';
|
|
55
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
56
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
57
|
+
parts.push(middle);
|
|
58
|
+
parts.push(`:${trailing}`);
|
|
59
|
+
return { text: parts.join(' ') };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Formats an uptime delta (ms) as `Server Up <d> days, <h>:<mm>:<ss>`,
|
|
64
|
+
* matching charybdis's `RPL_STATSUPTIME` text. Days and hours are not
|
|
65
|
+
* zero-padded; minutes and seconds are.
|
|
66
|
+
*/
|
|
67
|
+
function formatUptime(deltaMs: number): string {
|
|
68
|
+
let totalSec = Math.floor(deltaMs / 1000);
|
|
69
|
+
const days = Math.floor(totalSec / 86_400);
|
|
70
|
+
totalSec -= days * 86_400;
|
|
71
|
+
const hours = Math.floor(totalSec / 3_600);
|
|
72
|
+
totalSec -= hours * 3_600;
|
|
73
|
+
const minutes = Math.floor(totalSec / 60);
|
|
74
|
+
totalSec -= minutes * 60;
|
|
75
|
+
const seconds = totalSec;
|
|
76
|
+
const mm = minutes.toString().padStart(2, '0');
|
|
77
|
+
const ss = seconds.toString().padStart(2, '0');
|
|
78
|
+
return `Server Up ${days} days, ${hours}:${mm}:${ss}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handles `STATS <query> [<server>]`.
|
|
83
|
+
*
|
|
84
|
+
* @param snapshot The aggregated network statistics (only `uptimeStartedAt`
|
|
85
|
+
* is consulted here; full counts are surfaced by LUSERS).
|
|
86
|
+
* @param msg The parsed `STATS` message.
|
|
87
|
+
* @param ctx The per-invocation context.
|
|
88
|
+
* @param serverTarget When set, the actor determined `<server>` names a
|
|
89
|
+
* non-local server; the reducer emits `402 ERR_NOSUCHSERVER` against this
|
|
90
|
+
* name.
|
|
91
|
+
*/
|
|
92
|
+
export function statsReducer(
|
|
93
|
+
snapshot: ServerStatsSnapshot,
|
|
94
|
+
msg: { command: string; params: readonly string[] },
|
|
95
|
+
ctx: Ctx,
|
|
96
|
+
serverTarget?: string,
|
|
97
|
+
): { effects: EffectType[] } {
|
|
98
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
99
|
+
|
|
100
|
+
const effects: EffectType[] = [];
|
|
101
|
+
|
|
102
|
+
if (serverTarget !== undefined) {
|
|
103
|
+
effects.push(
|
|
104
|
+
Effect.send(ctx.connId, [
|
|
105
|
+
numericLine(ctx, Numerics.ERR_NOSUCHSERVER, NOSUCHSERVER_TRAILING_TEXT, serverTarget),
|
|
106
|
+
]),
|
|
107
|
+
);
|
|
108
|
+
return { effects };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Normalise: STATS with no query letter is treated as the empty string;
|
|
112
|
+
// case is folded to lower so the terminator matches the canonical charybdis
|
|
113
|
+
// output (`STATS U` and `STATS u` both terminate with `219 <nick> u`).
|
|
114
|
+
const rawQuery = msg.params[0] ?? '';
|
|
115
|
+
const query = rawQuery.toLowerCase();
|
|
116
|
+
|
|
117
|
+
if (query === 'u') {
|
|
118
|
+
const uptimeMs = ctx.clock.now() - snapshot.uptimeStartedAt;
|
|
119
|
+
const code = Numerics.RPL_STATSUPTIME.toString().padStart(3, '0');
|
|
120
|
+
effects.push(
|
|
121
|
+
Effect.send(ctx.connId, [
|
|
122
|
+
{
|
|
123
|
+
text: `:${ctx.serverName} ${code} ${ctx.connection.nick ?? '*'} :${formatUptime(uptimeMs)}`,
|
|
124
|
+
},
|
|
125
|
+
]),
|
|
126
|
+
);
|
|
127
|
+
} else if (query === 'l') {
|
|
128
|
+
// Single-server: one link-info line for the bound server. All sendq /
|
|
129
|
+
// byte / message counters are zero for a stateless serverless runtime
|
|
130
|
+
// (no persistent S2S link); the trailing text is the configured server
|
|
131
|
+
// version so the client sees a self-describing entry.
|
|
132
|
+
const code = Numerics.RPL_STATSLINKINFO.toString().padStart(3, '0');
|
|
133
|
+
const requester = ctx.connection.nick ?? '*';
|
|
134
|
+
const version = ctx.serverConfig.serverVersion ?? '';
|
|
135
|
+
effects.push(
|
|
136
|
+
Effect.send(ctx.connId, [
|
|
137
|
+
{
|
|
138
|
+
text: `:${ctx.serverName} ${code} ${requester} ${ctx.serverName} 0 0 0 0 0 0 :${version}`,
|
|
139
|
+
},
|
|
140
|
+
]),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
// Terminator always emitted, even for unknown letters (charybdis
|
|
144
|
+
// semantics: unknown STATS letters produce no body lines, just 219).
|
|
145
|
+
effects.push(
|
|
146
|
+
Effect.send(ctx.connId, [
|
|
147
|
+
numericLine(ctx, Numerics.RPL_ENDOFSTATS, ENDOFSTATS_TRAILING_TEXT, query),
|
|
148
|
+
]),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return { effects };
|
|
152
|
+
}
|
|
@@ -27,10 +27,58 @@ import type { ChannelState } from '../state/channel.js';
|
|
|
27
27
|
import { hostmaskOf } from '../state/connection.js';
|
|
28
28
|
import type { ConnectionState } from '../state/connection.js';
|
|
29
29
|
import type { Ctx, Reducer } from '../types.js';
|
|
30
|
+
import {
|
|
31
|
+
READ_MARKER_CAP,
|
|
32
|
+
isReadMarkerTagmsg,
|
|
33
|
+
persistReadMarker,
|
|
34
|
+
readMarkerMsgid,
|
|
35
|
+
} from './read-marker.js';
|
|
30
36
|
|
|
31
37
|
/** The cap name advertising message-tags support. */
|
|
32
38
|
const MESSAGE_TAGS_CAP = 'message-tags';
|
|
33
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The cap name advertising IRCv3 `draft/typing` support. Typing TAGMSGs
|
|
42
|
+
* (those carrying the `+draft/typing` client tag) fan out to members that
|
|
43
|
+
* announced EITHER `message-tags` (the baseline for every TAGMSG) OR
|
|
44
|
+
* `draft/typing` (the spec's client-tag exception for peers that did not
|
|
45
|
+
* negotiate `message-tags`).
|
|
46
|
+
*/
|
|
47
|
+
const TYPING_CAP = 'draft/typing';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The client-tag key that identifies a typing TAGMSG. Presence of this key
|
|
51
|
+
* (regardless of value, including the valueless form) triggers the OR-caps
|
|
52
|
+
* broadcast above.
|
|
53
|
+
*/
|
|
54
|
+
const TYPING_TAG = '+draft/typing';
|
|
55
|
+
|
|
56
|
+
/** Returns true iff `tags` carries the `+draft/typing` client tag. */
|
|
57
|
+
function isTypingTagmsg(tags: Readonly<Record<string, string>>): boolean {
|
|
58
|
+
return TYPING_TAG in tags;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns the OR-semantics cap list a TAGMSG fanout should use, or `undefined`
|
|
63
|
+
* when the plain single-cap (`message-tags`) broadcast path applies.
|
|
64
|
+
*
|
|
65
|
+
* A TAGMSG carrying `+draft/typing` widens to `message-tags` OR `draft/typing`;
|
|
66
|
+
* one carrying `+draft/read-marker` widens to `message-tags` OR
|
|
67
|
+
* `draft/read-marker`. Both spec exceptions let a peer that announced only the
|
|
68
|
+
* draft cap (not `message-tags`) still receive the notification. The baseline
|
|
69
|
+
* `message-tags` cap always leads the list so message-tags peers keep receiving
|
|
70
|
+
* every TAGMSG.
|
|
71
|
+
*/
|
|
72
|
+
function widenAudienceCaps(tags: Readonly<Record<string, string>>): readonly string[] | undefined {
|
|
73
|
+
const typing = isTypingTagmsg(tags);
|
|
74
|
+
const readMarker = isReadMarkerTagmsg(tags);
|
|
75
|
+
if (!typing && !readMarker) return undefined;
|
|
76
|
+
const caps = [MESSAGE_TAGS_CAP];
|
|
77
|
+
if (typing) caps.push(TYPING_CAP);
|
|
78
|
+
if (readMarker) caps.push(READ_MARKER_CAP);
|
|
79
|
+
return caps;
|
|
80
|
+
}
|
|
81
|
+
|
|
34
82
|
/**
|
|
35
83
|
* Returns true iff `hostmask` matches the IRC ban mask `mask`. Identical to
|
|
36
84
|
* the implementation in `privmsg.ts`; duplicated to keep the message path
|
|
@@ -149,10 +197,32 @@ export const tagmsgChannelReducer: Reducer<ChannelState> = (state, msg, ctx) =>
|
|
|
149
197
|
});
|
|
150
198
|
}
|
|
151
199
|
|
|
200
|
+
// IRCv3 draft/read-marker: a TAGMSG carrying `+draft/read-marker=<msgid>`
|
|
201
|
+
// marks `<msgid>` as the last read message for the sender's account in
|
|
202
|
+
// this channel. Persist the marker (advances the connection's in-memory
|
|
203
|
+
// marker too); a valueless tag carries no msgid and is dropped here. The
|
|
204
|
+
// TAGMSG still fans out below so the user's other connections learn it.
|
|
205
|
+
if (isReadMarkerTagmsg(msg.tags)) {
|
|
206
|
+
const msgid = readMarkerMsgid(msg.tags);
|
|
207
|
+
if (msgid !== undefined) {
|
|
208
|
+
persistReadMarker(ctx, state.nameLower, msgid);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
152
212
|
// Broadcast the tag-bearing line to message-tags-enabled members only.
|
|
213
|
+
// When the TAGMSG carries `+draft/typing` or `+draft/read-marker`, the
|
|
214
|
+
// audience widens to also include members that announced the matching
|
|
215
|
+
// draft cap (the spec's client-tag exception): such peers receive the
|
|
216
|
+
// notification even without `message-tags`. The caps are passed via
|
|
217
|
+
// `caps` so dispatch applies OR semantics per recipient.
|
|
153
218
|
const tagPrefix = serializeTags(msg.tags);
|
|
154
219
|
const line: RawLine = { text: `${tagPrefix}:${hostmask} TAGMSG ${state.name}` };
|
|
155
|
-
|
|
220
|
+
const widenCaps = widenAudienceCaps(msg.tags);
|
|
221
|
+
if (widenCaps !== undefined) {
|
|
222
|
+
effects.push(Effect.broadcast(state.name, [line], ctx.connId, undefined, undefined, widenCaps));
|
|
223
|
+
} else {
|
|
224
|
+
effects.push(Effect.broadcast(state.name, [line], ctx.connId, MESSAGE_TAGS_CAP));
|
|
225
|
+
}
|
|
156
226
|
|
|
157
227
|
// IRCv3 echo-message: echo the same line back to the sender, after
|
|
158
228
|
// the broadcast effect so clients observe their own message last.
|
|
@@ -35,12 +35,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** Formats a numeric error/reply line addressed to the connection's nick. */
|
|
38
|
-
function numericErr(ctx: Ctx, code: number, trailing: string, middle
|
|
38
|
+
function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
39
39
|
const nick = ctx.connection.nick ?? '*';
|
|
40
40
|
const codeStr = code.toString().padStart(3, '0');
|
|
41
|
-
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
42
|
-
if (middle !== undefined) parts.push(middle);
|
|
43
|
-
parts.push(`:${trailing}`);
|
|
41
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
|
|
44
42
|
return { text: parts.join(' ') };
|
|
45
43
|
}
|
|
46
44
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure reducer for the IRC `TRACE` command (RFC 2812 §4.6.3).
|
|
3
|
+
*
|
|
4
|
+
* PLAN §2.1 location-of-authority: TRACE is a query-only diagnostic. The
|
|
5
|
+
* actor layer resolves the optional `<target>` and — when it is a nick —
|
|
6
|
+
* hands the resolved {@link ConnectionState} here. The reducer never
|
|
7
|
+
* mutates anything but the requester's `lastSeen`.
|
|
8
|
+
*
|
|
9
|
+
* In a single-server serverless IRCd, multi-hop routing collapses to the
|
|
10
|
+
* local server. The classic `200 RPL_TRACELINK` / `206 RPL_TRACESERVER`
|
|
11
|
+
* server-link dump is therefore trivial (one server, zero hops); modern
|
|
12
|
+
* daemons emit just the per-connection `205`/`204` lines and the
|
|
13
|
+
* terminating `262 RPL_ENDOFTRACE`. This reducer follows that convention.
|
|
14
|
+
*
|
|
15
|
+
* Wire format (RFC 2812 §4.6.3 + modern practice):
|
|
16
|
+
* - `TRACE [<target>]`
|
|
17
|
+
* - `205 RPL_TRACEUSER`:
|
|
18
|
+
* `:<server> 205 <nick> User <hops> <user> <host>` (per matched conn)
|
|
19
|
+
* - `204 RPL_TRACEOPERATOR`:
|
|
20
|
+
* `:<server> 204 <nick> Oper <hops> <user> <host>` (oper conns only)
|
|
21
|
+
* - `262 RPL_ENDOFTRACE`:
|
|
22
|
+
* `:<server> 262 <nick> <target> :End of TRACE`
|
|
23
|
+
* - `402 ERR_NOSUCHSERVER`:
|
|
24
|
+
* `:<server> 402 <nick> <server> :No such server`
|
|
25
|
+
*
|
|
26
|
+
* Detail visibility is oper-gated:
|
|
27
|
+
* - Non-opers see only the `262` terminator (no per-connection detail),
|
|
28
|
+
* so a non-oper cannot use `TRACE` to enumerate online users. This
|
|
29
|
+
* also implicitly enforces `+i` invisibility for non-opers.
|
|
30
|
+
* - Opers see one `205`/`204` line per matched connection and bypass
|
|
31
|
+
* the `+i` filter (RFC 2812 §4.6.3).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { Effect } from '../effects.js';
|
|
35
|
+
import type { Effect as EffectType, RawLine } from '../effects.js';
|
|
36
|
+
import { Numerics } from '../protocol/numerics.js';
|
|
37
|
+
import type { ConnectionState } from '../state/connection.js';
|
|
38
|
+
import type { Ctx } from '../types.js';
|
|
39
|
+
|
|
40
|
+
/** Fixed text emitted for `402 ERR_NOSUCHSERVER`. */
|
|
41
|
+
const NOSUCHSERVER_TRAILING_TEXT = 'No such server';
|
|
42
|
+
|
|
43
|
+
/** Fixed text emitted for `262 RPL_ENDOFTRACE`. */
|
|
44
|
+
const ENDOFTRACE_TRAILING_TEXT = 'End of TRACE';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Builds a `:<server> <code> <nick> <Class> <hops> <nick> <user> <host>`
|
|
48
|
+
* line for one matched connection. `<Class>` is `Oper` for an oper,
|
|
49
|
+
* otherwise `User` (matching RFC 2812 §4.6.3 + common client rendering).
|
|
50
|
+
* `<hops>` is always 0 in a single-server deployment.
|
|
51
|
+
*/
|
|
52
|
+
function traceDetailLine(ctx: Ctx, code: number, cls: string, target: ConnectionState): RawLine {
|
|
53
|
+
const requesterNick = ctx.connection.nick ?? '*';
|
|
54
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
55
|
+
const tNick = target.nick ?? '*';
|
|
56
|
+
const tUser = target.user ?? '?';
|
|
57
|
+
const tHost = target.host ?? '?';
|
|
58
|
+
return {
|
|
59
|
+
text: `:${ctx.serverName} ${codeStr} ${requesterNick} ${cls} 0 ${tNick} ${tUser} ${tHost}`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Builds a `:<server> <code> <nick> [<middle>] :<trailing>` numeric line
|
|
65
|
+
* addressed to the requester. When `middle` is empty the middle field is
|
|
66
|
+
* omitted entirely.
|
|
67
|
+
*/
|
|
68
|
+
function numericLine(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
|
|
69
|
+
const nick = ctx.connection.nick ?? '*';
|
|
70
|
+
const codeStr = code.toString().padStart(3, '0');
|
|
71
|
+
const parts = [`:${ctx.serverName}`, codeStr, nick];
|
|
72
|
+
if (middle !== '') parts.push(middle);
|
|
73
|
+
parts.push(`:${trailing}`);
|
|
74
|
+
return { text: parts.join(' ') };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Handles `TRACE [<target>]`.
|
|
79
|
+
*
|
|
80
|
+
* The actor layer resolves `<target>`:
|
|
81
|
+
* - Empty / absent / equal to the local server name → `target` is
|
|
82
|
+
* `undefined` and `serverTarget` is unset; the reducer emits only the
|
|
83
|
+
* terminating `262`.
|
|
84
|
+
* - A nick that resolves → `target` is the live
|
|
85
|
+
* {@link ConnectionState}; the reducer emits `205`/`204` (oper
|
|
86
|
+
* requester only) then `262`.
|
|
87
|
+
* - A nick that does not resolve → `target` is `undefined`; the reducer
|
|
88
|
+
* emits only the `262` (carrying the requested nick).
|
|
89
|
+
* - A name that is not the local server (treated as a remote server) →
|
|
90
|
+
* the actor passes `serverTarget = '<name>'`; the reducer emits
|
|
91
|
+
* `402 ERR_NOSUCHSERVER`.
|
|
92
|
+
*
|
|
93
|
+
* @param target The resolved target connection, or `undefined`.
|
|
94
|
+
* @param msg The parsed `TRACE` message.
|
|
95
|
+
* @param ctx The per-invocation context.
|
|
96
|
+
* @param serverTarget When set, the actor determined `<target>` names a
|
|
97
|
+
* non-local server; the reducer emits `402` against this name.
|
|
98
|
+
*/
|
|
99
|
+
export function traceReducer(
|
|
100
|
+
target: ConnectionState | undefined,
|
|
101
|
+
msg: { command: string; params: readonly string[] },
|
|
102
|
+
ctx: Ctx,
|
|
103
|
+
serverTarget?: string,
|
|
104
|
+
): { effects: EffectType[] } {
|
|
105
|
+
ctx.connection.lastSeen = ctx.clock.now();
|
|
106
|
+
|
|
107
|
+
const effects: EffectType[] = [];
|
|
108
|
+
const rawTarget = msg.params[0] ?? '';
|
|
109
|
+
|
|
110
|
+
if (serverTarget !== undefined) {
|
|
111
|
+
effects.push(
|
|
112
|
+
Effect.send(ctx.connId, [
|
|
113
|
+
numericLine(ctx, Numerics.ERR_NOSUCHSERVER, NOSUCHSERVER_TRAILING_TEXT, serverTarget),
|
|
114
|
+
]),
|
|
115
|
+
);
|
|
116
|
+
return { effects };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const isOper = ctx.connection.userModes.oper;
|
|
120
|
+
if (target !== undefined && isOper) {
|
|
121
|
+
// Per-connection detail is oper-gated, which also implicitly enforces
|
|
122
|
+
// `+i` invisibility for non-opers (they never receive detail). Opers
|
|
123
|
+
// bypass `+i` per RFC 2812 §4.6.3 and always see the matched conn.
|
|
124
|
+
const code = target.userModes.oper ? Numerics.RPL_TRACEOPERATOR : Numerics.RPL_TRACEUSER;
|
|
125
|
+
const cls = target.userModes.oper ? 'Oper' : 'User';
|
|
126
|
+
effects.push(Effect.send(ctx.connId, [traceDetailLine(ctx, code, cls, target)]));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const endMiddle = rawTarget.length > 0 ? rawTarget : '';
|
|
130
|
+
effects.push(
|
|
131
|
+
Effect.send(ctx.connId, [
|
|
132
|
+
numericLine(ctx, Numerics.RPL_ENDOFTRACE, ENDOFTRACE_TRAILING_TEXT, endMiddle),
|
|
133
|
+
]),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return { effects };
|
|
137
|
+
}
|