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,166 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isonReducer } from '../../src/commands/ison';
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 baseServerConfig: 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(state: ConnectionState): Ctx {
22
+ return buildCtx({
23
+ serverConfig: baseServerConfig,
24
+ clock: new FakeClock(5_000),
25
+ ids: new SequentialIdFactory(),
26
+ motd: EmptyMotdProvider,
27
+ connection: state,
28
+ });
29
+ }
30
+
31
+ function makeState(): ConnectionState {
32
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
33
+ s.nick = 'alice';
34
+ s.user = 'alice';
35
+ s.host = 'example.com';
36
+ s.realname = 'Alice';
37
+ s.registration = 'registered';
38
+ return s;
39
+ }
40
+
41
+ const L = (text: string): RawLine => ({ text });
42
+
43
+ const ison = (...nicks: string[]) => ({ command: 'ISON', params: nicks, tags: {} }) as const;
44
+
45
+ /** Builds an online map from display-case nicks (auto-folds the key). */
46
+ const onlineMap = (...nicks: string[]): Map<string, string> =>
47
+ new Map(nicks.map((n) => [n.toLowerCase(), n]));
48
+
49
+ describe('isonReducer', () => {
50
+ it('emits 303 RPL_ISON with the online nicks from the requested set', () => {
51
+ const state = makeState();
52
+ const ctx = makeCtx(state);
53
+
54
+ const out = isonReducer(onlineMap('bob', 'carol'), ison('bob', 'carol', 'dave'), ctx);
55
+
56
+ expect(out.effects).toEqual<EffectType[]>([
57
+ Effect.send('c1', [L(':irc.example.com 303 alice :bob carol')]),
58
+ ]);
59
+ });
60
+
61
+ it('only includes nicks that are online', () => {
62
+ const state = makeState();
63
+ const ctx = makeCtx(state);
64
+
65
+ const out = isonReducer(onlineMap('bob'), ison('bob', 'carol', 'dave'), ctx);
66
+
67
+ expect(out.effects).toEqual<EffectType[]>([
68
+ Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),
69
+ ]);
70
+ });
71
+
72
+ it('emits an empty trailing when no requested nicks are online', () => {
73
+ const state = makeState();
74
+ const ctx = makeCtx(state);
75
+
76
+ const out = isonReducer(new Map(), ison('nobody'), ctx);
77
+
78
+ expect(out.effects).toEqual<EffectType[]>([
79
+ Effect.send('c1', [L(':irc.example.com 303 alice :')]),
80
+ ]);
81
+ });
82
+
83
+ it('preserves the registered display spelling from the online map', () => {
84
+ const state = makeState();
85
+ const ctx = makeCtx(state);
86
+
87
+ const out = isonReducer(onlineMap('Bob'), ison('Bob'), ctx);
88
+
89
+ expect(out.effects).toEqual<EffectType[]>([
90
+ Effect.send('c1', [L(':irc.example.com 303 alice :Bob')]),
91
+ ]);
92
+ });
93
+
94
+ it('matches case-insensitively (rfc1459 case-mapping)', () => {
95
+ const state = makeState();
96
+ const ctx = makeCtx(state);
97
+
98
+ const out = isonReducer(onlineMap('bob'), ison('BOB'), ctx);
99
+
100
+ expect(out.effects).toEqual<EffectType[]>([
101
+ Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),
102
+ ]);
103
+ });
104
+
105
+ it('reports the registered spelling even when requested with different case', () => {
106
+ const state = makeState();
107
+ const ctx = makeCtx(state);
108
+
109
+ const out = isonReducer(onlineMap('Bob'), ison('BOB'), ctx);
110
+
111
+ expect(out.effects).toEqual<EffectType[]>([
112
+ Effect.send('c1', [L(':irc.example.com 303 alice :Bob')]),
113
+ ]);
114
+ });
115
+
116
+ it('uses * as nick placeholder for unregistered connections', () => {
117
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
118
+ const ctx = makeCtx(state);
119
+
120
+ const out = isonReducer(onlineMap('bob'), ison('bob'), ctx);
121
+
122
+ expect(out.effects[0]).toEqual<EffectType>(
123
+ Effect.send('c1', [L(':irc.example.com 303 * :bob')]),
124
+ );
125
+ });
126
+
127
+ it('updates lastSeen to ctx.clock.now()', () => {
128
+ const state = makeState();
129
+ const ctx = makeCtx(state);
130
+
131
+ isonReducer(new Map(), ison('bob'), ctx);
132
+
133
+ expect(state.lastSeen).toBe(5_000);
134
+ });
135
+
136
+ it('returns the same state reference', () => {
137
+ const state = makeState();
138
+ const ctx = makeCtx(state);
139
+
140
+ const out = isonReducer(new Map(), ison(), ctx);
141
+
142
+ expect(out.state).toBe(state);
143
+ });
144
+
145
+ it('handles no-nick argument (empty request)', () => {
146
+ const state = makeState();
147
+ const ctx = makeCtx(state);
148
+
149
+ const out = isonReducer(onlineMap('bob'), ison(), ctx);
150
+
151
+ expect(out.effects).toEqual<EffectType[]>([
152
+ Effect.send('c1', [L(':irc.example.com 303 alice :')]),
153
+ ]);
154
+ });
155
+
156
+ it('skips empty-string params in the nick list', () => {
157
+ const state = makeState();
158
+ const ctx = makeCtx(state);
159
+
160
+ const out = isonReducer(onlineMap('bob'), ison('', 'bob'), ctx);
161
+
162
+ expect(out.effects).toEqual<EffectType[]>([
163
+ Effect.send('c1', [L(':irc.example.com 303 alice :bob')]),
164
+ ]);
165
+ });
166
+ });
@@ -0,0 +1,243 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { killReducer } from '../../src/commands/kill';
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
+ function makeTarget(id = 'c2', nick = 'bob'): ConnectionState {
43
+ const s = createConnection({ id, connectedSince: 0 });
44
+ s.nick = nick;
45
+ s.user = nick;
46
+ s.host = 'example.com';
47
+ s.realname = 'Bob';
48
+ s.registration = 'registered';
49
+ return s;
50
+ }
51
+
52
+ const L = (text: string): RawLine => ({ text });
53
+
54
+ const kill = (nick?: string, comment?: string) =>
55
+ ({
56
+ command: 'KILL',
57
+ params: nick === undefined ? [] : comment === undefined ? [nick] : [nick, comment],
58
+ tags: {},
59
+ }) as const;
60
+
61
+ describe('killReducer — oper happy path', () => {
62
+ it('emits a Disconnect effect for the target with a kill-formatted reason', () => {
63
+ const oper = makeOper();
64
+ const target = makeTarget();
65
+ const ctx = makeCtx(oper);
66
+
67
+ const out = killReducer(target, kill('bob', 'flooding'), ctx);
68
+
69
+ expect(out.effects).toEqual<EffectType[]>([
70
+ Effect.disconnect('c2', 'Killed (alice (flooding))'),
71
+ ]);
72
+ });
73
+
74
+ it('uses the oper display nick in the reason, not the target nick', () => {
75
+ const oper = makeOper('c1', 'theOper');
76
+ const target = makeTarget('c2', 'bob');
77
+ const ctx = makeCtx(oper);
78
+
79
+ const out = killReducer(target, kill('bob', 'bye'), ctx);
80
+
81
+ expect(out.effects).toEqual<EffectType[]>([Effect.disconnect('c2', 'Killed (theOper (bye))')]);
82
+ });
83
+
84
+ it('formats multi-word comments verbatim inside the inner parens', () => {
85
+ const oper = makeOper();
86
+ const target = makeTarget();
87
+ const ctx = makeCtx(oper);
88
+
89
+ const out = killReducer(target, kill('bob', 'stop flooding the channel'), ctx);
90
+
91
+ expect(out.effects).toEqual<EffectType[]>([
92
+ Effect.disconnect('c2', 'Killed (alice (stop flooding the channel))'),
93
+ ]);
94
+ });
95
+
96
+ it('updates the oper lastSeen to ctx.clock.now()', () => {
97
+ const oper = makeOper();
98
+ expect(oper.lastSeen).toBe(0);
99
+ const ctx = makeCtx(oper);
100
+
101
+ killReducer(makeTarget(), kill('bob', 'flooding'), ctx);
102
+
103
+ expect(oper.lastSeen).toBe(5_000);
104
+ });
105
+
106
+ it('falls back to "*" for the oper nick in the reason when the oper has none', () => {
107
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
108
+ oper.userModes.oper = true;
109
+ const target = makeTarget();
110
+ const ctx = makeCtx(oper);
111
+
112
+ const out = killReducer(target, kill('bob', 'x'), ctx);
113
+
114
+ expect(out.effects).toEqual<EffectType[]>([Effect.disconnect('c2', 'Killed (* (x))')]);
115
+ });
116
+ });
117
+
118
+ describe('killReducer — self-kill (RFC-permitted)', () => {
119
+ it('allows an oper to KILL themselves, disconnecting their own connection', () => {
120
+ const oper = makeOper('c1', 'alice');
121
+ const ctx = makeCtx(oper);
122
+
123
+ const out = killReducer(oper, kill('alice', 'self-terminate'), ctx);
124
+
125
+ expect(out.effects).toEqual<EffectType[]>([
126
+ Effect.disconnect('c1', 'Killed (alice (self-terminate))'),
127
+ ]);
128
+ });
129
+ });
130
+
131
+ describe('killReducer — non-oper (481 ERR_NOPRIVILEGES)', () => {
132
+ it('emits 481 when the sender is not an oper', () => {
133
+ const oper = makeOper();
134
+ oper.userModes.oper = false;
135
+ const target = makeTarget();
136
+ const ctx = makeCtx(oper);
137
+
138
+ const out = killReducer(target, kill('bob', 'flooding'), ctx);
139
+
140
+ expect(out.effects).toEqual<EffectType[]>([
141
+ Effect.send('c1', [
142
+ L(":irc.example.com 481 alice :Permission Denied - You're not an IRC operator"),
143
+ ]),
144
+ ]);
145
+ });
146
+
147
+ it('does not disconnect the target when the sender is not an oper', () => {
148
+ const oper = makeOper();
149
+ oper.userModes.oper = false;
150
+ const target = makeTarget();
151
+ const ctx = makeCtx(oper);
152
+
153
+ const out = killReducer(target, kill('bob', 'flooding'), ctx);
154
+
155
+ expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
156
+ });
157
+
158
+ it('gates before param validation: a non-oper with missing params still gets 481', () => {
159
+ const oper = makeOper();
160
+ oper.userModes.oper = false;
161
+ const ctx = makeCtx(oper);
162
+
163
+ const out = killReducer(undefined, kill(), 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 = killReducer(undefined, kill('bob', 'x'), 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('killReducer — missing parameters (461 ERR_NEEDMOREPARAMS)', () => {
188
+ it('emits 461 when no parameters are supplied', () => {
189
+ const oper = makeOper();
190
+ const ctx = makeCtx(oper);
191
+
192
+ const out = killReducer(undefined, kill(), ctx);
193
+
194
+ expect(out.effects).toEqual<EffectType[]>([
195
+ Effect.send('c1', [L(':irc.example.com 461 alice KILL :Not enough parameters')]),
196
+ ]);
197
+ });
198
+
199
+ it('emits 461 when only the nick is supplied (no comment)', () => {
200
+ const oper = makeOper();
201
+ const ctx = makeCtx(oper);
202
+
203
+ const out = killReducer(makeTarget(), kill('bob'), ctx);
204
+
205
+ expect(out.effects).toEqual<EffectType[]>([
206
+ Effect.send('c1', [L(':irc.example.com 461 alice KILL :Not enough parameters')]),
207
+ ]);
208
+ });
209
+
210
+ it('uses "*" as the nick in 461 when the connection has no nick', () => {
211
+ const oper = createConnection({ id: 'c1', connectedSince: 0 });
212
+ oper.userModes.oper = true;
213
+ const ctx = makeCtx(oper);
214
+
215
+ const out = killReducer(undefined, kill('bob'), ctx);
216
+
217
+ expect(out.effects).toEqual<EffectType[]>([
218
+ Effect.send('c1', [L(':irc.example.com 461 * KILL :Not enough parameters')]),
219
+ ]);
220
+ });
221
+ });
222
+
223
+ describe('killReducer — unknown nick (401 ERR_NOSUCHNICK)', () => {
224
+ it('emits 401 when the target resolves to undefined', () => {
225
+ const oper = makeOper();
226
+ const ctx = makeCtx(oper);
227
+
228
+ const out = killReducer(undefined, kill('ghost', 'flooding'), ctx);
229
+
230
+ expect(out.effects).toEqual<EffectType[]>([
231
+ Effect.send('c1', [L(':irc.example.com 401 alice ghost :No such nick/channel')]),
232
+ ]);
233
+ });
234
+
235
+ it('does not emit a Disconnect when the target is unknown', () => {
236
+ const oper = makeOper();
237
+ const ctx = makeCtx(oper);
238
+
239
+ const out = killReducer(undefined, kill('ghost', 'flooding'), ctx);
240
+
241
+ expect(out.effects.some((e) => e.tag === 'Disconnect')).toBe(false);
242
+ });
243
+ });