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
@@ -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
  // ============================================================================
@@ -288,6 +382,81 @@ describe('ServerConfigSchema — operCreds', () => {
288
382
  });
289
383
  });
290
384
 
385
+ // ============================================================================
386
+ // ServerConfigSchema — saslAccounts
387
+ // ============================================================================
388
+
389
+ describe('ServerConfigSchema — saslAccounts', () => {
390
+ it('accepts an array of {username,password} entries', () => {
391
+ const cfg = parseServerConfig({
392
+ serverName: 's',
393
+ networkName: 'n',
394
+ saslAccounts: [
395
+ { username: 'alice', password: 'p1' },
396
+ { username: 'bob', password: 'p2' },
397
+ ],
398
+ });
399
+ expect(cfg.saslAccounts).toHaveLength(2);
400
+ expect(cfg.saslAccounts[0]?.username).toBe('alice');
401
+ expect(cfg.saslAccounts[1]?.password).toBe('p2');
402
+ });
403
+
404
+ it('defaults saslAccounts to an empty array when omitted', () => {
405
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
406
+ expect(cfg.saslAccounts).toEqual([]);
407
+ });
408
+
409
+ it('rejects saslAccounts entries missing username', () => {
410
+ expect(() =>
411
+ parseServerConfig({
412
+ serverName: 's',
413
+ networkName: 'n',
414
+ saslAccounts: [{ password: 'p' }],
415
+ }),
416
+ ).toThrowError(/username/u);
417
+ });
418
+
419
+ it('rejects saslAccounts entries missing password', () => {
420
+ expect(() =>
421
+ parseServerConfig({
422
+ serverName: 's',
423
+ networkName: 'n',
424
+ saslAccounts: [{ username: 'u' }],
425
+ }),
426
+ ).toThrowError(/password/u);
427
+ });
428
+
429
+ it('rejects saslAccounts entries with empty username', () => {
430
+ expect(() =>
431
+ parseServerConfig({
432
+ serverName: 's',
433
+ networkName: 'n',
434
+ saslAccounts: [{ username: '', password: 'p' }],
435
+ }),
436
+ ).toThrowError(/username/u);
437
+ });
438
+
439
+ it('rejects saslAccounts entries with empty password', () => {
440
+ expect(() =>
441
+ parseServerConfig({
442
+ serverName: 's',
443
+ networkName: 'n',
444
+ saslAccounts: [{ username: 'u', password: '' }],
445
+ }),
446
+ ).toThrowError(/password/u);
447
+ });
448
+
449
+ it('rejects a non-array saslAccounts', () => {
450
+ expect(() =>
451
+ parseServerConfig({
452
+ serverName: 's',
453
+ networkName: 'n',
454
+ saslAccounts: { username: 'u', password: 'p' },
455
+ }),
456
+ ).toThrowError(/saslAccounts/u);
457
+ });
458
+ });
459
+
291
460
  // ============================================================================
292
461
  // ServerConfigSchema — limit knobs
293
462
  // ============================================================================
@@ -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);
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { FakeClock } from '../src/ports';
3
+ import {
4
+ InMemoryNickHistoryStore,
5
+ type NickHistoryEntry,
6
+ type NickHistoryStore,
7
+ } from '../src/ports';
8
+
9
+ /** Builds a history entry with sensible defaults for tests. */
10
+ function entry(
11
+ nick: string,
12
+ signoffTime: number,
13
+ opts: Partial<NickHistoryEntry> = {},
14
+ ): NickHistoryEntry {
15
+ return {
16
+ nick,
17
+ connId: opts.connId ?? 'c1',
18
+ signoffTime,
19
+ ...(opts.username !== undefined ? { username: opts.username } : {}),
20
+ ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
21
+ ...(opts.realname !== undefined ? { realname: opts.realname } : {}),
22
+ };
23
+ }
24
+
25
+ describe('InMemoryNickHistoryStore — record + query', () => {
26
+ it('implements the NickHistoryStore port', () => {
27
+ const store: NickHistoryStore = new InMemoryNickHistoryStore(new FakeClock(1_000));
28
+ expect(store).toBeDefined();
29
+ });
30
+
31
+ it('returns a recorded entry on query (most-recent-first)', () => {
32
+ const clock = new FakeClock(1_000);
33
+ const store = new InMemoryNickHistoryStore(clock);
34
+ store.record(entry('alice', 1_000, { username: 'alice', hostname: 'ex.org' }));
35
+
36
+ const out = store.query('alice', 10);
37
+
38
+ expect(out).toHaveLength(1);
39
+ expect(out[0]?.nick).toBe('alice');
40
+ expect(out[0]?.username).toBe('alice');
41
+ expect(out[0]?.hostname).toBe('ex.org');
42
+ expect(out[0]?.signoffTime).toBe(1_000);
43
+ });
44
+
45
+ it('returns multiple entries most-recent-first', () => {
46
+ const clock = new FakeClock(1_000);
47
+ const store = new InMemoryNickHistoryStore(clock);
48
+ store.record(entry('alice', 1_000, { connId: 'c1' }));
49
+ clock.advance(500);
50
+ store.record(entry('alice', 1_500, { connId: 'c2' }));
51
+ clock.advance(500);
52
+ store.record(entry('alice', 2_000, { connId: 'c3' }));
53
+
54
+ const out = store.query('alice', 10);
55
+ expect(out.map((e) => e.connId)).toEqual(['c3', 'c2', 'c1']);
56
+ });
57
+
58
+ it('caps the result at the requested count', () => {
59
+ const clock = new FakeClock(1_000);
60
+ const store = new InMemoryNickHistoryStore(clock);
61
+ store.record(entry('alice', 1_000, { connId: 'c1' }));
62
+ clock.advance(500);
63
+ store.record(entry('alice', 1_500, { connId: 'c2' }));
64
+ clock.advance(500);
65
+ store.record(entry('alice', 2_000, { connId: 'c3' }));
66
+
67
+ const out = store.query('alice', 2);
68
+
69
+ expect(out.map((e) => e.connId)).toEqual(['c3', 'c2']);
70
+ });
71
+
72
+ it('returns an empty array for a nick with no history', () => {
73
+ const clock = new FakeClock(1_000);
74
+ const store = new InMemoryNickHistoryStore(clock);
75
+ expect(store.query('ghost', 10)).toEqual([]);
76
+ });
77
+
78
+ it('matches nicks case-insensitively (rfc1459 fold)', () => {
79
+ const clock = new FakeClock(1_000);
80
+ const store = new InMemoryNickHistoryStore(clock);
81
+ store.record(entry('Ali[e', 1_000));
82
+
83
+ expect(store.query('ali{e', 10)).toHaveLength(1);
84
+ expect(store.query('ALI{E', 10)).toHaveLength(1);
85
+ expect(store.query('ALI[E', 10)).toHaveLength(1);
86
+ });
87
+
88
+ it('returns an empty array when count is zero', () => {
89
+ const clock = new FakeClock(1_000);
90
+ const store = new InMemoryNickHistoryStore(clock);
91
+ store.record(entry('alice', 1_000));
92
+
93
+ expect(store.query('alice', 0)).toEqual([]);
94
+ });
95
+
96
+ it('keeps nick histories independent', () => {
97
+ const clock = new FakeClock(1_000);
98
+ const store = new InMemoryNickHistoryStore(clock);
99
+ store.record(entry('alice', 1_000));
100
+ clock.advance(500);
101
+ store.record(entry('bob', 1_500));
102
+
103
+ expect(store.query('alice', 10)).toHaveLength(1);
104
+ expect(store.query('bob', 10)).toHaveLength(1);
105
+ });
106
+ });
107
+
108
+ describe('InMemoryNickHistoryStore — TTL purge', () => {
109
+ it('purges entries older than maxAge on query', () => {
110
+ const clock = new FakeClock(1_000);
111
+ const store = new InMemoryNickHistoryStore(clock, { maxAge: 5_000 });
112
+ store.record(entry('alice', 1_000, { connId: 'c1' }));
113
+ clock.advance(3_000);
114
+ store.record(entry('alice', 4_000, { connId: 'c2' }));
115
+ clock.advance(3_000);
116
+
117
+ const out = store.query('alice', 10);
118
+
119
+ expect(out.map((e) => e.connId)).toEqual(['c2']);
120
+ });
121
+
122
+ it('purges all entries once they exceed maxAge', () => {
123
+ const clock = new FakeClock(1_000);
124
+ const store = new InMemoryNickHistoryStore(clock, { maxAge: 1_000 });
125
+ store.record(entry('alice', 1_000));
126
+ clock.advance(2_000);
127
+
128
+ expect(store.query('alice', 10)).toEqual([]);
129
+ });
130
+
131
+ it('purges on record so the per-nick buffer does not grow unbounded', () => {
132
+ const clock = new FakeClock(1_000);
133
+ const store = new InMemoryNickHistoryStore(clock, { maxAge: 1_000 });
134
+ for (let i = 0; i < 50; i++) {
135
+ store.record(entry('alice', clock.now(), { connId: `c${i}` }));
136
+ clock.advance(100);
137
+ }
138
+ // clock.now() is now 6_000; entries older than cutoff 5_000 are purged.
139
+ const out = store.query('alice', 100);
140
+ expect(out.length).toBeLessThan(50);
141
+ });
142
+
143
+ it('purges to an empty array when all entries expire, then query returns empty', () => {
144
+ const clock = new FakeClock(1_000);
145
+ const store = new InMemoryNickHistoryStore(clock, { maxAge: 1_000 });
146
+ store.record(entry('alice', 1_000, { connId: 'c1' }));
147
+ clock.advance(2_000);
148
+ // First query purges the lone entry → empty buffer remains in the map.
149
+ expect(store.query('alice', 10)).toEqual([]);
150
+ // Second query hits purgeExpired on the now-empty buffer (no-op splice).
151
+ expect(store.query('alice', 10)).toEqual([]);
152
+ });
153
+
154
+ it('uses a sane default maxAge when omitted', () => {
155
+ const clock = new FakeClock(1_000);
156
+ const store = new InMemoryNickHistoryStore(clock);
157
+ store.record(entry('alice', 1_000));
158
+ clock.advance(10_000);
159
+
160
+ expect(store.query('alice', 10)).toHaveLength(1);
161
+ });
162
+ });
@@ -23,6 +23,18 @@ describe('Numerics registry', () => {
23
23
  expect(Numerics.ERR_NEEDMOREPARAMS).toBe(461);
24
24
  });
25
25
 
26
+ it('maps RPL_WHOWASUSER to 314', () => {
27
+ expect(Numerics.RPL_WHOWASUSER).toBe(314);
28
+ });
29
+
30
+ it('maps RPL_ENDOFWHOWAS to 369', () => {
31
+ expect(Numerics.RPL_ENDOFWHOWAS).toBe(369);
32
+ });
33
+
34
+ it('maps ERR_WASNOSUCHNICK to 406', () => {
35
+ expect(Numerics.ERR_WASNOSUCHNICK).toBe(406);
36
+ });
37
+
26
38
  it('maps ERR_NICKNAMEINUSE to 433', () => {
27
39
  expect(Numerics.ERR_NICKNAMEINUSE).toBe(433);
28
40
  });
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { RawLine } from '../src/effects';
3
3
  import { Effect as EffectValue } from '../src/effects';
4
- import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../src/ports';
4
+ import {
5
+ EmptyMotdProvider,
6
+ FakeClock,
7
+ InMemoryNickHistoryStore,
8
+ SequentialIdFactory,
9
+ } from '../src/ports';
5
10
  import { createConnection } from '../src/state/connection';
6
11
  import {
7
12
  type Ctx,
@@ -97,6 +102,35 @@ describe('Ctx / Reducer types', () => {
97
102
  const keys = Object.keys(ctx) as Array<keyof Ctx>;
98
103
  expect(keys).not.toContain('msg');
99
104
  });
105
+
106
+ it('threads an optional history store through buildCtx onto ctx.history', () => {
107
+ const history = new InMemoryNickHistoryStore(new FakeClock(0));
108
+ const serverConfig: ServerConfig = {
109
+ serverName: 'irc.example.com',
110
+ networkName: 'ExampleNet',
111
+ maxChannelsPerUser: 30,
112
+ maxTargetsPerCommand: 10,
113
+ maxListEntries: 50,
114
+ nickLen: 30,
115
+ channelLen: 50,
116
+ topicLen: 390,
117
+ quitMessage: 'Client Quit',
118
+ };
119
+ const ctx = buildCtx({
120
+ serverConfig,
121
+ clock: new FakeClock(1_000),
122
+ ids: new SequentialIdFactory(),
123
+ motd: EmptyMotdProvider,
124
+ connection: createConnection({ id: 'c1', connectedSince: 0 }),
125
+ history,
126
+ });
127
+ expect(ctx.history).toBe(history);
128
+ });
129
+
130
+ it('omits ctx.history when no history store is supplied', () => {
131
+ const ctx = makeCtx();
132
+ expect(ctx.history).toBeUndefined();
133
+ });
100
134
  });
101
135
 
102
136
  describe('ServerConfig shape', () => {
@@ -5,7 +5,7 @@
5
5
  "rootDir": "./src",
6
6
  "outDir": "./dist",
7
7
  "tsBuildInfoFile": "./.tsbuildinfo",
8
- "types": []
8
+ "types": ["node"]
9
9
  },
10
10
  "include": ["src/**/*.ts"],
11
11
  "exclude": ["dist", "tests", "**/*.test.ts"]
@@ -2,7 +2,7 @@
2
2
  "extends": "../../tsconfig.base.json",
3
3
  "compilerOptions": {
4
4
  "noEmit": true,
5
- "types": [],
5
+ "types": ["node"],
6
6
  "composite": false
7
7
  },
8
8
  "include": ["src/**/*.ts", "tests/**/*.ts"],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-server",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",