serverless-ircd 0.4.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 (124) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +88 -19
  3. package/apps/aws-stack/README.md +4 -3
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/aws-stack/src/aws-stack.ts +32 -4
  6. package/apps/aws-stack/tests/stack.test.ts +47 -1
  7. package/apps/cf-tcp-container/package.json +1 -1
  8. package/apps/cf-worker/package.json +1 -1
  9. package/apps/local-cli/package.json +1 -1
  10. package/apps/local-cli/src/server.ts +129 -21
  11. package/apps/local-cli/tests/e2e.test.ts +1 -1
  12. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  13. package/package.json +2 -2
  14. package/packages/aws-adapter/package.json +1 -1
  15. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  16. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  17. package/packages/aws-adapter/src/handlers/default.ts +63 -5
  18. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  19. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  20. package/packages/aws-adapter/src/index.ts +2 -0
  21. package/packages/aws-adapter/src/serialize.ts +11 -1
  22. package/packages/aws-adapter/src/stats.ts +80 -0
  23. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  24. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  25. package/packages/aws-adapter/tests/connect.test.ts +97 -1
  26. package/packages/aws-adapter/tests/handlers.test.ts +148 -0
  27. package/packages/aws-adapter/tests/nlb-stream.test.ts +2 -0
  28. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  29. package/packages/cf-adapter/package.json +5 -1
  30. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  31. package/packages/cf-adapter/src/channel-do.ts +2 -2
  32. package/packages/cf-adapter/src/connection-do.ts +182 -54
  33. package/packages/cf-adapter/src/env.ts +25 -6
  34. package/packages/cf-adapter/src/index.ts +2 -0
  35. package/packages/cf-adapter/src/registry-do.ts +22 -3
  36. package/packages/cf-adapter/src/sharding.ts +1 -2
  37. package/packages/cf-adapter/src/stats.ts +65 -0
  38. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  39. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  40. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  41. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  42. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  43. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  44. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  45. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  46. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  47. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  48. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  49. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  50. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  51. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  52. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  53. package/packages/cf-adapter/wrangler.test.toml +7 -0
  54. package/packages/in-memory-runtime/package.json +1 -1
  55. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  56. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  57. package/packages/irc-core/package.json +1 -1
  58. package/packages/irc-core/src/admission.ts +16 -15
  59. package/packages/irc-core/src/caps/capabilities.ts +1 -1
  60. package/packages/irc-core/src/commands/index.ts +8 -0
  61. package/packages/irc-core/src/commands/invite.ts +2 -4
  62. package/packages/irc-core/src/commands/isupport.ts +6 -2
  63. package/packages/irc-core/src/commands/kick.ts +2 -4
  64. package/packages/irc-core/src/commands/kill.ts +127 -0
  65. package/packages/irc-core/src/commands/list.ts +1 -1
  66. package/packages/irc-core/src/commands/lusers.ts +204 -0
  67. package/packages/irc-core/src/commands/mode.ts +4 -8
  68. package/packages/irc-core/src/commands/names.ts +3 -5
  69. package/packages/irc-core/src/commands/part.ts +2 -4
  70. package/packages/irc-core/src/commands/rehash.ts +119 -0
  71. package/packages/irc-core/src/commands/setname.ts +109 -0
  72. package/packages/irc-core/src/commands/stats.ts +152 -0
  73. package/packages/irc-core/src/commands/topic.ts +2 -4
  74. package/packages/irc-core/src/commands/trace.ts +137 -0
  75. package/packages/irc-core/src/commands/wallops.ts +118 -0
  76. package/packages/irc-core/src/config.ts +7 -0
  77. package/packages/irc-core/src/effects.ts +27 -1
  78. package/packages/irc-core/src/index.ts +2 -0
  79. package/packages/irc-core/src/ports.ts +179 -0
  80. package/packages/irc-core/src/protocol/numerics.ts +42 -11
  81. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  82. package/packages/irc-core/src/types.ts +8 -1
  83. package/packages/irc-core/src/ws-framing.ts +132 -0
  84. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  85. package/packages/irc-core/tests/admission.test.ts +18 -0
  86. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  87. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  88. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  89. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  90. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  91. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  92. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  93. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  94. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  95. package/packages/irc-core/tests/effects.test.ts +14 -0
  96. package/packages/irc-core/tests/numerics.test.ts +90 -0
  97. package/packages/irc-core/tests/outbound.test.ts +51 -0
  98. package/packages/irc-core/tests/ports.test.ts +22 -0
  99. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  100. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  101. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  102. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  103. package/packages/irc-server/package.json +1 -1
  104. package/packages/irc-server/src/actor.ts +249 -16
  105. package/packages/irc-server/src/dispatch.ts +1 -0
  106. package/packages/irc-server/src/routing.ts +3 -0
  107. package/packages/irc-server/src/runtime.ts +31 -0
  108. package/packages/irc-server/src/transport.ts +10 -7
  109. package/packages/irc-server/tests/actor.test.ts +1089 -1
  110. package/packages/irc-server/tests/dispatch.test.ts +37 -0
  111. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  112. package/packages/irc-server/tests/routing.test.ts +1 -0
  113. package/packages/irc-server/tests/runtime.test.ts +7 -0
  114. package/packages/irc-test-support/package.json +1 -1
  115. package/packages/irc-test-support/src/scenarios.ts +9 -1
  116. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  117. package/pnpm-workspace.yaml +1 -0
  118. package/tools/ci-hardening/package.json +1 -1
  119. package/tools/package.json +6 -1
  120. package/tools/seed-cf-accounts.ts +4 -1
  121. package/tools/tcp-ws-forwarder/package.json +1 -1
  122. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  123. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  124. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -0,0 +1,282 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { traceReducer } from '../../src/commands/trace';
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 makeConn(id = 'c1', nick = 'alice', oper = false): 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 = oper;
39
+ return s;
40
+ }
41
+
42
+ function makeTarget(
43
+ id: string,
44
+ nick: string,
45
+ opts: { oper?: boolean; invisible?: boolean } = {},
46
+ ): ConnectionState {
47
+ const s = createConnection({ id, connectedSince: 0 });
48
+ s.nick = nick;
49
+ s.user = nick;
50
+ s.host = 'example.com';
51
+ s.realname = 'Real Name';
52
+ s.registration = 'registered';
53
+ if (opts.oper !== undefined) s.userModes.oper = opts.oper;
54
+ if (opts.invisible !== undefined) s.userModes.invisible = opts.invisible;
55
+ return s;
56
+ }
57
+
58
+ const L = (text: string): RawLine => ({ text });
59
+
60
+ const trace = (target?: string) =>
61
+ ({
62
+ command: 'TRACE',
63
+ params: target === undefined ? [] : [target],
64
+ tags: {},
65
+ }) as const;
66
+
67
+ // ============================================================================
68
+ // traceReducer — no target
69
+ // ============================================================================
70
+
71
+ describe('traceReducer — no target', () => {
72
+ it('emits the 262 terminator with no middle when there is no target', () => {
73
+ const requester = makeConn();
74
+ const ctx = makeCtx(requester);
75
+
76
+ const out = traceReducer(undefined, trace(), ctx);
77
+
78
+ expect(out.effects).toEqual<EffectType[]>([
79
+ Effect.send('c1', [L(':irc.example.com 262 alice :End of TRACE')]),
80
+ ]);
81
+ });
82
+
83
+ it('omits any 200/204/205 detail line when there is no target', () => {
84
+ const requester = makeConn();
85
+ const ctx = makeCtx(requester);
86
+
87
+ const out = traceReducer(undefined, trace(), ctx);
88
+
89
+ const text = out.effects
90
+ .map((e) => (e.tag === 'Send' ? e.lines.map((l) => l.text).join('|') : ''))
91
+ .join('|');
92
+ expect(text).not.toContain(' 200 ');
93
+ expect(text).not.toContain(' 204 ');
94
+ expect(text).not.toContain(' 205 ');
95
+ });
96
+
97
+ it('updates the requester lastSeen to ctx.clock.now()', () => {
98
+ const requester = makeConn();
99
+ expect(requester.lastSeen).toBe(0);
100
+ const ctx = makeCtx(requester);
101
+
102
+ traceReducer(undefined, trace(), ctx);
103
+
104
+ expect(requester.lastSeen).toBe(5_000);
105
+ });
106
+
107
+ it('uses "*" as the nick when the requester has none', () => {
108
+ const requester = createConnection({ id: 'c1', connectedSince: 0 });
109
+ const ctx = makeCtx(requester);
110
+
111
+ const out = traceReducer(undefined, trace(), ctx);
112
+
113
+ expect(out.effects).toEqual<EffectType[]>([
114
+ Effect.send('c1', [L(':irc.example.com 262 * :End of TRACE')]),
115
+ ]);
116
+ });
117
+ });
118
+
119
+ // ============================================================================
120
+ // traceReducer — non-oper detail suppression
121
+ // ============================================================================
122
+
123
+ describe('traceReducer — non-oper suppression', () => {
124
+ it('emits only the 262 terminator for a non-oper TRACE <nick> (no leak)', () => {
125
+ const requester = makeConn('c1', 'alice', false);
126
+ const ctx = makeCtx(requester);
127
+ const target = makeTarget('c2', 'bob');
128
+
129
+ const out = traceReducer(target, trace('bob'), ctx);
130
+
131
+ expect(out.effects).toEqual<EffectType[]>([
132
+ Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
133
+ ]);
134
+ });
135
+
136
+ it('does not emit a 205 detail line for a non-oper requester', () => {
137
+ const requester = makeConn('c1', 'alice', false);
138
+ const ctx = makeCtx(requester);
139
+ const target = makeTarget('c2', 'bob');
140
+
141
+ const out = traceReducer(target, trace('bob'), 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(' 205 ');
147
+ expect(text).not.toContain(' 204 ');
148
+ });
149
+ });
150
+
151
+ // ============================================================================
152
+ // traceReducer — oper detail
153
+ // ============================================================================
154
+
155
+ describe('traceReducer — oper detail', () => {
156
+ it('emits a 205 RPL_TRACEUSER line for an oper tracing a normal user', () => {
157
+ const requester = makeConn('c1', 'alice', true);
158
+ const ctx = makeCtx(requester);
159
+ const target = makeTarget('c2', 'bob');
160
+
161
+ const out = traceReducer(target, trace('bob'), ctx);
162
+
163
+ expect(out.effects).toEqual<EffectType[]>([
164
+ Effect.send('c1', [L(':irc.example.com 205 alice User 0 bob bob example.com')]),
165
+ Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
166
+ ]);
167
+ });
168
+
169
+ it('emits a 204 RPL_TRACEOPERATOR line for an oper tracing another oper', () => {
170
+ const requester = makeConn('c1', 'alice', true);
171
+ const ctx = makeCtx(requester);
172
+ const target = makeTarget('c2', 'bob', { oper: true });
173
+
174
+ const out = traceReducer(target, trace('bob'), ctx);
175
+
176
+ expect(out.effects).toEqual<EffectType[]>([
177
+ Effect.send('c1', [L(':irc.example.com 204 alice Oper 0 bob bob example.com')]),
178
+ Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
179
+ ]);
180
+ });
181
+
182
+ it('uses "*" nick fallbacks in 204/205 lines for an oper without a nick', () => {
183
+ const requester = createConnection({ id: 'c1', connectedSince: 0 });
184
+ requester.userModes.oper = true;
185
+ const ctx = makeCtx(requester);
186
+ const target = makeTarget('c2', 'bob');
187
+
188
+ const out = traceReducer(target, trace('bob'), ctx);
189
+
190
+ expect(out.effects[0]).toEqual<EffectType>(
191
+ Effect.send('c1', [L(':irc.example.com 205 * User 0 bob bob example.com')]),
192
+ );
193
+ });
194
+
195
+ it('uses "?" / "*" fallbacks in 205 line when target lacks user/host/nick', () => {
196
+ const requester = makeConn('c1', 'alice', true);
197
+ const ctx = makeCtx(requester);
198
+ const target = createConnection({ id: 'c2', connectedSince: 0 });
199
+ target.registration = 'registered';
200
+
201
+ const out = traceReducer(target, trace('ghost'), ctx);
202
+
203
+ expect(out.effects[0]).toEqual<EffectType>(
204
+ Effect.send('c1', [L(':irc.example.com 205 alice User 0 * ? ?')]),
205
+ );
206
+ });
207
+ });
208
+
209
+ // ============================================================================
210
+ // traceReducer — +i invisible filtering
211
+ // ============================================================================
212
+
213
+ describe('traceReducer — +i invisible filtering', () => {
214
+ it('omits detail for an invisible target a non-oper does not share a channel with', () => {
215
+ const requester = makeConn('c1', 'alice', false);
216
+ const ctx = makeCtx(requester);
217
+ const target = makeTarget('c2', 'bob', { invisible: true });
218
+
219
+ const out = traceReducer(target, trace('bob'), ctx);
220
+
221
+ expect(out.effects).toEqual<EffectType[]>([
222
+ Effect.send('c1', [L(':irc.example.com 262 alice bob :End of TRACE')]),
223
+ ]);
224
+ });
225
+
226
+ it('shows a 205 line for an invisible target when the requester is an oper (oper bypasses +i)', () => {
227
+ const requester = makeConn('c1', 'alice', true);
228
+ const ctx = makeCtx(requester);
229
+ const target = makeTarget('c2', 'bob', { invisible: true });
230
+
231
+ const out = traceReducer(target, trace('bob'), ctx);
232
+
233
+ expect(out.effects[0]).toEqual<EffectType>(
234
+ Effect.send('c1', [L(':irc.example.com 205 alice User 0 bob bob example.com')]),
235
+ );
236
+ });
237
+ });
238
+
239
+ // ============================================================================
240
+ // traceReducer — remote server target
241
+ // ============================================================================
242
+
243
+ describe('traceReducer — remote server target', () => {
244
+ it('emits 402 ERR_NOSUCHSERVER when the actor flags a non-local server target', () => {
245
+ const requester = makeConn('c1', 'alice', true);
246
+ const ctx = makeCtx(requester);
247
+
248
+ const out = traceReducer(undefined, trace('remote.example.org'), ctx, 'remote.example.org');
249
+
250
+ expect(out.effects).toEqual<EffectType[]>([
251
+ Effect.send('c1', [L(':irc.example.com 402 alice remote.example.org :No such server')]),
252
+ ]);
253
+ });
254
+
255
+ it('uses "*" nick in 402 when the requester has none', () => {
256
+ const requester = createConnection({ id: 'c1', connectedSince: 0 });
257
+ const ctx = makeCtx(requester);
258
+
259
+ const out = traceReducer(undefined, trace('remote.example.org'), ctx, 'remote.example.org');
260
+
261
+ expect(out.effects).toEqual<EffectType[]>([
262
+ Effect.send('c1', [L(':irc.example.com 402 * remote.example.org :No such server')]),
263
+ ]);
264
+ });
265
+ });
266
+
267
+ // ============================================================================
268
+ // traceReducer — unknown nick (actor could not resolve)
269
+ // ============================================================================
270
+
271
+ describe('traceReducer — unknown nick', () => {
272
+ it('emits 262 with the requested nick when target resolves to undefined', () => {
273
+ const requester = makeConn('c1', 'alice', true);
274
+ const ctx = makeCtx(requester);
275
+
276
+ const out = traceReducer(undefined, trace('ghost'), ctx);
277
+
278
+ expect(out.effects).toEqual<EffectType[]>([
279
+ Effect.send('c1', [L(':irc.example.com 262 alice ghost :End of TRACE')]),
280
+ ]);
281
+ });
282
+ });
@@ -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
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Regression guards for the formally-dropped S2S and obsolete RFC 2812 verbs.
3
+ *
4
+ * - S2S drop: `CONNECT` / `SQUIT` / `LINKS` are server-to-server commands.
5
+ * S2S is an explicit PLAN non-goal (`PLAN.md` §1), so the three verbs are
6
+ * formally dropped (they still return `421 ERR_UNKNOWNCOMMAND` — that is
7
+ * the correct response for an unimplemented command). Their reserved
8
+ * numerics (`RPL_LINKS`, `RPL_ENDOFLINKS`, `ERR_CANTKILLSERVER`) are removed
9
+ * here.
10
+ *
11
+ * - Obsolete-verb drop: `SERVICE` / `SUMMON` / `USERS` are RFC 2812 verbs
12
+ * that were never widely implemented and are effectively dead. They are
13
+ * formally dropped (also still `421`). Their reserved numerics
14
+ * (`RPL_YOURESERVICE`, `ERR_NOSUCHSERVICE`, `ERR_SUMMONDISABLED`,
15
+ * `ERR_USERSDISABLED`) are removed here.
16
+ *
17
+ * Because the drop is "remove the numeric, keep the 421", there is no runtime
18
+ * behaviour to assert — these are source-level / registry-level regression
19
+ * guards that prevent the numerics (and the "deferred verb" framing) from
20
+ * creeping back in.
21
+ */
22
+
23
+ import { describe, expect, it } from 'vitest';
24
+ import { Numerics, numericToName } from '../src/protocol/numerics';
25
+ import numericsSource from '../src/protocol/numerics.ts?raw';
26
+
27
+ describe('dropped S2S verbs (CONNECT / SQUIT / LINKS)', () => {
28
+ // S2S is a PLAN non-goal; the three verbs are formally dropped. Their
29
+ // reserved numerics are removed from the registry.
30
+ const droppedS2SNumerics = ['RPL_LINKS', 'RPL_ENDOFLINKS', 'ERR_CANTKILLSERVER'] as const;
31
+
32
+ it.each(droppedS2SNumerics)('numerics.ts source no longer defines %s', (name) => {
33
+ expect(numericsSource).not.toContain(`${name}:`);
34
+ });
35
+
36
+ it.each(droppedS2SNumerics)('Numerics runtime object has no %s key', (name) => {
37
+ expect((Numerics as Record<string, unknown>)[name]).toBeUndefined();
38
+ });
39
+
40
+ it('numericToName no longer resolves the dropped S2S codes (364, 365, 483)', () => {
41
+ expect(numericToName.get(364)).toBeUndefined();
42
+ expect(numericToName.get(365)).toBeUndefined();
43
+ expect(numericToName.get(483)).toBeUndefined();
44
+ });
45
+
46
+ it('numerics.ts source documents the S2S drop', () => {
47
+ // The drop must be explained where the numerics used to live, so the
48
+ // absence is not mistaken for a gap.
49
+ expect(numericsSource).toMatch(/S2S/);
50
+ expect(numericsSource).toMatch(/PLAN non-goal/);
51
+ });
52
+
53
+ it('ERR_NOSUCHSERVER (402) is retained (used by LUSERS/STATS/TRACE)', () => {
54
+ // 402 stays because the implemented query verbs use it for remote-server
55
+ // gating.
56
+ expect(Numerics.ERR_NOSUCHSERVER).toBe(402);
57
+ expect(numericToName.get(402)).toBe('ERR_NOSUCHSERVER');
58
+ });
59
+ });
60
+
61
+ describe('dropped obsolete RFC 2812 verbs (SERVICE / SUMMON / USERS)', () => {
62
+ // RFC 2812 obsolete verbs, never widely implemented. Formally dropped;
63
+ // their reserved numerics are removed from the registry.
64
+ const droppedObsoleteNumerics = [
65
+ 'RPL_YOURESERVICE',
66
+ 'ERR_NOSUCHSERVICE',
67
+ 'ERR_SUMMONDISABLED',
68
+ 'ERR_USERSDISABLED',
69
+ ] as const;
70
+
71
+ it.each(droppedObsoleteNumerics)('numerics.ts source no longer defines %s', (name) => {
72
+ expect(numericsSource).not.toContain(`${name}:`);
73
+ });
74
+
75
+ it.each(droppedObsoleteNumerics)('Numerics runtime object has no %s key', (name) => {
76
+ expect((Numerics as Record<string, unknown>)[name]).toBeUndefined();
77
+ });
78
+
79
+ it('numericToName no longer resolves the dropped obsolete codes (383, 408, 445, 446)', () => {
80
+ expect(numericToName.get(383)).toBeUndefined();
81
+ expect(numericToName.get(408)).toBeUndefined();
82
+ expect(numericToName.get(445)).toBeUndefined();
83
+ expect(numericToName.get(446)).toBeUndefined();
84
+ });
85
+
86
+ it('numerics.ts source documents the obsolete-verb drop', () => {
87
+ expect(numericsSource).toMatch(/RFC 2812/);
88
+ expect(numericsSource).toMatch(/obsolete/);
89
+ });
90
+ });
@@ -82,6 +82,18 @@ describe('Effect constructors', () => {
82
82
  notFoundLines: [{ text: '401' }],
83
83
  });
84
84
  });
85
+
86
+ it('BroadcastWallops carries lines and optional except', () => {
87
+ expect(Effect.broadcastWallops([line(':a WALLOPS :hi')])).toEqual<EffectType>({
88
+ tag: 'BroadcastWallops',
89
+ lines: [{ text: ':a WALLOPS :hi' }],
90
+ });
91
+ expect(Effect.broadcastWallops([line(':a WALLOPS :hi')], 'c1')).toEqual<EffectType>({
92
+ tag: 'BroadcastWallops',
93
+ lines: [{ text: ':a WALLOPS :hi' }],
94
+ except: 'c1',
95
+ });
96
+ });
85
97
  });
86
98
 
87
99
  describe('Effect tag set', () => {
@@ -95,6 +107,7 @@ describe('Effect tag set', () => {
95
107
  'ReleaseNick',
96
108
  'ApplyChannelDelta',
97
109
  'SendToNick',
110
+ 'BroadcastWallops',
98
111
  ];
99
112
 
100
113
  it.each(expectedTags)('exposes a constructor for the %s tag', (tag) => {
@@ -107,6 +120,7 @@ describe('Effect tag set', () => {
107
120
  ReleaseNick: Effect.releaseNick('n'),
108
121
  ApplyChannelDelta: Effect.applyChannelDelta('#c', { memberships: [] }),
109
122
  SendToNick: Effect.sendToNick('n', 'c', []),
123
+ BroadcastWallops: Effect.broadcastWallops([]),
110
124
  };
111
125
  expect(constructors[tag].tag).toBe(tag);
112
126
  });