serverless-ircd 0.3.0 → 0.4.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 (159) 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 +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  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 +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  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 +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ adminReducer,
4
+ infoReducer,
5
+ timeReducer,
6
+ versionReducer,
7
+ } from '../../src/commands/server-info';
8
+ import { DEFAULT_SERVER_VERSION } from '../../src/config';
9
+ import { Effect } from '../../src/effects';
10
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
11
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
12
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
13
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
14
+
15
+ const baseServerConfig: ServerConfig = {
16
+ serverName: 'irc.example.com',
17
+ networkName: 'ExampleNet',
18
+ maxChannelsPerUser: 30,
19
+ maxTargetsPerCommand: 10,
20
+ maxListEntries: 50,
21
+ nickLen: 30,
22
+ channelLen: 50,
23
+ topicLen: 390,
24
+ quitMessage: 'Client Quit',
25
+ };
26
+
27
+ function makeCtx(state: ConnectionState, clockMs = 5_000): Ctx {
28
+ return buildCtx({
29
+ serverConfig: baseServerConfig,
30
+ clock: new FakeClock(clockMs),
31
+ ids: new SequentialIdFactory(),
32
+ motd: EmptyMotdProvider,
33
+ connection: state,
34
+ });
35
+ }
36
+
37
+ function makeState(): ConnectionState {
38
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
39
+ s.nick = 'alice';
40
+ s.user = 'alice';
41
+ s.host = 'example.com';
42
+ s.realname = 'Alice';
43
+ s.registration = 'registered';
44
+ return s;
45
+ }
46
+
47
+ const L = (text: string): RawLine => ({ text });
48
+
49
+ const version = (server?: string) =>
50
+ ({ command: 'VERSION', params: server === undefined ? [] : [server], tags: {} }) as const;
51
+
52
+ const time = (server?: string) =>
53
+ ({ command: 'TIME', params: server === undefined ? [] : [server], tags: {} }) as const;
54
+
55
+ const admin = (server?: string) =>
56
+ ({ command: 'ADMIN', params: server === undefined ? [] : [server], tags: {} }) as const;
57
+
58
+ const info = (server?: string) =>
59
+ ({ command: 'INFO', params: server === undefined ? [] : [server], tags: {} }) as const;
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // VERSION
63
+ // ---------------------------------------------------------------------------
64
+
65
+ describe('versionReducer', () => {
66
+ it('emits 351 RPL_VERSION with the server version and name', () => {
67
+ const state = makeState();
68
+ const ctx = makeCtx(state);
69
+
70
+ const out = versionReducer(state, version(), ctx);
71
+
72
+ expect(out.effects).toEqual<EffectType[]>([
73
+ Effect.send('c1', [
74
+ L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
75
+ ]),
76
+ ]);
77
+ });
78
+
79
+ it('updates lastSeen to ctx.clock.now()', () => {
80
+ const state = makeState();
81
+ const ctx = makeCtx(state, 9_999);
82
+
83
+ versionReducer(state, version(), ctx);
84
+
85
+ expect(state.lastSeen).toBe(9_999);
86
+ });
87
+
88
+ it('uses * as nick placeholder for unregistered connections', () => {
89
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
90
+ const ctx = makeCtx(state);
91
+
92
+ const out = versionReducer(state, version(), ctx);
93
+
94
+ expect(out.effects[0]).toEqual<EffectType>(
95
+ Effect.send('c1', [
96
+ L(`:irc.example.com 351 * ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
97
+ ]),
98
+ );
99
+ });
100
+
101
+ it('ignores the optional server target parameter (single-server deployment)', () => {
102
+ const state = makeState();
103
+ const ctx = makeCtx(state);
104
+
105
+ const out = versionReducer(state, version('other.server'), ctx);
106
+
107
+ expect(out.effects[0]).toEqual<EffectType>(
108
+ Effect.send('c1', [
109
+ L(`:irc.example.com 351 alice ${DEFAULT_SERVER_VERSION} irc.example.com :ExampleNet`),
110
+ ]),
111
+ );
112
+ });
113
+
114
+ it('returns the same state reference', () => {
115
+ const state = makeState();
116
+ const ctx = makeCtx(state);
117
+
118
+ const out = versionReducer(state, version(), ctx);
119
+
120
+ expect(out.state).toBe(state);
121
+ });
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // TIME
126
+ // ---------------------------------------------------------------------------
127
+
128
+ describe('timeReducer', () => {
129
+ it('emits 391 RPL_TIME with a human-readable timestamp from the clock', () => {
130
+ const state = makeState();
131
+ const ctx = makeCtx(state, 1_700_000_000_000);
132
+
133
+ const out = timeReducer(state, time(), ctx);
134
+
135
+ expect(out.effects).toEqual<EffectType[]>([
136
+ Effect.send('c1', [
137
+ L(':irc.example.com 391 alice irc.example.com :2023-11-14T22:13:20.000Z'),
138
+ ]),
139
+ ]);
140
+ });
141
+
142
+ it('updates lastSeen to ctx.clock.now()', () => {
143
+ const state = makeState();
144
+ const ctx = makeCtx(state, 42_000);
145
+
146
+ timeReducer(state, time(), ctx);
147
+
148
+ expect(state.lastSeen).toBe(42_000);
149
+ });
150
+
151
+ it('uses * as nick placeholder for unregistered connections', () => {
152
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
153
+ const ctx = makeCtx(state, 1_700_000_000_000);
154
+
155
+ const out = timeReducer(state, time(), ctx);
156
+
157
+ expect(out.effects[0]).toEqual<EffectType>(
158
+ Effect.send('c1', [L(':irc.example.com 391 * irc.example.com :2023-11-14T22:13:20.000Z')]),
159
+ );
160
+ });
161
+
162
+ it('ignores the optional server target parameter', () => {
163
+ const state = makeState();
164
+ const ctx = makeCtx(state, 1_700_000_000_000);
165
+
166
+ const out = timeReducer(state, time('elsewhere'), ctx);
167
+
168
+ expect(out.effects[0]).toEqual<EffectType>(
169
+ Effect.send('c1', [
170
+ L(':irc.example.com 391 alice irc.example.com :2023-11-14T22:13:20.000Z'),
171
+ ]),
172
+ );
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // ADMIN
178
+ // ---------------------------------------------------------------------------
179
+
180
+ describe('adminReducer', () => {
181
+ it('emits 256 RPL_ADMINME with the server name', () => {
182
+ const state = makeState();
183
+ const ctx = makeCtx(state);
184
+
185
+ const out = adminReducer(state, admin(), ctx);
186
+
187
+ expect(out.effects).toEqual<EffectType[]>([
188
+ Effect.send('c1', [L(':irc.example.com 256 alice irc.example.com :Administrative info')]),
189
+ ]);
190
+ });
191
+
192
+ it('updates lastSeen to ctx.clock.now()', () => {
193
+ const state = makeState();
194
+ const ctx = makeCtx(state, 7_777);
195
+
196
+ adminReducer(state, admin(), ctx);
197
+
198
+ expect(state.lastSeen).toBe(7_777);
199
+ });
200
+
201
+ it('uses * as nick placeholder for unregistered connections', () => {
202
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
203
+ const ctx = makeCtx(state);
204
+
205
+ const out = adminReducer(state, admin(), ctx);
206
+
207
+ expect(out.effects[0]).toEqual<EffectType>(
208
+ Effect.send('c1', [L(':irc.example.com 256 * irc.example.com :Administrative info')]),
209
+ );
210
+ });
211
+
212
+ it('ignores the optional server target parameter', () => {
213
+ const state = makeState();
214
+ const ctx = makeCtx(state);
215
+
216
+ const out = adminReducer(state, admin('foo'), ctx);
217
+
218
+ expect(out.effects[0]).toEqual<EffectType>(
219
+ Effect.send('c1', [L(':irc.example.com 256 alice irc.example.com :Administrative info')]),
220
+ );
221
+ });
222
+ });
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // INFO
226
+ // ---------------------------------------------------------------------------
227
+
228
+ describe('infoReducer', () => {
229
+ it('emits 371 RPL_INFO lines followed by 374 RPL_ENDOFINFO', () => {
230
+ const state = makeState();
231
+ const ctx = makeCtx(state);
232
+
233
+ const out = infoReducer(state, info(), ctx);
234
+
235
+ const lines = (out.effects[0] as { lines: RawLine[] }).lines;
236
+ expect(lines[0]).toEqual(
237
+ L(`:irc.example.com 371 alice :ServerlessIRCd - version ${DEFAULT_SERVER_VERSION}`),
238
+ );
239
+ expect(lines[1]).toEqual(L(':irc.example.com 371 alice :Running on network ExampleNet'));
240
+ expect(lines[lines.length - 1]).toEqual(L(':irc.example.com 374 alice :End of INFO list'));
241
+ });
242
+
243
+ it('includes at least one 371 line and the 374 terminator', () => {
244
+ const state = makeState();
245
+ const ctx = makeCtx(state);
246
+
247
+ const out = infoReducer(state, info(), ctx);
248
+
249
+ const texts = (out.effects[0] as { lines: RawLine[] }).lines.map((l) => l.text);
250
+ const rpl371Count = texts.filter((t) => / 371 /.test(t)).length;
251
+ const rpl374Count = texts.filter((t) => / 374 /.test(t)).length;
252
+ expect(rpl371Count).toBeGreaterThanOrEqual(1);
253
+ expect(rpl374Count).toBe(1);
254
+ });
255
+
256
+ it('updates lastSeen to ctx.clock.now()', () => {
257
+ const state = makeState();
258
+ const ctx = makeCtx(state, 3_333);
259
+
260
+ infoReducer(state, info(), ctx);
261
+
262
+ expect(state.lastSeen).toBe(3_333);
263
+ });
264
+
265
+ it('uses * as nick placeholder for unregistered connections', () => {
266
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
267
+ const ctx = makeCtx(state);
268
+
269
+ const out = infoReducer(state, info(), ctx);
270
+
271
+ const texts = (out.effects[0] as { lines: RawLine[] }).lines.map((l) => l.text);
272
+ expect(texts.every((t) => t.includes(' * '))).toBe(true);
273
+ });
274
+ });
@@ -102,11 +102,7 @@ describe('tagmsgChannelReducer — success', () => {
102
102
  const conn = makeConn();
103
103
  const ctx = makeCtx(conn);
104
104
 
105
- const out = tagmsgChannelReducer(
106
- chan,
107
- { command: 'TAGMSG', params: ['#foo'], tags: {} },
108
- ctx,
109
- );
105
+ const out = tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);
110
106
 
111
107
  expect(out.effects).toEqual<EffectType[]>([
112
108
  Effect.broadcast('#foo', [L(':alice!alice@example.com TAGMSG #foo')], 'c1', 'message-tags'),
@@ -516,11 +512,7 @@ describe('tagmsgChannelReducer — defensive hostmask', () => {
516
512
  conn.caps.add('message-tags');
517
513
  const ctx = makeCtx(conn);
518
514
 
519
- const out = tagmsgChannelReducer(
520
- chan,
521
- { command: 'TAGMSG', params: ['#foo'], tags: {} },
522
- ctx,
523
- );
515
+ const out = tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);
524
516
 
525
517
  const broadcast = out.effects[0];
526
518
  expect(broadcast?.tag).toBe('Broadcast');
@@ -535,11 +527,7 @@ describe('tagmsgChannelReducer — defensive hostmask', () => {
535
527
  conn.caps.add('message-tags');
536
528
  const ctx = makeCtx(conn);
537
529
 
538
- const out = tagmsgChannelReducer(
539
- chan,
540
- { command: 'TAGMSG', params: ['#foo'], tags: {} },
541
- ctx,
542
- );
530
+ const out = tagmsgChannelReducer(chan, { command: 'TAGMSG', params: ['#foo'], tags: {} }, ctx);
543
531
 
544
532
  const broadcast = out.effects[0];
545
533
  expect(broadcast?.tag).toBe('Broadcast');
@@ -564,11 +552,7 @@ describe('tagmsgUserReducer', () => {
564
552
  );
565
553
 
566
554
  expect(out.effects).toEqual<EffectType[]>([
567
- Effect.sendToNick(
568
- 'bob',
569
- 'c1',
570
- [L('@+typing=active :alice!alice@example.com TAGMSG bob')],
571
- ),
555
+ Effect.sendToNick('bob', 'c1', [L('@+typing=active :alice!alice@example.com TAGMSG bob')]),
572
556
  ]);
573
557
  });
574
558
 
@@ -583,11 +567,9 @@ describe('tagmsgUserReducer', () => {
583
567
  );
584
568
 
585
569
  expect(out.effects).toEqual<EffectType[]>([
586
- Effect.sendToNick(
587
- 'nobody',
588
- 'c1',
589
- [L('@+typing=active :alice!alice@example.com TAGMSG nobody')],
590
- ),
570
+ Effect.sendToNick('nobody', 'c1', [
571
+ L('@+typing=active :alice!alice@example.com TAGMSG nobody'),
572
+ ]),
591
573
  ]);
592
574
  });
593
575
 
@@ -642,11 +624,7 @@ describe('tagmsgUserReducer', () => {
642
624
  const conn = makeConn();
643
625
  const ctx = makeCtx(conn);
644
626
 
645
- const out = tagmsgUserReducer(
646
- conn,
647
- { command: 'TAGMSG', params: ['bob'], tags: {} },
648
- ctx,
649
- );
627
+ const out = tagmsgUserReducer(conn, { command: 'TAGMSG', params: ['bob'], tags: {} }, ctx);
650
628
 
651
629
  expect(out.effects).toEqual<EffectType[]>([
652
630
  Effect.sendToNick('bob', 'c1', [L(':alice!alice@example.com TAGMSG bob')]),
@@ -667,11 +645,7 @@ describe('tagmsgUserReducer', () => {
667
645
  const conn = makeConn();
668
646
  const ctx = makeCtx(conn);
669
647
 
670
- const out = tagmsgUserReducer(
671
- conn,
672
- { command: 'TAGMSG', params: ['bob'], tags: {} },
673
- ctx,
674
- );
648
+ const out = tagmsgUserReducer(conn, { command: 'TAGMSG', params: ['bob'], tags: {} }, ctx);
675
649
 
676
650
  expect(out.state).toBe(conn);
677
651
  });
@@ -0,0 +1,264 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { formatUserhostReply, userhostReducer } from '../../src/commands/userhost';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
6
+ import {
7
+ type ConnSnapshot,
8
+ type ConnectionState,
9
+ createConnection,
10
+ toSnapshot,
11
+ } from '../../src/state/connection';
12
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
13
+
14
+ const baseServerConfig: 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(state: ConnectionState): Ctx {
27
+ return buildCtx({
28
+ serverConfig: baseServerConfig,
29
+ clock: new FakeClock(5_000),
30
+ ids: new SequentialIdFactory(),
31
+ motd: EmptyMotdProvider,
32
+ connection: state,
33
+ });
34
+ }
35
+
36
+ function makeState(): ConnectionState {
37
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
38
+ s.nick = 'alice';
39
+ s.user = 'alice';
40
+ s.host = 'example.com';
41
+ s.realname = 'Alice';
42
+ s.registration = 'registered';
43
+ return s;
44
+ }
45
+
46
+ function makeSnapshot(
47
+ nick: string,
48
+ user: string,
49
+ host: string,
50
+ opts?: { away?: string; oper?: boolean },
51
+ ): ConnSnapshot {
52
+ const s = createConnection({ id: `c-${nick}`, connectedSince: 0 });
53
+ s.nick = nick;
54
+ s.user = user;
55
+ s.host = host;
56
+ s.registration = 'registered';
57
+ if (opts?.away !== undefined) s.away = opts.away;
58
+ if (opts?.oper) s.userModes.oper = true;
59
+ return toSnapshot(s);
60
+ }
61
+
62
+ const L = (text: string): RawLine => ({ text });
63
+
64
+ const userhost = (...nicks: string[]) =>
65
+ ({ command: 'USERHOST', params: nicks, tags: {} }) as const;
66
+
67
+ describe('userhostReducer', () => {
68
+ it('emits 302 RPL_USERHOST with the resolved hostmask for one nick', () => {
69
+ const state = makeState();
70
+ const ctx = makeCtx(state);
71
+ const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost')]]);
72
+
73
+ const out = userhostReducer(resolved, userhost('bob'), ctx);
74
+
75
+ expect(out.effects).toEqual<EffectType[]>([
76
+ Effect.send('c1', [L(':irc.example.com 302 alice :bob=+bobuser@bobhost')]),
77
+ ]);
78
+ });
79
+
80
+ it('includes multiple replies space-separated in one 302 line', () => {
81
+ const state = makeState();
82
+ const ctx = makeCtx(state);
83
+ const resolved = new Map([
84
+ ['bob', makeSnapshot('bob', 'bobuser', 'bobhost')],
85
+ ['carol', makeSnapshot('carol', 'caroluser', 'carolhost')],
86
+ ]);
87
+
88
+ const out = userhostReducer(resolved, userhost('bob', 'carol'), ctx);
89
+
90
+ expect(out.effects).toEqual<EffectType[]>([
91
+ Effect.send('c1', [
92
+ L(':irc.example.com 302 alice :bob=+bobuser@bobhost carol=+caroluser@carolhost'),
93
+ ]),
94
+ ]);
95
+ });
96
+
97
+ it('marks away users with - instead of +', () => {
98
+ const state = makeState();
99
+ const ctx = makeCtx(state);
100
+ const resolved = new Map([
101
+ ['bob', makeSnapshot('bob', 'bobuser', 'bobhost', { away: 'gone' })],
102
+ ]);
103
+
104
+ const out = userhostReducer(resolved, userhost('bob'), ctx);
105
+
106
+ expect(out.effects).toEqual<EffectType[]>([
107
+ Effect.send('c1', [L(':irc.example.com 302 alice :bob=-bobuser@bobhost')]),
108
+ ]);
109
+ });
110
+
111
+ it('prefixes oper users with *', () => {
112
+ const state = makeState();
113
+ const ctx = makeCtx(state);
114
+ const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost', { oper: true })]]);
115
+
116
+ const out = userhostReducer(resolved, userhost('bob'), ctx);
117
+
118
+ expect(out.effects).toEqual<EffectType[]>([
119
+ Effect.send('c1', [L(':irc.example.com 302 alice :bob*=+bobuser@bobhost')]),
120
+ ]);
121
+ });
122
+
123
+ it('omits nicks that are offline (null in the resolved map)', () => {
124
+ const state = makeState();
125
+ const ctx = makeCtx(state);
126
+ const resolved = new Map<string, ConnSnapshot | null>([
127
+ ['bob', makeSnapshot('bob', 'bobuser', 'bobhost')],
128
+ ['ghost', null],
129
+ ]);
130
+
131
+ const out = userhostReducer(resolved, userhost('bob', 'ghost'), ctx);
132
+
133
+ expect(out.effects).toEqual<EffectType[]>([
134
+ Effect.send('c1', [L(':irc.example.com 302 alice :bob=+bobuser@bobhost')]),
135
+ ]);
136
+ });
137
+
138
+ it('emits an empty trailing when no requested nicks are online', () => {
139
+ const state = makeState();
140
+ const ctx = makeCtx(state);
141
+ const resolved = new Map<string, ConnSnapshot | null>([['ghost', null]]);
142
+
143
+ const out = userhostReducer(resolved, userhost('ghost'), ctx);
144
+
145
+ expect(out.effects).toEqual<EffectType[]>([
146
+ Effect.send('c1', [L(':irc.example.com 302 alice :')]),
147
+ ]);
148
+ });
149
+
150
+ it('emits an empty trailing when no nicks are requested', () => {
151
+ const state = makeState();
152
+ const ctx = makeCtx(state);
153
+ const resolved = new Map<string, ConnSnapshot | null>();
154
+
155
+ const out = userhostReducer(resolved, userhost(), ctx);
156
+
157
+ expect(out.effects).toEqual<EffectType[]>([
158
+ Effect.send('c1', [L(':irc.example.com 302 alice :')]),
159
+ ]);
160
+ });
161
+
162
+ it('uses * as nick placeholder for unregistered connections', () => {
163
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
164
+ const ctx = makeCtx(state);
165
+ const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost')]]);
166
+
167
+ const out = userhostReducer(resolved, userhost('bob'), ctx);
168
+
169
+ expect(out.effects[0]).toEqual<EffectType>(
170
+ Effect.send('c1', [L(':irc.example.com 302 * :bob=+bobuser@bobhost')]),
171
+ );
172
+ });
173
+
174
+ it('updates lastSeen to ctx.clock.now()', () => {
175
+ const state = makeState();
176
+ const ctx = makeCtx(state);
177
+
178
+ userhostReducer(new Map(), userhost('bob'), ctx);
179
+
180
+ expect(state.lastSeen).toBe(5_000);
181
+ });
182
+
183
+ it('returns the same state reference', () => {
184
+ const state = makeState();
185
+ const ctx = makeCtx(state);
186
+
187
+ const out = userhostReducer(new Map(), userhost(), ctx);
188
+
189
+ expect(out.state).toBe(state);
190
+ });
191
+
192
+ it('respects the max-targets cap (caps the number of queried nicks)', () => {
193
+ const state = makeState();
194
+ const config: ServerConfig = { ...baseServerConfig, maxTargetsPerCommand: 2 };
195
+ const ctx = buildCtx({
196
+ serverConfig: config,
197
+ clock: new FakeClock(5_000),
198
+ ids: new SequentialIdFactory(),
199
+ motd: EmptyMotdProvider,
200
+ connection: state,
201
+ });
202
+ const resolved = new Map([
203
+ ['bob', makeSnapshot('bob', 'bu', 'bh')],
204
+ ['carol', makeSnapshot('carol', 'cu', 'ch')],
205
+ ['dave', makeSnapshot('dave', 'du', 'dh')],
206
+ ]);
207
+
208
+ const out = userhostReducer(resolved, userhost('bob', 'carol', 'dave'), ctx);
209
+
210
+ const text = (out.effects[0] as { lines: RawLine[] }).lines[0]?.text;
211
+ // Only the first two nicks should appear; the third is beyond the cap.
212
+ expect(text).not.toContain('dave');
213
+ expect(text).toContain('bob');
214
+ expect(text).toContain('carol');
215
+ });
216
+
217
+ it('skips an empty-string nick in the parameter list', () => {
218
+ const state = makeState();
219
+ const ctx = makeCtx(state);
220
+ const resolved = new Map([['bob', makeSnapshot('bob', 'bobuser', 'bobhost')]]);
221
+
222
+ const out = userhostReducer(resolved, userhost('', 'bob'), ctx);
223
+
224
+ const text = (out.effects[0] as { lines: RawLine[] }).lines[0]?.text;
225
+ expect(text).toContain('bob=+bobuser@bobhost');
226
+ });
227
+
228
+ it('omits a resolved snapshot that is missing required fields (host absent)', () => {
229
+ const state = makeState();
230
+ const ctx = makeCtx(state);
231
+ const incomplete = makeSnapshot('bob', 'bobuser', 'bobhost');
232
+ (incomplete as { host: string | undefined }).host = undefined;
233
+ const resolved = new Map<string, ConnSnapshot | null>([
234
+ ['bob', incomplete],
235
+ ['carol', makeSnapshot('carol', 'caroluser', 'carolhost')],
236
+ ]);
237
+
238
+ const out = userhostReducer(resolved, userhost('bob', 'carol'), ctx);
239
+
240
+ const text = (out.effects[0] as { lines: RawLine[] }).lines[0]?.text;
241
+ expect(text).not.toContain('bob');
242
+ expect(text).toContain('carol=+caroluser@carolhost');
243
+ });
244
+ });
245
+
246
+ describe('formatUserhostReply', () => {
247
+ it('returns null when the snapshot is missing a nick', () => {
248
+ const snap = makeSnapshot('bob', 'bu', 'bh');
249
+ (snap as { nick: string | undefined }).nick = undefined;
250
+ expect(formatUserhostReply(snap)).toBeNull();
251
+ });
252
+
253
+ it('returns null when the snapshot is missing a user', () => {
254
+ const snap = makeSnapshot('bob', 'bu', 'bh');
255
+ (snap as { user: string | undefined }).user = undefined;
256
+ expect(formatUserhostReply(snap)).toBeNull();
257
+ });
258
+
259
+ it('returns null when the snapshot is missing a host', () => {
260
+ const snap = makeSnapshot('bob', 'bu', 'bh');
261
+ (snap as { host: string | undefined }).host = undefined;
262
+ expect(formatUserhostReply(snap)).toBeNull();
263
+ });
264
+ });