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
@@ -0,0 +1,368 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { lusersReducer } from '../../src/commands/lusers';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ SequentialIdFactory,
9
+ type ServerStatsSnapshot,
10
+ } from '../../src/ports';
11
+ import { createConnection } from '../../src/state/connection';
12
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
13
+
14
+ const serverConfig: ServerConfig = {
15
+ serverName: 'irc.example.com',
16
+ networkName: 'ExampleNet',
17
+ maxChannelsPerUser: 30,
18
+ maxTargetsPerCommand: 10,
19
+ maxListEntries: 50,
20
+ nickLen: 30,
21
+ channelLen: 50,
22
+ topicLen: 390,
23
+ quitMessage: 'Client Quit',
24
+ };
25
+
26
+ function makeCtx(connId = 'c1', nick = 'alice', clock = new FakeClock(5_000)): Ctx {
27
+ const conn = createConnection({ id: connId, connectedSince: 0 });
28
+ conn.nick = nick;
29
+ conn.user = nick;
30
+ conn.host = 'example.com';
31
+ conn.realname = 'Alice';
32
+ conn.registration = 'registered';
33
+ return buildCtx({
34
+ serverConfig,
35
+ clock,
36
+ ids: new SequentialIdFactory(),
37
+ motd: EmptyMotdProvider,
38
+ connection: conn,
39
+ });
40
+ }
41
+
42
+ const L = (text: string): RawLine => ({ text });
43
+
44
+ const lusers = (...params: string[]) =>
45
+ ({
46
+ command: 'LUSERS',
47
+ params,
48
+ tags: {},
49
+ }) as const;
50
+
51
+ const snap = (overrides: Partial<ServerStatsSnapshot> = {}): ServerStatsSnapshot => ({
52
+ users: 2,
53
+ invisible: 1,
54
+ opers: 0,
55
+ unknownConnections: 0,
56
+ channels: 0,
57
+ servers: 1,
58
+ localConns: 3,
59
+ globalConns: 3,
60
+ maxLocalConns: 5,
61
+ maxGlobalConns: 5,
62
+ uptimeStartedAt: 1_000,
63
+ ...overrides,
64
+ });
65
+
66
+ // ============================================================================
67
+ // lusersReducer — 251–255 baseline
68
+ // ============================================================================
69
+
70
+ describe('lusersReducer — 251–255 baseline', () => {
71
+ it('emits 251 RPL_LUSERCLIENT with the visible/invisible/server counts', () => {
72
+ const ctx = makeCtx();
73
+
74
+ const out = lusersReducer(snap(), lusers(), ctx);
75
+
76
+ expect(out.effects).toContainEqual<EffectType>(
77
+ Effect.send('c1', [
78
+ L(':irc.example.com 251 alice :There are 2 users and 1 invisible on 1 servers'),
79
+ ]),
80
+ );
81
+ });
82
+
83
+ it('emits 255 RPL_LUSERME with the local client/server counts', () => {
84
+ const ctx = makeCtx();
85
+
86
+ const out = lusersReducer(snap(), lusers(), ctx);
87
+
88
+ expect(out.effects).toContainEqual<EffectType>(
89
+ Effect.send('c1', [L(':irc.example.com 255 alice :I have 3 clients and 1 servers')]),
90
+ );
91
+ });
92
+
93
+ it('updates the requester lastSeen to ctx.clock.now()', () => {
94
+ const ctx = makeCtx();
95
+ expect(ctx.connection.lastSeen).toBe(0);
96
+
97
+ lusersReducer(snap(), lusers(), ctx);
98
+
99
+ expect(ctx.connection.lastSeen).toBe(5_000);
100
+ });
101
+
102
+ it('uses "*" as the nick when the requester has none', () => {
103
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
104
+ conn.registration = 'registered';
105
+ const ctx = buildCtx({
106
+ serverConfig,
107
+ clock: new FakeClock(5_000),
108
+ ids: new SequentialIdFactory(),
109
+ motd: EmptyMotdProvider,
110
+ connection: conn,
111
+ });
112
+
113
+ const out = lusersReducer(snap(), lusers(), ctx);
114
+
115
+ expect(out.effects).toContainEqual<EffectType>(
116
+ Effect.send('c1', [
117
+ L(':irc.example.com 251 * :There are 2 users and 1 invisible on 1 servers'),
118
+ ]),
119
+ );
120
+ });
121
+ });
122
+
123
+ // ============================================================================
124
+ // lusersReducer — non-zero omission (RFC: only emit 252/253/254 when non-zero)
125
+ // ============================================================================
126
+
127
+ describe('lusersReducer — non-zero omission', () => {
128
+ it('emits 252 RPL_LUSEROP when opers is non-zero', () => {
129
+ const ctx = makeCtx();
130
+
131
+ const out = lusersReducer(snap({ opers: 2 }), lusers(), ctx);
132
+
133
+ expect(out.effects).toContainEqual<EffectType>(
134
+ Effect.send('c1', [L(':irc.example.com 252 alice 2 :operator(s) online')]),
135
+ );
136
+ });
137
+
138
+ it('omits 252 when opers is zero', () => {
139
+ const ctx = makeCtx();
140
+
141
+ const out = lusersReducer(snap({ opers: 0 }), lusers(), ctx);
142
+
143
+ const text = out.effects
144
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
145
+ .join('|');
146
+ expect(text).not.toContain(' 252 ');
147
+ });
148
+
149
+ it('emits 253 RPL_LUSERUNKNOWN when unknownConnections is non-zero', () => {
150
+ const ctx = makeCtx();
151
+
152
+ const out = lusersReducer(snap({ unknownConnections: 4 }), lusers(), ctx);
153
+
154
+ expect(out.effects).toContainEqual<EffectType>(
155
+ Effect.send('c1', [L(':irc.example.com 253 alice 4 :unknown connection(s)')]),
156
+ );
157
+ });
158
+
159
+ it('omits 253 when unknownConnections is zero', () => {
160
+ const ctx = makeCtx();
161
+
162
+ const out = lusersReducer(snap({ unknownConnections: 0 }), lusers(), ctx);
163
+
164
+ const text = out.effects
165
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
166
+ .join('|');
167
+ expect(text).not.toContain(' 253 ');
168
+ });
169
+
170
+ it('emits 254 RPL_LUSERCHANNELS when channels is non-zero', () => {
171
+ const ctx = makeCtx();
172
+
173
+ const out = lusersReducer(snap({ channels: 7 }), lusers(), ctx);
174
+
175
+ expect(out.effects).toContainEqual<EffectType>(
176
+ Effect.send('c1', [L(':irc.example.com 254 alice 7 :channel(s) formed')]),
177
+ );
178
+ });
179
+
180
+ it('omits 254 when channels is zero', () => {
181
+ const ctx = makeCtx();
182
+
183
+ const out = lusersReducer(snap({ channels: 0 }), lusers(), ctx);
184
+
185
+ const text = out.effects
186
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
187
+ .join('|');
188
+ expect(text).not.toContain(' 254 ');
189
+ });
190
+ });
191
+
192
+ // ============================================================================
193
+ // lusersReducer — 265/266 local/global context
194
+ // ============================================================================
195
+
196
+ describe('lusersReducer — 265/266 local/global', () => {
197
+ it('emits 265 RPL_LOCALUSERS with the current and max local counts', () => {
198
+ const ctx = makeCtx();
199
+
200
+ const out = lusersReducer(snap({ localConns: 3, maxLocalConns: 5 }), lusers(), ctx);
201
+
202
+ expect(out.effects).toContainEqual<EffectType>(
203
+ Effect.send('c1', [L(':irc.example.com 265 alice 3 5 :Current local users 3, max 5')]),
204
+ );
205
+ });
206
+
207
+ it('emits 266 RPL_GLOBALUSERS with the current and max global counts', () => {
208
+ const ctx = makeCtx();
209
+
210
+ const out = lusersReducer(snap({ globalConns: 3, maxGlobalConns: 5 }), lusers(), ctx);
211
+
212
+ expect(out.effects).toContainEqual<EffectType>(
213
+ Effect.send('c1', [L(':irc.example.com 266 alice 3 5 :Current global users 3, max 5')]),
214
+ );
215
+ });
216
+
217
+ it('always emits 265 and 266 (modern clients rely on them for the user list count)', () => {
218
+ const ctx = makeCtx();
219
+
220
+ const out = lusersReducer(snap(), lusers(), ctx);
221
+
222
+ const text = out.effects
223
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
224
+ .join('|');
225
+ expect(text).toContain(' 265 ');
226
+ expect(text).toContain(' 266 ');
227
+ });
228
+ });
229
+
230
+ // ============================================================================
231
+ // lusersReducer — empty deployment
232
+ // ============================================================================
233
+
234
+ describe('lusersReducer — empty deployment', () => {
235
+ it('still emits 251 and 255 reporting zero counts', () => {
236
+ const ctx = makeCtx();
237
+ const empty: ServerStatsSnapshot = {
238
+ users: 0,
239
+ invisible: 0,
240
+ opers: 0,
241
+ unknownConnections: 0,
242
+ channels: 0,
243
+ servers: 1,
244
+ localConns: 0,
245
+ globalConns: 0,
246
+ maxLocalConns: 0,
247
+ maxGlobalConns: 0,
248
+ uptimeStartedAt: 1_000,
249
+ };
250
+
251
+ const out = lusersReducer(empty, lusers(), ctx);
252
+
253
+ expect(out.effects).toContainEqual<EffectType>(
254
+ Effect.send('c1', [
255
+ L(':irc.example.com 251 alice :There are 0 users and 0 invisible on 1 servers'),
256
+ ]),
257
+ );
258
+ expect(out.effects).toContainEqual<EffectType>(
259
+ Effect.send('c1', [L(':irc.example.com 255 alice :I have 0 clients and 1 servers')]),
260
+ );
261
+ });
262
+ });
263
+
264
+ // ============================================================================
265
+ // lusersReducer — remote server target
266
+ // ============================================================================
267
+
268
+ describe('lusersReducer — remote server target', () => {
269
+ it('emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target', () => {
270
+ const ctx = makeCtx();
271
+
272
+ const out = lusersReducer(snap(), lusers('remote.example.org'), ctx, 'remote.example.org');
273
+
274
+ expect(out.effects).toEqual<EffectType[]>([
275
+ Effect.send('c1', [L(':irc.example.com 402 alice remote.example.org :No such server')]),
276
+ ]);
277
+ });
278
+
279
+ it('uses "*" as the nick in 402 when the requester has none', () => {
280
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
281
+ conn.registration = 'registered';
282
+ const ctx = buildCtx({
283
+ serverConfig,
284
+ clock: new FakeClock(5_000),
285
+ ids: new SequentialIdFactory(),
286
+ motd: EmptyMotdProvider,
287
+ connection: conn,
288
+ });
289
+
290
+ const out = lusersReducer(snap(), lusers('remote.example.org'), ctx, 'remote.example.org');
291
+
292
+ expect(out.effects).toEqual<EffectType[]>([
293
+ Effect.send('c1', [L(':irc.example.com 402 * remote.example.org :No such server')]),
294
+ ]);
295
+ });
296
+
297
+ it('does not emit 402 when no serverTarget is flagged', () => {
298
+ const ctx = makeCtx();
299
+
300
+ const out = lusersReducer(snap(), lusers(), ctx);
301
+
302
+ const text = out.effects
303
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
304
+ .join('|');
305
+ expect(text).not.toContain(' 402 ');
306
+ });
307
+ });
308
+
309
+ // ============================================================================
310
+ // lusersReducer — ordering
311
+ // ============================================================================
312
+
313
+ describe('lusersReducer — ordering', () => {
314
+ it('emits numerics in ascending code order (251, 252, 253, 254, 255, 265, 266)', () => {
315
+ const ctx = makeCtx();
316
+ const populated = snap({
317
+ opers: 1,
318
+ unknownConnections: 1,
319
+ channels: 1,
320
+ });
321
+
322
+ const out = lusersReducer(populated, lusers(), ctx);
323
+
324
+ const codes: number[] = [];
325
+ for (const e of out.effects) {
326
+ if (e.tag !== 'Send') continue;
327
+ for (const line of e.lines) {
328
+ const m = line.text.match(/\s(\d{3})\s/);
329
+ if (m) codes.push(Number(m[1]));
330
+ }
331
+ }
332
+ expect(codes).toEqual([251, 252, 253, 254, 255, 265, 266]);
333
+ });
334
+ });
335
+
336
+ // ============================================================================
337
+ // lusersReducer — "*" nick fallback for count/usage lines
338
+ // ============================================================================
339
+
340
+ describe('lusersReducer — "*" nick fallback for count/usage lines', () => {
341
+ it('uses "*" in 252/253/254/265/266 when the requester has no nick', () => {
342
+ const conn = createConnection({ id: 'c1', connectedSince: 0 });
343
+ conn.registration = 'registered';
344
+ const ctx = buildCtx({
345
+ serverConfig,
346
+ clock: new FakeClock(5_000),
347
+ ids: new SequentialIdFactory(),
348
+ motd: EmptyMotdProvider,
349
+ connection: conn,
350
+ });
351
+
352
+ const out = lusersReducer(
353
+ snap({ opers: 1, unknownConnections: 1, channels: 1 }),
354
+ lusers(),
355
+ ctx,
356
+ );
357
+
358
+ const text = out.effects
359
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
360
+ .join('|');
361
+ // Every emitted numeric uses "*" as the requester nick.
362
+ expect(text).toContain(' 252 * 1 ');
363
+ expect(text).toContain(' 253 * 1 ');
364
+ expect(text).toContain(' 254 * 1 ');
365
+ expect(text).toContain(' 265 * 3 5 ');
366
+ expect(text).toContain(' 266 * 3 5 ');
367
+ });
368
+ });
@@ -200,6 +200,36 @@ describe('channelModeReducer — prefix modes', () => {
200
200
  ]);
201
201
  });
202
202
 
203
+ it('+oo <nick1> <nick2> grants op to both members in one combined delta', () => {
204
+ // Exercises the already-initialized branch of `pushMembershipChange`:
205
+ // the second membership push reuses the `delta.memberships` array the
206
+ // first one created.
207
+ const chan = makeChan('#foo');
208
+ addMember(chan, 'c1', 'alice', true);
209
+ addMember(chan, 'c2', 'bob');
210
+ addMember(chan, 'c3', 'carol');
211
+ const conn = makeConn();
212
+ const ctx = makeCtx(conn);
213
+
214
+ const out = channelModeReducer(
215
+ chan,
216
+ { command: 'MODE', params: ['#foo', '+oo', 'bob', 'carol'], tags: {} },
217
+ ctx,
218
+ );
219
+
220
+ expect(chan.members.get('c2')?.op).toBe(true);
221
+ expect(chan.members.get('c3')?.op).toBe(true);
222
+ expect(out.effects).toEqual<EffectType[]>([
223
+ Effect.applyChannelDelta('#foo', {
224
+ memberships: [
225
+ { type: 'add', conn: 'c2', nick: 'bob', op: true, voice: false },
226
+ { type: 'add', conn: 'c3', nick: 'carol', op: true, voice: false },
227
+ ],
228
+ }),
229
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +oo bob carol')]),
230
+ ]);
231
+ });
232
+
203
233
  it('-o <nick> removes op from the named member', () => {
204
234
  const chan = makeChan('#foo');
205
235
  addMember(chan, 'c1', 'alice', true);
@@ -483,6 +513,33 @@ describe('channelModeReducer — ban masks', () => {
483
513
  ]);
484
514
  });
485
515
 
516
+ it('+bb <mask1> <mask2> adds both masks in one combined delta', () => {
517
+ // Exercises the already-initialized branch of `pushBanMaskChange`: the
518
+ // second ban push reuses the `delta.banMaskChanges` array the first one
519
+ // created.
520
+ const chan = makeChan('#foo');
521
+ addMember(chan, 'c1', 'alice', true);
522
+ const conn = makeConn();
523
+ const ctx = makeCtx(conn);
524
+
525
+ const out = channelModeReducer(
526
+ chan,
527
+ { command: 'MODE', params: ['#foo', '+bb', 'bob!*@*', 'carol!*@*'], tags: {} },
528
+ ctx,
529
+ );
530
+
531
+ expect([...chan.banMasks]).toEqual(['bob!*@*', 'carol!*@*']);
532
+ expect(out.effects).toEqual<EffectType[]>([
533
+ Effect.applyChannelDelta('#foo', {
534
+ banMaskChanges: [
535
+ { type: 'add', mask: 'bob!*@*' },
536
+ { type: 'add', mask: 'carol!*@*' },
537
+ ],
538
+ }),
539
+ Effect.broadcast('#foo', [L(':alice!alice@example.com MODE #foo +bb bob!*@* carol!*@*')]),
540
+ ]);
541
+ });
542
+
486
543
  it('-b <mask> removes from the ban list', () => {
487
544
  const chan = makeChan('#foo');
488
545
  chan.banMasks.add('bob!*@*');
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
2
2
  import { quitReducer } from '../../src/commands/quit';
3
3
  import { Effect } from '../../src/effects';
4
4
  import type { Effect as EffectType, RawLine } from '../../src/effects';
5
- import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ InMemoryNickHistoryStore,
9
+ SequentialIdFactory,
10
+ } from '../../src/ports';
6
11
  import { type ConnectionState, createConnection } from '../../src/state/connection';
7
12
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
13
 
@@ -18,13 +23,18 @@ const serverConfig: ServerConfig = {
18
23
  quitMessage: 'Client Quit',
19
24
  };
20
25
 
21
- function makeCtx(state: ConnectionState, clock = new FakeClock(1_000)): Ctx {
26
+ function makeCtx(
27
+ state: ConnectionState,
28
+ clock = new FakeClock(1_000),
29
+ history?: InMemoryNickHistoryStore,
30
+ ): Ctx {
22
31
  return buildCtx({
23
32
  serverConfig,
24
33
  clock,
25
34
  ids: new SequentialIdFactory(),
26
35
  motd: EmptyMotdProvider,
27
36
  connection: state,
37
+ ...(history !== undefined ? { history } : {}),
28
38
  });
29
39
  }
30
40
 
@@ -217,4 +227,61 @@ describe('quitReducer', () => {
217
227
  Effect.disconnect('c1'),
218
228
  ]);
219
229
  });
230
+
231
+ it('records a nick-history entry when ctx.history is bound', () => {
232
+ const state = makeRegisteredState();
233
+ const clock = new FakeClock(5_000);
234
+ const history = new InMemoryNickHistoryStore(clock);
235
+ const ctx = makeCtx(state, clock, history);
236
+
237
+ quitReducer(state, { command: 'QUIT', params: ['bye'], tags: {} }, ctx);
238
+
239
+ const entries = history.query('alice', 10);
240
+ expect(entries).toHaveLength(1);
241
+ expect(entries[0]?.nick).toBe('alice');
242
+ expect(entries[0]?.connId).toBe('c1');
243
+ expect(entries[0]?.username).toBe('alice');
244
+ expect(entries[0]?.hostname).toBe('example.com');
245
+ expect(entries[0]?.realname).toBe('Alice');
246
+ expect(entries[0]?.signoffTime).toBe(5_000);
247
+ });
248
+
249
+ it('does not record history when no store is bound (ctx.history undefined)', () => {
250
+ const state = makeRegisteredState();
251
+ const ctx = makeCtx(state);
252
+
253
+ const out = quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
254
+
255
+ expect(out.effects).toContainEqual<EffectType>(Effect.disconnect('c1'));
256
+ // No crash; the no-store path is a graceful no-op.
257
+ });
258
+
259
+ it('does not record history when the connection was never registered', () => {
260
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
261
+ const clock = new FakeClock(5_000);
262
+ const history = new InMemoryNickHistoryStore(clock);
263
+ const ctx = makeCtx(state, clock, history);
264
+
265
+ quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
266
+
267
+ expect(history.query('ghost', 10)).toEqual([]);
268
+ });
269
+
270
+ it('records a history entry with only nick/connId when user/host/realname are absent', () => {
271
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
272
+ state.nick = 'alice';
273
+ state.registration = 'registered';
274
+ const clock = new FakeClock(5_000);
275
+ const history = new InMemoryNickHistoryStore(clock);
276
+ const ctx = makeCtx(state, clock, history);
277
+
278
+ quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
279
+
280
+ const entries = history.query('alice', 10);
281
+ expect(entries).toHaveLength(1);
282
+ expect(entries[0]?.nick).toBe('alice');
283
+ expect(entries[0]?.username).toBeUndefined();
284
+ expect(entries[0]?.hostname).toBeUndefined();
285
+ expect(entries[0]?.realname).toBeUndefined();
286
+ });
220
287
  });