serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -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();
@@ -13,7 +13,7 @@
13
13
  ## - The container image is built from apps/cf-tcp-container/Dockerfile.
14
14
  ##
15
15
  ## Deploy:
16
- ## wrangler deploy --env staging
16
+ ## wrangler deploy
17
17
  ##
18
18
 
19
19
  name = "sirc-tcp-origin"
@@ -52,12 +52,3 @@ NETWORK_NAME = "ExampleNet"
52
52
  MOTD_LINES = "Welcome to the ServerlessIRCd TCP origin."
53
53
  TCP_PORT = "6667"
54
54
  PERSISTENCE_PATH = "/data/state.json"
55
-
56
- # --- Staging ---
57
- [env.staging]
58
- name = "sirc-tcp-origin-staging"
59
-
60
- [env.staging.vars]
61
- SERVER_NAME = "irc-staging.example.com"
62
- NETWORK_NAME = "StagingNet"
63
- MOTD_LINES = "Welcome to the staging ServerlessIRCd TCP origin."
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/cf-worker",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -13,9 +13,8 @@
13
13
  "test:watch": "vitest",
14
14
  "coverage": "vitest run --coverage",
15
15
  "dev": "wrangler dev",
16
- "deploy:staging": "wrangler deploy --env staging",
17
- "deploy:prod": "wrangler deploy",
18
- "smoke:staging": "node scripts/smoke.mjs",
16
+ "deploy": "wrangler deploy",
17
+ "smoke": "node scripts/smoke.mjs",
19
18
  "clean": "rimraf dist coverage .tsbuildinfo .turbo"
20
19
  },
21
20
  "dependencies": {
@@ -3,8 +3,7 @@
3
3
  ## Cloudflare Worker production config for ServerlessIRCd.
4
4
  ##
5
5
  ## Deploy:
6
- ## pnpm deploy:cf:staging # wrangler deploy --env staging
7
- ## pnpm deploy:cf:prod # wrangler deploy
6
+ ## pnpm deploy:cf # wrangler deploy
8
7
  ##
9
8
  ## The `apps/cf-worker` worker is the public edge for the IRC service.
10
9
  ## It owns the three Durable Object namespaces from the cf-adapter
@@ -13,10 +12,8 @@
13
12
  ## `src/worker.ts` and PLAN §6.1.
14
13
  ##
15
14
  ## Secrets: NEVER put secrets (API tokens, server passwords, account
16
- ## credentials) in this file. Use `wrangler secret put <NAME>` per
17
- ## environment; they live in the Workers KV secrets store, not the repo.
18
- ## As of v1 the worker does not consume any secret bindings — server
19
- ## password, SASL `AccountStore`, and oper creds are future work.
15
+ ## credentials) in this file. Use `wrangler secret put <NAME>`;
16
+ ## they live in the Workers KV secrets store, not the repo.
20
17
 
21
18
  name = "serverless-ircd"
22
19
  main = "src/worker.ts"
@@ -104,55 +101,20 @@ not_found_handling = "none"
104
101
  run_worker_first = true
105
102
 
106
103
  ## ---------------------------------------------------------------------------
107
- ## Staging environment.
104
+ ## D1 database — unified services persistence (NickServ / ChanServ / SASL).
108
105
  ## ---------------------------------------------------------------------------
109
- ## `wrangler deploy --env staging` ships to the staging Worker + DO
110
- ## namespace. The CI workflow (`.github/workflows/deploy-cf.yml`) deploys
111
- ## here on every push to `main` and then runs the smoke e2e.
112
- [env.staging]
113
- name = "serverless-ircd-staging"
114
-
115
- [env.staging.observability]
116
- enabled = true
117
- head_sampling_rate = 1
118
-
119
- [env.staging.vars]
120
- SERVER_NAME = "irc-staging.example.com"
121
- NETWORK_NAME = "ServerlessIRCd (staging)"
122
- MOTD_LINES = """
123
- Welcome to ServerlessIRCd staging.
124
- This is a deployed Cloudflare Worker.
125
- This Worker was deployed by CI from the main branch.
126
- """
127
-
128
- # Staging serves the same `apps/web/dist` asset layer. Wrangler requires the
129
- # per-env assets block so `--env staging` deploys both the Worker and its
130
- # assets in a single `wrangler deploy --env staging` (the same `pnpm build`
131
- # populates `dist/` ahead of the deploy step).
132
- [env.staging.assets]
133
- directory = "../../apps/web/dist"
134
- binding = "ASSETS"
135
- not_found_handling = "none"
136
- run_worker_first = true
137
-
138
- ## ---------------------------------------------------------------------------
139
- ## D1 database — SASL account persistence.
140
- ## ---------------------------------------------------------------------------
141
- ## The `accounts` table stores scrypt-hashed SASL PLAIN credentials (never
142
- ## plaintext). Loaded at ConnectionDO construction; when empty or unreachable
143
- ## the resolver falls back to the `SASL_ACCOUNTS` env-var seed. Provision with:
106
+ ## The `nickserv_accounts` table is the single credential home for SASL
107
+ ## PLAIN, SASL EXTERNAL, PASS-auth, and NickServ IDENTIFY. All scrypt-hashed
108
+ ## credentials live here; the standalone `accounts` table was dropped.
109
+ ## Provision with:
144
110
  ## wrangler d1 create serverless-ircd-accounts
145
- ## wrangler d1 execute serverless-ircd-accounts --command \
146
- ## "CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)"
147
- ## Seed accounts via `tools/seed-cf-accounts.ts`.
111
+ ## The services tables self-provision on first load (ConnectionDO runs
112
+ ## `CREATE TABLE IF NOT EXISTS` for each table). Seed accounts via
113
+ ## `tools/seed-cf-accounts.ts`.
148
114
  [[d1_databases]]
149
115
  binding = "ACCOUNTS_DB"
150
116
  database_name = "serverless-ircd-accounts"
151
- # database_id is filled in by `wrangler d1 create` and is env-specific.
152
-
153
- [[env.staging.d1_databases]]
154
- binding = "ACCOUNTS_DB"
155
- database_name = "serverless-ircd-accounts-staging"
117
+ # database_id is filled in by `wrangler d1 create`.
156
118
 
157
119
  ## ---------------------------------------------------------------------------
158
120
  ## Durable Object bindings.
@@ -179,23 +141,6 @@ class_name = "ChannelDO"
179
141
  name = "CHANNEL_REGISTRY_DO"
180
142
  class_name = "ChannelRegistryDO"
181
143
 
182
- # Staging environment bindings (wrangler requires per-env blocks).
183
- [[env.staging.durable_objects.bindings]]
184
- name = "CONNECTION_DO"
185
- class_name = "ConnectionDO"
186
-
187
- [[env.staging.durable_objects.bindings]]
188
- name = "REGISTRY_DO"
189
- class_name = "RegistryDO"
190
-
191
- [[env.staging.durable_objects.bindings]]
192
- name = "CHANNEL_DO"
193
- class_name = "ChannelDO"
194
-
195
- [[env.staging.durable_objects.bindings]]
196
- name = "CHANNEL_REGISTRY_DO"
197
- class_name = "ChannelRegistryDO"
198
-
199
144
  # ## ---------------------------------------------------------------------------
200
145
  # ## Durable Object migrations.
201
146
  # ## ---------------------------------------------------------------------------
@@ -210,10 +155,6 @@ class_name = "ChannelRegistryDO"
210
155
  # tag = "v1"
211
156
  # new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
212
157
 
213
- # [[env.staging.migrations]]
214
- # tag = "v1"
215
- # new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
216
-
217
158
  [exports.ConnectionDO]
218
159
  type = "durable-object"
219
160
  storage = "sqlite"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",