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,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
+ });
@@ -0,0 +1,231 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { wallopsReducer } from '../../src/commands/wallops';
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 { type ConnectionState, createConnection } from '../../src/state/connection';
7
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
+
9
+ const serverConfig: ServerConfig = {
10
+ serverName: 'irc.example.com',
11
+ networkName: 'ExampleNet',
12
+ maxChannelsPerUser: 30,
13
+ maxTargetsPerCommand: 10,
14
+ maxListEntries: 50,
15
+ nickLen: 30,
16
+ channelLen: 50,
17
+ topicLen: 390,
18
+ quitMessage: 'Client Quit',
19
+ };
20
+
21
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(5_000)): Ctx {
22
+ return buildCtx({
23
+ serverConfig,
24
+ clock,
25
+ ids: new SequentialIdFactory(),
26
+ motd: EmptyMotdProvider,
27
+ connection: conn,
28
+ });
29
+ }
30
+
31
+ function makeOper(id = 'c1', nick = 'alice'): ConnectionState {
32
+ const s = createConnection({ id, connectedSince: 0 });
33
+ s.nick = nick;
34
+ s.user = nick;
35
+ s.host = 'example.com';
36
+ s.realname = 'Alice';
37
+ s.registration = 'registered';
38
+ s.userModes.oper = true;
39
+ return s;
40
+ }
41
+
42
+ const L = (text: string): RawLine => ({ text });
43
+
44
+ const wallops = (message?: string) =>
45
+ ({
46
+ command: 'WALLOPS',
47
+ params: message === undefined ? [] : [message],
48
+ tags: {},
49
+ }) as const;
50
+
51
+ describe('wallopsReducer — oper happy path', () => {
52
+ it('emits a global BroadcastWallops effect carrying the oper source prefix', () => {
53
+ const oper = makeOper();
54
+ const ctx = makeCtx(oper);
55
+
56
+ const out = wallopsReducer(oper, wallops('hello'), ctx);
57
+
58
+ expect(out.effects).toEqual<EffectType[]>([
59
+ Effect.broadcastWallops([L(':alice!alice@example.com WALLOPS :hello')], 'c1'),
60
+ ]);
61
+ });
62
+
63
+ it('passes the message text through verbatim (multi-word)', () => {
64
+ const oper = makeOper();
65
+ const ctx = makeCtx(oper);
66
+
67
+ const out = wallopsReducer(oper, wallops('restart in 5 minutes'), ctx);
68
+
69
+ expect(out.effects).toEqual<EffectType[]>([
70
+ Effect.broadcastWallops([L(':alice!alice@example.com WALLOPS :restart in 5 minutes')], 'c1'),
71
+ ]);
72
+ });
73
+
74
+ it('defaults except to the sender (skip-self, NOTICE/PRIVMSG semantics)', () => {
75
+ const oper = makeOper();
76
+ const ctx = makeCtx(oper);
77
+
78
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
79
+
80
+ expect(out.effects[0]?.tag).toBe('BroadcastWallops');
81
+ // except is the oper's own connection id, so the runtime never echoes
82
+ // the wallops back to its originator.
83
+ expect((out.effects[0] as { except?: string }).except).toBe('c1');
84
+ });
85
+
86
+ it('updates the oper lastSeen to ctx.clock.now()', () => {
87
+ const oper = makeOper();
88
+ expect(oper.lastSeen).toBe(0);
89
+ const ctx = makeCtx(oper);
90
+
91
+ wallopsReducer(oper, wallops('hi'), ctx);
92
+
93
+ expect(oper.lastSeen).toBe(5_000);
94
+ });
95
+
96
+ it('returns the same state reference unchanged', () => {
97
+ const oper = makeOper();
98
+ const ctx = makeCtx(oper);
99
+
100
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
101
+
102
+ expect(out.state).toBe(oper);
103
+ });
104
+
105
+ it('falls back to the bare nick when the hostmask is incomplete (no user/host)', () => {
106
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
107
+ oper.nick = 'alice';
108
+ oper.registration = 'registered';
109
+ oper.userModes.oper = true;
110
+ const ctx = makeCtx(oper);
111
+
112
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
113
+
114
+ expect(out.effects).toEqual<EffectType[]>([
115
+ Effect.broadcastWallops([L(':alice WALLOPS :hi')], 'c1'),
116
+ ]);
117
+ });
118
+
119
+ it('falls back to "*" when the oper has neither nick nor hostmask (defensive)', () => {
120
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
121
+ oper.registration = 'registered';
122
+ oper.userModes.oper = true;
123
+ const ctx = makeCtx(oper);
124
+
125
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
126
+
127
+ expect(out.effects).toEqual<EffectType[]>([
128
+ Effect.broadcastWallops([L(':* WALLOPS :hi')], 'c1'),
129
+ ]);
130
+ });
131
+ });
132
+
133
+ describe('wallopsReducer — non-oper (481 ERR_NOPRIVILEGES)', () => {
134
+ it('emits 481 when the sender is not an oper', () => {
135
+ const oper = makeOper();
136
+ oper.userModes.oper = false;
137
+ const ctx = makeCtx(oper);
138
+
139
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
140
+
141
+ expect(out.effects).toEqual<EffectType[]>([
142
+ Effect.send('c1', [
143
+ L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
144
+ ]),
145
+ ]);
146
+ });
147
+
148
+ it('does not emit a BroadcastWallops when the sender is not an oper', () => {
149
+ const oper = makeOper();
150
+ oper.userModes.oper = false;
151
+ const ctx = makeCtx(oper);
152
+
153
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
154
+
155
+ expect(out.effects.some((e) => e.tag === 'BroadcastWallops')).toBe(false);
156
+ });
157
+
158
+ it('gates before param validation: a non-oper with no message still gets 481', () => {
159
+ const oper = makeOper();
160
+ oper.userModes.oper = false;
161
+ const ctx = makeCtx(oper);
162
+
163
+ const out = wallopsReducer(oper, wallops(), ctx);
164
+
165
+ expect(out.effects).toEqual<EffectType[]>([
166
+ Effect.send('c1', [
167
+ L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
168
+ ]),
169
+ ]);
170
+ });
171
+
172
+ it('uses "*" as the nick in 481 when the connection has no nick', () => {
173
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
174
+ oper.userModes.oper = false;
175
+ const ctx = makeCtx(oper);
176
+
177
+ const out = wallopsReducer(oper, wallops('hi'), ctx);
178
+
179
+ expect(out.effects).toEqual<EffectType[]>([
180
+ Effect.send('c1', [
181
+ L(":irc.example.com 481 * :Permission Denied - You're not an IRC operator"),
182
+ ]),
183
+ ]);
184
+ });
185
+ });
186
+
187
+ describe('wallopsReducer — empty message (412 ERR_NOTEXTTOSEND)', () => {
188
+ it('emits 412 when no message parameter is supplied', () => {
189
+ const oper = makeOper();
190
+ const ctx = makeCtx(oper);
191
+
192
+ const out = wallopsReducer(oper, wallops(), ctx);
193
+
194
+ expect(out.effects).toEqual<EffectType[]>([
195
+ Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),
196
+ ]);
197
+ });
198
+
199
+ it('emits 412 when the message is the empty string', () => {
200
+ const oper = makeOper();
201
+ const ctx = makeCtx(oper);
202
+
203
+ const out = wallopsReducer(oper, wallops(''), ctx);
204
+
205
+ expect(out.effects).toEqual<EffectType[]>([
206
+ Effect.send('c1', [L(':irc.example.com 412 alice :No text to send')]),
207
+ ]);
208
+ });
209
+
210
+ it('does not broadcast when the message is empty', () => {
211
+ const oper = makeOper();
212
+ const ctx = makeCtx(oper);
213
+
214
+ const out = wallopsReducer(oper, wallops(''), ctx);
215
+
216
+ expect(out.effects.some((e) => e.tag === 'BroadcastWallops')).toBe(false);
217
+ });
218
+
219
+ it('uses "*" as the nick in 412 when the connection has no nick', () => {
220
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
221
+ oper.registration = 'registered';
222
+ oper.userModes.oper = true;
223
+ const ctx = makeCtx(oper);
224
+
225
+ const out = wallopsReducer(oper, wallops(''), ctx);
226
+
227
+ expect(out.effects).toEqual<EffectType[]>([
228
+ Effect.send('c1', [L(':irc.example.com 412 * :No text to send')]),
229
+ ]);
230
+ });
231
+ });