serverless-ircd 0.1.0 → 0.3.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 (204) 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/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,721 @@
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 — saslAccounts
293
+ // ============================================================================
294
+
295
+ describe('ServerConfigSchema — saslAccounts', () => {
296
+ it('accepts an array of {username,password} entries', () => {
297
+ const cfg = parseServerConfig({
298
+ serverName: 's',
299
+ networkName: 'n',
300
+ saslAccounts: [
301
+ { username: 'alice', password: 'p1' },
302
+ { username: 'bob', password: 'p2' },
303
+ ],
304
+ });
305
+ expect(cfg.saslAccounts).toHaveLength(2);
306
+ expect(cfg.saslAccounts[0]?.username).toBe('alice');
307
+ expect(cfg.saslAccounts[1]?.password).toBe('p2');
308
+ });
309
+
310
+ it('defaults saslAccounts to an empty array when omitted', () => {
311
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
312
+ expect(cfg.saslAccounts).toEqual([]);
313
+ });
314
+
315
+ it('rejects saslAccounts entries missing username', () => {
316
+ expect(() =>
317
+ parseServerConfig({
318
+ serverName: 's',
319
+ networkName: 'n',
320
+ saslAccounts: [{ password: 'p' }],
321
+ }),
322
+ ).toThrowError(/username/u);
323
+ });
324
+
325
+ it('rejects saslAccounts entries missing password', () => {
326
+ expect(() =>
327
+ parseServerConfig({
328
+ serverName: 's',
329
+ networkName: 'n',
330
+ saslAccounts: [{ username: 'u' }],
331
+ }),
332
+ ).toThrowError(/password/u);
333
+ });
334
+
335
+ it('rejects saslAccounts entries with empty username', () => {
336
+ expect(() =>
337
+ parseServerConfig({
338
+ serverName: 's',
339
+ networkName: 'n',
340
+ saslAccounts: [{ username: '', password: 'p' }],
341
+ }),
342
+ ).toThrowError(/username/u);
343
+ });
344
+
345
+ it('rejects saslAccounts entries with empty password', () => {
346
+ expect(() =>
347
+ parseServerConfig({
348
+ serverName: 's',
349
+ networkName: 'n',
350
+ saslAccounts: [{ username: 'u', password: '' }],
351
+ }),
352
+ ).toThrowError(/password/u);
353
+ });
354
+
355
+ it('rejects a non-array saslAccounts', () => {
356
+ expect(() =>
357
+ parseServerConfig({
358
+ serverName: 's',
359
+ networkName: 'n',
360
+ saslAccounts: { username: 'u', password: 'p' },
361
+ }),
362
+ ).toThrowError(/saslAccounts/u);
363
+ });
364
+ });
365
+
366
+ // ============================================================================
367
+ // ServerConfigSchema — limit knobs
368
+ // ============================================================================
369
+
370
+ describe('ServerConfigSchema — limit knobs', () => {
371
+ it('rejects non-positive maxChannelsPerUser', () => {
372
+ expect(() =>
373
+ parseServerConfig({
374
+ serverName: 's',
375
+ networkName: 'n',
376
+ maxChannelsPerUser: 0,
377
+ }),
378
+ ).toThrowError(/maxChannelsPerUser/u);
379
+ });
380
+
381
+ it('rejects non-positive nickLen', () => {
382
+ expect(() =>
383
+ parseServerConfig({
384
+ serverName: 's',
385
+ networkName: 'n',
386
+ nickLen: 0,
387
+ }),
388
+ ).toThrowError(/nickLen/u);
389
+ });
390
+
391
+ it('rejects negative maxListEntries', () => {
392
+ expect(() =>
393
+ parseServerConfig({
394
+ serverName: 's',
395
+ networkName: 'n',
396
+ maxListEntries: -1,
397
+ }),
398
+ ).toThrowError(/maxListEntries/u);
399
+ });
400
+
401
+ it('accepts zero maxListEntries (disable list entirely)', () => {
402
+ const cfg = parseServerConfig({
403
+ serverName: 's',
404
+ networkName: 'n',
405
+ maxListEntries: 0,
406
+ });
407
+ expect(cfg.maxListEntries).toBe(0);
408
+ });
409
+
410
+ it('rejects negative topicLen', () => {
411
+ expect(() =>
412
+ parseServerConfig({
413
+ serverName: 's',
414
+ networkName: 'n',
415
+ topicLen: -5,
416
+ }),
417
+ ).toThrowError(/topicLen/u);
418
+ });
419
+ });
420
+
421
+ // ============================================================================
422
+ // ServerConfigSchema — failure diagnostics
423
+ // ============================================================================
424
+
425
+ describe('ServerConfigSchema — failure diagnostics', () => {
426
+ it('fails fast at boot with a readable Error mentioning the field', () => {
427
+ try {
428
+ parseServerConfig({
429
+ serverName: 's',
430
+ networkName: 'n',
431
+ maxClients: 'oops',
432
+ });
433
+ expect.fail('expected parseServerConfig to throw');
434
+ } catch (err) {
435
+ expect(err).toBeInstanceOf(Error);
436
+ const msg = (err as Error).message;
437
+ expect(msg).toMatch(/invalid server config/iu);
438
+ expect(msg).toMatch(/maxClients/u);
439
+ }
440
+ });
441
+
442
+ it('aggregates multiple errors into a single readable message', () => {
443
+ try {
444
+ parseServerConfig({
445
+ serverName: '',
446
+ networkName: '',
447
+ maxClients: -3,
448
+ nickLen: 0,
449
+ });
450
+ expect.fail('expected parseServerConfig to throw');
451
+ } catch (err) {
452
+ expect(err).toBeInstanceOf(Error);
453
+ const msg = (err as Error).message;
454
+ // At least two of the bad fields are surfaced.
455
+ const hits = ['serverName', 'networkName', 'maxClients', 'nickLen'].filter((f) =>
456
+ msg.includes(f),
457
+ ).length;
458
+ expect(hits).toBeGreaterThanOrEqual(2);
459
+ }
460
+ });
461
+
462
+ it('rejects a non-object root input', () => {
463
+ expect(() => parseServerConfig('hello')).toThrowError(/invalid server config/iu);
464
+ });
465
+
466
+ it('rejects null', () => {
467
+ expect(() => parseServerConfig(null)).toThrowError(/invalid server config/iu);
468
+ });
469
+
470
+ it('rejects an array as the root config', () => {
471
+ expect(() => parseServerConfig([])).toThrowError(/invalid server config/iu);
472
+ });
473
+ });
474
+
475
+ // ============================================================================
476
+ // ServerConfigSchema — defaults
477
+ // ============================================================================
478
+
479
+ describe('ServerConfigSchema — DEFAULT_SERVER_CONFIG', () => {
480
+ it('is a valid parsed config', () => {
481
+ expect(DEFAULT_SERVER_CONFIG.serverName.length).toBeGreaterThan(0);
482
+ expect(DEFAULT_SERVER_CONFIG.networkName.length).toBeGreaterThan(0);
483
+ expect(Array.isArray(DEFAULT_SERVER_CONFIG.motdLines)).toBe(true);
484
+ expect(DEFAULT_SERVER_CONFIG.channelPrefixes.length).toBeGreaterThan(0);
485
+ expect(DEFAULT_SERVER_CONFIG.maxClients).toBeGreaterThan(0);
486
+ expect(Array.isArray(DEFAULT_SERVER_CONFIG.operCreds)).toBe(true);
487
+ });
488
+
489
+ it('round-trips through the schema unchanged', () => {
490
+ const reparsed = parseServerConfig(DEFAULT_SERVER_CONFIG);
491
+ expect(reparsed).toEqual(DEFAULT_SERVER_CONFIG);
492
+ });
493
+ });
494
+
495
+ // ============================================================================
496
+ // ServerConfigSchema — direct schema usage
497
+ // ============================================================================
498
+
499
+ describe('ServerConfigSchema — direct usage', () => {
500
+ it('safeParse returns {success:true} for a valid input', () => {
501
+ const result = ServerConfigSchema.safeParse({
502
+ serverName: 's',
503
+ networkName: 'n',
504
+ });
505
+ expect(result.success).toBe(true);
506
+ });
507
+
508
+ it('safeParse returns {success:false} for an invalid input', () => {
509
+ const result = ServerConfigSchema.safeParse({ serverName: 1 });
510
+ expect(result.success).toBe(false);
511
+ });
512
+ });
513
+
514
+ // ============================================================================
515
+ // ServerConfigSchema — serverPassword
516
+ // ============================================================================
517
+
518
+ describe('ServerConfigSchema — serverPassword', () => {
519
+ it('defaults serverPassword to undefined when omitted (no password required)', () => {
520
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
521
+ expect(cfg.serverPassword).toBeUndefined();
522
+ });
523
+
524
+ it('accepts a non-empty serverPassword', () => {
525
+ const cfg = parseServerConfig({
526
+ serverName: 's',
527
+ networkName: 'n',
528
+ serverPassword: 's3cret',
529
+ });
530
+ expect(cfg.serverPassword).toBe('s3cret');
531
+ });
532
+
533
+ it('accepts an empty-string serverPassword as "disabled"', () => {
534
+ // Operators set "" to explicitly disable; matches the default.
535
+ const cfg = parseServerConfig({
536
+ serverName: 's',
537
+ networkName: 'n',
538
+ serverPassword: '',
539
+ });
540
+ expect(cfg.serverPassword).toBe('');
541
+ });
542
+
543
+ it('rejects a non-string serverPassword', () => {
544
+ expect(() =>
545
+ parseServerConfig({
546
+ serverName: 's',
547
+ networkName: 'n',
548
+ serverPassword: 42,
549
+ }),
550
+ ).toThrowError(/serverPassword/u);
551
+ });
552
+ });
553
+
554
+ // ============================================================================
555
+ // ServerConfigSchema — cloaking
556
+ // ============================================================================
557
+
558
+ describe('ServerConfigSchema — cloaking', () => {
559
+ it('defaults cloaking to undefined when omitted (no cloaking)', () => {
560
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
561
+ expect(cfg.cloaking).toBeUndefined();
562
+ });
563
+
564
+ it('accepts a valid cloaking config with enabled + secret', () => {
565
+ const cfg = parseServerConfig({
566
+ serverName: 's',
567
+ networkName: 'n',
568
+ cloaking: { enabled: true, secret: 'hunter2' },
569
+ });
570
+ expect(cfg.cloaking).toEqual({ enabled: true, secret: 'hunter2' });
571
+ });
572
+
573
+ it('accepts a custom cloakedSuffix', () => {
574
+ const cfg = parseServerConfig({
575
+ serverName: 's',
576
+ networkName: 'n',
577
+ cloaking: { enabled: true, secret: 'k', cloakedSuffix: 'hidden.lan' },
578
+ });
579
+ expect(cfg.cloaking?.cloakedSuffix).toBe('hidden.lan');
580
+ });
581
+
582
+ it('rejects a cloaking config missing secret', () => {
583
+ expect(() =>
584
+ parseServerConfig({
585
+ serverName: 's',
586
+ networkName: 'n',
587
+ cloaking: { enabled: true },
588
+ }),
589
+ ).toThrowError(/secret/u);
590
+ });
591
+
592
+ it('rejects a cloaking config with empty secret when enabled', () => {
593
+ expect(() =>
594
+ parseServerConfig({
595
+ serverName: 's',
596
+ networkName: 'n',
597
+ cloaking: { enabled: true, secret: '' },
598
+ }),
599
+ ).toThrowError(/secret/u);
600
+ });
601
+
602
+ it('rejects a non-object cloaking value', () => {
603
+ expect(() =>
604
+ parseServerConfig({
605
+ serverName: 's',
606
+ networkName: 'n',
607
+ cloaking: 'yes',
608
+ }),
609
+ ).toThrowError(/cloaking/u);
610
+ });
611
+ });
612
+
613
+ // ============================================================================
614
+ // ServerConfigSchema — maxConnectionsPerIp / maxConnectionsPerUser
615
+ // ============================================================================
616
+
617
+ describe('ServerConfigSchema — per-IP / per-user connection caps', () => {
618
+ it('defaults maxConnectionsPerIp to a positive integer when omitted', () => {
619
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
620
+ expect(cfg.maxConnectionsPerIp).toBeGreaterThan(0);
621
+ expect(Number.isInteger(cfg.maxConnectionsPerIp)).toBe(true);
622
+ });
623
+
624
+ it('defaults maxConnectionsPerUser to a positive integer when omitted', () => {
625
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
626
+ expect(cfg.maxConnectionsPerUser).toBeGreaterThan(0);
627
+ expect(Number.isInteger(cfg.maxConnectionsPerUser)).toBe(true);
628
+ });
629
+
630
+ it('accepts explicit positive integers for both', () => {
631
+ const cfg = parseServerConfig({
632
+ serverName: 's',
633
+ networkName: 'n',
634
+ maxConnectionsPerIp: 5,
635
+ maxConnectionsPerUser: 10,
636
+ });
637
+ expect(cfg.maxConnectionsPerIp).toBe(5);
638
+ expect(cfg.maxConnectionsPerUser).toBe(10);
639
+ });
640
+
641
+ it('rejects zero maxConnectionsPerIp', () => {
642
+ expect(() =>
643
+ parseServerConfig({
644
+ serverName: 's',
645
+ networkName: 'n',
646
+ maxConnectionsPerIp: 0,
647
+ }),
648
+ ).toThrowError(/maxConnectionsPerIp/u);
649
+ });
650
+
651
+ it('rejects negative maxConnectionsPerIp', () => {
652
+ expect(() =>
653
+ parseServerConfig({
654
+ serverName: 's',
655
+ networkName: 'n',
656
+ maxConnectionsPerIp: -1,
657
+ }),
658
+ ).toThrowError(/maxConnectionsPerIp/u);
659
+ });
660
+
661
+ it('rejects zero maxConnectionsPerUser', () => {
662
+ expect(() =>
663
+ parseServerConfig({
664
+ serverName: 's',
665
+ networkName: 'n',
666
+ maxConnectionsPerUser: 0,
667
+ }),
668
+ ).toThrowError(/maxConnectionsPerUser/u);
669
+ });
670
+ });
671
+
672
+ // ============================================================================
673
+ // ServerConfigSchema — per-IP connection-rate limit
674
+ // ============================================================================
675
+
676
+ describe('ServerConfigSchema — perIpConnectionRate', () => {
677
+ it('defaults to a positive rate cap when omitted', () => {
678
+ const cfg = parseServerConfig({ serverName: 's', networkName: 'n' });
679
+ expect(cfg.perIpConnectionRate.max).toBeGreaterThan(0);
680
+ expect(cfg.perIpConnectionRate.windowMs).toBeGreaterThan(0);
681
+ });
682
+
683
+ it('accepts a valid rate limit', () => {
684
+ const cfg = parseServerConfig({
685
+ serverName: 's',
686
+ networkName: 'n',
687
+ perIpConnectionRate: { max: 3, windowMs: 10_000 },
688
+ });
689
+ expect(cfg.perIpConnectionRate).toEqual({ max: 3, windowMs: 10_000 });
690
+ });
691
+
692
+ it('rejects zero max', () => {
693
+ expect(() =>
694
+ parseServerConfig({
695
+ serverName: 's',
696
+ networkName: 'n',
697
+ perIpConnectionRate: { max: 0, windowMs: 10_000 },
698
+ }),
699
+ ).toThrowError(/max/u);
700
+ });
701
+
702
+ it('rejects zero windowMs', () => {
703
+ expect(() =>
704
+ parseServerConfig({
705
+ serverName: 's',
706
+ networkName: 'n',
707
+ perIpConnectionRate: { max: 3, windowMs: 0 },
708
+ }),
709
+ ).toThrowError(/windowMs/u);
710
+ });
711
+
712
+ it('rejects a non-object value', () => {
713
+ expect(() =>
714
+ parseServerConfig({
715
+ serverName: 's',
716
+ networkName: 'n',
717
+ perIpConnectionRate: 'fast',
718
+ }),
719
+ ).toThrowError(/perIpConnectionRate/u);
720
+ });
721
+ });