serverless-ircd 0.8.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 (80) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +160 -200
  4. package/apps/aws-stack/package.json +1 -1
  5. package/apps/cf-tcp-container/package.json +1 -1
  6. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  7. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  8. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  9. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  10. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  11. package/apps/cf-worker/package.json +1 -1
  12. package/apps/local-cli/package.json +1 -1
  13. package/apps/local-cli/src/server.ts +94 -31
  14. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  15. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  16. package/apps/local-cli/tests/rehash.test.ts +147 -0
  17. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  18. package/apps/local-cli/tests/tcp.test.ts +89 -0
  19. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  20. package/apps/web/landing/index.html +226 -3
  21. package/apps/web/package.json +2 -1
  22. package/apps/web/scripts/build.mjs +25 -2
  23. package/apps/web/src/render-docs.ts +292 -0
  24. package/apps/web/tests/build-smoke.test.ts +31 -2
  25. package/apps/web/tests/landing-content.test.ts +103 -0
  26. package/apps/web/tests/render-docs.test.ts +198 -0
  27. package/docs/AWS-Adapter-Architecture.md +3 -2
  28. package/docs/Services.md +33 -1
  29. package/package.json +2 -2
  30. package/packages/aws-adapter/package.json +1 -1
  31. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  32. package/packages/aws-adapter/src/handlers/nlb-stream.ts +10 -2
  33. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  34. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  35. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  36. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  37. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  38. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  39. package/packages/cf-adapter/package.json +1 -1
  40. package/packages/cf-adapter/src/connection-do.ts +18 -6
  41. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  42. package/packages/in-memory-runtime/package.json +1 -1
  43. package/packages/irc-core/package.json +1 -1
  44. package/packages/irc-core/src/commands/account-auth.ts +46 -18
  45. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  46. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  47. package/packages/irc-core/src/commands/index.ts +1 -0
  48. package/packages/irc-core/src/commands/join.ts +41 -35
  49. package/packages/irc-core/src/commands/nickserv.ts +16 -4
  50. package/packages/irc-core/src/commands/registration.ts +27 -16
  51. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  52. package/packages/irc-core/src/commands/topic.ts +23 -10
  53. package/packages/irc-core/src/state/channel.ts +17 -0
  54. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  55. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  56. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  57. package/packages/irc-core/tests/commands/nickserv.test.ts +185 -2
  58. package/packages/irc-core/tests/commands/registration.test.ts +227 -6
  59. package/packages/irc-core/tests/commands/sasl.test.ts +44 -0
  60. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  61. package/packages/irc-server/package.json +1 -1
  62. package/packages/irc-server/src/actor.ts +80 -30
  63. package/packages/irc-server/tests/actor.test.ts +365 -3
  64. package/packages/irc-test-support/package.json +1 -1
  65. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  66. package/packages/irc-test-support/src/scenarios.ts +21 -6
  67. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  68. package/packages/irc-test-support/vitest.config.ts +6 -1
  69. package/tools/ci-hardening/package.json +1 -1
  70. package/tools/load-test/package.json +1 -1
  71. package/tools/load-test/src/client.ts +13 -13
  72. package/tools/load-test/tests/client.test.ts +258 -2
  73. package/tools/load-test/tests/config.test.ts +39 -0
  74. package/tools/load-test/tests/harness.test.ts +21 -0
  75. package/tools/load-test/tests/metrics.test.ts +7 -0
  76. package/tools/tcp-ws-forwarder/package.json +1 -1
  77. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  78. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  79. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  80. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
@@ -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.8.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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.8.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",
@@ -77,6 +77,7 @@ type ResolvedServerConfig = Omit<
77
77
  | 'perIpConnectionRate'
78
78
  | 'historyMaxPerChannel'
79
79
  | 'saslAccounts'
80
+ | 'operCreds'
80
81
  > &
81
82
  Pick<
82
83
  LocalServerConfig,
@@ -86,6 +87,7 @@ type ResolvedServerConfig = Omit<
86
87
  | 'maxConnectionsPerUser'
87
88
  | 'perIpConnectionRate'
88
89
  | 'saslAccounts'
90
+ | 'operCreds'
89
91
  >;
90
92
 
91
93
  /**
@@ -107,6 +109,7 @@ function serverConfigFromResolved(cfg: ResolvedServerConfig): ServerConfig {
107
109
  quitMessage: cfg.quitMessage,
108
110
  ...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
109
111
  ...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
112
+ ...(cfg.operCreds !== undefined ? { operCreds: cfg.operCreds } : {}),
110
113
  };
111
114
  }
112
115
 
@@ -171,6 +174,14 @@ export interface LocalServerConfig {
171
174
  * disabled (`ctx.accounts` undefined → `904 ERR_SASLFAIL`).
172
175
  */
173
176
  saslAccounts?: Array<{ username: string; password: string }>;
177
+ /**
178
+ * IRC operator credentials consulted by the `OPER` reducer. When omitted
179
+ * (or empty) `OPER` receives `491 ERR_NOOPERHOST` and oper-only commands
180
+ * like `REHASH` are unreachable; supply a non-empty list to let a client
181
+ * oper up. Threaded straight through to the reducer-facing
182
+ * {@link ServerConfig} via the shared schema.
183
+ */
184
+ readonly operCreds?: Array<{ user: string; password: string }>;
174
185
  }
175
186
 
176
187
  export interface StartServerOptions extends LocalServerConfig {
@@ -235,6 +246,7 @@ const DEFAULT_CONFIG: Omit<
235
246
  | 'perIpConnectionRate'
236
247
  | 'historyMaxPerChannel'
237
248
  | 'saslAccounts'
249
+ | 'operCreds'
238
250
  | 'serverName'
239
251
  | 'networkName'
240
252
  > = {
@@ -303,6 +315,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
303
315
  ...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
304
316
  ...(opts.saslAccounts !== undefined ? { saslAccounts: opts.saslAccounts } : {}),
305
317
  ...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
318
+ ...(opts.operCreds !== undefined ? { operCreds: opts.operCreds } : {}),
306
319
  });
307
320
  } catch (err) {
308
321
  return Promise.reject(err);
@@ -332,6 +345,9 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
332
345
  ...(opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
333
346
  ? { saslAccounts: opts.saslAccounts }
334
347
  : {}),
348
+ ...(opts.operCreds !== undefined && opts.operCreds.length > 0
349
+ ? { operCreds: opts.operCreds }
350
+ : {}),
335
351
  };
336
352
  const hostname = opts.hostname ?? '127.0.0.1';
337
353
 
@@ -399,7 +415,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
399
415
  // Admission gate: refuse the connection BEFORE we attach an actor / emit
400
416
  // any IRC state. Keeps per-IP / per-user counters authoritative and
401
417
  // avoids burning a nick or runtime slot on a denied client.
402
- const ip = req.socket.remoteAddress ?? 'unknown';
418
+ const ip = peerHost(req.socket.remoteAddress);
403
419
  const decision = runtime.admitConnection(ip, undefined);
404
420
  if (!decision.ok) {
405
421
  ws.send(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
@@ -485,12 +501,15 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
485
501
  const onWsListening = (): void => {
486
502
  wss.removeListener('error', onWsError);
487
503
  const addr = wss.address();
488
- // c8 ignore next 1 - defensive fallback for non-IP transports; with a
489
- // TCP port `wss.address()` always returns an AddressInfo object.
490
- const wsPort = typeof addr === 'object' && addr !== null ? addr.port : opts.port;
504
+ const wsPort = listenerPort(addr, opts.port);
505
+
506
+ // Capture the requested TCP port as a `const` so TS narrows it to
507
+ // `number` past the `undefined` early-return below (a property access
508
+ // like `opts.tcpPort` is not narrowed across the closure).
509
+ const requestedTcpPort = opts.tcpPort;
491
510
 
492
511
  // If no TCP listener was requested, resolve immediately with just WS.
493
- if (opts.tcpPort === undefined) {
512
+ if (requestedTcpPort === undefined) {
494
513
  finishResolve({ wsPort, tcpPort: undefined, tcpServer: undefined });
495
514
  return;
496
515
  }
@@ -506,12 +525,13 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
506
525
 
507
526
  // Admission gate — same policy as the WS path: refuse before the
508
527
  // actor is attached so per-IP / per-user counters stay authoritative.
509
- const ip = socket.remoteAddress ?? 'unknown';
528
+ const ip = peerHost(socket.remoteAddress);
510
529
  const decision = runtime.admitConnection(ip, undefined);
511
530
  if (!decision.ok) {
512
- if (socket.writable) {
513
- socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
514
- }
531
+ safeSocketWrite(
532
+ socket,
533
+ `ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`,
534
+ );
515
535
  socket.destroy();
516
536
  tcpSockets.delete(socket);
517
537
  return;
@@ -521,7 +541,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
521
541
 
522
542
  const { state, actor } = attachConnection(runtime, cfg, messages, accounts, history, {
523
543
  sendText: (text) => {
524
- if (socket.writable) socket.write(text);
544
+ safeSocketWrite(socket, text);
525
545
  },
526
546
  closeTransport: () => {
527
547
  // `destroy()` over `end()` so a half-closed peer can't hold the
@@ -562,12 +582,10 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
562
582
  });
563
583
  tcpServer.once('listening', () => {
564
584
  const tcpAddr = tcpServer.address();
565
- // c8 ignore next 1 - defensive: a TCP listener always yields AddressInfo.
566
- const tcpPort =
567
- typeof tcpAddr === 'object' && tcpAddr !== null ? tcpAddr.port : opts.tcpPort;
585
+ const tcpPort = listenerPort(tcpAddr, requestedTcpPort);
568
586
  finishResolve({ wsPort, tcpPort, tcpServer, tcpConnections, tcpSockets });
569
587
  });
570
- tcpServer.listen(opts.tcpPort, hostname);
588
+ tcpServer.listen(requestedTcpPort, hostname);
571
589
  };
572
590
 
573
591
  const finishResolve = (args: {
@@ -618,7 +636,12 @@ function attachConnection(
618
636
  sendText: (text: string) => void;
619
637
  closeTransport: () => void;
620
638
  admissionRecordId?: string;
621
- sourceHost?: string;
639
+ /**
640
+ * Transport-visible source host recorded on the connection state (cloak
641
+ * input / visible host). Both transports always supply the peer IP, so
642
+ * this is required rather than optional.
643
+ */
644
+ sourceHost: string;
622
645
  /**
623
646
  * Negotiated IRCv3 WebSocket frame mode for this connection. Spec modes
624
647
  * (`spec-text` / `spec-binary`) switch outbound delivery to one WS
@@ -639,9 +662,7 @@ function attachConnection(
639
662
  const state = createConnection({ id, connectedSince: Date.now() });
640
663
  // Record the transport-visible source host up front. Used as the cloak
641
664
  // input when cloaking is enabled, and as the visible host otherwise.
642
- if (transport.sourceHost !== undefined) {
643
- state.host = transport.sourceHost;
644
- }
665
+ state.host = transport.sourceHost;
645
666
 
646
667
  // Per-connection logger: every record the actor emits
647
668
  // carries this connection's id so the local-cli log stream can be
@@ -731,29 +752,71 @@ function closeEverything(args: {
731
752
  // Tear down WS connections + server first.
732
753
  const wsClosed = teardownWss(wss, wsConnections);
733
754
 
734
- // Tear down TCP connections + server in parallel (if enabled).
755
+ // Tear down TCP connections + server in parallel (if enabled). Both are
756
+ // captured together (a TCP-disabled server has neither), so the guard is a
757
+ // single expression covering both the disabled and enabled cases.
735
758
  const tcpClosed =
736
- tcpServer === undefined
759
+ tcpServer === undefined || tcpConnections === undefined
737
760
  ? Promise.resolve()
738
761
  : new Promise<void>((res) => {
739
- if (tcpConnections !== undefined) {
740
- for (const socket of tcpConnections.keys()) {
741
- // Defensive: reachable only if a socket was already destroyed
742
- // (e.g. via a reducer-driven disconnect racing the close sweep)
743
- // but hasn't yet been removed from `tcpConnections`. The cleanup
744
- // handler always removes on 'close'/'error', so this branch is a
745
- // no-op in steady state.
746
- // c8 ignore next 1
747
- if (!socket.destroyed) socket.destroy();
748
- }
749
- tcpConnections.clear();
762
+ for (const socket of tcpConnections.keys()) {
763
+ // Idempotent: a socket already torn down by a reducer-driven
764
+ // disconnect racing the close sweep is skipped.
765
+ safeDestroy(socket);
750
766
  }
767
+ tcpConnections.clear();
751
768
  tcpServer.close(() => res());
752
769
  });
753
770
 
754
771
  return Promise.all([wsClosed, tcpClosed]).then(() => undefined);
755
772
  }
756
773
 
774
+ /**
775
+ * Resolves the visible peer IP for a freshly-accepted socket, falling back to
776
+ * `'unknown'` when the transport exposes no remote address (e.g. a socket
777
+ * already torn down at upgrade time). Extracted as a pure helper so the
778
+ * fallback arm is unit-testable in isolation — the live WS/TCP paths always
779
+ * observe a concrete `remoteAddress`, so the `'unknown'` arm is unreachable
780
+ * through the integration suite.
781
+ */
782
+ export function peerHost(remoteAddress: string | undefined): string {
783
+ return remoteAddress ?? 'unknown';
784
+ }
785
+
786
+ /**
787
+ * Reads the bound port from a listener's `address()` result, falling back to
788
+ * the requested port for non-IP transports (named-pipe / unix-socket) which
789
+ * local-cli never binds but the `node:net` typing permits. Pure helper so the
790
+ * fallback arm is unit-testable; the live listeners always bind IP.
791
+ */
792
+ export function listenerPort(addr: { port: number } | string | null, fallback: number): number {
793
+ return typeof addr === 'object' && addr !== null ? addr.port : fallback;
794
+ }
795
+
796
+ /**
797
+ * Writes `data` to `socket` only while it is still writable, silently
798
+ * skipping a destroyed / non-writable socket. Extracted so the non-writable
799
+ * arm is unit-testable with a fake — the live WS/TCP send paths always
800
+ * observe a writable transport, and the TCP admission-deny write races a
801
+ * socket that a peer RST may already have torn down.
802
+ */
803
+ export function safeSocketWrite(
804
+ socket: { writable: boolean; write: (data: string) => void },
805
+ data: string,
806
+ ): void {
807
+ if (socket.writable) socket.write(data);
808
+ }
809
+
810
+ /**
811
+ * Destroys `socket` unless it is already destroyed (idempotent teardown).
812
+ * Extracted so the already-destroyed arm is unit-testable with a fake — the
813
+ * race that produces an already-destroyed socket still present in the
814
+ * connection map is not deterministically reproducible in the suite.
815
+ */
816
+ export function safeDestroy(socket: { destroyed: boolean; destroy(): void }): void {
817
+ if (!socket.destroyed) socket.destroy();
818
+ }
819
+
757
820
  function logError(msg: string, err: unknown): void {
758
821
  // Structured JSON per acceptance criteria.
759
822
  const payload = { ts: new Date().toISOString(), level: 'error', msg, err: stringifyErr(err) };