serverless-ircd 0.3.0 → 0.5.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 (223) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -42,12 +42,10 @@ function callerHostmask(conn: ConnectionState): string {
42
42
  }
43
43
 
44
44
  /** Formats a numeric error/reply line addressed to the connection's nick. */
45
- function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
45
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
46
46
  const nick = ctx.connection.nick ?? '*';
47
47
  const codeStr = code.toString().padStart(3, '0');
48
- const parts = [`:${ctx.serverName}`, codeStr, nick];
49
- if (middle !== undefined) parts.push(middle);
50
- parts.push(`:${trailing}`);
48
+ const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
51
49
  return { text: parts.join(' ') };
52
50
  }
53
51
 
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Pure reducer for the IRC `ISON` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: ISON is read-only — it checks which of the
5
+ * requested nicks are currently online and emits a single `303 RPL_ISON`
6
+ * line. The reducer itself does not perform lookups (those are async and
7
+ * belong to the actor / runtime layer); it receives the set of online nicks
8
+ * and the requested list, then reports the intersection.
9
+ *
10
+ * Wire format (RFC 2812 §5.8):
11
+ * - `ISON <nick>{ <nick>}`
12
+ * - `303 RPL_ISON`:
13
+ * `:<server> 303 <nick> :<online_nick1> <online_nick2> …`
14
+ *
15
+ * Matching is case-insensitive per rfc1459 case-mapping. The reply carries
16
+ * the registered (display) spelling from the online map, not the requester's
17
+ * spelling, so a request for `BOB` reports `Bob` when that is the registered
18
+ * form.
19
+ */
20
+
21
+ import { Effect } from '../effects.js';
22
+ import type { Effect as EffectType, RawLine } from '../effects.js';
23
+ import { Numerics } from '../protocol/numerics.js';
24
+ import type { ConnectionState } from '../state/connection.js';
25
+ import type { Ctx, ReducerResult } from '../types.js';
26
+
27
+ /**
28
+ * Handles `ISON <nick>{ <nick>}`.
29
+ *
30
+ * `online` maps each online nick's case-folded (lowercased) form to its
31
+ * display spelling. Each requested nick is folded and looked up; matches are
32
+ * reported using the display spelling from the map.
33
+ *
34
+ * Signature is bespoke (not {@link Reducer}) because the actor must resolve
35
+ * the nicks asynchronously before formatting — mirrors `whoReducer` /
36
+ * `whoisReducer`.
37
+ */
38
+ export function isonReducer(
39
+ online: ReadonlyMap<string, string>,
40
+ msg: { params: readonly string[] },
41
+ ctx: Ctx,
42
+ ): ReducerResult<ConnectionState> {
43
+ ctx.connection.lastSeen = ctx.clock.now();
44
+
45
+ const found: string[] = [];
46
+ for (const requested of msg.params) {
47
+ if (requested.length === 0) continue;
48
+ const display = online.get(requested.toLowerCase());
49
+ if (display !== undefined) {
50
+ found.push(display);
51
+ }
52
+ }
53
+
54
+ const nick = ctx.connection.nick ?? '*';
55
+ const codeStr = Numerics.RPL_ISON.toString().padStart(3, '0');
56
+ const trailing = found.join(' ');
57
+ const line: RawLine = { text: `:${ctx.serverName} ${codeStr} ${nick} :${trailing}` };
58
+
59
+ const effects: EffectType[] = [Effect.send(ctx.connId, [line])];
60
+ return { state: ctx.connection, effects };
61
+ }
@@ -82,6 +82,10 @@ function buildTokens(cfg: ServerConfig): string[] {
82
82
  * batch. The caller appends the trailer separately.
83
83
  *
84
84
  * Each batch returns just the joined token string (no `005`, no trailer).
85
+ *
86
+ * Contract: `tokens` is non-empty. The only caller (`generateIsupport`)
87
+ * satisfies this — `buildTokens` always emits at least the network/casing
88
+ * tokens — so no defensive empty-input handling is needed here.
85
89
  */
86
90
  function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
87
91
  const batches: string[] = [];
@@ -96,7 +100,7 @@ function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
96
100
  current = candidate;
97
101
  }
98
102
  }
99
- if (current !== '') batches.push(current);
103
+ batches.push(current);
100
104
  return batches;
101
105
  }
102
106
 
@@ -106,7 +110,7 @@ function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
106
110
  * @param cfg Server configuration (size limits, network name).
107
111
  * @param _connCaps IRCv3 capabilities negotiated by the connection
108
112
  * (reserved for `BOT=` and similar v3 tokens; currently
109
- * unused — kept in the signature per the ticket spec).
113
+ * unused — kept in the signature for forward compatibility).
110
114
  * @param nick Optional nick for the reply target; defaults to `*`.
111
115
  * @returns One or more {@link RawLine}s, each within the 510-byte ceiling.
112
116
  * Only the final line carries the `:are supported by this server`
@@ -35,12 +35,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
35
35
  * nick (or `*` when unregistered). Optional `middle` is emitted before the
36
36
  * trailing `:`-prefixed text.
37
37
  */
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,127 @@
1
+ /**
2
+ * Pure reducer for the IRC `KILL` command (oper-gated force-disconnect).
3
+ *
4
+ * PLAN §2.1 location-of-authority: KILL runs against the *target*'s
5
+ * connection. The actor layer resolves `<nick>` to a live
6
+ * {@link ConnectionState} via the nick registry and passes it here. The
7
+ * reducer performs the oper gate, validates parameters, and emits a single
8
+ * {@link DisconnectEffect} for the target. The runtime's `disconnect`
9
+ * method is responsible for closing the target's transport and fanning the
10
+ * QUIT out to shared-channel peers — the reducer does not model that
11
+ * fanout itself.
12
+ *
13
+ * Wire format (RFC 2812 §4.6.1):
14
+ * - `KILL <nickname> <comment>`
15
+ * - The target receives an `ERROR` / `QUIT` carrying a reason formatted
16
+ * as `Killed (<oper> (<comment>))`.
17
+ * - `481 ERR_NOPRIVILEGES`: `:<server> 481 <nick> :Permission Denied - ...`
18
+ * - `461 ERR_NEEDMOREPARAMS`:
19
+ * `:<server> 461 <nick> KILL :Not enough parameters`
20
+ * - `401 ERR_NOSUCHNICK`: `:<server> 401 <nick> <target> :No such nick/channel`
21
+ *
22
+ * Behaviour:
23
+ * - Non-oper sender → `481` (checked before parameters so a non-oper
24
+ * cannot probe which nicks are online).
25
+ * - Missing `<nickname>` or `<comment>` → `461`.
26
+ * - Unknown nick → `401`.
27
+ * - Otherwise emit `Disconnect(targetConnId, "Killed (<oper> (<comment>))")`.
28
+ * - An oper may `KILL` themselves (RFC-permitted); no special-casing.
29
+ *
30
+ * Platform asymmetry: the emitted `Disconnect` is interpreted by the bound
31
+ * runtime. On CF the `disconnect` RPC closes the target DO's socket
32
+ * immediately; on AWS a `$default` invocation cannot close a remote APIGW
33
+ * WebSocket, so QUIT fanout to peers fires but the target's live socket
34
+ * only closes on its next `$disconnect`/sweeper cycle. The reducer is
35
+ * identical across adapters — only the runtime's `disconnect` semantics
36
+ * differ.
37
+ */
38
+
39
+ import { Effect } from '../effects.js';
40
+ import type { Effect as EffectType, RawLine } from '../effects.js';
41
+ import { Numerics } from '../protocol/numerics.js';
42
+ import type { ConnectionState } from '../state/connection.js';
43
+ import type { Ctx } from '../types.js';
44
+
45
+ /** Fixed text emitted for `481 ERR_NOPRIVILEGES`. */
46
+ const NOPRIVILEGES_TRAILING_TEXT = "Permission Denied - You're not an IRC operator";
47
+
48
+ /** Fixed text emitted for `461 ERR_NEEDMOREPARAMS`. */
49
+ const NEEDMOREPARAMS_TRAILING_TEXT = 'Not enough parameters';
50
+
51
+ /** Fixed text emitted for `401 ERR_NOSUCHNICK`. */
52
+ const NOSUCHNICK_TRAILING_TEXT = 'No such nick/channel';
53
+
54
+ /**
55
+ * Formats the canonical kill reason, `Killed (<oper> (<comment>))`, that
56
+ * the target receives on its closing transport. `<oper>` is the killing
57
+ * oper's display nick; `<comment>` is the oper-supplied reason verbatim.
58
+ */
59
+ export function formatKillReason(operNick: string, comment: string): string {
60
+ return `Killed (${operNick} (${comment}))`;
61
+ }
62
+
63
+ /**
64
+ * Handles `KILL <nick> <comment>`.
65
+ *
66
+ * `target` is the live {@link ConnectionState} the actor resolved for
67
+ * `<nick>`, or `undefined` when the nick is offline / unknown. The
68
+ * signature is bespoke (not {@link Reducer}) because the actor must
69
+ * resolve the target asynchronously — mirrors `whoisReducer`.
70
+ */
71
+ export function killReducer(
72
+ target: ConnectionState | undefined,
73
+ msg: { command: string; params: readonly string[] },
74
+ ctx: Ctx,
75
+ ): { effects: EffectType[] } {
76
+ ctx.connection.lastSeen = ctx.clock.now();
77
+
78
+ if (!ctx.connection.userModes.oper) {
79
+ return {
80
+ effects: [
81
+ Effect.send(ctx.connId, [
82
+ numericLine(ctx, Numerics.ERR_NOPRIVILEGES, NOPRIVILEGES_TRAILING_TEXT),
83
+ ]),
84
+ ],
85
+ };
86
+ }
87
+
88
+ const targetNick = msg.params[0];
89
+ const comment = msg.params[1];
90
+
91
+ if (targetNick === undefined || comment === undefined) {
92
+ return {
93
+ effects: [
94
+ Effect.send(ctx.connId, [
95
+ numericLine(ctx, Numerics.ERR_NEEDMOREPARAMS, NEEDMOREPARAMS_TRAILING_TEXT, 'KILL'),
96
+ ]),
97
+ ],
98
+ };
99
+ }
100
+
101
+ if (target === undefined) {
102
+ return {
103
+ effects: [
104
+ Effect.send(ctx.connId, [
105
+ numericLine(ctx, Numerics.ERR_NOSUCHNICK, NOSUCHNICK_TRAILING_TEXT, targetNick),
106
+ ]),
107
+ ],
108
+ };
109
+ }
110
+
111
+ const operNick = ctx.connection.nick ?? '*';
112
+ return { effects: [Effect.disconnect(target.id, formatKillReason(operNick, comment))] };
113
+ }
114
+
115
+ /**
116
+ * Builds a `:<server> <code> <nick> [<middle>] :<trailing>` line. The nick
117
+ * falls back to `*` when the connection has not yet registered one,
118
+ * matching the rest of the codebase's numeric formatting.
119
+ */
120
+ function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
121
+ const nick = ctx.connection.nick ?? '*';
122
+ const codeStr = code.toString().padStart(3, '0');
123
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
124
+ if (middle !== undefined) parts.push(middle);
125
+ parts.push(`:${trailing}`);
126
+ return { text: parts.join(' ') };
127
+ }
@@ -11,7 +11,7 @@
11
11
  * - `322 RPL_LIST` — `:<server> 322 <nick> <chan> <#users> :<topic>`
12
12
  * - `323 RPL_LISTEND` — `:<server> 323 <nick> :End of /LIST`
13
13
  *
14
- * Visibility rules (ticket spec):
14
+ * Visibility rules:
15
15
  * - Secret (`+s`) channels are hidden from non-members.
16
16
  * - Membership is checked against `chan.members` using `ctx.connId`.
17
17
  *
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Pure reducer for the IRC `LUSERS` command (RFC 2812 §4.6.2).
3
+ *
4
+ * PLAN §2.1 location-of-authority: LUSERS is a read-only diagnostic that
5
+ * reports network-wide counts. The reducer takes an already-aggregated
6
+ * {@link ServerStatsSnapshot} (the actor layer awaits a {@link ServerStats}
7
+ * backend before calling here — same pattern as `listChannels` →
8
+ * `listReducer`). The reducer never mutates anything but the requester's
9
+ * `lastSeen`.
10
+ *
11
+ * Wire format (RFC 2812 §5.1):
12
+ * - `LUSERS [<mask> [<server>]]`
13
+ * - `251 RPL_LUSERCLIENT`:
14
+ * `:<server> 251 <nick> :There are <users> users and <invisible>
15
+ * invisible on <servers> servers`
16
+ * - `252 RPL_LUSEROP`:
17
+ * `:<server> 252 <nick> <count> :operator(s) online` (only when non-zero)
18
+ * - `253 RPL_LUSERUNKNOWN`:
19
+ * `:<server> 253 <nick> <count> :unknown connection(s)` (only when
20
+ * non-zero)
21
+ * - `254 RPL_LUSERCHANNELS`:
22
+ * `:<server> 254 <nick> <count> :channel(s) formed` (only when non-zero)
23
+ * - `255 RPL_LUSERME`:
24
+ * `:<server> 255 <nick> :I have <localConns> clients and <servers>
25
+ * servers`
26
+ * - `265 RPL_LOCALUSERS`:
27
+ * `:<server> 265 <nick> <cur> <max> :Current local users <cur>, max
28
+ * <max>`
29
+ * - `266 RPL_GLOBALUSERS`:
30
+ * `:<server> 266 <nick> <cur> <max> :Current global users <cur>, max
31
+ * <max>`
32
+ * - `402 ERR_NOSUCHSERVER`:
33
+ * `:<server> 402 <nick> <server> :No such server`
34
+ *
35
+ * Non-zero omission: 252/253/254 are suppressed when their count is zero
36
+ * (per RFC 2812). 251, 255, 265, and 266 are always emitted — modern
37
+ * clients rely on 265/266 for the user-list count and charybdis emits
38
+ * them unconditionally.
39
+ *
40
+ * Single-server deployment: `servers` is always 1 (PLAN §1 — S2S linking
41
+ * is a non-goal). `<mask>` is accepted for RFC compliance but not applied
42
+ * (the serverless deployment has one server, so a wildcard match is
43
+ * trivially true).
44
+ */
45
+
46
+ import { Effect } from '../effects.js';
47
+ import type { Effect as EffectType, RawLine } from '../effects.js';
48
+ import type { ServerStatsSnapshot } from '../ports.js';
49
+ import { Numerics } from '../protocol/numerics.js';
50
+ import type { Ctx } from '../types.js';
51
+
52
+ /** Fixed text emitted for `402 ERR_NOSUCHSERVER`. */
53
+ const NOSUCHSERVER_TRAILING_TEXT = 'No such server';
54
+
55
+ /** Human-readable trailing templates per numeric. */
56
+ const LUSERCLIENT_TEXT = (users: number, invisible: number, servers: number): string =>
57
+ `There are ${users} users and ${invisible} invisible on ${servers} servers`;
58
+ const LUSEROP_TEXT = 'operator(s) online';
59
+ const LUSERUNKNOWN_TEXT = 'unknown connection(s)';
60
+ const LUSERCHANNELS_TEXT = 'channel(s) formed';
61
+ const LUSERME_TEXT = (localConns: number, servers: number): string =>
62
+ `I have ${localConns} clients and ${servers} servers`;
63
+ const LOCALUSERS_TEXT = (cur: number, max: number): string =>
64
+ `Current local users ${cur}, max ${max}`;
65
+ const GLOBALUSERS_TEXT = (cur: number, max: number): string =>
66
+ `Current global users ${cur}, max ${max}`;
67
+
68
+ /**
69
+ * Builds a `:<server> <code> <nick> :<trailing>` line. The middle field is
70
+ * empty for the summary numerics; the count-formatting helpers below carry
71
+ * a `<count>` middle for 252/253/254.
72
+ */
73
+ function trailingLine(ctx: Ctx, code: number, trailing: string): RawLine {
74
+ const nick = ctx.connection.nick ?? '*';
75
+ const codeStr = code.toString().padStart(3, '0');
76
+ return {
77
+ text: `:${ctx.serverName} ${codeStr} ${nick} :${trailing}`,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Builds a `:<server> <code> <nick> <count> :<trailing>` line for the
83
+ * count-carrying numerics (252/253/254).
84
+ */
85
+ function countLine(ctx: Ctx, code: number, count: number, trailing: string): RawLine {
86
+ const nick = ctx.connection.nick ?? '*';
87
+ const codeStr = code.toString().padStart(3, '0');
88
+ return {
89
+ text: `:${ctx.serverName} ${codeStr} ${nick} ${count} :${trailing}`,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Builds a `:<server> <code> <nick> <cur> <max> :<trailing>` line for
95
+ * 265/266.
96
+ */
97
+ function usageLine(ctx: Ctx, code: number, cur: number, max: number, trailing: string): RawLine {
98
+ const nick = ctx.connection.nick ?? '*';
99
+ const codeStr = code.toString().padStart(3, '0');
100
+ return {
101
+ text: `:${ctx.serverName} ${codeStr} ${nick} ${cur} ${max} :${trailing}`,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Handles `LUSERS [<mask> [<server>]]`.
107
+ *
108
+ * @param snapshot The aggregated network statistics (counts).
109
+ * @param _msg The parsed `LUSERS` message. The optional `<mask>`
110
+ * is accepted for RFC compliance but not applied — single-server
111
+ * deployments collapse any wildcard match to the local server.
112
+ * @param ctx The per-invocation context.
113
+ * @param serverTarget When set, the actor determined `<server>` names a
114
+ * non-local server; the reducer emits `402 ERR_NOSUCHSERVER` against this
115
+ * name and nothing else.
116
+ */
117
+ export function lusersReducer(
118
+ snapshot: ServerStatsSnapshot,
119
+ _msg: { command: string; params: readonly string[] },
120
+ ctx: Ctx,
121
+ serverTarget?: string,
122
+ ): { effects: EffectType[] } {
123
+ ctx.connection.lastSeen = ctx.clock.now();
124
+
125
+ const effects: EffectType[] = [];
126
+
127
+ if (serverTarget !== undefined) {
128
+ const nick = ctx.connection.nick ?? '*';
129
+ const code = Numerics.ERR_NOSUCHSERVER.toString().padStart(3, '0');
130
+ effects.push(
131
+ Effect.send(ctx.connId, [
132
+ {
133
+ text: `:${ctx.serverName} ${code} ${nick} ${serverTarget} :${NOSUCHSERVER_TRAILING_TEXT}`,
134
+ },
135
+ ]),
136
+ );
137
+ return { effects };
138
+ }
139
+
140
+ // 251 always.
141
+ effects.push(
142
+ Effect.send(ctx.connId, [
143
+ trailingLine(
144
+ ctx,
145
+ Numerics.RPL_LUSERCLIENT,
146
+ LUSERCLIENT_TEXT(snapshot.users, snapshot.invisible, snapshot.servers),
147
+ ),
148
+ ]),
149
+ );
150
+
151
+ // 252/253/254 only when non-zero (RFC: omit zero-count summary lines).
152
+ if (snapshot.opers > 0) {
153
+ effects.push(
154
+ Effect.send(ctx.connId, [countLine(ctx, Numerics.RPL_LUSEROP, snapshot.opers, LUSEROP_TEXT)]),
155
+ );
156
+ }
157
+ if (snapshot.unknownConnections > 0) {
158
+ effects.push(
159
+ Effect.send(ctx.connId, [
160
+ countLine(ctx, Numerics.RPL_LUSERUNKNOWN, snapshot.unknownConnections, LUSERUNKNOWN_TEXT),
161
+ ]),
162
+ );
163
+ }
164
+ if (snapshot.channels > 0) {
165
+ effects.push(
166
+ Effect.send(ctx.connId, [
167
+ countLine(ctx, Numerics.RPL_LUSERCHANNELS, snapshot.channels, LUSERCHANNELS_TEXT),
168
+ ]),
169
+ );
170
+ }
171
+
172
+ // 255 always.
173
+ effects.push(
174
+ Effect.send(ctx.connId, [
175
+ trailingLine(ctx, Numerics.RPL_LUSERME, LUSERME_TEXT(snapshot.localConns, snapshot.servers)),
176
+ ]),
177
+ );
178
+
179
+ // 265/266 always (modern clients depend on them).
180
+ effects.push(
181
+ Effect.send(ctx.connId, [
182
+ usageLine(
183
+ ctx,
184
+ Numerics.RPL_LOCALUSERS,
185
+ snapshot.localConns,
186
+ snapshot.maxLocalConns,
187
+ LOCALUSERS_TEXT(snapshot.localConns, snapshot.maxLocalConns),
188
+ ),
189
+ ]),
190
+ );
191
+ effects.push(
192
+ Effect.send(ctx.connId, [
193
+ usageLine(
194
+ ctx,
195
+ Numerics.RPL_GLOBALUSERS,
196
+ snapshot.globalConns,
197
+ snapshot.maxGlobalConns,
198
+ GLOBALUSERS_TEXT(snapshot.globalConns, snapshot.maxGlobalConns),
199
+ ),
200
+ ]),
201
+ );
202
+
203
+ return { effects };
204
+ }
@@ -191,14 +191,10 @@ export const channelModeReducer: Reducer<ChannelState> = (state, msg, ctx) => {
191
191
  // authority BEFORE the broadcast so distributed runtimes (CF
192
192
  // ChannelDO) have persisted the change before any peer's next
193
193
  // prefetch observes it. In the in-memory runtime the actor's
194
- // `state` IS authoritative so this is redundant.
195
- if (
196
- (delta.modeChanges !== undefined && delta.modeChanges.length > 0) ||
197
- (delta.banMaskChanges !== undefined && delta.banMaskChanges.length > 0) ||
198
- (delta.memberships !== undefined && delta.memberships.length > 0)
199
- ) {
200
- effects.push(Effect.applyChannelDelta(state.name, delta));
201
- }
194
+ // `state` IS authoritative so this is redundant. Every non-null
195
+ // `applyChannelOp` result is paired with at least one delta push,
196
+ // so `delta` is guaranteed non-empty here.
197
+ effects.push(Effect.applyChannelDelta(state.name, delta));
202
198
  const hostmask = callerHostmask(ctx.connection);
203
199
  effects.push(Effect.broadcast(state.name, [buildModeBroadcast(hostmask, state.name, applied)]));
204
200
  }
@@ -14,7 +14,7 @@
14
14
  * `@` (private `+p`). Private wins over secret when both are set, matching
15
15
  * the JOIN reducer's NAMES reply and RFC 2812 §3.2.4.
16
16
  *
17
- * Visibility rules (PLAN §3 + ticket spec):
17
+ * Visibility rules (PLAN §3):
18
18
  * - `+s` or `+p` set: requester must be a member to receive the NAMES list.
19
19
  * Otherwise `442 ERR_NOTONCHANNEL`.
20
20
  * - Otherwise: any connection may list.
@@ -44,12 +44,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
44
44
  }
45
45
 
46
46
  /** Formats a numeric error/reply line addressed to the connection's nick. */
47
- function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
47
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
48
48
  const nick = ctx.connection.nick ?? '*';
49
49
  const codeStr = code.toString().padStart(3, '0');
50
- const parts = [`:${ctx.serverName}`, codeStr, nick];
51
- if (middle !== undefined) parts.push(middle);
52
- parts.push(`:${trailing}`);
50
+ const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
53
51
  return { text: parts.join(' ') };
54
52
  }
55
53
 
@@ -33,12 +33,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
33
33
  * nick (or `*` when unregistered). Optional `middle` is emitted before the
34
34
  * trailing `:`-prefixed text.
35
35
  */
36
- function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
36
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
37
37
  const nick = ctx.connection.nick ?? '*';
38
38
  const codeStr = code.toString().padStart(3, '0');
39
- const parts = [`:${ctx.serverName}`, codeStr, nick];
40
- if (middle !== undefined) parts.push(middle);
41
- parts.push(`:${trailing}`);
39
+ const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
42
40
  return { text: parts.join(' ') };
43
41
  }
44
42
 
@@ -52,6 +52,18 @@ export const quitReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
52
52
  );
53
53
  }
54
54
  effects.push(Effect.releaseNick(nick));
55
+ // Record the sign-off into nick history so WHOWAS can replay it. Only
56
+ // consulted when a NickHistoryStore is bound; absent → no-op.
57
+ if (ctx.history !== undefined) {
58
+ ctx.history.record({
59
+ nick,
60
+ connId: ctx.connId,
61
+ signoffTime: ctx.clock.now(),
62
+ ...(state.user !== undefined ? { username: state.user } : {}),
63
+ ...(state.host !== undefined ? { hostname: state.host } : {}),
64
+ ...(state.realname !== undefined ? { realname: state.realname } : {}),
65
+ });
66
+ }
55
67
  }
56
68
 
57
69
  effects.push(Effect.disconnect(ctx.connId));
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { cloakHost } from '../cloak.js';
17
+ import { formatCreatedAt, resolveServerVersion } from '../config.js';
17
18
  import { Effect } from '../effects.js';
18
19
  import type { Effect as EffectType, RawLine } from '../effects.js';
19
20
  import { Numerics } from '../protocol/numerics.js';
@@ -23,15 +24,6 @@ import type { Ctx, Reducer } from '../types.js';
23
24
  import { generateIsupport } from './isupport.js';
24
25
  import { buildMotdNumerics } from './motd-lines.js';
25
26
 
26
- /**
27
- * Server version reported in `002` / `004`. Placeholder until a config-driven
28
- * version lands.
29
- */
30
- const SERVER_VERSION = '0.2.0';
31
-
32
- /** "Created" text for `003 RPL_CREATED`. Placeholder. */
33
- const CREATED_TEXT = 'Phase 1';
34
-
35
27
  /** User mode letters for `004 RPL_MYINFO` (invisible, oper, wallops, server-notices). */
36
28
  const USER_MODES_004 = 'iosw';
37
29
 
@@ -70,15 +62,17 @@ export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
70
62
  // always defined, so hostmaskOf always returns at least `nick`. The cast
71
63
  // avoids a dead `??` branch that v8 coverage cannot satisfy.
72
64
  const hostmask = hostmaskOf(state) as string;
65
+ const version = resolveServerVersion(ctx.serverConfig);
66
+ const created = formatCreatedAt(ctx.serverConfig.createdAt);
73
67
 
74
68
  return [
75
69
  { text: `${prefix} 001 ${nick} :Welcome to the ${ctx.networkName} IRC Network, ${hostmask}` },
76
70
  {
77
- text: `${prefix} 002 ${nick} :Your host is ${ctx.serverName}, running version ${SERVER_VERSION}`,
71
+ text: `${prefix} 002 ${nick} :Your host is ${ctx.serverName}, running version ${version}`,
78
72
  },
79
- { text: `${prefix} 003 ${nick} :This server was created in ${CREATED_TEXT}` },
73
+ { text: `${prefix} 003 ${nick} :This server was created in ${created}` },
80
74
  {
81
- text: `${prefix} 004 ${nick} ${ctx.serverName} ${SERVER_VERSION} ${USER_MODES_004} ${CHANNEL_MODES_004}`,
75
+ text: `${prefix} 004 ${nick} ${ctx.serverName} ${version} ${USER_MODES_004} ${CHANNEL_MODES_004}`,
82
76
  },
83
77
  // 005 RPL_ISUPPORT — real tokens generated from serverConfig.
84
78
  ...generateIsupport(ctx.serverConfig, state.caps, nick),
@@ -214,6 +208,18 @@ export const nickReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
214
208
  }
215
209
  // hostmaskOf is guaranteed non-undefined here because oldNick is defined.
216
210
  const source = hostmaskOf(state) as string;
211
+ // Record the old nick into history so WHOWAS can replay it. Only
212
+ // consulted when a NickHistoryStore is bound; absent → no-op.
213
+ if (ctx.history !== undefined) {
214
+ ctx.history.record({
215
+ nick: oldNick,
216
+ connId: ctx.connId,
217
+ signoffTime: ctx.clock.now(),
218
+ ...(state.user !== undefined ? { username: state.user } : {}),
219
+ ...(state.host !== undefined ? { hostname: state.host } : {}),
220
+ ...(state.realname !== undefined ? { realname: state.realname } : {}),
221
+ });
222
+ }
217
223
  state.nick = nick;
218
224
  effects.push(Effect.changeNick(ctx.connId, oldNick, nick));
219
225
  effects.push(Effect.send(ctx.connId, [{ text: `:${source} NICK ${nick}` }]));