serverless-ircd 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. package/docs/Release-Process.md +0 -443
@@ -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',
@@ -103,6 +82,18 @@ describe('Effect constructors', () => {
103
82
  notFoundLines: [{ text: '401' }],
104
83
  });
105
84
  });
85
+
86
+ it('BroadcastWallops carries lines and optional except', () => {
87
+ expect(Effect.broadcastWallops([line(':a WALLOPS :hi')])).toEqual<EffectType>({
88
+ tag: 'BroadcastWallops',
89
+ lines: [{ text: ':a WALLOPS :hi' }],
90
+ });
91
+ expect(Effect.broadcastWallops([line(':a WALLOPS :hi')], 'c1')).toEqual<EffectType>({
92
+ tag: 'BroadcastWallops',
93
+ lines: [{ text: ':a WALLOPS :hi' }],
94
+ except: 'c1',
95
+ });
96
+ });
106
97
  });
107
98
 
108
99
  describe('Effect tag set', () => {
@@ -115,10 +106,8 @@ describe('Effect tag set', () => {
115
106
  'ChangeNick',
116
107
  'ReleaseNick',
117
108
  'ApplyChannelDelta',
118
- 'LookupNick',
119
- 'GetConnectionInfo',
120
- 'GetChannelSnapshot',
121
109
  'SendToNick',
110
+ 'BroadcastWallops',
122
111
  ];
123
112
 
124
113
  it.each(expectedTags)('exposes a constructor for the %s tag', (tag) => {
@@ -130,10 +119,8 @@ describe('Effect tag set', () => {
130
119
  ChangeNick: Effect.changeNick('c', 'a', 'b'),
131
120
  ReleaseNick: Effect.releaseNick('n'),
132
121
  ApplyChannelDelta: Effect.applyChannelDelta('#c', { memberships: [] }),
133
- LookupNick: Effect.lookupNick('n'),
134
- GetConnectionInfo: Effect.getConnectionInfo('c'),
135
- GetChannelSnapshot: Effect.getChannelSnapshot('#c'),
136
122
  SendToNick: Effect.sendToNick('n', 'c', []),
123
+ BroadcastWallops: Effect.broadcastWallops([]),
137
124
  };
138
125
  expect(constructors[tag].tag).toBe(tag);
139
126
  });
@@ -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,80 @@ 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 RPL_TRACELINK to 200', () => {
35
+ expect(Numerics.RPL_TRACELINK).toBe(200);
36
+ });
37
+
38
+ it('maps RPL_TRACEOPERATOR to 204', () => {
39
+ expect(Numerics.RPL_TRACEOPERATOR).toBe(204);
40
+ });
41
+
42
+ it('maps RPL_TRACEUSER to 205', () => {
43
+ expect(Numerics.RPL_TRACEUSER).toBe(205);
44
+ });
45
+
46
+ it('maps RPL_TRACESERVER to 206', () => {
47
+ expect(Numerics.RPL_TRACESERVER).toBe(206);
48
+ });
49
+
50
+ it('maps RPL_ENDOFTRACE to 262', () => {
51
+ expect(Numerics.RPL_ENDOFTRACE).toBe(262);
52
+ });
53
+
54
+ // -- LUSERS (RFC 2812 §4.6.2) ----------------------------------------------
55
+ it('maps RPL_LUSERCLIENT to 251', () => {
56
+ expect(Numerics.RPL_LUSERCLIENT).toBe(251);
57
+ });
58
+
59
+ it('maps RPL_LUSEROP to 252', () => {
60
+ expect(Numerics.RPL_LUSEROP).toBe(252);
61
+ });
62
+
63
+ it('maps RPL_LUSERUNKNOWN to 253', () => {
64
+ expect(Numerics.RPL_LUSERUNKNOWN).toBe(253);
65
+ });
66
+
67
+ it('maps RPL_LUSERCHANNELS to 254', () => {
68
+ expect(Numerics.RPL_LUSERCHANNELS).toBe(254);
69
+ });
70
+
71
+ it('maps RPL_LUSERME to 255', () => {
72
+ expect(Numerics.RPL_LUSERME).toBe(255);
73
+ });
74
+
75
+ it('maps RPL_LOCALUSERS to 265', () => {
76
+ expect(Numerics.RPL_LOCALUSERS).toBe(265);
77
+ });
78
+
79
+ it('maps RPL_GLOBALUSERS to 266', () => {
80
+ expect(Numerics.RPL_GLOBALUSERS).toBe(266);
81
+ });
82
+
83
+ // -- STATS (RFC 2812 §4.6.3) -----------------------------------------------
84
+ it('maps RPL_STATSLINKINFO to 211', () => {
85
+ expect(Numerics.RPL_STATSLINKINFO).toBe(211);
86
+ });
87
+
88
+ it('maps RPL_ENDOFSTATS to 219', () => {
89
+ expect(Numerics.RPL_ENDOFSTATS).toBe(219);
90
+ });
91
+
92
+ it('maps RPL_STATSUPTIME to 242', () => {
93
+ expect(Numerics.RPL_STATSUPTIME).toBe(242);
94
+ });
95
+
96
+ it('maps ERR_WASNOSUCHNICK to 406', () => {
97
+ expect(Numerics.ERR_WASNOSUCHNICK).toBe(406);
98
+ });
99
+
26
100
  it('maps ERR_NICKNAMEINUSE to 433', () => {
27
101
  expect(Numerics.ERR_NICKNAMEINUSE).toBe(433);
28
102
  });
@@ -65,6 +139,26 @@ describe('numericToName', () => {
65
139
  expect(numericToName.get(1)).toBe('RPL_WELCOME');
66
140
  });
67
141
 
142
+ it('reverses RPL_LUSERCLIENT', () => {
143
+ expect(numericToName.get(251)).toBe('RPL_LUSERCLIENT');
144
+ });
145
+
146
+ it('reverses RPL_GLOBALUSERS', () => {
147
+ expect(numericToName.get(266)).toBe('RPL_GLOBALUSERS');
148
+ });
149
+
150
+ it('reverses RPL_STATSLINKINFO', () => {
151
+ expect(numericToName.get(211)).toBe('RPL_STATSLINKINFO');
152
+ });
153
+
154
+ it('reverses RPL_ENDOFSTATS', () => {
155
+ expect(numericToName.get(219)).toBe('RPL_ENDOFSTATS');
156
+ });
157
+
158
+ it('reverses RPL_STATSUPTIME', () => {
159
+ expect(numericToName.get(242)).toBe('RPL_STATSUPTIME');
160
+ });
161
+
68
162
  it('returns undefined for an unregistered code', () => {
69
163
  expect(numericToName.get(600)).toBeUndefined();
70
164
  });
@@ -95,4 +189,12 @@ describe('formatNumeric', () => {
95
189
  it('zero-pads RPL_ISUPPORT', () => {
96
190
  expect(formatNumeric('RPL_ISUPPORT')).toBe('005');
97
191
  });
192
+
193
+ it('formats RPL_LUSERCLIENT as 251', () => {
194
+ expect(formatNumeric('RPL_LUSERCLIENT')).toBe('251');
195
+ });
196
+
197
+ it('formats RPL_ENDOFSTATS as 219', () => {
198
+ expect(formatNumeric('RPL_ENDOFSTATS')).toBe('219');
199
+ });
98
200
  });
@@ -9,6 +9,7 @@ import {
9
9
  } from '../src/protocol/outbound';
10
10
  import { parse } from '../src/protocol/parser';
11
11
  import { serialize } from '../src/protocol/serializer';
12
+ import { MAX_WS_MESSAGE_BYTES } from '../src/ws-framing';
12
13
 
13
14
  const L = (text: string): RawLine => ({ text });
14
15
 
@@ -192,6 +193,56 @@ describe('enforceLineLimit', () => {
192
193
  });
193
194
  });
194
195
 
196
+ // ============================================================================
197
+ // enforceLineLimit — budget-aware optional WsFrameMode parameter
198
+ // ============================================================================
199
+
200
+ describe('enforceLineLimit — WsFrameMode budget', () => {
201
+ // The optional `mode` parameter selects the byte budget. Spec modes cap at
202
+ // MAX_WS_MESSAGE_BYTES (510) directly — there is no trailing CRLF on a WS
203
+ // message. Legacy mode caps at MAX_LINE_BYTES - 2 (512 - 2 = 510). Both
204
+ // resolve to 510 today, but the API is mode-aware so a future change to
205
+ // either constant needs no call-site edits.
206
+ const SPEC_BUDGET = MAX_WS_MESSAGE_BYTES;
207
+
208
+ it('accepts an optional spec-text mode and caps at MAX_WS_MESSAGE_BYTES', () => {
209
+ const exact: RawLine = L('x'.repeat(SPEC_BUDGET));
210
+ expect(enforceLineLimit(exact, new Set<string>(), 'spec-text')).toBe(exact);
211
+
212
+ const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));
213
+ const out = enforceLineLimit(tooLong, new Set<string>(), 'spec-text');
214
+ expect(out.text.length).toBe(SPEC_BUDGET);
215
+ });
216
+
217
+ it('accepts an optional spec-binary mode and caps at MAX_WS_MESSAGE_BYTES', () => {
218
+ const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));
219
+ const out = enforceLineLimit(tooLong, new Set<string>(), 'spec-binary');
220
+ expect(out.text.length).toBe(SPEC_BUDGET);
221
+ });
222
+
223
+ it('uses the legacy budget when mode is explicitly "legacy"', () => {
224
+ const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));
225
+ const out = enforceLineLimit(tooLong, new Set<string>(), 'legacy');
226
+ expect(out.text.length).toBe(SPEC_BUDGET);
227
+ });
228
+
229
+ it('omitting mode defaults to the legacy budget (backward compatible)', () => {
230
+ const tooLong: RawLine = L('x'.repeat(SPEC_BUDGET + 5));
231
+ const withDefault = enforceLineLimit(tooLong, new Set<string>());
232
+ const withLegacy = enforceLineLimit(tooLong, new Set<string>(), 'legacy');
233
+ expect(withDefault).toEqual(withLegacy);
234
+ });
235
+
236
+ it('preserves the tag section in spec-text mode just as in legacy mode', () => {
237
+ const tagPrefix = '@time=2024-01-15T12:34:56.789Z ';
238
+ const body = `:alice PRIVMSG #foo :${'x'.repeat(500)}`;
239
+ const line: RawLine = L(`${tagPrefix}${body}`);
240
+ const out = enforceLineLimit(line, new Set<string>(['message-tags']), 'spec-text');
241
+ expect(out.text.length).toBe(SPEC_BUDGET);
242
+ expect(out.text.startsWith(tagPrefix)).toBe(true);
243
+ });
244
+ });
245
+
195
246
  // ============================================================================
196
247
  // Round-trip through serialize -> applyServerTime -> parse
197
248
  // ============================================================================
@@ -5,6 +5,7 @@ import {
5
5
  ConsoleLogger,
6
6
  FakeClock,
7
7
  type IdFactory,
8
+ InMemoryMtlsIdentityProvider,
8
9
  LogLevel,
9
10
  type LogRecord,
10
11
  type Logger,
@@ -362,3 +363,24 @@ const _assertLogRecord: LogRecord = {
362
363
  msg: '',
363
364
  };
364
365
  void _assertLogRecord;
366
+
367
+ describe('InMemoryMtlsIdentityProvider', () => {
368
+ it('returns the certificate subject for a known connection id', () => {
369
+ const provider = new InMemoryMtlsIdentityProvider([
370
+ { connId: 'c1', identity: 'CN=alice' },
371
+ { connId: 'c2', identity: 'CN=bob' },
372
+ ]);
373
+ expect(provider.getIdentity('c1')).toBe('CN=alice');
374
+ expect(provider.getIdentity('c2')).toBe('CN=bob');
375
+ });
376
+
377
+ it('returns undefined for an unknown connection id', () => {
378
+ const provider = new InMemoryMtlsIdentityProvider([{ connId: 'c1', identity: 'CN=alice' }]);
379
+ expect(provider.getIdentity('nope')).toBeUndefined();
380
+ });
381
+
382
+ it('handles an empty identity table', () => {
383
+ const provider = new InMemoryMtlsIdentityProvider([]);
384
+ expect(provider.getIdentity('c1')).toBeUndefined();
385
+ });
386
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Ambient declaration so `?raw` imports (Vite's built-in transform that
3
+ * inlines a file's text at bundle time) typecheck under the strict
4
+ * `tsconfig.test.json`. Used by source-level regression guards that
5
+ * assert removed numerics stay removed.
6
+ */
7
+ declare module '*?raw' {
8
+ const content: string;
9
+ // eslint-disable-next-line import/no-default-export
10
+ export default content;
11
+ }
@@ -0,0 +1,222 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ FakeClock,
4
+ InMemoryStats,
5
+ type ServerStats,
6
+ type ServerStatsSnapshot,
7
+ computeStatsSnapshot,
8
+ } from '../src/ports';
9
+ import type { ChannelState } from '../src/state/channel';
10
+ import { createChannel } from '../src/state/channel';
11
+ import type { ConnId, ConnectionState } from '../src/state/connection';
12
+ import { createConnection } from '../src/state/connection';
13
+
14
+ /** Builds a registered connection with the supplied modes. */
15
+ function makeConn(
16
+ id: ConnId,
17
+ opts: { oper?: boolean; invisible?: boolean; registered?: boolean } = {},
18
+ ): ConnectionState {
19
+ const s = createConnection({ id, connectedSince: 0 });
20
+ s.nick = id;
21
+ s.user = id;
22
+ s.host = 'example.com';
23
+ s.realname = id;
24
+ s.registration = opts.registered === false ? 'pre-registration' : 'registered';
25
+ if (opts.oper !== undefined) s.userModes.oper = opts.oper;
26
+ if (opts.invisible !== undefined) s.userModes.invisible = opts.invisible;
27
+ return s;
28
+ }
29
+
30
+ function emptyChannel(name: string): ChannelState {
31
+ return createChannel({ name, nameLower: name.toLowerCase(), createdAt: 0 });
32
+ }
33
+
34
+ describe('InMemoryStats', () => {
35
+ it('implements the ServerStats port', () => {
36
+ const stats: ServerStats = new InMemoryStats({
37
+ connections: new Map(),
38
+ channels: new Map(),
39
+ uptimeStartedAt: 0,
40
+ });
41
+ expect(stats).toBeDefined();
42
+ });
43
+
44
+ it('returns zero counts for an empty deployment', async () => {
45
+ const stats = new InMemoryStats({
46
+ connections: new Map(),
47
+ channels: new Map(),
48
+ uptimeStartedAt: 0,
49
+ });
50
+
51
+ const snap = await stats.getStats();
52
+
53
+ expect(snap).toEqual<ServerStatsSnapshot>({
54
+ users: 0,
55
+ invisible: 0,
56
+ opers: 0,
57
+ unknownConnections: 0,
58
+ channels: 0,
59
+ servers: 1,
60
+ localConns: 0,
61
+ globalConns: 0,
62
+ uptimeStartedAt: 0,
63
+ maxLocalConns: 0,
64
+ maxGlobalConns: 0,
65
+ });
66
+ });
67
+
68
+ it('derives the seed counts from the maps (3 conns, 1 oper, 2 channels)', async () => {
69
+ const conns = new Map<ConnId, ConnectionState>([
70
+ ['c1', makeConn('c1')],
71
+ ['c2', makeConn('c2', { invisible: true })],
72
+ ['c3', makeConn('c3', { oper: true })],
73
+ ]);
74
+ const channels = new Map<string, ChannelState>([
75
+ ['#a', emptyChannel('#a')],
76
+ ['#b', emptyChannel('#b')],
77
+ ]);
78
+ const stats = new InMemoryStats({
79
+ connections: conns,
80
+ channels,
81
+ uptimeStartedAt: 1_000,
82
+ });
83
+
84
+ const snap = await stats.getStats();
85
+
86
+ // 3 registered (2 visible + 1 invisible), 1 oper, 0 unknown, 2 channels.
87
+ expect(snap.users).toBe(2); // visible registered
88
+ expect(snap.invisible).toBe(1);
89
+ expect(snap.opers).toBe(1);
90
+ expect(snap.unknownConnections).toBe(0);
91
+ expect(snap.channels).toBe(2);
92
+ expect(snap.servers).toBe(1);
93
+ expect(snap.localConns).toBe(3);
94
+ expect(snap.globalConns).toBe(3);
95
+ expect(snap.uptimeStartedAt).toBe(1_000);
96
+ });
97
+
98
+ it('counts pre-registration connections as unknown', async () => {
99
+ const conns = new Map<ConnId, ConnectionState>([
100
+ ['c1', makeConn('c1')],
101
+ ['c2', makeConn('c2', { registered: false })],
102
+ ]);
103
+ const stats = new InMemoryStats({
104
+ connections: conns,
105
+ channels: new Map(),
106
+ uptimeStartedAt: 0,
107
+ });
108
+
109
+ const snap = await stats.getStats();
110
+
111
+ expect(snap.users).toBe(1);
112
+ expect(snap.unknownConnections).toBe(1);
113
+ expect(snap.localConns).toBe(2);
114
+ });
115
+
116
+ it('reports the live max as equal to current count when never higher', async () => {
117
+ const conns = new Map<ConnId, ConnectionState>([['c1', makeConn('c1')]]);
118
+ const stats = new InMemoryStats({
119
+ connections: conns,
120
+ channels: new Map(),
121
+ uptimeStartedAt: 0,
122
+ maxLocalConns: 5,
123
+ maxGlobalConns: 7,
124
+ });
125
+
126
+ const snap = await stats.getStats();
127
+
128
+ expect(snap.maxLocalConns).toBe(5);
129
+ expect(snap.maxGlobalConns).toBe(7);
130
+ });
131
+
132
+ it('clamps max to the current count when the live count exceeds the recorded max', async () => {
133
+ const conns = new Map<ConnId, ConnectionState>([
134
+ ['c1', makeConn('c1')],
135
+ ['c2', makeConn('c2')],
136
+ ['c3', makeConn('c3')],
137
+ ]);
138
+ const stats = new InMemoryStats({
139
+ connections: conns,
140
+ channels: new Map(),
141
+ uptimeStartedAt: 0,
142
+ maxLocalConns: 2,
143
+ maxGlobalConns: 2,
144
+ });
145
+
146
+ const snap = await stats.getStats();
147
+
148
+ // Live count (3) > seeded max (2); the snapshot reports the live value
149
+ // so the wire numeric never claims a max below the current count.
150
+ expect(snap.maxLocalConns).toBe(3);
151
+ expect(snap.maxGlobalConns).toBe(3);
152
+ });
153
+
154
+ it('reflects map mutations on subsequent getStats() calls', async () => {
155
+ const conns = new Map<ConnId, ConnectionState>([['c1', makeConn('c1')]]);
156
+ const stats = new InMemoryStats({
157
+ connections: conns,
158
+ channels: new Map(),
159
+ uptimeStartedAt: 0,
160
+ });
161
+
162
+ let snap = await stats.getStats();
163
+ expect(snap.users).toBe(1);
164
+
165
+ conns.set('c2', makeConn('c2'));
166
+ conns.set('c3', makeConn('c3', { invisible: true }));
167
+ snap = await stats.getStats();
168
+ expect(snap.users).toBe(2);
169
+ expect(snap.invisible).toBe(1);
170
+ expect(snap.localConns).toBe(3);
171
+ });
172
+ });
173
+
174
+ describe('computeStatsSnapshot', () => {
175
+ it('counts opers separately from users (an oper still counts as a user)', () => {
176
+ const snap = computeStatsSnapshot([makeConn('c1'), makeConn('c2', { oper: true })], 0, 0);
177
+ expect(snap.users).toBe(2);
178
+ expect(snap.opers).toBe(1);
179
+ });
180
+
181
+ it('counts an oper who is also invisible under invisible + opers (not users)', () => {
182
+ const snap = computeStatsSnapshot([makeConn('c1', { oper: true, invisible: true })], 0, 0);
183
+ expect(snap.invisible).toBe(1);
184
+ expect(snap.opers).toBe(1);
185
+ // users = visible registered (total registered − invisible).
186
+ expect(snap.users).toBe(0);
187
+ });
188
+
189
+ it('reports users as the visible-only count (total registered − invisible)', () => {
190
+ const snap = computeStatsSnapshot(
191
+ [
192
+ makeConn('c1'),
193
+ makeConn('c2'),
194
+ makeConn('c3', { invisible: true }),
195
+ makeConn('c4', { invisible: true }),
196
+ ],
197
+ 0,
198
+ 0,
199
+ );
200
+ expect(snap.users).toBe(2);
201
+ expect(snap.invisible).toBe(2);
202
+ expect(snap.localConns).toBe(4);
203
+ });
204
+
205
+ it('defaults maxConns to the live count when seed omitted', () => {
206
+ const snap = computeStatsSnapshot([makeConn('c1'), makeConn('c2')], 0, 0);
207
+ expect(snap.maxLocalConns).toBe(2);
208
+ expect(snap.maxGlobalConns).toBe(2);
209
+ });
210
+
211
+ it('uses the FakeClock only for uptime (not in the snapshot)', async () => {
212
+ const clock = new FakeClock(99_000);
213
+ const stats = new InMemoryStats({
214
+ connections: new Map(),
215
+ channels: new Map(),
216
+ uptimeStartedAt: 1_000,
217
+ clock,
218
+ });
219
+ const snap = await stats.getStats();
220
+ expect(snap.uptimeStartedAt).toBe(1_000);
221
+ });
222
+ });