serverless-ircd 0.1.0 → 0.2.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 (198) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,646 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DEFAULT_SERVER_CONFIG, ServerConfigSchema, parseServerConfig } from '../src/config';
3
+
4
+ // ============================================================================
5
+ // ServerConfigSchema — valid inputs
6
+ // ============================================================================
7
+
8
+ describe('ServerConfigSchema — valid inputs', () => {
9
+ it('parses a complete valid config', () => {
10
+ const cfg = parseServerConfig({
11
+ serverName: 'irc.example.com',
12
+ networkName: 'ExampleNet',
13
+ motdLines: ['Welcome.', 'Have fun.'],
14
+ channelPrefixes: '#&+',
15
+ maxClients: 5000,
16
+ operCreds: [{ user: 'admin', password: 's3cret' }],
17
+ maxChannelsPerUser: 30,
18
+ maxTargetsPerCommand: 10,
19
+ maxListEntries: 50,
20
+ nickLen: 30,
21
+ channelLen: 50,
22
+ topicLen: 390,
23
+ quitMessage: 'Client Quit',
24
+ });
25
+ expect(cfg.serverName).toBe('irc.example.com');
26
+ expect(cfg.networkName).toBe('ExampleNet');
27
+ expect(cfg.motdLines).toEqual(['Welcome.', 'Have fun.']);
28
+ expect(cfg.channelPrefixes).toBe('#&+');
29
+ expect(cfg.maxClients).toBe(5000);
30
+ expect(cfg.operCreds).toEqual([{ user: 'admin', password: 's3cret' }]);
31
+ expect(cfg.maxChannelsPerUser).toBe(30);
32
+ expect(cfg.maxTargetsPerCommand).toBe(10);
33
+ expect(cfg.maxListEntries).toBe(50);
34
+ expect(cfg.nickLen).toBe(30);
35
+ expect(cfg.channelLen).toBe(50);
36
+ expect(cfg.topicLen).toBe(390);
37
+ expect(cfg.quitMessage).toBe('Client Quit');
38
+ });
39
+
40
+ it('parses a minimal config (serverName + networkName), applying defaults', () => {
41
+ const cfg = parseServerConfig({
42
+ serverName: 'irc.example.com',
43
+ networkName: 'ExampleNet',
44
+ });
45
+ expect(cfg.motdLines).toEqual([]);
46
+ expect(cfg.channelPrefixes).toBe('#');
47
+ expect(cfg.maxClients).toBeGreaterThan(0);
48
+ expect(cfg.operCreds).toEqual([]);
49
+ expect(cfg.maxChannelsPerUser).toBeGreaterThan(0);
50
+ expect(cfg.maxTargetsPerCommand).toBeGreaterThan(0);
51
+ expect(cfg.maxListEntries).toBeGreaterThanOrEqual(0);
52
+ expect(cfg.nickLen).toBeGreaterThan(0);
53
+ expect(cfg.channelLen).toBeGreaterThan(0);
54
+ expect(cfg.topicLen).toBeGreaterThanOrEqual(0);
55
+ expect(cfg.quitMessage.length).toBeGreaterThan(0);
56
+ });
57
+ });
58
+
59
+ // ============================================================================
60
+ // ServerConfigSchema — required fields
61
+ // ============================================================================
62
+
63
+ describe('ServerConfigSchema — required fields', () => {
64
+ it('rejects a config missing serverName', () => {
65
+ expect(() => parseServerConfig({ networkName: 'ExampleNet' })).toThrowError(/serverName/u);
66
+ });
67
+
68
+ it('rejects a config missing networkName', () => {
69
+ expect(() => parseServerConfig({ serverName: 'irc.example.com' })).toThrowError(/networkName/u);
70
+ });
71
+
72
+ it('rejects an empty serverName', () => {
73
+ expect(() => parseServerConfig({ serverName: '', networkName: 'ExampleNet' })).toThrowError(
74
+ /serverName/u,
75
+ );
76
+ });
77
+
78
+ it('rejects an empty networkName', () => {
79
+ expect(() =>
80
+ parseServerConfig({ serverName: 'irc.example.com', networkName: '' }),
81
+ ).toThrowError(/networkName/u);
82
+ });
83
+ });
84
+
85
+ // ============================================================================
86
+ // ServerConfigSchema — MOTD
87
+ // ============================================================================
88
+
89
+ describe('ServerConfigSchema — MOTD', () => {
90
+ it('accepts a non-empty motdLines array', () => {
91
+ const cfg = parseServerConfig({
92
+ serverName: 's',
93
+ networkName: 'n',
94
+ motdLines: ['line 1', 'line 2', ''],
95
+ });
96
+ expect(cfg.motdLines).toEqual(['line 1', 'line 2', '']);
97
+ });
98
+
99
+ it('defaults motdLines to an empty array when omitted', () => {
100
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
101
+ expect(cfg.motdLines).toEqual([]);
102
+ });
103
+
104
+ it('rejects a non-array motdLines', () => {
105
+ expect(() =>
106
+ parseServerConfig({
107
+ serverName: 's',
108
+ networkName: 'n',
109
+ motdLines: 'welcome',
110
+ }),
111
+ ).toThrowError(/motdLines/u);
112
+ });
113
+
114
+ it('rejects motdLines with non-string entries', () => {
115
+ expect(() =>
116
+ parseServerConfig({
117
+ serverName: 's',
118
+ networkName: 'n',
119
+ motdLines: ['ok', 42],
120
+ }),
121
+ ).toThrowError(/motdLines/u);
122
+ });
123
+ });
124
+
125
+ // ============================================================================
126
+ // ServerConfigSchema — channel prefixes
127
+ // ============================================================================
128
+
129
+ describe('ServerConfigSchema — channel prefixes', () => {
130
+ it('accepts a non-empty channelPrefixes string', () => {
131
+ const cfg = parseServerConfig({
132
+ serverName: 's',
133
+ networkName: 'n',
134
+ channelPrefixes: '#&',
135
+ });
136
+ expect(cfg.channelPrefixes).toBe('#&');
137
+ });
138
+
139
+ it('defaults channelPrefixes to "#" when omitted', () => {
140
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
141
+ expect(cfg.channelPrefixes).toBe('#');
142
+ });
143
+
144
+ it('rejects an empty channelPrefixes string', () => {
145
+ expect(() =>
146
+ parseServerConfig({
147
+ serverName: 's',
148
+ networkName: 'n',
149
+ channelPrefixes: '',
150
+ }),
151
+ ).toThrowError(/channelPrefixes/u);
152
+ });
153
+ });
154
+
155
+ // ============================================================================
156
+ // ServerConfigSchema — maxClients
157
+ // ============================================================================
158
+
159
+ describe('ServerConfigSchema — maxClients', () => {
160
+ it('accepts a positive integer', () => {
161
+ const cfg = parseServerConfig({
162
+ serverName: 's',
163
+ networkName: 'n',
164
+ maxClients: 12345,
165
+ });
166
+ expect(cfg.maxClients).toBe(12345);
167
+ });
168
+
169
+ it('defaults maxClients when omitted', () => {
170
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
171
+ expect(cfg.maxClients).toBeGreaterThan(0);
172
+ expect(Number.isInteger(cfg.maxClients)).toBe(true);
173
+ });
174
+
175
+ it('rejects zero', () => {
176
+ expect(() =>
177
+ parseServerConfig({
178
+ serverName: 's',
179
+ networkName: 'n',
180
+ maxClients: 0,
181
+ }),
182
+ ).toThrowError(/maxClients/u);
183
+ });
184
+
185
+ it('rejects a negative value', () => {
186
+ expect(() =>
187
+ parseServerConfig({
188
+ serverName: 's',
189
+ networkName: 'n',
190
+ maxClients: -1,
191
+ }),
192
+ ).toThrowError(/maxClients/u);
193
+ });
194
+
195
+ it('rejects a non-integer', () => {
196
+ expect(() =>
197
+ parseServerConfig({
198
+ serverName: 's',
199
+ networkName: 'n',
200
+ maxClients: 1.5,
201
+ }),
202
+ ).toThrowError(/maxClients/u);
203
+ });
204
+
205
+ it('rejects a non-number', () => {
206
+ expect(() =>
207
+ parseServerConfig({
208
+ serverName: 's',
209
+ networkName: 'n',
210
+ maxClients: 'many',
211
+ }),
212
+ ).toThrowError(/maxClients/u);
213
+ });
214
+ });
215
+
216
+ // ============================================================================
217
+ // ServerConfigSchema — operCreds
218
+ // ============================================================================
219
+
220
+ describe('ServerConfigSchema — operCreds', () => {
221
+ it('accepts an array of {user,password} entries', () => {
222
+ const cfg = parseServerConfig({
223
+ serverName: 's',
224
+ networkName: 'n',
225
+ operCreds: [
226
+ { user: 'op1', password: 'p1' },
227
+ { user: 'op2', password: 'p2' },
228
+ ],
229
+ });
230
+ expect(cfg.operCreds).toHaveLength(2);
231
+ expect(cfg.operCreds[0]?.user).toBe('op1');
232
+ expect(cfg.operCreds[1]?.password).toBe('p2');
233
+ });
234
+
235
+ it('defaults operCreds to an empty array when omitted', () => {
236
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
237
+ expect(cfg.operCreds).toEqual([]);
238
+ });
239
+
240
+ it('rejects operCreds entries missing user', () => {
241
+ expect(() =>
242
+ parseServerConfig({
243
+ serverName: 's',
244
+ networkName: 'n',
245
+ operCreds: [{ password: 'p' }],
246
+ }),
247
+ ).toThrowError(/user/u);
248
+ });
249
+
250
+ it('rejects operCreds entries missing password', () => {
251
+ expect(() =>
252
+ parseServerConfig({
253
+ serverName: 's',
254
+ networkName: 'n',
255
+ operCreds: [{ user: 'op' }],
256
+ }),
257
+ ).toThrowError(/password/u);
258
+ });
259
+
260
+ it('rejects operCreds entries with empty user', () => {
261
+ expect(() =>
262
+ parseServerConfig({
263
+ serverName: 's',
264
+ networkName: 'n',
265
+ operCreds: [{ user: '', password: 'p' }],
266
+ }),
267
+ ).toThrowError(/user/u);
268
+ });
269
+
270
+ it('rejects operCreds entries with empty password', () => {
271
+ expect(() =>
272
+ parseServerConfig({
273
+ serverName: 's',
274
+ networkName: 'n',
275
+ operCreds: [{ user: 'op', password: '' }],
276
+ }),
277
+ ).toThrowError(/password/u);
278
+ });
279
+
280
+ it('rejects a non-array operCreds', () => {
281
+ expect(() =>
282
+ parseServerConfig({
283
+ serverName: 's',
284
+ networkName: 'n',
285
+ operCreds: { user: 'op', password: 'p' },
286
+ }),
287
+ ).toThrowError(/operCreds/u);
288
+ });
289
+ });
290
+
291
+ // ============================================================================
292
+ // ServerConfigSchema — limit knobs
293
+ // ============================================================================
294
+
295
+ describe('ServerConfigSchema — limit knobs', () => {
296
+ it('rejects non-positive maxChannelsPerUser', () => {
297
+ expect(() =>
298
+ parseServerConfig({
299
+ serverName: 's',
300
+ networkName: 'n',
301
+ maxChannelsPerUser: 0,
302
+ }),
303
+ ).toThrowError(/maxChannelsPerUser/u);
304
+ });
305
+
306
+ it('rejects non-positive nickLen', () => {
307
+ expect(() =>
308
+ parseServerConfig({
309
+ serverName: 's',
310
+ networkName: 'n',
311
+ nickLen: 0,
312
+ }),
313
+ ).toThrowError(/nickLen/u);
314
+ });
315
+
316
+ it('rejects negative maxListEntries', () => {
317
+ expect(() =>
318
+ parseServerConfig({
319
+ serverName: 's',
320
+ networkName: 'n',
321
+ maxListEntries: -1,
322
+ }),
323
+ ).toThrowError(/maxListEntries/u);
324
+ });
325
+
326
+ it('accepts zero maxListEntries (disable list entirely)', () => {
327
+ const cfg = parseServerConfig({
328
+ serverName: 's',
329
+ networkName: 'n',
330
+ maxListEntries: 0,
331
+ });
332
+ expect(cfg.maxListEntries).toBe(0);
333
+ });
334
+
335
+ it('rejects negative topicLen', () => {
336
+ expect(() =>
337
+ parseServerConfig({
338
+ serverName: 's',
339
+ networkName: 'n',
340
+ topicLen: -5,
341
+ }),
342
+ ).toThrowError(/topicLen/u);
343
+ });
344
+ });
345
+
346
+ // ============================================================================
347
+ // ServerConfigSchema — failure diagnostics
348
+ // ============================================================================
349
+
350
+ describe('ServerConfigSchema — failure diagnostics', () => {
351
+ it('fails fast at boot with a readable Error mentioning the field', () => {
352
+ try {
353
+ parseServerConfig({
354
+ serverName: 's',
355
+ networkName: 'n',
356
+ maxClients: 'oops',
357
+ });
358
+ expect.fail('expected parseServerConfig to throw');
359
+ } catch (err) {
360
+ expect(err).toBeInstanceOf(Error);
361
+ const msg = (err as Error).message;
362
+ expect(msg).toMatch(/invalid server config/iu);
363
+ expect(msg).toMatch(/maxClients/u);
364
+ }
365
+ });
366
+
367
+ it('aggregates multiple errors into a single readable message', () => {
368
+ try {
369
+ parseServerConfig({
370
+ serverName: '',
371
+ networkName: '',
372
+ maxClients: -3,
373
+ nickLen: 0,
374
+ });
375
+ expect.fail('expected parseServerConfig to throw');
376
+ } catch (err) {
377
+ expect(err).toBeInstanceOf(Error);
378
+ const msg = (err as Error).message;
379
+ // At least two of the bad fields are surfaced.
380
+ const hits = ['serverName', 'networkName', 'maxClients', 'nickLen'].filter((f) =>
381
+ msg.includes(f),
382
+ ).length;
383
+ expect(hits).toBeGreaterThanOrEqual(2);
384
+ }
385
+ });
386
+
387
+ it('rejects a non-object root input', () => {
388
+ expect(() => parseServerConfig('hello')).toThrowError(/invalid server config/iu);
389
+ });
390
+
391
+ it('rejects null', () => {
392
+ expect(() => parseServerConfig(null)).toThrowError(/invalid server config/iu);
393
+ });
394
+
395
+ it('rejects an array as the root config', () => {
396
+ expect(() => parseServerConfig([])).toThrowError(/invalid server config/iu);
397
+ });
398
+ });
399
+
400
+ // ============================================================================
401
+ // ServerConfigSchema — defaults
402
+ // ============================================================================
403
+
404
+ describe('ServerConfigSchema — DEFAULT_SERVER_CONFIG', () => {
405
+ it('is a valid parsed config', () => {
406
+ expect(DEFAULT_SERVER_CONFIG.serverName.length).toBeGreaterThan(0);
407
+ expect(DEFAULT_SERVER_CONFIG.networkName.length).toBeGreaterThan(0);
408
+ expect(Array.isArray(DEFAULT_SERVER_CONFIG.motdLines)).toBe(true);
409
+ expect(DEFAULT_SERVER_CONFIG.channelPrefixes.length).toBeGreaterThan(0);
410
+ expect(DEFAULT_SERVER_CONFIG.maxClients).toBeGreaterThan(0);
411
+ expect(Array.isArray(DEFAULT_SERVER_CONFIG.operCreds)).toBe(true);
412
+ });
413
+
414
+ it('round-trips through the schema unchanged', () => {
415
+ const reparsed = parseServerConfig(DEFAULT_SERVER_CONFIG);
416
+ expect(reparsed).toEqual(DEFAULT_SERVER_CONFIG);
417
+ });
418
+ });
419
+
420
+ // ============================================================================
421
+ // ServerConfigSchema — direct schema usage
422
+ // ============================================================================
423
+
424
+ describe('ServerConfigSchema — direct usage', () => {
425
+ it('safeParse returns {success:true} for a valid input', () => {
426
+ const result = ServerConfigSchema.safeParse({
427
+ serverName: 's',
428
+ networkName: 'n',
429
+ });
430
+ expect(result.success).toBe(true);
431
+ });
432
+
433
+ it('safeParse returns {success:false} for an invalid input', () => {
434
+ const result = ServerConfigSchema.safeParse({ serverName: 1 });
435
+ expect(result.success).toBe(false);
436
+ });
437
+ });
438
+
439
+ // ============================================================================
440
+ // ServerConfigSchema — serverPassword
441
+ // ============================================================================
442
+
443
+ describe('ServerConfigSchema — serverPassword', () => {
444
+ it('defaults serverPassword to undefined when omitted (no password required)', () => {
445
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
446
+ expect(cfg.serverPassword).toBeUndefined();
447
+ });
448
+
449
+ it('accepts a non-empty serverPassword', () => {
450
+ const cfg = parseServerConfig({
451
+ serverName: 's',
452
+ networkName: 'n',
453
+ serverPassword: 's3cret',
454
+ });
455
+ expect(cfg.serverPassword).toBe('s3cret');
456
+ });
457
+
458
+ it('accepts an empty-string serverPassword as "disabled"', () => {
459
+ // Operators set "" to explicitly disable; matches the default.
460
+ const cfg = parseServerConfig({
461
+ serverName: 's',
462
+ networkName: 'n',
463
+ serverPassword: '',
464
+ });
465
+ expect(cfg.serverPassword).toBe('');
466
+ });
467
+
468
+ it('rejects a non-string serverPassword', () => {
469
+ expect(() =>
470
+ parseServerConfig({
471
+ serverName: 's',
472
+ networkName: 'n',
473
+ serverPassword: 42,
474
+ }),
475
+ ).toThrowError(/serverPassword/u);
476
+ });
477
+ });
478
+
479
+ // ============================================================================
480
+ // ServerConfigSchema — cloaking
481
+ // ============================================================================
482
+
483
+ describe('ServerConfigSchema — cloaking', () => {
484
+ it('defaults cloaking to undefined when omitted (no cloaking)', () => {
485
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
486
+ expect(cfg.cloaking).toBeUndefined();
487
+ });
488
+
489
+ it('accepts a valid cloaking config with enabled + secret', () => {
490
+ const cfg = parseServerConfig({
491
+ serverName: 's',
492
+ networkName: 'n',
493
+ cloaking: { enabled: true, secret: 'hunter2' },
494
+ });
495
+ expect(cfg.cloaking).toEqual({ enabled: true, secret: 'hunter2' });
496
+ });
497
+
498
+ it('accepts a custom cloakedSuffix', () => {
499
+ const cfg = parseServerConfig({
500
+ serverName: 's',
501
+ networkName: 'n',
502
+ cloaking: { enabled: true, secret: 'k', cloakedSuffix: 'hidden.lan' },
503
+ });
504
+ expect(cfg.cloaking?.cloakedSuffix).toBe('hidden.lan');
505
+ });
506
+
507
+ it('rejects a cloaking config missing secret', () => {
508
+ expect(() =>
509
+ parseServerConfig({
510
+ serverName: 's',
511
+ networkName: 'n',
512
+ cloaking: { enabled: true },
513
+ }),
514
+ ).toThrowError(/secret/u);
515
+ });
516
+
517
+ it('rejects a cloaking config with empty secret when enabled', () => {
518
+ expect(() =>
519
+ parseServerConfig({
520
+ serverName: 's',
521
+ networkName: 'n',
522
+ cloaking: { enabled: true, secret: '' },
523
+ }),
524
+ ).toThrowError(/secret/u);
525
+ });
526
+
527
+ it('rejects a non-object cloaking value', () => {
528
+ expect(() =>
529
+ parseServerConfig({
530
+ serverName: 's',
531
+ networkName: 'n',
532
+ cloaking: 'yes',
533
+ }),
534
+ ).toThrowError(/cloaking/u);
535
+ });
536
+ });
537
+
538
+ // ============================================================================
539
+ // ServerConfigSchema — maxConnectionsPerIp / maxConnectionsPerUser
540
+ // ============================================================================
541
+
542
+ describe('ServerConfigSchema — per-IP / per-user connection caps', () => {
543
+ it('defaults maxConnectionsPerIp to a positive integer when omitted', () => {
544
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
545
+ expect(cfg.maxConnectionsPerIp).toBeGreaterThan(0);
546
+ expect(Number.isInteger(cfg.maxConnectionsPerIp)).toBe(true);
547
+ });
548
+
549
+ it('defaults maxConnectionsPerUser to a positive integer when omitted', () => {
550
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
551
+ expect(cfg.maxConnectionsPerUser).toBeGreaterThan(0);
552
+ expect(Number.isInteger(cfg.maxConnectionsPerUser)).toBe(true);
553
+ });
554
+
555
+ it('accepts explicit positive integers for both', () => {
556
+ const cfg = parseServerConfig({
557
+ serverName: 's',
558
+ networkName: 'n',
559
+ maxConnectionsPerIp: 5,
560
+ maxConnectionsPerUser: 10,
561
+ });
562
+ expect(cfg.maxConnectionsPerIp).toBe(5);
563
+ expect(cfg.maxConnectionsPerUser).toBe(10);
564
+ });
565
+
566
+ it('rejects zero maxConnectionsPerIp', () => {
567
+ expect(() =>
568
+ parseServerConfig({
569
+ serverName: 's',
570
+ networkName: 'n',
571
+ maxConnectionsPerIp: 0,
572
+ }),
573
+ ).toThrowError(/maxConnectionsPerIp/u);
574
+ });
575
+
576
+ it('rejects negative maxConnectionsPerIp', () => {
577
+ expect(() =>
578
+ parseServerConfig({
579
+ serverName: 's',
580
+ networkName: 'n',
581
+ maxConnectionsPerIp: -1,
582
+ }),
583
+ ).toThrowError(/maxConnectionsPerIp/u);
584
+ });
585
+
586
+ it('rejects zero maxConnectionsPerUser', () => {
587
+ expect(() =>
588
+ parseServerConfig({
589
+ serverName: 's',
590
+ networkName: 'n',
591
+ maxConnectionsPerUser: 0,
592
+ }),
593
+ ).toThrowError(/maxConnectionsPerUser/u);
594
+ });
595
+ });
596
+
597
+ // ============================================================================
598
+ // ServerConfigSchema — per-IP connection-rate limit
599
+ // ============================================================================
600
+
601
+ describe('ServerConfigSchema — perIpConnectionRate', () => {
602
+ it('defaults to a positive rate cap when omitted', () => {
603
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
604
+ expect(cfg.perIpConnectionRate.max).toBeGreaterThan(0);
605
+ expect(cfg.perIpConnectionRate.windowMs).toBeGreaterThan(0);
606
+ });
607
+
608
+ it('accepts a valid rate limit', () => {
609
+ const cfg = parseServerConfig({
610
+ serverName: 's',
611
+ networkName: 'n',
612
+ perIpConnectionRate: { max: 3, windowMs: 10_000 },
613
+ });
614
+ expect(cfg.perIpConnectionRate).toEqual({ max: 3, windowMs: 10_000 });
615
+ });
616
+
617
+ it('rejects zero max', () => {
618
+ expect(() =>
619
+ parseServerConfig({
620
+ serverName: 's',
621
+ networkName: 'n',
622
+ perIpConnectionRate: { max: 0, windowMs: 10_000 },
623
+ }),
624
+ ).toThrowError(/max/u);
625
+ });
626
+
627
+ it('rejects zero windowMs', () => {
628
+ expect(() =>
629
+ parseServerConfig({
630
+ serverName: 's',
631
+ networkName: 'n',
632
+ perIpConnectionRate: { max: 3, windowMs: 0 },
633
+ }),
634
+ ).toThrowError(/windowMs/u);
635
+ });
636
+
637
+ it('rejects a non-object value', () => {
638
+ expect(() =>
639
+ parseServerConfig({
640
+ serverName: 's',
641
+ networkName: 'n',
642
+ perIpConnectionRate: 'fast',
643
+ }),
644
+ ).toThrowError(/perIpConnectionRate/u);
645
+ });
646
+ });