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.
Files changed (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. 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: [Effect.send(ctx.connId, [loggedIn(ctx, result.account), saslSuccess(ctx)])],
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
- effects.push(Effect.broadcast(state.name, [line], ctx.connId, MESSAGE_TAGS_CAP));
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?: string): RawLine {
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
+ }