serverless-ircd 0.2.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 (221) 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 +258 -18
  8. package/README.md +72 -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/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  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 +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -0,0 +1,257 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { matchOperCred, operReducer } from '../../src/commands/oper';
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
+ const serverConfigWithCreds: ServerConfig = {
22
+ ...baseServerConfig,
23
+ operCreds: [
24
+ { user: 'alice', password: 'secret' },
25
+ { user: 'bob', password: 'hunter2' },
26
+ ],
27
+ };
28
+
29
+ function makeCtx(state: ConnectionState, config: ServerConfig = serverConfigWithCreds): Ctx {
30
+ return buildCtx({
31
+ serverConfig: config,
32
+ clock: new FakeClock(5_000),
33
+ ids: new SequentialIdFactory(),
34
+ motd: EmptyMotdProvider,
35
+ connection: state,
36
+ });
37
+ }
38
+
39
+ function makeState(): ConnectionState {
40
+ const s = createConnection({ id: 'c1', connectedSince: 0 });
41
+ s.nick = 'alice';
42
+ s.user = 'alice';
43
+ s.host = 'example.com';
44
+ s.realname = 'Alice';
45
+ s.registration = 'registered';
46
+ return s;
47
+ }
48
+
49
+ const L = (text: string): RawLine => ({ text });
50
+
51
+ const oper = (name?: string, password?: string) =>
52
+ ({
53
+ command: 'OPER',
54
+ params: name === undefined ? [] : password === undefined ? [name] : [name, password],
55
+ tags: {},
56
+ }) as const;
57
+
58
+ describe('operReducer — successful authentication', () => {
59
+ it('sets userModes.oper and emits 381 RPL_YOUREOPER on matching credentials', () => {
60
+ const state = makeState();
61
+ const ctx = makeCtx(state);
62
+
63
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
64
+
65
+ expect(out.state.userModes.oper).toBe(true);
66
+ expect(out.effects).toEqual<EffectType[]>([
67
+ Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
68
+ ]);
69
+ });
70
+
71
+ it('matches the second configured credential pair', () => {
72
+ const state = makeState();
73
+ const ctx = makeCtx(state);
74
+
75
+ const out = operReducer(state, oper('bob', 'hunter2'), ctx);
76
+
77
+ expect(out.state.userModes.oper).toBe(true);
78
+ });
79
+
80
+ it('returns the same state reference (mutation permitted, no copy)', () => {
81
+ const state = makeState();
82
+ const ctx = makeCtx(state);
83
+
84
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
85
+
86
+ expect(out.state).toBe(state);
87
+ });
88
+
89
+ it('updates connection lastSeen to ctx.clock.now()', () => {
90
+ const state = makeState();
91
+ expect(state.lastSeen).toBe(0);
92
+ const ctx = makeCtx(state);
93
+
94
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
95
+
96
+ expect(out.state.lastSeen).toBe(5_000);
97
+ });
98
+
99
+ it('accepts a lower-case command token', () => {
100
+ const state = makeState();
101
+ const ctx = makeCtx(state);
102
+
103
+ const out = operReducer(state, { command: 'oper', params: ['alice', 'secret'], tags: {} }, ctx);
104
+
105
+ expect(out.state.userModes.oper).toBe(true);
106
+ });
107
+ });
108
+
109
+ describe('operReducer — already an operator (graceful no-op)', () => {
110
+ it('re-emits 381 without error when the connection is already oper', () => {
111
+ const state = makeState();
112
+ state.userModes.oper = true;
113
+ const ctx = makeCtx(state);
114
+
115
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
116
+
117
+ expect(out.state.userModes.oper).toBe(true);
118
+ expect(out.effects).toEqual<EffectType[]>([
119
+ Effect.send('c1', [L(':irc.example.com 381 alice :You are now an IRC operator')]),
120
+ ]);
121
+ });
122
+
123
+ it('does not require correct credentials when already oper', () => {
124
+ const state = makeState();
125
+ state.userModes.oper = true;
126
+ const ctx = makeCtx(state);
127
+
128
+ const out = operReducer(state, oper('alice', 'wrong'), ctx);
129
+
130
+ expect(out.state.userModes.oper).toBe(true);
131
+ expect(out.effects.some((e) => e.tag === 'Send')).toBe(true);
132
+ // Critical: never a 464 for an already-oper connection.
133
+ for (const e of out.effects) {
134
+ if (e.tag === 'Send') for (const line of e.lines) expect(line.text).not.toContain(' 464 ');
135
+ }
136
+ });
137
+ });
138
+
139
+ describe('operReducer — wrong credentials', () => {
140
+ it('emits 464 ERR_PASSWDMISMATCH for a wrong password', () => {
141
+ const state = makeState();
142
+ const ctx = makeCtx(state);
143
+
144
+ const out = operReducer(state, oper('alice', 'wrong'), ctx);
145
+
146
+ expect(out.state.userModes.oper).toBe(false);
147
+ expect(out.effects).toEqual<EffectType[]>([
148
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
149
+ ]);
150
+ });
151
+
152
+ it('emits 464 ERR_PASSWDMISMATCH for an unknown operator name', () => {
153
+ const state = makeState();
154
+ const ctx = makeCtx(state);
155
+
156
+ const out = operReducer(state, oper('mallory', 'whatever'), ctx);
157
+
158
+ expect(out.state.userModes.oper).toBe(false);
159
+ expect(out.effects).toEqual<EffectType[]>([
160
+ Effect.send('c1', [L(':irc.example.com 464 alice :Password Incorrect')]),
161
+ ]);
162
+ });
163
+ });
164
+
165
+ describe('operReducer — no credentials configured', () => {
166
+ it('emits 491 ERR_NOOPERHOST when operCreds is absent', () => {
167
+ const state = makeState();
168
+ const ctx = makeCtx(state, baseServerConfig);
169
+
170
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
171
+
172
+ expect(out.state.userModes.oper).toBe(false);
173
+ expect(out.effects).toEqual<EffectType[]>([
174
+ Effect.send('c1', [L(':irc.example.com 491 alice :No O-lines for your host')]),
175
+ ]);
176
+ });
177
+
178
+ it('emits 491 ERR_NOOPERHOST when operCreds is an empty array', () => {
179
+ const state = makeState();
180
+ const ctx = makeCtx(state, { ...baseServerConfig, operCreds: [] });
181
+
182
+ const out = operReducer(state, oper('alice', 'secret'), ctx);
183
+
184
+ expect(out.state.userModes.oper).toBe(false);
185
+ expect(out.effects.some((e) => e.tag === 'Send')).toBe(true);
186
+ for (const e of out.effects) {
187
+ if (e.tag === 'Send') for (const line of e.lines) expect(line.text).toContain(' 491 ');
188
+ }
189
+ });
190
+ });
191
+
192
+ describe('operReducer — missing parameters', () => {
193
+ it('emits 461 ERR_NEEDMOREPARAMS when no parameters are supplied', () => {
194
+ const state = makeState();
195
+ const ctx = makeCtx(state);
196
+
197
+ const out = operReducer(state, oper(), ctx);
198
+
199
+ expect(out.state.userModes.oper).toBe(false);
200
+ expect(out.effects).toEqual<EffectType[]>([
201
+ Effect.send('c1', [L(':irc.example.com 461 alice OPER :Not enough parameters')]),
202
+ ]);
203
+ });
204
+
205
+ it('emits 461 ERR_NEEDMOREPARAMS when only the name is supplied', () => {
206
+ const state = makeState();
207
+ const ctx = makeCtx(state);
208
+
209
+ const out = operReducer(state, oper('alice'), ctx);
210
+
211
+ expect(out.state.userModes.oper).toBe(false);
212
+ expect(out.effects).toEqual<EffectType[]>([
213
+ Effect.send('c1', [L(':irc.example.com 461 alice OPER :Not enough parameters')]),
214
+ ]);
215
+ });
216
+
217
+ it('uses "*" as the nick in 461 when the connection has no nick yet', () => {
218
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
219
+ const ctx = makeCtx(state);
220
+
221
+ const out = operReducer(state, oper(), ctx);
222
+
223
+ expect(out.effects).toEqual<EffectType[]>([
224
+ Effect.send('c1', [L(':irc.example.com 461 * OPER :Not enough parameters')]),
225
+ ]);
226
+ });
227
+ });
228
+
229
+ describe('matchOperCred', () => {
230
+ it('returns true when a credential pair matches exactly', () => {
231
+ const creds = [
232
+ { user: 'alice', password: 'secret' },
233
+ { user: 'bob', password: 'hunter2' },
234
+ ];
235
+ expect(matchOperCred(creds, 'bob', 'hunter2')).toBe(true);
236
+ });
237
+
238
+ it('returns false when the password does not match', () => {
239
+ const creds = [{ user: 'alice', password: 'secret' }];
240
+ expect(matchOperCred(creds, 'alice', 'nope')).toBe(false);
241
+ });
242
+
243
+ it('returns false when no user matches', () => {
244
+ const creds = [{ user: 'alice', password: 'secret' }];
245
+ expect(matchOperCred(creds, 'mallory', 'secret')).toBe(false);
246
+ });
247
+
248
+ it('returns false for an empty credential list', () => {
249
+ expect(matchOperCred([], 'alice', 'secret')).toBe(false);
250
+ });
251
+
252
+ it('does a case-sensitive comparison of both fields', () => {
253
+ const creds = [{ user: 'alice', password: 'secret' }];
254
+ expect(matchOperCred(creds, 'Alice', 'secret')).toBe(false);
255
+ expect(matchOperCred(creds, 'alice', 'Secret')).toBe(false);
256
+ });
257
+ });
@@ -2,7 +2,12 @@ import { describe, expect, it } from 'vitest';
2
2
  import { quitReducer } from '../../src/commands/quit';
3
3
  import { Effect } from '../../src/effects';
4
4
  import type { Effect as EffectType, RawLine } from '../../src/effects';
5
- import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ InMemoryNickHistoryStore,
9
+ SequentialIdFactory,
10
+ } from '../../src/ports';
6
11
  import { type ConnectionState, createConnection } from '../../src/state/connection';
7
12
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
8
13
 
@@ -18,13 +23,18 @@ const serverConfig: ServerConfig = {
18
23
  quitMessage: 'Client Quit',
19
24
  };
20
25
 
21
- function makeCtx(state: ConnectionState, clock = new FakeClock(1_000)): Ctx {
26
+ function makeCtx(
27
+ state: ConnectionState,
28
+ clock = new FakeClock(1_000),
29
+ history?: InMemoryNickHistoryStore,
30
+ ): Ctx {
22
31
  return buildCtx({
23
32
  serverConfig,
24
33
  clock,
25
34
  ids: new SequentialIdFactory(),
26
35
  motd: EmptyMotdProvider,
27
36
  connection: state,
37
+ ...(history !== undefined ? { history } : {}),
28
38
  });
29
39
  }
30
40
 
@@ -217,4 +227,61 @@ describe('quitReducer', () => {
217
227
  Effect.disconnect('c1'),
218
228
  ]);
219
229
  });
230
+
231
+ it('records a nick-history entry when ctx.history is bound', () => {
232
+ const state = makeRegisteredState();
233
+ const clock = new FakeClock(5_000);
234
+ const history = new InMemoryNickHistoryStore(clock);
235
+ const ctx = makeCtx(state, clock, history);
236
+
237
+ quitReducer(state, { command: 'QUIT', params: ['bye'], tags: {} }, ctx);
238
+
239
+ const entries = history.query('alice', 10);
240
+ expect(entries).toHaveLength(1);
241
+ expect(entries[0]?.nick).toBe('alice');
242
+ expect(entries[0]?.connId).toBe('c1');
243
+ expect(entries[0]?.username).toBe('alice');
244
+ expect(entries[0]?.hostname).toBe('example.com');
245
+ expect(entries[0]?.realname).toBe('Alice');
246
+ expect(entries[0]?.signoffTime).toBe(5_000);
247
+ });
248
+
249
+ it('does not record history when no store is bound (ctx.history undefined)', () => {
250
+ const state = makeRegisteredState();
251
+ const ctx = makeCtx(state);
252
+
253
+ const out = quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
254
+
255
+ expect(out.effects).toContainEqual<EffectType>(Effect.disconnect('c1'));
256
+ // No crash; the no-store path is a graceful no-op.
257
+ });
258
+
259
+ it('does not record history when the connection was never registered', () => {
260
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
261
+ const clock = new FakeClock(5_000);
262
+ const history = new InMemoryNickHistoryStore(clock);
263
+ const ctx = makeCtx(state, clock, history);
264
+
265
+ quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
266
+
267
+ expect(history.query('ghost', 10)).toEqual([]);
268
+ });
269
+
270
+ it('records a history entry with only nick/connId when user/host/realname are absent', () => {
271
+ const state = createConnection({ id: 'c1', connectedSince: 0 });
272
+ state.nick = 'alice';
273
+ state.registration = 'registered';
274
+ const clock = new FakeClock(5_000);
275
+ const history = new InMemoryNickHistoryStore(clock);
276
+ const ctx = makeCtx(state, clock, history);
277
+
278
+ quitReducer(state, { command: 'QUIT', params: [], tags: {} }, ctx);
279
+
280
+ const entries = history.query('alice', 10);
281
+ expect(entries).toHaveLength(1);
282
+ expect(entries[0]?.nick).toBe('alice');
283
+ expect(entries[0]?.username).toBeUndefined();
284
+ expect(entries[0]?.hostname).toBeUndefined();
285
+ expect(entries[0]?.realname).toBeUndefined();
286
+ });
220
287
  });
@@ -9,9 +9,17 @@ import {
9
9
  passReducer,
10
10
  userReducer,
11
11
  } from '../../src/commands/registration';
12
+ import { DEFAULT_CREATED_TEXT, DEFAULT_SERVER_VERSION } from '../../src/config';
12
13
  import { Effect } from '../../src/effects';
13
14
  import type { Effect as EffectType, RawLine } from '../../src/effects';
14
- import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
15
+ import {
16
+ EmptyMotdProvider,
17
+ FakeClock,
18
+ InMemoryNickHistoryStore,
19
+ SequentialIdFactory,
20
+ StaticMotdProvider,
21
+ } from '../../src/ports';
22
+ import type { MotdProvider } from '../../src/ports';
15
23
  import { type ConnectionState, createConnection } from '../../src/state/connection';
16
24
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
17
25
 
@@ -27,13 +35,33 @@ const serverConfig: ServerConfig = {
27
35
  quitMessage: 'Client Quit',
28
36
  };
29
37
 
30
- function makeCtx(state: ConnectionState, clock = new FakeClock(1_000)): Ctx {
38
+ function makeCtx(
39
+ state: ConnectionState,
40
+ clock = new FakeClock(1_000),
41
+ motd: MotdProvider = EmptyMotdProvider,
42
+ ): Ctx {
43
+ return buildCtx({
44
+ serverConfig,
45
+ clock,
46
+ ids: new SequentialIdFactory(),
47
+ motd,
48
+ connection: state,
49
+ });
50
+ }
51
+
52
+ /** Like {@link makeCtx} but also threads a bound nick-history store. */
53
+ function makeCtxWithHistory(
54
+ state: ConnectionState,
55
+ history: InMemoryNickHistoryStore,
56
+ clock = new FakeClock(1_000),
57
+ ): Ctx {
31
58
  return buildCtx({
32
59
  serverConfig,
33
60
  clock,
34
61
  ids: new SequentialIdFactory(),
35
62
  motd: EmptyMotdProvider,
36
63
  connection: state,
64
+ history,
37
65
  });
38
66
  }
39
67
 
@@ -76,13 +104,11 @@ function expectedWelcome(nick: string, user: string, host: string | undefined):
76
104
  );
77
105
  return [
78
106
  L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
79
- L(`${prefix} 002 ${nick} :Your host is ${sv}, running version 0.2.0`),
80
- L(`${prefix} 003 ${nick} :This server was created in Phase 1`),
81
- L(`${prefix} 004 ${nick} ${sv} 0.2.0 iosw biklmnstp`),
107
+ L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${DEFAULT_SERVER_VERSION}`),
108
+ L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),
109
+ L(`${prefix} 004 ${nick} ${sv} ${DEFAULT_SERVER_VERSION} iosw biklmnstp`),
82
110
  ...isupport,
83
- L(`${prefix} 375 ${nick} :- ${sv} Message of the day -`),
84
- L(`${prefix} 372 ${nick} :- MOTD placeholder`),
85
- L(`${prefix} 376 ${nick} :End of MOTD command`),
111
+ L(`${prefix} 422 ${nick} :MOTD File is missing`),
86
112
  ];
87
113
  }
88
114
 
@@ -319,6 +345,63 @@ describe('nickReducer', () => {
319
345
  expect(out.effects).toEqual([]);
320
346
  expect(out.state.nick).toBeUndefined();
321
347
  });
348
+
349
+ it('records the old nick into ctx.history on a post-registration nick change', () => {
350
+ const state = registeredState();
351
+ const clock = new FakeClock(5_000);
352
+ const history = new InMemoryNickHistoryStore(clock);
353
+ const ctx = makeCtxWithHistory(state, history, clock);
354
+
355
+ nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
356
+
357
+ const entries = history.query('alice', 10);
358
+ expect(entries).toHaveLength(1);
359
+ expect(entries[0]?.nick).toBe('alice');
360
+ expect(entries[0]?.connId).toBe('c1');
361
+ expect(entries[0]?.username).toBe('alice');
362
+ expect(entries[0]?.hostname).toBe('example.com');
363
+ expect(entries[0]?.realname).toBe('Alice');
364
+ expect(entries[0]?.signoffTime).toBe(5_000);
365
+ });
366
+
367
+ it('does not record history on a pre-registration NICK', () => {
368
+ const state = makeState();
369
+ const clock = new FakeClock(5_000);
370
+ const history = new InMemoryNickHistoryStore(clock);
371
+ const ctx = makeCtxWithHistory(state, history, clock);
372
+
373
+ nickReducer(state, { command: 'NICK', params: ['alice'], tags: {} }, ctx);
374
+
375
+ expect(history.query('alice', 10)).toEqual([]);
376
+ });
377
+
378
+ it('does not record history on a nick change when no store is bound', () => {
379
+ const state = registeredState();
380
+ const ctx = makeCtx(state);
381
+
382
+ const out = nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
383
+
384
+ // No crash; the no-store path is a graceful no-op.
385
+ expect(out.state.nick).toBe('bob');
386
+ });
387
+
388
+ it('records a history entry with only nick/connId when user/host/realname are absent', () => {
389
+ const state = makeState();
390
+ state.nick = 'alice';
391
+ state.registration = 'registered';
392
+ const clock = new FakeClock(5_000);
393
+ const history = new InMemoryNickHistoryStore(clock);
394
+ const ctx = makeCtxWithHistory(state, history, clock);
395
+
396
+ nickReducer(state, { command: 'NICK', params: ['bob'], tags: {} }, ctx);
397
+
398
+ const entries = history.query('alice', 10);
399
+ expect(entries).toHaveLength(1);
400
+ expect(entries[0]?.nick).toBe('alice');
401
+ expect(entries[0]?.username).toBeUndefined();
402
+ expect(entries[0]?.hostname).toBeUndefined();
403
+ expect(entries[0]?.realname).toBeUndefined();
404
+ });
322
405
  });
323
406
 
324
407
  // ============================================================================
@@ -489,19 +572,22 @@ describe('passReducer', () => {
489
572
  // ============================================================================
490
573
 
491
574
  describe('buildWelcomeLines', () => {
492
- it('produces the welcome block in the correct numeric order', () => {
575
+ it('produces the welcome block in the correct numeric order with MOTD content', () => {
493
576
  const state = makeState();
494
577
  state.nick = 'alice';
495
578
  state.user = 'alice';
496
579
  state.host = 'example.com';
497
- const lines = buildWelcomeLines(state, makeCtx(state));
498
- // 8 base lines (001-004, one or more 005, 375, 372, 376).
580
+ const lines = buildWelcomeLines(
581
+ state,
582
+ makeCtx(state, new FakeClock(1_000), new StaticMotdProvider(['line one', 'line two'])),
583
+ );
499
584
  expect(lines.length).toBeGreaterThanOrEqual(8);
500
585
  const codes = lines.map((l) => l.text.split(' ')[1]);
501
- // First four numerics in fixed order; 005 (>=1); final three in order.
586
+ // First four numerics in fixed order; 005 (>=1); MOTD block last.
502
587
  expect(codes.slice(0, 4)).toEqual(['001', '002', '003', '004']);
503
588
  expect(codes[4]).toBe('005');
504
- expect(codes.at(-3)).toBe('375');
589
+ expect(codes.at(-4)).toBe('375');
590
+ expect(codes.at(-3)).toBe('372');
505
591
  expect(codes.at(-2)).toBe('372');
506
592
  expect(codes.at(-1)).toBe('376');
507
593
  });
@@ -557,6 +643,159 @@ describe('buildWelcomeLines', () => {
557
643
  });
558
644
  });
559
645
 
646
+ // ============================================================================
647
+ // buildWelcomeLines — server version / created text driven from ServerConfig
648
+ // ============================================================================
649
+
650
+ describe('buildWelcomeLines — config-driven version & created text', () => {
651
+ /** Builds a ctx whose serverConfig carries the supplied version/created. */
652
+ function makeCtxWithMeta(
653
+ state: ConnectionState,
654
+ serverVersion: string | undefined,
655
+ createdAt: string | number | undefined,
656
+ ): Ctx {
657
+ const cfg: ServerConfig = {
658
+ ...serverConfig,
659
+ ...(serverVersion !== undefined ? { serverVersion } : {}),
660
+ ...(createdAt !== undefined ? { createdAt } : {}),
661
+ };
662
+ return buildCtx({
663
+ serverConfig: cfg,
664
+ clock: new FakeClock(1_000),
665
+ ids: new SequentialIdFactory(),
666
+ motd: EmptyMotdProvider,
667
+ connection: state,
668
+ });
669
+ }
670
+
671
+ function registeredConn(): ConnectionState {
672
+ const s = makeState();
673
+ s.nick = 'alice';
674
+ s.user = 'alice';
675
+ s.host = 'example.com';
676
+ return s;
677
+ }
678
+
679
+ it('reflects the configured serverVersion in the 002 and 004 lines', () => {
680
+ const ctx = makeCtxWithMeta(registeredConn(), '9.9.9', undefined);
681
+ const lines = buildWelcomeLines(ctx.connection, ctx);
682
+ const line002 = lines.find((l) => l.text.split(' ')[1] === '002');
683
+ const line004 = lines.find((l) => l.text.split(' ')[1] === '004');
684
+ expect(line002?.text).toContain('9.9.9');
685
+ expect(line004?.text).toContain('9.9.9');
686
+ });
687
+
688
+ it('reflects the configured createdAt string in the 003 line', () => {
689
+ const ctx = makeCtxWithMeta(registeredConn(), undefined, '2024-06-01');
690
+ const lines = buildWelcomeLines(ctx.connection, ctx);
691
+ const line003 = lines.find((l) => l.text.split(' ')[1] === '003');
692
+ expect(line003?.text).toContain('2024-06-01');
693
+ });
694
+
695
+ it('formats a numeric createdAt (epoch ms) into the 003 line', () => {
696
+ const ctx = makeCtxWithMeta(registeredConn(), undefined, 1_700_000_000_000);
697
+ const lines = buildWelcomeLines(ctx.connection, ctx);
698
+ const line003 = lines.find((l) => l.text.split(' ')[1] === '003');
699
+ // 1700000000000ms → 2023-11-14T22:13:20.000Z ; the formatted text must
700
+ // contain a human-readable rendering of that instant, not the raw number.
701
+ expect(line003?.text).not.toContain('1700000000000');
702
+ expect(line003?.text).toContain('2023');
703
+ });
704
+
705
+ it('falls back to the default version when serverVersion is omitted', () => {
706
+ const ctx = makeCtxWithMeta(registeredConn(), undefined, undefined);
707
+ const lines = buildWelcomeLines(ctx.connection, ctx);
708
+ const line002 = lines.find((l) => l.text.split(' ')[1] === '002');
709
+ // The hardcoded placeholder '0.2.0' must no longer appear; the default
710
+ // is the irc-core package version (DEFAULT_SERVER_VERSION).
711
+ expect(line002?.text).not.toContain('0.2.0');
712
+ expect(line002?.text).toContain(DEFAULT_SERVER_VERSION);
713
+ });
714
+ });
715
+
716
+ // ============================================================================
717
+ // buildWelcomeLines — MOTD driven from MotdProvider
718
+ // ============================================================================
719
+
720
+ describe('buildWelcomeLines — MOTD content', () => {
721
+ it('emits one 372 per MOTD line sourced from the MotdProvider', () => {
722
+ const state = makeState();
723
+ state.nick = 'alice';
724
+ state.user = 'alice';
725
+ state.host = 'example.com';
726
+ const lines = buildWelcomeLines(
727
+ state,
728
+ makeCtx(state, new FakeClock(1_000), new StaticMotdProvider(['line A', 'line B'])),
729
+ );
730
+ const rpl372 = lines.filter((l) => l.text.split(' ')[1] === '372');
731
+ expect(rpl372).toHaveLength(2);
732
+ expect(rpl372[0]).toEqual(L(':irc.example.com 372 alice :- line A'));
733
+ expect(rpl372[1]).toEqual(L(':irc.example.com 372 alice :- line B'));
734
+ });
735
+
736
+ it('frames the MOTD content with 375 start and 376 end', () => {
737
+ const state = makeState();
738
+ state.nick = 'alice';
739
+ state.user = 'alice';
740
+ const lines = buildWelcomeLines(
741
+ state,
742
+ makeCtx(state, new FakeClock(1_000), new StaticMotdProvider(['hi'])),
743
+ );
744
+ expect(lines).toContainEqual(
745
+ L(':irc.example.com 375 alice :- irc.example.com Message of the day -'),
746
+ );
747
+ expect(lines).toContainEqual(L(':irc.example.com 372 alice :- hi'));
748
+ expect(lines).toContainEqual(L(':irc.example.com 376 alice :End of MOTD command'));
749
+ });
750
+
751
+ it('reads MOTD lines fresh from the provider on each call', () => {
752
+ const state = makeState();
753
+ state.nick = 'alice';
754
+ state.user = 'alice';
755
+ const provider = new StaticMotdProvider(['first']);
756
+ const ctx = makeCtx(state, new FakeClock(1_000), provider);
757
+ const first = buildWelcomeLines(state, ctx).filter((l) => l.text.split(' ')[1] === '372');
758
+ provider.setLines(['first', 'second']);
759
+ const second = buildWelcomeLines(state, ctx).filter((l) => l.text.split(' ')[1] === '372');
760
+ expect(first).toHaveLength(1);
761
+ expect(second).toHaveLength(2);
762
+ });
763
+
764
+ it('splits over-long MOTD lines so every emitted line fits the 510-byte budget', () => {
765
+ const state = makeState();
766
+ state.nick = 'alice';
767
+ state.user = 'alice';
768
+ state.host = 'example.com';
769
+ const long = 'A'.repeat(2_000);
770
+ const lines = buildWelcomeLines(
771
+ state,
772
+ makeCtx(state, new FakeClock(1_000), new StaticMotdProvider([long])),
773
+ );
774
+ for (const line of lines) {
775
+ expect(line.text.length).toBeLessThanOrEqual(510);
776
+ }
777
+ // The single over-long line is hard-wrapped into many 372 chunks.
778
+ const rpl372 = lines.filter((l) => l.text.split(' ')[1] === '372');
779
+ expect(rpl372.length).toBeGreaterThan(1);
780
+ });
781
+ });
782
+
783
+ describe('buildWelcomeLines — empty MOTD', () => {
784
+ it('emits 422 ERR_NOMOTD instead of a placeholder when no MOTD is configured', () => {
785
+ const state = makeState();
786
+ state.nick = 'alice';
787
+ state.user = 'alice';
788
+ const lines = buildWelcomeLines(state, makeCtx(state));
789
+ const codes = lines.map((l) => l.text.split(' ')[1]);
790
+ expect(codes.at(-1)).toBe('422');
791
+ expect(lines).toContainEqual(L(':irc.example.com 422 alice :MOTD File is missing'));
792
+ // No MOTD framing numerics leak through on the empty path.
793
+ expect(codes).not.toContain('375');
794
+ expect(codes).not.toContain('372');
795
+ expect(codes).not.toContain('376');
796
+ });
797
+ });
798
+
560
799
  // ============================================================================
561
800
  // emitWelcomeIfReady
562
801
  // ============================================================================
@@ -856,12 +1095,10 @@ function expectedWelcomeWithHost(nick: string, hostmask: string): RawLine[] {
856
1095
  );
857
1096
  return [
858
1097
  L(`${prefix} 001 ${nick} :Welcome to the ExampleNet IRC Network, ${hostmask}`),
859
- L(`${prefix} 002 ${nick} :Your host is ${sv}, running version 0.2.0`),
860
- L(`${prefix} 003 ${nick} :This server was created in Phase 1`),
861
- L(`${prefix} 004 ${nick} ${sv} 0.2.0 iosw biklmnstp`),
1098
+ L(`${prefix} 002 ${nick} :Your host is ${sv}, running version ${DEFAULT_SERVER_VERSION}`),
1099
+ L(`${prefix} 003 ${nick} :This server was created in ${DEFAULT_CREATED_TEXT}`),
1100
+ L(`${prefix} 004 ${nick} ${sv} ${DEFAULT_SERVER_VERSION} iosw biklmnstp`),
862
1101
  ...isupport,
863
- L(`${prefix} 375 ${nick} :- ${sv} Message of the day -`),
864
- L(`${prefix} 372 ${nick} :- MOTD placeholder`),
865
- L(`${prefix} 376 ${nick} :End of MOTD command`),
1102
+ L(`${prefix} 422 ${nick} :MOTD File is missing`),
866
1103
  ];
867
1104
  }