serverless-ircd 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -0,0 +1,312 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { whowasReducer } from '../../src/commands/whowas';
3
+ import { Effect } from '../../src/effects';
4
+ import type { Effect as EffectType, RawLine } from '../../src/effects';
5
+ import {
6
+ EmptyMotdProvider,
7
+ FakeClock,
8
+ InMemoryNickHistoryStore,
9
+ SequentialIdFactory,
10
+ } from '../../src/ports';
11
+ import { type ConnectionState, createConnection } from '../../src/state/connection';
12
+ import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
13
+
14
+ const serverConfig: ServerConfig = {
15
+ serverName: 'irc.example.com',
16
+ networkName: 'ExampleNet',
17
+ maxChannelsPerUser: 30,
18
+ maxTargetsPerCommand: 10,
19
+ maxListEntries: 50,
20
+ nickLen: 30,
21
+ channelLen: 50,
22
+ topicLen: 390,
23
+ quitMessage: 'Client Quit',
24
+ };
25
+
26
+ function makeCtx(conn: ConnectionState, clock = new FakeClock(10_000)): Ctx {
27
+ return buildCtx({
28
+ serverConfig,
29
+ clock,
30
+ ids: new SequentialIdFactory(),
31
+ motd: EmptyMotdProvider,
32
+ connection: conn,
33
+ });
34
+ }
35
+
36
+ function makeConn(id = 'c1', nick = 'alice'): ConnectionState {
37
+ const s = createConnection({ id, connectedSince: 0 });
38
+ s.nick = nick;
39
+ s.user = nick;
40
+ s.host = 'example.com';
41
+ s.realname = 'Alice';
42
+ s.registration = 'registered';
43
+ return s;
44
+ }
45
+
46
+ const L = (text: string): RawLine => ({ text });
47
+
48
+ describe('whowasReducer — single nick with history', () => {
49
+ it('emits 314 RPL_WHOWASUSER then 369 RPL_ENDOFWHOWAS for a recorded nick', () => {
50
+ const requester = makeConn();
51
+ const clock = new FakeClock(10_000);
52
+ const ctx = makeCtx(requester, clock);
53
+ const history = new InMemoryNickHistoryStore(clock);
54
+ history.record({
55
+ nick: 'bob',
56
+ connId: 'c2',
57
+ username: 'bob',
58
+ hostname: 'target.example.net',
59
+ realname: 'Bob',
60
+ signoffTime: 5_000,
61
+ });
62
+
63
+ const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);
64
+
65
+ expect(out.effects).toEqual<EffectType[]>([
66
+ Effect.send('c1', [L(':irc.example.com 314 alice bob bob target.example.net * :Bob')]),
67
+ Effect.send('c1', [L(':irc.example.com 369 alice bob :End of WHOWAS')]),
68
+ ]);
69
+ });
70
+
71
+ it('updates the requester lastSeen to ctx.clock.now()', () => {
72
+ const requester = makeConn();
73
+ const clock = new FakeClock(7_700);
74
+ const ctx = makeCtx(requester, clock);
75
+ const history = new InMemoryNickHistoryStore(clock);
76
+ history.record({ nick: 'bob', connId: 'c2', signoffTime: 1_000 });
77
+
78
+ whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);
79
+
80
+ expect(requester.lastSeen).toBe(7_700);
81
+ });
82
+
83
+ it('uses ? fallbacks when the entry has no user/host and nick as realname', () => {
84
+ const requester = makeConn();
85
+ const clock = new FakeClock(10_000);
86
+ const ctx = makeCtx(requester, clock);
87
+ const history = new InMemoryNickHistoryStore(clock);
88
+ history.record({ nick: 'bob', connId: 'c2', signoffTime: 1_000 });
89
+
90
+ const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);
91
+
92
+ const send = out.effects[0];
93
+ if (send?.tag === 'Send') {
94
+ expect(send.lines[0]?.text).toBe(':irc.example.com 314 alice bob ? ? * :bob');
95
+ }
96
+ });
97
+ });
98
+
99
+ describe('whowasReducer — no match', () => {
100
+ it('emits 406 ERR_WASNOSUCHNICK then 369 for an unknown nick', () => {
101
+ const requester = makeConn();
102
+ const ctx = makeCtx(requester);
103
+ const history = new InMemoryNickHistoryStore(new FakeClock(10_000));
104
+
105
+ const out = whowasReducer(history, { command: 'WHOWAS', params: ['ghost'] }, ctx);
106
+
107
+ expect(out.effects).toEqual<EffectType[]>([
108
+ Effect.send('c1', [L(':irc.example.com 406 alice ghost :There was no such nickname')]),
109
+ Effect.send('c1', [L(':irc.example.com 369 alice ghost :End of WHOWAS')]),
110
+ ]);
111
+ });
112
+ });
113
+
114
+ describe('whowasReducer — multiple comma-separated nicks', () => {
115
+ it('emits a 314+369 (or 406+369) block per queried nick in order', () => {
116
+ const requester = makeConn();
117
+ const clock = new FakeClock(10_000);
118
+ const ctx = makeCtx(requester, clock);
119
+ const history = new InMemoryNickHistoryStore(clock);
120
+ history.record({
121
+ nick: 'bob',
122
+ connId: 'c2',
123
+ username: 'bob',
124
+ hostname: 'h',
125
+ realname: 'Bob',
126
+ signoffTime: 1_000,
127
+ });
128
+
129
+ const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob,ghost'] }, ctx);
130
+
131
+ expect(out.effects).toEqual<EffectType[]>([
132
+ Effect.send('c1', [L(':irc.example.com 314 alice bob bob h * :Bob')]),
133
+ Effect.send('c1', [L(':irc.example.com 369 alice bob :End of WHOWAS')]),
134
+ Effect.send('c1', [L(':irc.example.com 406 alice ghost :There was no such nickname')]),
135
+ Effect.send('c1', [L(':irc.example.com 369 alice ghost :End of WHOWAS')]),
136
+ ]);
137
+ });
138
+ });
139
+
140
+ describe('whowasReducer — count cap', () => {
141
+ it('caps the number of 314 lines at the requested count', () => {
142
+ const requester = makeConn();
143
+ const clock = new FakeClock(10_000);
144
+ const ctx = makeCtx(requester, clock);
145
+ const history = new InMemoryNickHistoryStore(clock);
146
+ history.record({
147
+ nick: 'bob',
148
+ connId: 'c1',
149
+ username: 'u1',
150
+ hostname: 'h',
151
+ realname: 'R',
152
+ signoffTime: 1_000,
153
+ });
154
+ clock.advance(100);
155
+ history.record({
156
+ nick: 'bob',
157
+ connId: 'c2',
158
+ username: 'u2',
159
+ hostname: 'h',
160
+ realname: 'R',
161
+ signoffTime: 1_100,
162
+ });
163
+ clock.advance(100);
164
+ history.record({
165
+ nick: 'bob',
166
+ connId: 'c3',
167
+ username: 'u3',
168
+ hostname: 'h',
169
+ realname: 'R',
170
+ signoffTime: 1_200,
171
+ });
172
+
173
+ const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob', '1'] }, ctx);
174
+
175
+ // Only the most-recent entry (c3) is emitted, then the single 369.
176
+ expect(out.effects).toEqual<EffectType[]>([
177
+ Effect.send('c1', [L(':irc.example.com 314 alice bob u3 h * :R')]),
178
+ Effect.send('c1', [L(':irc.example.com 369 alice bob :End of WHOWAS')]),
179
+ ]);
180
+ });
181
+
182
+ it('emits all matches when count exceeds the available history', () => {
183
+ const requester = makeConn();
184
+ const clock = new FakeClock(10_000);
185
+ const ctx = makeCtx(requester, clock);
186
+ const history = new InMemoryNickHistoryStore(clock);
187
+ history.record({
188
+ nick: 'bob',
189
+ connId: 'c1',
190
+ username: 'u1',
191
+ hostname: 'h',
192
+ realname: 'R',
193
+ signoffTime: 1_000,
194
+ });
195
+ history.record({
196
+ nick: 'bob',
197
+ connId: 'c2',
198
+ username: 'u2',
199
+ hostname: 'h',
200
+ realname: 'R',
201
+ signoffTime: 1_100,
202
+ });
203
+
204
+ const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob', '50'] }, ctx);
205
+
206
+ const lines314 = out.effects.filter((e) => {
207
+ if (e.tag !== 'Send') return false;
208
+ return e.lines.some((l) => l.text.includes(' 314 '));
209
+ });
210
+ expect(lines314).toHaveLength(2);
211
+ });
212
+
213
+ it('treats a non-positive or malformed count as unlimited', () => {
214
+ const requester = makeConn();
215
+ const clock = new FakeClock(10_000);
216
+ const ctx = makeCtx(requester, clock);
217
+ const history = new InMemoryNickHistoryStore(clock);
218
+ history.record({
219
+ nick: 'bob',
220
+ connId: 'c1',
221
+ username: 'u1',
222
+ hostname: 'h',
223
+ realname: 'R',
224
+ signoffTime: 1_000,
225
+ });
226
+ history.record({
227
+ nick: 'bob',
228
+ connId: 'c2',
229
+ username: 'u2',
230
+ hostname: 'h',
231
+ realname: 'R',
232
+ signoffTime: 1_100,
233
+ });
234
+
235
+ const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob', '0'] }, ctx);
236
+
237
+ const count314 = out.effects.filter((e) => {
238
+ if (e.tag !== 'Send') return false;
239
+ return e.lines.some((l) => l.text.includes(' 314 '));
240
+ });
241
+ expect(count314).toHaveLength(2);
242
+ });
243
+ });
244
+
245
+ describe('whowasReducer — no arg / 431', () => {
246
+ it('emits 431 ERR_NONICKNAMEGIVEN when no nick argument is supplied', () => {
247
+ const requester = makeConn();
248
+ const ctx = makeCtx(requester);
249
+ const history = new InMemoryNickHistoryStore(new FakeClock(10_000));
250
+
251
+ const out = whowasReducer(history, { command: 'WHOWAS', params: [] }, ctx);
252
+
253
+ expect(out.effects).toEqual<EffectType[]>([
254
+ Effect.send('c1', [L(':irc.example.com 431 alice :No nickname given')]),
255
+ ]);
256
+ });
257
+ });
258
+
259
+ describe('whowasReducer — case-insensitive lookup', () => {
260
+ it('matches the recorded nick regardless of case', () => {
261
+ const requester = makeConn();
262
+ const clock = new FakeClock(10_000);
263
+ const ctx = makeCtx(requester, clock);
264
+ const history = new InMemoryNickHistoryStore(clock);
265
+ history.record({
266
+ nick: 'Bob',
267
+ connId: 'c2',
268
+ username: 'bob',
269
+ hostname: 'h',
270
+ realname: 'Bob',
271
+ signoffTime: 1_000,
272
+ });
273
+
274
+ const out = whowasReducer(history, { command: 'WHOWAS', params: ['BOB'] }, ctx);
275
+
276
+ expect(out.effects[0]?.tag).toBe('Send');
277
+ const send = out.effects[0];
278
+ if (send?.tag === 'Send') {
279
+ expect(send.lines[0]?.text).toContain(' 314 alice Bob ');
280
+ }
281
+ });
282
+ });
283
+
284
+ describe('whowasReducer — no store bound', () => {
285
+ it('treats undefined history as no matches (406 + 369, no crash)', () => {
286
+ const requester = makeConn();
287
+ const ctx = makeCtx(requester);
288
+
289
+ const out = whowasReducer(undefined, { command: 'WHOWAS', params: ['ghost'] }, ctx);
290
+
291
+ expect(out.effects).toEqual<EffectType[]>([
292
+ Effect.send('c1', [L(':irc.example.com 406 alice ghost :There was no such nickname')]),
293
+ Effect.send('c1', [L(':irc.example.com 369 alice ghost :End of WHOWAS')]),
294
+ ]);
295
+ });
296
+ });
297
+
298
+ describe('whowasReducer — requester without nick', () => {
299
+ it('uses * as the requester nick in replies', () => {
300
+ const requester = createConnection({ id: 'c1', connectedSince: 0 });
301
+ const ctx = makeCtx(requester);
302
+ const history = new InMemoryNickHistoryStore(new FakeClock(10_000));
303
+ history.record({ nick: 'bob', connId: 'c2', signoffTime: 1_000 });
304
+
305
+ const out = whowasReducer(history, { command: 'WHOWAS', params: ['bob'] }, ctx);
306
+
307
+ const end = out.effects.at(-1);
308
+ if (end?.tag === 'Send') {
309
+ expect(end.lines[0]?.text).toBe(':irc.example.com 369 * bob :End of WHOWAS');
310
+ }
311
+ });
312
+ });
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { DEFAULT_SERVER_CONFIG, ServerConfigSchema, parseServerConfig } from '../src/config';
2
+ import {
3
+ DEFAULT_CREATED_TEXT,
4
+ DEFAULT_SERVER_CONFIG,
5
+ DEFAULT_SERVER_VERSION,
6
+ ServerConfigSchema,
7
+ formatCreatedAt,
8
+ parseServerConfig,
9
+ resolveServerVersion,
10
+ } from '../src/config';
3
11
 
4
12
  // ============================================================================
5
13
  // ServerConfigSchema — valid inputs
@@ -213,6 +221,92 @@ describe('ServerConfigSchema — maxClients', () => {
213
221
  });
214
222
  });
215
223
 
224
+ // ============================================================================
225
+ // ServerConfigSchema — serverVersion / createdAt
226
+ // ============================================================================
227
+
228
+ describe('ServerConfigSchema — serverVersion', () => {
229
+ it('accepts an explicit non-empty version', () => {
230
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n', serverVersion: '9.9.9' });
231
+ expect(cfg.serverVersion).toBe('9.9.9');
232
+ });
233
+
234
+ it('defaults to DEFAULT_SERVER_VERSION when omitted', () => {
235
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
236
+ expect(cfg.serverVersion).toBe(DEFAULT_SERVER_VERSION);
237
+ });
238
+
239
+ it('rejects an empty version string', () => {
240
+ expect(() =>
241
+ parseServerConfig({ serverName: 's', networkName: 'n', serverVersion: '' }),
242
+ ).toThrowError(/serverVersion/u);
243
+ });
244
+
245
+ it('rejects a non-string version', () => {
246
+ expect(() =>
247
+ parseServerConfig({ serverName: 's', networkName: 'n', serverVersion: 42 }),
248
+ ).toThrowError(/serverVersion/u);
249
+ });
250
+ });
251
+
252
+ describe('ServerConfigSchema — createdAt', () => {
253
+ it('accepts a non-empty string verbatim', () => {
254
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n', createdAt: '2024-06-01' });
255
+ expect(cfg.createdAt).toBe('2024-06-01');
256
+ });
257
+
258
+ it('accepts an epoch-ms number', () => {
259
+ const cfg = parseServerConfig({
260
+ serverName: 's',
261
+ networkName: 'n',
262
+ createdAt: 1_700_000_000_000,
263
+ });
264
+ expect(cfg.createdAt).toBe(1_700_000_000_000);
265
+ });
266
+
267
+ it('defaults to DEFAULT_CREATED_TEXT when omitted', () => {
268
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
269
+ expect(cfg.createdAt).toBe(DEFAULT_CREATED_TEXT);
270
+ });
271
+
272
+ it('rejects an empty string', () => {
273
+ expect(() =>
274
+ parseServerConfig({ serverName: 's', networkName: 'n', createdAt: '' }),
275
+ ).toThrowError(/createdAt/u);
276
+ });
277
+
278
+ it('rejects a non-string/non-number value', () => {
279
+ expect(() =>
280
+ parseServerConfig({ serverName: 's', networkName: 'n', createdAt: true }),
281
+ ).toThrowError(/createdAt/u);
282
+ });
283
+ });
284
+
285
+ // ============================================================================
286
+ // resolveServerVersion / formatCreatedAt helpers
287
+ // ============================================================================
288
+
289
+ describe('resolveServerVersion', () => {
290
+ it('returns the configured value when present', () => {
291
+ expect(resolveServerVersion({ serverVersion: '1.2.3' })).toBe('1.2.3');
292
+ });
293
+ it('falls back to DEFAULT_SERVER_VERSION when undefined', () => {
294
+ expect(resolveServerVersion({})).toBe(DEFAULT_SERVER_VERSION);
295
+ });
296
+ });
297
+
298
+ describe('formatCreatedAt', () => {
299
+ it('returns a string verbatim', () => {
300
+ expect(formatCreatedAt('2024-06-01')).toBe('2024-06-01');
301
+ });
302
+ it('formats an epoch-ms number as a UTC timestamp', () => {
303
+ expect(formatCreatedAt(1_700_000_000_000)).toBe(new Date(1_700_000_000_000).toUTCString());
304
+ });
305
+ it('falls back to DEFAULT_CREATED_TEXT when undefined', () => {
306
+ expect(formatCreatedAt(undefined)).toBe(DEFAULT_CREATED_TEXT);
307
+ });
308
+ });
309
+
216
310
  // ============================================================================
217
311
  // ServerConfigSchema — operCreds
218
312
  // ============================================================================
@@ -0,0 +1,170 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ type HashedAccountCredential,
4
+ HashedAccountStore,
5
+ hashAccountCredential,
6
+ verifyHashedPassword,
7
+ } from '../src/credential-hashing';
8
+ import type { AccountStore, SaslPayload } from '../src/ports';
9
+
10
+ describe('hashAccountCredential', () => {
11
+ it('produces an entry keyed by the username with scrypt', () => {
12
+ const entry = hashAccountCredential('alice', 'secret');
13
+ expect(entry.account).toBe('alice');
14
+ expect(entry.algorithm).toBe('scrypt');
15
+ expect(entry.salt.length).toBeGreaterThan(0);
16
+ expect(entry.hash.length).toBeGreaterThan(0);
17
+ });
18
+
19
+ it('never stores the plaintext password in any field', () => {
20
+ const entry = hashAccountCredential('alice', 'hunter2-secret');
21
+ expect(entry.account).not.toContain('hunter2');
22
+ expect(entry.salt).not.toContain('hunter2');
23
+ expect(entry.hash).not.toContain('hunter2');
24
+ });
25
+
26
+ it('produces different hashes for the same password under different salts', () => {
27
+ const a = hashAccountCredential('alice', 'secret', { salt: new Uint8Array(16).fill(0x61) });
28
+ const b = hashAccountCredential('alice', 'secret', { salt: new Uint8Array(16).fill(0x62) });
29
+ expect(a.hash).not.toBe(b.hash);
30
+ });
31
+
32
+ it('is deterministic for a fixed salt', () => {
33
+ const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
34
+ const a = hashAccountCredential('alice', 'secret', { salt });
35
+ const b = hashAccountCredential('alice', 'secret', { salt });
36
+ expect(a.hash).toBe(b.hash);
37
+ });
38
+
39
+ it('honours a custom key length', () => {
40
+ const entry = hashAccountCredential('alice', 'secret', { keyLen: 32 });
41
+ // base64 of 32 bytes → ~44 chars; decode to verify length.
42
+ const decoded = Buffer.from(entry.hash, 'base64');
43
+ expect(decoded.length).toBe(32);
44
+ });
45
+
46
+ it('generates a fresh random salt when none is supplied', () => {
47
+ const a = hashAccountCredential('alice', 'secret');
48
+ const b = hashAccountCredential('alice', 'secret');
49
+ expect(a.salt).not.toBe(b.salt);
50
+ expect(a.hash).not.toBe(b.hash);
51
+ });
52
+ });
53
+
54
+ describe('verifyHashedPassword', () => {
55
+ it('round-trips a hashed credential: correct password verifies', () => {
56
+ const entry = hashAccountCredential('alice', 's3cret');
57
+ expect(verifyHashedPassword('s3cret', entry)).toBe(true);
58
+ });
59
+
60
+ it('rejects a wrong password', () => {
61
+ const entry = hashAccountCredential('alice', 's3cret');
62
+ expect(verifyHashedPassword('wrong', entry)).toBe(false);
63
+ });
64
+
65
+ it('rejects a non-scrypt algorithm entry', () => {
66
+ const entry: HashedAccountCredential = {
67
+ account: 'alice',
68
+ algorithm: 'argon2' as 'scrypt',
69
+ salt: 'aaaa',
70
+ hash: 'bbbb',
71
+ };
72
+ expect(verifyHashedPassword('s3cret', entry)).toBe(false);
73
+ });
74
+
75
+ it('rejects a malformed base64 salt without throwing', () => {
76
+ const entry: HashedAccountCredential = {
77
+ account: 'alice',
78
+ algorithm: 'scrypt',
79
+ salt: 'not!!valid*b64',
80
+ hash: 'bbbb',
81
+ };
82
+ expect(verifyHashedPassword('s3cret', entry)).toBe(false);
83
+ });
84
+
85
+ it('rejects an entry whose decoded hash is empty', () => {
86
+ const entry: HashedAccountCredential = {
87
+ account: 'alice',
88
+ algorithm: 'scrypt',
89
+ salt: 'AAAA',
90
+ hash: '',
91
+ };
92
+ expect(verifyHashedPassword('s3cret', entry)).toBe(false);
93
+ });
94
+ });
95
+
96
+ describe('HashedAccountStore', () => {
97
+ function plain(username: string, password: string): SaslPayload {
98
+ return { kind: 'PLAIN', username, password };
99
+ }
100
+
101
+ it('implements the AccountStore port', () => {
102
+ const store: AccountStore = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
103
+ expect(store).toBeDefined();
104
+ });
105
+
106
+ it('accepts valid PLAIN credentials and returns the account name', () => {
107
+ const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
108
+ expect(store.verify('PLAIN', plain('alice', 's3cret'))).toEqual({
109
+ ok: true,
110
+ account: 'alice',
111
+ });
112
+ });
113
+
114
+ it('rejects a wrong password', () => {
115
+ const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
116
+ expect(store.verify('PLAIN', plain('alice', 'wrong')).ok).toBe(false);
117
+ });
118
+
119
+ it('rejects an unknown username', () => {
120
+ const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
121
+ expect(store.verify('PLAIN', plain('bob', 's3cret')).ok).toBe(false);
122
+ });
123
+
124
+ it('rejects a non-PLAIN mechanism', () => {
125
+ const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
126
+ expect(store.verify('EXTERNAL', plain('alice', 's3cret')).ok).toBe(false);
127
+ });
128
+
129
+ it('rejects a RAW payload', () => {
130
+ const store = new HashedAccountStore([hashAccountCredential('alice', 's3cret')]);
131
+ expect(store.verify('PLAIN', { kind: 'RAW', data: 'whatever' }).ok).toBe(false);
132
+ });
133
+
134
+ it('handles multiple loaded accounts', () => {
135
+ const store = new HashedAccountStore([
136
+ hashAccountCredential('alice', 'a-secret'),
137
+ hashAccountCredential('bob', 'b-secret'),
138
+ ]);
139
+ expect(store.verify('PLAIN', plain('alice', 'a-secret'))).toEqual({
140
+ ok: true,
141
+ account: 'alice',
142
+ });
143
+ expect(store.verify('PLAIN', plain('bob', 'b-secret'))).toEqual({
144
+ ok: true,
145
+ account: 'bob',
146
+ });
147
+ });
148
+
149
+ it('fails every verify when constructed with no entries', () => {
150
+ const store = new HashedAccountStore([]);
151
+ expect(store.verify('PLAIN', plain('alice', 's3cret')).ok).toBe(false);
152
+ });
153
+
154
+ it('reports the number of loaded entries via size', () => {
155
+ const store = new HashedAccountStore([
156
+ hashAccountCredential('alice', 'a'),
157
+ hashAccountCredential('bob', 'b'),
158
+ ]);
159
+ expect(store.size).toBe(2);
160
+ });
161
+
162
+ it('round-trips an externally-constructed HashedAccountCredential', () => {
163
+ const row: HashedAccountCredential = hashAccountCredential('carol', 'p@ss');
164
+ const store = new HashedAccountStore([row]);
165
+ expect(store.verify('PLAIN', plain('carol', 'p@ss'))).toEqual({
166
+ ok: true,
167
+ account: 'carol',
168
+ });
169
+ });
170
+ });
@@ -67,27 +67,6 @@ describe('Effect constructors', () => {
67
67
  });
68
68
  });
69
69
 
70
- it('LookupNick carries nick', () => {
71
- expect(Effect.lookupNick('alice')).toEqual<EffectType>({
72
- tag: 'LookupNick',
73
- nick: 'alice',
74
- });
75
- });
76
-
77
- it('GetConnectionInfo carries conn', () => {
78
- expect(Effect.getConnectionInfo('c1')).toEqual<EffectType>({
79
- tag: 'GetConnectionInfo',
80
- conn: 'c1',
81
- });
82
- });
83
-
84
- it('GetChannelSnapshot carries chan', () => {
85
- expect(Effect.getChannelSnapshot('#foo')).toEqual<EffectType>({
86
- tag: 'GetChannelSnapshot',
87
- chan: '#foo',
88
- });
89
- });
90
-
91
70
  it('SendToNick carries nick, sender, lines, and optional notFoundLines', () => {
92
71
  expect(Effect.sendToNick('alice', 'c1', [line('hi')])).toEqual<EffectType>({
93
72
  tag: 'SendToNick',
@@ -115,9 +94,6 @@ describe('Effect tag set', () => {
115
94
  'ChangeNick',
116
95
  'ReleaseNick',
117
96
  'ApplyChannelDelta',
118
- 'LookupNick',
119
- 'GetConnectionInfo',
120
- 'GetChannelSnapshot',
121
97
  'SendToNick',
122
98
  ];
123
99
 
@@ -130,9 +106,6 @@ describe('Effect tag set', () => {
130
106
  ChangeNick: Effect.changeNick('c', 'a', 'b'),
131
107
  ReleaseNick: Effect.releaseNick('n'),
132
108
  ApplyChannelDelta: Effect.applyChannelDelta('#c', { memberships: [] }),
133
- LookupNick: Effect.lookupNick('n'),
134
- GetConnectionInfo: Effect.getConnectionInfo('c'),
135
- GetChannelSnapshot: Effect.getChannelSnapshot('#c'),
136
109
  SendToNick: Effect.sendToNick('n', 'c', []),
137
110
  };
138
111
  expect(constructors[tag].tag).toBe(tag);