serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -161,6 +161,49 @@ describe('config-loader — container-specific knobs', () => {
161
161
  expect(cfg.server.operCreds).toEqual([{ user: 'admin', password: 'secret' }]);
162
162
  });
163
163
 
164
+ it('exercises the OPER_PASSWORD ?? "" default then rejects partial OPER_USER-only creds', () => {
165
+ // The `?? ''` default for the missing OPER_PASSWORD is exercised, but
166
+ // parseServerConfig then rejects the empty password — partial oper
167
+ // creds are a misconfiguration, not a silently-accepted one.
168
+ expect(() =>
169
+ loadContainerConfig({
170
+ SERVER_NAME: 'irc.test',
171
+ NETWORK_NAME: 'TestNet',
172
+ OPER_USER: 'admin',
173
+ }),
174
+ ).toThrowError(/password/u);
175
+ });
176
+
177
+ it('exercises the OPER_USER ?? "" default then rejects partial OPER_PASSWORD-only creds', () => {
178
+ expect(() =>
179
+ loadContainerConfig({
180
+ SERVER_NAME: 'irc.test',
181
+ NETWORK_NAME: 'TestNet',
182
+ OPER_PASSWORD: 'secret',
183
+ }),
184
+ ).toThrowError(/user/u);
185
+ });
186
+
187
+ it('treats an overflow-numeric CREATED_AT (Infinity) as a verbatim string', () => {
188
+ const cfg = loadContainerConfig({
189
+ SERVER_NAME: 'irc.test',
190
+ NETWORK_NAME: 'TestNet',
191
+ // 400 nines overflows Number.MAX_VALUE → Number() yields Infinity,
192
+ // which is not finite, so parseCreatedAt falls through to the raw string.
193
+ CREATED_AT: '9'.repeat(400),
194
+ });
195
+ expect(cfg.server.createdAt).toBe('9'.repeat(400));
196
+ });
197
+
198
+ it('skips a SASL line whose username is set but password is empty', () => {
199
+ const cfg = loadContainerConfig({
200
+ SERVER_NAME: 'irc.test',
201
+ NETWORK_NAME: 'TestNet',
202
+ SASL_ACCOUNTS: 'alice:\nbob:valid',
203
+ });
204
+ expect(cfg.server.saslAccounts).toEqual([{ username: 'bob', password: 'valid' }]);
205
+ });
206
+
164
207
  it('skips malformed SASL account lines', () => {
165
208
  const cfg = loadContainerConfig({
166
209
  SERVER_NAME: 'irc.test',
@@ -128,6 +128,58 @@ describe('container-server — lifecycle', () => {
128
128
  });
129
129
  });
130
130
 
131
+ describe('container-server — option wiring (coverage matrix)', () => {
132
+ // Exercises the `opts.X ?? default` / `opts.X !== undefined ? ... : ...`
133
+ // branches in startContainerServer by starting the server with each optional
134
+ // knob set, so every "option provided" branch is taken at least once.
135
+
136
+ it('binds the default host when `host` is omitted', async () => {
137
+ const srv = await startContainerServer({
138
+ port: 0,
139
+ serverName: 'irc.defaulthost',
140
+ networkName: 'DefaultHostNet',
141
+ });
142
+ expect(srv.host).toBe('0.0.0.0');
143
+ // Connecting via 127.0.0.1 still reaches the 0.0.0.0 listener.
144
+ const c = new TcpClient('127.0.0.1', srv.port);
145
+ await c.opened();
146
+ await c.close();
147
+ await srv.close();
148
+ });
149
+
150
+ it('applies the default admission knobs when only maxConnectionsPerUser is set', async () => {
151
+ // Setting only maxConnectionsPerUser builds the admission config, so
152
+ // maxConnectionsPerIp and perIpConnectionRate fall back to their defaults.
153
+ const srv = await startContainerServer({
154
+ port: 0,
155
+ host: '127.0.0.1',
156
+ serverName: 'irc.admission',
157
+ networkName: 'AdmissionNet',
158
+ maxConnectionsPerUser: 5,
159
+ });
160
+ const c = new TcpClient('127.0.0.1', srv.port);
161
+ await c.opened();
162
+ await c.close();
163
+ await srv.close();
164
+ });
165
+
166
+ it('wires saslAccounts, serverPassword, and cloaking when provided', async () => {
167
+ const srv = await startContainerServer({
168
+ port: 0,
169
+ host: '127.0.0.1',
170
+ serverName: 'irc.options',
171
+ networkName: 'OptionsNet',
172
+ serverPassword: 'secret',
173
+ cloaking: { enabled: true, secret: 'cloak-secret', cloakedSuffix: '.hidden' },
174
+ saslAccounts: [{ username: 'alice', password: 'pw' }],
175
+ });
176
+ const c = new TcpClient('127.0.0.1', srv.port);
177
+ await c.opened();
178
+ await c.close();
179
+ await srv.close();
180
+ });
181
+ });
182
+
131
183
  describe('container-server — IRC flows over TCP', () => {
132
184
  let server: ContainerServer;
133
185
 
@@ -329,7 +381,6 @@ describe('container-server — snapshot error resilience', () => {
329
381
  const path = join(dir, 'corrupt.json');
330
382
  const { writeFile } = await import('node:fs/promises');
331
383
  await writeFile(path, '{ this is not valid json', 'utf8');
332
-
333
384
  const srv = await startContainerServer({
334
385
  port: 0,
335
386
  host: '127.0.0.1',
@@ -373,6 +424,61 @@ describe('container-server — snapshot error resilience', () => {
373
424
  expect(c.socket.destroyed).toBe(true);
374
425
  await rm(dir, { recursive: true, force: true });
375
426
  });
427
+
428
+ it('close() skips sockets that are already destroyed', async () => {
429
+ // Covers closeServer's `!socket.destroyed` false branch: a server-side
430
+ // socket destroyed in the same tick as close() is still in the sockets
431
+ // set (its 'close' cleanup is async) but already destroyed.
432
+ const srv = await startContainerServer({
433
+ port: 0,
434
+ host: '127.0.0.1',
435
+ serverName: 'irc.skip',
436
+ networkName: 'SkipNet',
437
+ });
438
+ const c = new TcpClient('127.0.0.1', srv.port);
439
+ await c.opened();
440
+ for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
441
+ await new Promise((r) => setTimeout(r, 5));
442
+ }
443
+ const serverSideSocket = [...srv.testSockets][0];
444
+ if (serverSideSocket === undefined) throw new Error('no server-side socket');
445
+ // Destroy the server-side socket synchronously; its 'close' handler
446
+ // (which would remove it from the set) fires on a later tick.
447
+ serverSideSocket.destroy();
448
+ await expect(srv.close()).resolves.toBeUndefined();
449
+ await new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
450
+ });
451
+
452
+ it('logs and resolves when the final snapshot save fails on close', async () => {
453
+ // Pointing persistencePath at an existing directory makes the boot-time
454
+ // load fail (EISDIR → caught → start fresh) AND the close-time save fail
455
+ // (writeFile on a directory → EISDIR → caught at the closeServer catch).
456
+ const dir = await mkdtemp(join(tmpdir(), 'sirc-finalfail-'));
457
+ const { mkdir } = await import('node:fs/promises');
458
+ const dirPath = join(dir, 'snap-target-dir');
459
+ await mkdir(dirPath);
460
+
461
+ const srv = await startContainerServer({
462
+ port: 0,
463
+ host: '127.0.0.1',
464
+ serverName: 'irc.finalfail',
465
+ networkName: 'FinalFailNet',
466
+ persistencePath: dirPath,
467
+ snapshotIntervalMs: 999_999,
468
+ });
469
+ const c = new TcpClient('127.0.0.1', srv.port);
470
+ await c.opened();
471
+ await c.send('NICK finalFail');
472
+ await c.send('USER finalFail 0 * :FinalFail');
473
+ await c.waitFor((l) => l.startsWith(':irc.finalfail 001 '), 2000);
474
+ // The server destroys live sockets on close; capture the close event first.
475
+ const closed = new Promise<void>((resolve) => c.socket.once('close', () => resolve()));
476
+ // close() must resolve (not reject) even though the final snapshot write
477
+ // throws — the error is logged and swallowed.
478
+ await expect(srv.close()).resolves.toBeUndefined();
479
+ await closed;
480
+ await rm(dir, { recursive: true, force: true });
481
+ });
376
482
  });
377
483
 
378
484
  describe('container-server — error paths', () => {
@@ -407,6 +513,95 @@ describe('container-server — error paths', () => {
407
513
  await srv.close();
408
514
  });
409
515
 
516
+ it('logs a non-Error rejection from the actor without crashing', async () => {
517
+ // logError's `err instanceof Error ? {...} : err` false branch is only
518
+ // reached when the caught value is NOT an Error instance.
519
+ const srv = await startContainerServer({
520
+ port: 0,
521
+ host: '127.0.0.1',
522
+ serverName: 'irc.nonerr',
523
+ networkName: 'NonErrNet',
524
+ });
525
+ const original = srv.runtime.send.bind(srv.runtime);
526
+ srv.runtime.send = async () => {
527
+ // eslint-disable-next-line no-throw-literal
528
+ throw { code: 'SYNTHETIC', detail: 'not an Error' };
529
+ };
530
+ const client = new TcpClient('127.0.0.1', srv.port);
531
+ await client.opened();
532
+ await client.send('NICK nonErrUser');
533
+ await client.send('USER nonErrUser 0 * :NonErr');
534
+ await client.send('PING :will-throw-non-error');
535
+ await new Promise((r) => setTimeout(r, 100));
536
+
537
+ srv.runtime.send = original;
538
+ const next = new TcpClient('127.0.0.1', srv.port);
539
+ await next.opened();
540
+ await next.send('NICK recover2');
541
+ await next.send('USER recover2 0 * :Recover2');
542
+ await next.waitFor((l) => l.startsWith(':irc.nonerr 001 '), 3000);
543
+ await next.close();
544
+ await client.close();
545
+ await srv.close();
546
+ });
547
+
548
+ it('disconnects a connection without an ERROR notice when no reason is supplied', async () => {
549
+ // Covers the disconnect callback's `reason === undefined` branch: no
550
+ // `ERROR :Closing link` line is sent, the transport is just closed.
551
+ const srv = await startContainerServer({
552
+ port: 0,
553
+ host: '127.0.0.1',
554
+ serverName: 'irc.noreason',
555
+ networkName: 'NoReasonNet',
556
+ });
557
+ const client = new TcpClient('127.0.0.1', srv.port);
558
+ await client.opened();
559
+ await client.send('NICK noReason');
560
+ await client.send('USER noReason 0 * :NoReason');
561
+ await client.waitFor((l) => l.startsWith(':irc.noreason 001 '), 2000);
562
+
563
+ const connId = await srv.runtime.lookupNick('noReason');
564
+ expect(connId).not.toBeNull();
565
+ const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
566
+ await srv.runtime.disconnect(connId as string);
567
+ await closed;
568
+ // The client must NOT have received an `ERROR :Closing link` line.
569
+ expect(client.received.some((l) => l.startsWith('ERROR :Closing link:'))).toBe(false);
570
+ await srv.close();
571
+ });
572
+
573
+ it('skips writing when the server-side socket is no longer writable', async () => {
574
+ // Covers the sendText callback's `socket.writable` else branch: after the
575
+ // server-side socket is destroyed (writable=false) but before its 'close'
576
+ // handler removes the connection, a runtime send must skip the write.
577
+ const srv = await startContainerServer({
578
+ port: 0,
579
+ host: '127.0.0.1',
580
+ serverName: 'irc.unwritable',
581
+ networkName: 'UnwritableNet',
582
+ });
583
+ const client = new TcpClient('127.0.0.1', srv.port);
584
+ await client.opened();
585
+ await client.send('NICK unwritable');
586
+ await client.send('USER unwritable 0 * :Unwritable');
587
+ await client.waitFor((l) => l.startsWith(':irc.unwritable 001 '), 2000);
588
+ for (let i = 0; i < 50 && srv.testSockets.size === 0; i++) {
589
+ await new Promise((r) => setTimeout(r, 5));
590
+ }
591
+ const serverSideSocket = [...srv.testSockets][0];
592
+ if (serverSideSocket === undefined) throw new Error('no server-side socket');
593
+ const connId = (await srv.runtime.lookupNick('unwritable')) as string;
594
+ // Destroy the server-side socket: writable flips false synchronously,
595
+ // while the 'close' cleanup (which unregisters the connection) is queued.
596
+ serverSideSocket.destroy();
597
+ // The send resolves without writing because socket.writable is false.
598
+ await expect(
599
+ srv.runtime.send(connId, [{ text: ':irc.unwritable NOTICE unwritable :dropped' }]),
600
+ ).resolves.toBeUndefined();
601
+ await srv.close();
602
+ await new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
603
+ });
604
+
410
605
  it('logs and recovers from a per-socket error', async () => {
411
606
  const srv = await startContainerServer({
412
607
  port: 0,
@@ -463,3 +658,56 @@ describe('container-server — error paths', () => {
463
658
  await rm(dir, { recursive: true, force: true });
464
659
  });
465
660
  });
661
+
662
+ describe('container-server — draft/multiline BATCH to a nick target', () => {
663
+ // Driving a nick-target multi-line batch exercises the `batchId` and
664
+ // `nonce` id-factory seams in DEFAULT_ID_FACTORY (container-server lines
665
+ // 96/98), which are only invoked by closeMultilineBatchToNick.
666
+
667
+ /** Negotiates `batch` + `draft/multiline`, then registers the nick. */
668
+ async function registerMultilineCapable(srv: ContainerServer, nick: string): Promise<TcpClient> {
669
+ const c = new TcpClient('127.0.0.1', srv.port);
670
+ await c.opened();
671
+ await c.send('CAP LS 302');
672
+ await c.waitFor((l) => l.startsWith(':irc.ml CAP * LS :'), 2000);
673
+ await c.send('CAP REQ :batch draft/multiline');
674
+ await c.waitFor((l) => l.includes('CAP') && l.includes('ACK'), 2000);
675
+ await c.send('CAP END');
676
+ await c.send(`NICK ${nick}`);
677
+ await c.send(`USER ${nick} 0 * :${nick}`);
678
+ await c.waitFor((l) => l.startsWith(':irc.ml 001 '), 2000);
679
+ return c;
680
+ }
681
+
682
+ it('delivers a finalized nick-target batch and exercises batchId/nonce', async () => {
683
+ const srv = await startContainerServer({
684
+ port: 0,
685
+ host: '127.0.0.1',
686
+ serverName: 'irc.ml',
687
+ networkName: 'MLNet',
688
+ });
689
+ const alice = await registerMultilineCapable(srv, 'alice');
690
+ const bob = await registerMultilineCapable(srv, 'bob');
691
+
692
+ await alice.send('BATCH +m1 draft/multiline :bob');
693
+ await alice.send('PRIVMSG bob :line one');
694
+ await alice.send('PRIVMSG bob :line two');
695
+ await alice.send('BATCH -m1');
696
+
697
+ // Bob (also batch+draft/multiline capable) receives the replayed BATCH.
698
+ // The server allocates a fresh UUID batch id via the batchId() seam.
699
+ const open = await bob.waitFor(
700
+ (l) => /^BATCH \+[0-9a-f-]{36} draft\/multiline bob$/u.test(l),
701
+ 3000,
702
+ );
703
+ const batchId = open?.match(/^BATCH \+([0-9a-f-]{36})/u)?.[1];
704
+ expect(batchId).toBeDefined();
705
+ await bob.waitFor((l) => l.includes(':alice!') && l.includes('PRIVMSG bob :line one'), 2000);
706
+ await bob.waitFor((l) => l.includes(':alice!') && l.includes('PRIVMSG bob :line two'), 2000);
707
+ await bob.waitFor((l) => l === `BATCH -${batchId}`, 2000);
708
+
709
+ await alice.close();
710
+ await bob.close();
711
+ await srv.close();
712
+ });
713
+ });
@@ -143,6 +143,15 @@ describe('persistence — saveSnapshot / loadSnapshot file I/O', () => {
143
143
  expect(loaded).toBeNull();
144
144
  });
145
145
 
146
+ it('re-throws non-ENOENT read errors (e.g. EISDIR when the path is a directory)', async () => {
147
+ // Reading a directory produces EISDIR, which is NOT ENOENT, so
148
+ // loadSnapshot must propagate it instead of treating it as first-boot.
149
+ const { mkdir } = await import('node:fs/promises');
150
+ const subDir = join(dir, 'not-a-file-dir');
151
+ await mkdir(subDir);
152
+ await expect(loadSnapshot(subDir)).rejects.toThrow();
153
+ });
154
+
146
155
  it('overwrites a previous snapshot on re-save', async () => {
147
156
  const path = join(dir, 'overwrite.json');
148
157
  const rt1 = new InMemoryRuntime({ clock: { now: () => 0 } });
@@ -1,4 +1,4 @@
1
- import { execFileSync } from 'node:child_process';
1
+ import { execFile } from 'node:child_process';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { mkdtemp, readFile, rm } from 'node:fs/promises';
4
4
  import { type Socket, createConnection } from 'node:net';
@@ -10,9 +10,21 @@ import {
10
10
  connect as tlsConnect,
11
11
  createServer as tlsCreateServer,
12
12
  } from 'node:tls';
13
+ import { promisify } from 'node:util';
13
14
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
14
15
  import { type ContainerServer, startContainerServer } from '../src/container-server.js';
15
16
 
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ /**
20
+ * Maximum wall-clock budget for a single `openssl` invocation. RSA-2048
21
+ * keygen finishes in well under a second on any modern CPU; if it hasn't
22
+ * returned by this point the binary is wedged (or the runner is so starved
23
+ * that the TLS e2e would be flaky anyway), so we skip the suite instead of
24
+ * stalling the test process.
25
+ */
26
+ const OPENSSL_TIMEOUT_MS = 15_000;
27
+
16
28
  /**
17
29
  * Generates a self-signed TLS certificate for the test proxy using the
18
30
  * system `openssl` binary. This mirrors what Cloudflare Spectrum does in
@@ -26,7 +38,12 @@ async function generateSelfSignedCert(dir: string): Promise<{ key: string; cert:
26
38
  const keyPath = join(dir, 'key.pem');
27
39
  const certPath = join(dir, 'cert.pem');
28
40
  try {
29
- execFileSync(
41
+ // Bound openssl: the previous `execFileSync` blocked the event loop,
42
+ // which defeated vitest's hook timer and let a stuck/missing binary
43
+ // hang the whole suite. The async call + `timeout` fails fast and
44
+ // yields control back to the runner; beforeAll swallows the throw and
45
+ // skips the suite (each test also guards on `cert === undefined`).
46
+ await execFileAsync(
30
47
  'openssl',
31
48
  [
32
49
  'req',
@@ -43,10 +60,12 @@ async function generateSelfSignedCert(dir: string): Promise<{ key: string; cert:
43
60
  '-subj',
44
61
  '/CN=localhost',
45
62
  ],
46
- { stdio: 'pipe' },
63
+ { timeout: OPENSSL_TIMEOUT_MS },
47
64
  );
48
65
  } catch {
49
- throw new Error('openssl not available — required to generate test TLS cert');
66
+ throw new Error(
67
+ `openssl unavailable or did not finish within ${OPENSSL_TIMEOUT_MS}ms — required to generate test TLS cert`,
68
+ );
50
69
  }
51
70
  const key = await readFile(keyPath, 'utf8');
52
71
  const cert = await readFile(certPath, 'utf8');
@@ -205,7 +224,7 @@ describe('container-server — TLS e2e via in-process proxy (Spectrum analogue)'
205
224
  originHost: '127.0.0.1',
206
225
  originPort: origin.port,
207
226
  });
208
- });
227
+ }, 30_000);
209
228
 
210
229
  afterAll(async () => {
211
230
  if (proxy !== undefined) await proxy.close();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-worker",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Cloudflare Worker deploy glue: WebSocket edge entry point + DO bindings + wrangler pipeline",
6
6
  "license": "BSD-3-Clause",
@@ -18,7 +18,7 @@
18
18
  * catch-all response, now scoped to an explicit path so the root URL
19
19
  * is free for the landing page).
20
20
  * • everything else — falls through to the `[assets]` binding, which
21
- * serves the Kiwi SPA at `/app/` and the landing page at `/` from
21
+ * serves the Kiwi SPA at `/webclient/` and the landing page at `/` from
22
22
  * `apps/web/dist`. The asset layer is configured in `wrangler.toml`.
23
23
  *
24
24
  * The DO classes are re-exported so `wrangler.toml`'s
@@ -47,8 +47,8 @@ export type { Env };
47
47
  /**
48
48
  * Production {@link Env} for the worker entry. Extends the cf-adapter
49
49
  * {@link Env} with the `ASSETS` binding (the static-asset `Fetcher` for
50
- * `/app/` SPA + `/` landing page). The cf-adapter's internal types stay
51
- * asset-agnostic; only the edge entry knows about the asset layer.
50
+ * `/webclient/` SPA + `/` landing page). The cf-adapter's internal types
51
+ * stay asset-agnostic; only the edge entry knows about the asset layer.
52
52
  */
53
53
  export interface WorkerEnv extends Env {
54
54
  /**
@@ -106,7 +106,7 @@ const ORIGIN_UNCONFIGURED_BODY =
106
106
  * 1. WebSocket upgrade → CSWSH `Origin` check, then per-connection
107
107
  * `ConnectionDO`.
108
108
  * 2. `GET /health` → plaintext liveness response.
109
- * 3. everything else → `env.ASSETS` (Kiwi SPA at `/app/`, landing at `/`).
109
+ * 3. everything else → `env.ASSETS` (Kiwi SPA at `/webclient/`, landing at `/`).
110
110
  *
111
111
  * The connection id is allocated here via `crypto.randomUUID()` (the
112
112
  * Web Crypto API is sync-available in Workers for RFC-4122 v4 UUIDs).
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <!--
3
+ Test-only fixture mirroring the real `apps/web/dist/webclient/index.html`
4
+ produced by the Kiwi SPA build (`pnpm --filter web build`). The cf-worker
5
+ smoke suite points its `[assets]` binding here so the worker's fall-through
6
+ routing (WS → ConnectionDO, /health → plaintext, else → ASSETS) is exercised
7
+ without depending on the (slow, submodule-heavy) Kiwi build. Production
8
+ points at `apps/web/dist`; this fixture only stands in for the test run.
9
+ -->
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <title>KiwiIRC</title>
14
+ </head>
15
+ <body>
16
+ <div id="app"></div>
17
+ </body>
18
+ </html>
@@ -32,9 +32,9 @@ declare global {
32
32
  MOTD_LINES: string;
33
33
  // Static-assets binding (`[assets]` in `wrangler.test.toml`, pointed at
34
34
  // `tests/fixtures/web-dist`). Production points at `apps/web/dist`; the
35
- // fixture mirrors the `app/index.html` + `index.html` layout so the
36
- // worker's fall-through routing is exercised without depending on the
37
- // Kiwi SPA build.
35
+ // fixture mirrors the `webclient/index.html` + `index.html` layout so
36
+ // the worker's fall-through routing is exercised without depending on
37
+ // the Kiwi SPA build.
38
38
  ASSETS: Fetcher;
39
39
  }
40
40
  }
@@ -61,8 +61,8 @@ describe('cf-worker deploy glue', () => {
61
61
  expect(body.toLowerCase()).toContain('serverlessircd');
62
62
  });
63
63
 
64
- it('GET /app/ serves the SPA index from the [assets] binding', async () => {
65
- const response = await worker.fetch(new Request('https://irc.example.com/app/'), env);
64
+ it('GET /webclient/ serves the SPA index from the [assets] binding', async () => {
65
+ const response = await worker.fetch(new Request('https://irc.example.com/webclient/'), env);
66
66
  expect(response.status).toBe(200);
67
67
  const body = await response.text();
68
68
  // The fixture SPA HTML carries this marker so the test asserts the
@@ -14,15 +14,15 @@ compatibility_flags = ["nodejs_compat"]
14
14
  # Static-assets binding. Production points at `../../apps/web/dist` (the Kiwi
15
15
  # SPA + landing page built by `apps/web`); the test config points at a fixture
16
16
  # so the smoke suite does not depend on the (slow) Kiwi upstream build. The
17
- # fixture mirrors the `app/index.html` + `index.html` layout; the worker's
18
- # fall-through routing (`/health` → plaintext, else → ASSETS) is what's under
19
- # test, not the SPA contents.
17
+ # fixture mirrors the `webclient/index.html` + `index.html` layout; the
18
+ # worker's fall-through routing (`/health` → plaintext, else → ASSETS) is
19
+ # what's under test, not the SPA contents.
20
20
  [assets]
21
21
  directory = "./tests/fixtures/web-dist"
22
22
  binding = "ASSETS"
23
- # `none` returns 404 for unmatched paths — the SPA lives at `/app/` and the
24
- # landing page at `/`, both with real `index.html` files, so no SPA fallback
25
- # is required.
23
+ # `none` returns 404 for unmatched paths — the SPA lives at `/webclient/` and
24
+ # the landing page at `/`, both with real `index.html` files, so no SPA
25
+ # fallback is required.
26
26
  not_found_handling = "none"
27
27
  # Mirror production: route every request through the Worker first so WS
28
28
  # upgrades at `/` aren't intercepted by the asset platform (which would
@@ -72,8 +72,9 @@ This is a deployed Cloudflare Worker.
72
72
  ## The `[assets]` binding serves `apps/web/dist` (built by `pnpm --filter web
73
73
  ## build`). The non-WS, non-`/health` branch of `worker.ts`'s `fetch` falls
74
74
  ## through to this binding, so:
75
- ## • `/app/` → the Kiwi IRC SPA (`dist/app/index.html` + its
76
- ## hashed assets under `/app/assets/...`).
75
+ ## • `/webclient/` → the Kiwi IRC SPA (`dist/webclient/index.html`
76
+ ## + its hashed assets under
77
+ ## `/webclient/assets/...`).
77
78
  ## • `/` → the landing page (`dist/index.html`, shipped by
78
79
  ## the `apps/web` build).
79
80
  ## • `/health` → plaintext liveness (handled by the worker, not
@@ -86,9 +87,10 @@ This is a deployed Cloudflare Worker.
86
87
  directory = "../../apps/web/dist"
87
88
  ## Expose the asset `Fetcher` on `env.ASSETS` (see `WorkerEnv` in `worker.ts`).
88
89
  binding = "ASSETS"
89
- ## Return 404 for unmatched paths. The SPA lives at `/app/` and the landing
90
- ## page at `/`; both are concrete `index.html` files, so no SPA fallback is
91
- ## required. Kiwi uses query/hash-based client routing, not path routing.
90
+ ## Return 404 for unmatched paths. The SPA lives at `/webclient/` and the
91
+ ## landing page at `/`; both are concrete `index.html` files, so no SPA
92
+ ## fallback is required. Kiwi uses query/hash-based client routing, not path
93
+ ## routing.
92
94
  not_found_handling = "none"
93
95
  ## Route EVERY request through the Worker before the asset platform serves a
94
96
  ## matching static file. Without this, the default (`run_worker_first = false`)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
6
  "license": "BSD-3-Clause",
@@ -35,6 +35,14 @@ export interface CliConfigInput {
35
35
  channelPrefixes?: string;
36
36
  maxClients?: number;
37
37
  operCreds?: Array<{ user: string; password: string }>;
38
+ /**
39
+ * Server-password gate. Treat as a secret; the loader threads it
40
+ * straight through (via `stripUndefined`) to `ServerConfig.serverPassword`
41
+ * so the registration reducer requires `PASS <value>` (or a SASL-
42
+ * identified connection) before `001 RPL_WELCOME`. Undefined disables
43
+ * the gate.
44
+ */
45
+ serverPassword?: string | undefined;
38
46
  maxChannelsPerUser?: number;
39
47
  maxTargetsPerCommand?: number;
40
48
  maxListEntries?: number;
@@ -35,6 +35,12 @@ interface CliArgs {
35
35
  serverName: string;
36
36
  /** Network name advertised in 005 NETWORK=... */
37
37
  networkName: string;
38
+ /**
39
+ * Server-password gate. Undefined disables the gate; when set every
40
+ * connection must supply the matching `PASS <value>` (or SASL-identify)
41
+ * before `001 RPL_WELCOME`.
42
+ */
43
+ serverPassword: string | undefined;
38
44
  }
39
45
 
40
46
  const DEFAULT_PORT = 6667;
@@ -49,6 +55,7 @@ function parseArgs(argv: string[]): CliArgs {
49
55
  motdFile: undefined,
50
56
  serverName: DEFAULT_SERVER_NAME,
51
57
  networkName: DEFAULT_NETWORK_NAME,
58
+ serverPassword: undefined,
52
59
  };
53
60
  let tcpDisabled = false;
54
61
  let tcpPortOverride: number | undefined;
@@ -73,6 +80,9 @@ function parseArgs(argv: string[]): CliArgs {
73
80
  } else if (a === '--network-name' && next !== undefined) {
74
81
  out.networkName = next;
75
82
  i++;
83
+ } else if (a === '--server-password' && next !== undefined) {
84
+ out.serverPassword = next;
85
+ i++;
76
86
  } else if (a === '--no-tcp') {
77
87
  tcpDisabled = true;
78
88
  } else if (a === '--help' || a === '-h') {
@@ -92,6 +102,11 @@ function parseArgs(argv: string[]): CliArgs {
92
102
  ' Optional; defaults to irc.localhost for the local CLI.',
93
103
  ' --network-name <n> Network name in 005 NETWORK=...',
94
104
  ' Optional; defaults to LocalNet.',
105
+ ' --server-password <p>',
106
+ ' Server-password gate (optional). When set, every',
107
+ ' connection must supply the matching PASS <p> (or',
108
+ ' SASL-identify) before 001 RPL_WELCOME. Treat as a',
109
+ ' secret; do not log it.',
95
110
  ' -h, --help Show this help and exit.',
96
111
  '',
97
112
  ].join('\n'),
@@ -119,6 +134,7 @@ async function main(): Promise<void> {
119
134
  tcpPort: args.tcpPort,
120
135
  serverName: args.serverName,
121
136
  networkName: args.networkName,
137
+ ...(args.serverPassword !== undefined ? { serverPassword: args.serverPassword } : {}),
122
138
  ...(motdLines !== undefined ? { motdLines } : {}),
123
139
  });
124
140