serverless-ircd 0.4.0 → 0.6.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 (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -12,6 +12,7 @@ import {
12
12
  type Clock,
13
13
  type ConnectionState,
14
14
  type RawLine,
15
+ type ServerConfig,
15
16
  createConnection,
16
17
  } from '@serverless-ircd/irc-core';
17
18
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -398,6 +399,85 @@ describe.skipIf(!available)('AwsRuntime', () => {
398
399
  });
399
400
  });
400
401
 
402
+ // -------------------------------------------------------------------------
403
+ // broadcastWallops — Scan-based fan-out to every +w connection
404
+ // -------------------------------------------------------------------------
405
+
406
+ describe('broadcastWallops', () => {
407
+ it('delivers to every +w connection and skips the except id', async () => {
408
+ const receivedA: string[] = [];
409
+ const receivedB: string[] = [];
410
+ const stateA = createConnection({ id: 'w-a', connectedSince: 0 });
411
+ stateA.userModes.wallops = true;
412
+ const stateB = createConnection({ id: 'w-b', connectedSince: 0 });
413
+ stateB.userModes.wallops = true;
414
+
415
+ const mgmt = new LocalPostToConnection();
416
+ mgmt.register('w-a', (data) => {
417
+ for (const line of data.split('\r\n')) {
418
+ if (line.length > 0) receivedA.push(line);
419
+ }
420
+ });
421
+ mgmt.register('w-b', (data) => {
422
+ for (const line of data.split('\r\n')) {
423
+ if (line.length > 0) receivedB.push(line);
424
+ }
425
+ });
426
+
427
+ const rtA = makeRuntime('w-a', handlersSink(receivedA, stateA), undefined, mgmt);
428
+ const rtB = makeRuntime('w-b', handlersSink(receivedB, stateB), undefined, mgmt);
429
+
430
+ await rtA.persistConnectionState(stateA);
431
+ await rtB.persistConnectionState(stateB);
432
+
433
+ // Caller 'w-a' is excepted; only 'w-b' should receive the line.
434
+ await rtA.broadcastWallops([{ text: ':oper NOTICE * :hello' }], 'w-a');
435
+ expect(receivedA).toEqual([]);
436
+ expect(receivedB).toEqual([':oper NOTICE * :hello']);
437
+ });
438
+
439
+ it('delivers to the bound connection via in-process handlers when not excepted', async () => {
440
+ // The bound connection's own +w delivery goes through handlers.send,
441
+ // not APIGW. With no `except`, the bound conn is also a recipient.
442
+ const received: string[] = [];
443
+ const state = createConnection({ id: 'self', connectedSince: 0 });
444
+ state.userModes.wallops = true;
445
+ const rt = makeRuntime('self', handlersSink(received, state), undefined, null);
446
+ await rt.persistConnectionState(state);
447
+ await rt.broadcastWallops([{ text: ':oper NOTICE * :hi' }]);
448
+ expect(received).toEqual([':oper NOTICE * :hi']);
449
+ });
450
+ });
451
+
452
+ // -------------------------------------------------------------------------
453
+ // Nick registry — error propagation
454
+ // -------------------------------------------------------------------------
455
+
456
+ describe('reserveNick error propagation', () => {
457
+ it('rethrows non-conditional errors from the PutCommand', async () => {
458
+ // A non-ConditionalCheckFailedException error must propagate rather
459
+ // than be treated as a nick collision. We force a real DynamoDB
460
+ // error by sending the reserveNick PutCommand against a table that
461
+ // does not exist (deleted mid-flight), which yields a
462
+ // ResourceNotFoundException.
463
+ const rt = makeRuntime('c1', noopHandlers());
464
+ // Drop the Nicks table out from under the runtime.
465
+ await fx.client.send(new DeleteTableCommand({ TableName: fx.tables.Nicks }));
466
+ await expect(rt.reserveNick('Ghost', 'c1')).rejects.toThrow(
467
+ /ResourceNotFoundException|non-existent table/,
468
+ );
469
+ // Recreate the table so afterEach cleanup does not fail on a missing table.
470
+ await fx.client.send(
471
+ new CreateTableCommand({
472
+ TableName: fx.tables.Nicks,
473
+ AttributeDefinitions: [{ AttributeName: 'nickLower', AttributeType: 'S' }],
474
+ KeySchema: [{ AttributeName: 'nickLower', KeyType: 'HASH' }],
475
+ BillingMode: 'PAY_PER_REQUEST',
476
+ }),
477
+ );
478
+ });
479
+ });
480
+
401
481
  // -------------------------------------------------------------------------
402
482
  // listChannels + getChannelConnections
403
483
  // -------------------------------------------------------------------------
@@ -652,3 +732,63 @@ function handlersSink(sink: string[], snapshot?: ConnectionState): AwsRuntimeHan
652
732
  snapshot: () => snapshot,
653
733
  };
654
734
  }
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // reloadConfig (REHASH) — no DynamoDB access, so this group runs unconditionally.
738
+ // ---------------------------------------------------------------------------
739
+
740
+ describe('AwsRuntime — reloadConfig (REHASH)', () => {
741
+ const baseConfig: ServerConfig = {
742
+ serverName: 'irc.example.com',
743
+ networkName: 'ExampleNet',
744
+ maxChannelsPerUser: 30,
745
+ maxTargetsPerCommand: 10,
746
+ maxListEntries: 50,
747
+ nickLen: 30,
748
+ channelLen: 50,
749
+ topicLen: 390,
750
+ quitMessage: 'Client Quit',
751
+ };
752
+
753
+ function makeReloadRuntime(loader?: () => Promise<ServerConfig>): AwsRuntime {
754
+ return new AwsRuntime({
755
+ // reloadConfig never touches DynamoDB; a default client + empty tables
756
+ // suffice for the no-IO reload path.
757
+ dynamo: createDynamoDocumentClient({}),
758
+ tables: {} as TablesConfig,
759
+ connId: 'c1',
760
+ handlers: noopHandlers(),
761
+ managementApi: null,
762
+ ...(loader !== undefined ? { configLoader: loader } : {}),
763
+ });
764
+ }
765
+
766
+ it('re-invokes the bound loader and returns the fresh config', async () => {
767
+ let password = 'old';
768
+ const rt = makeReloadRuntime(async () => ({
769
+ ...baseConfig,
770
+ operCreds: [{ user: 'admin', password }],
771
+ }));
772
+
773
+ const first = await rt.reloadConfig();
774
+ expect(first.operCreds).toEqual([{ user: 'admin', password: 'old' }]);
775
+
776
+ password = 'rotated';
777
+ const second = await rt.reloadConfig();
778
+ expect(second.operCreds).toEqual([{ user: 'admin', password: 'rotated' }]);
779
+ });
780
+
781
+ it('propagates a loader rejection so the actor applies the graceful 382', async () => {
782
+ const rt = makeReloadRuntime(async () => {
783
+ throw new Error('Secrets Manager unreachable');
784
+ });
785
+
786
+ await expect(rt.reloadConfig()).rejects.toThrow('Secrets Manager unreachable');
787
+ });
788
+
789
+ it('rejects when no loader was bound at construction', async () => {
790
+ const rt = makeReloadRuntime();
791
+
792
+ await expect(rt.reloadConfig()).rejects.toThrow('no config loader bound');
793
+ });
794
+ });
@@ -90,6 +90,15 @@ describe('buildLambdaConfigInput', () => {
90
90
  );
91
91
  });
92
92
 
93
+ it('passes an overflowing numeric CREATED_AT through verbatim (not Infinity)', () => {
94
+ // A purely-numeric string that overflows Number.MAX_VALUE renders as
95
+ // `Infinity`, which is not a finite epoch-ms. The parser must fall
96
+ // back to returning the raw string rather than emitting `Infinity`
97
+ // into the config (which would serialize as `null` in JSON).
98
+ const huge = '9'.repeat(400);
99
+ expect(buildLambdaConfigInput({ CREATED_AT: huge }).createdAt).toBe(huge);
100
+ });
101
+
93
102
  it('omits serverVersion/createdAt when their env vars are unset', () => {
94
103
  const out = buildLambdaConfigInput({});
95
104
  expect('serverVersion' in out).toBe(false);
@@ -154,8 +163,8 @@ describe('loadServerConfigFromLambdaEnv', () => {
154
163
  expect(typeof cfg.nickLen).toBe('number');
155
164
  });
156
165
 
157
- it('throws when SERVER_NAME is missing', () => {
158
- expect(() => loadServerConfigFromLambdaEnv({})).toThrow();
166
+ it('throws a readable error naming serverName when SERVER_NAME is missing', () => {
167
+ expect(() => loadServerConfigFromLambdaEnv({})).toThrowError(/serverName/u);
159
168
  });
160
169
 
161
170
  it('round-trips MOTD lines into ParsedServerConfig', () => {
@@ -8,9 +8,10 @@
8
8
  */
9
9
 
10
10
  import { type DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb';
11
- import { DEFAULT_SERVER_CONFIG } from '@serverless-ircd/irc-core';
11
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
12
12
  import { describe, expect, it } from 'vitest';
13
13
  import { handleConnect } from '../src/handlers/connect.js';
14
+ import type { MarshalledConnection } from '../src/serialize.js';
14
15
  import type { TablesConfig } from '../src/tables.js';
15
16
 
16
17
  const TABLES: TablesConfig = {
@@ -24,12 +25,15 @@ const TABLES: TablesConfig = {
24
25
  interface Stub {
25
26
  dynamo: DynamoDBDocumentClient;
26
27
  puts: number;
28
+ /** Most recently PutCommand Item (undefined when no put happened). */
29
+ lastPutItem: Record<string, unknown> | undefined;
27
30
  }
28
31
 
29
32
  /** Builds a stub client that serves the supplied `Scan` pages in order. */
30
33
  function makeStub(pages: Array<Record<string, unknown>>): Stub {
31
34
  let scanIdx = 0;
32
35
  const calls = { puts: 0 };
36
+ let lastPutItem: Record<string, unknown> | undefined;
33
37
  const dynamo = {
34
38
  send: async (cmd: unknown) => {
35
39
  if (cmd instanceof ScanCommand) {
@@ -37,6 +41,7 @@ function makeStub(pages: Array<Record<string, unknown>>): Stub {
37
41
  }
38
42
  if (cmd instanceof PutCommand) {
39
43
  calls.puts++;
44
+ lastPutItem = cmd.input.Item as Record<string, unknown> | undefined;
40
45
  return {};
41
46
  }
42
47
  throw new Error('unexpected command');
@@ -47,6 +52,9 @@ function makeStub(pages: Array<Record<string, unknown>>): Stub {
47
52
  get puts() {
48
53
  return calls.puts;
49
54
  },
55
+ get lastPutItem() {
56
+ return lastPutItem;
57
+ },
50
58
  };
51
59
  }
52
60
 
@@ -58,9 +66,9 @@ describe('handleConnect admission plumbing', () => {
58
66
  tables: TABLES,
59
67
  connectionId: 'c-paged',
60
68
  now: 42,
61
- serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
69
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
62
70
  });
63
- expect(outcome).toEqual({ admitted: true });
71
+ expect(outcome).toEqual({ admitted: true, subprotocol: null });
64
72
  expect(stub.puts).toBe(1);
65
73
  });
66
74
 
@@ -70,7 +78,95 @@ describe('handleConnect admission plumbing', () => {
70
78
  dynamo: stub.dynamo,
71
79
  tables: TABLES,
72
80
  connectionId: 'c-full',
73
- serverConfig: { ...DEFAULT_SERVER_CONFIG, maxClients: 5 },
81
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
82
+ });
83
+ expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
84
+ expect(stub.puts).toBe(0);
85
+ });
86
+ });
87
+
88
+ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
89
+ it('selects binary.ircv3.net, echoes it, and persists a spec-binary mode row', async () => {
90
+ const stub = makeStub([{ Count: 0 }]);
91
+ const outcome = await handleConnect({
92
+ dynamo: stub.dynamo,
93
+ tables: TABLES,
94
+ connectionId: 'c-binary',
95
+ now: 42,
96
+ secWebSocketProtocol: 'binary.ircv3.net',
97
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
98
+ });
99
+ expect(outcome).toEqual({ admitted: true, subprotocol: 'binary.ircv3.net' });
100
+ expect(stub.puts).toBe(1);
101
+ const item = stub.lastPutItem as MarshalledConnection | undefined;
102
+ expect(item?.wsMode).toBe('spec-binary');
103
+ });
104
+
105
+ it('selects text.ircv3.net and persists a spec-text mode row', async () => {
106
+ const stub = makeStub([{ Count: 0 }]);
107
+ const outcome = await handleConnect({
108
+ dynamo: stub.dynamo,
109
+ tables: TABLES,
110
+ connectionId: 'c-text',
111
+ now: 42,
112
+ secWebSocketProtocol: 'text.ircv3.net',
113
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
114
+ });
115
+ expect(outcome).toEqual({ admitted: true, subprotocol: 'text.ircv3.net' });
116
+ const item = stub.lastPutItem as MarshalledConnection | undefined;
117
+ expect(item?.wsMode).toBe('spec-text');
118
+ });
119
+
120
+ it('honours client-preference order when both subprotocols are offered', async () => {
121
+ const stub = makeStub([{ Count: 0 }]);
122
+ const outcome = await handleConnect({
123
+ dynamo: stub.dynamo,
124
+ tables: TABLES,
125
+ connectionId: 'c-pref',
126
+ now: 42,
127
+ secWebSocketProtocol: 'text.ircv3.net, binary.ircv3.net',
128
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
129
+ });
130
+ expect(outcome).toEqual({ admitted: true, subprotocol: 'text.ircv3.net' });
131
+ });
132
+
133
+ it('falls back to legacy (null subprotocol, no mode column) when no offer is present', async () => {
134
+ const stub = makeStub([{ Count: 0 }]);
135
+ const outcome = await handleConnect({
136
+ dynamo: stub.dynamo,
137
+ tables: TABLES,
138
+ connectionId: 'c-legacy',
139
+ now: 42,
140
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
141
+ });
142
+ expect(outcome).toEqual({ admitted: true, subprotocol: null });
143
+ const item = stub.lastPutItem as MarshalledConnection | undefined;
144
+ expect(item?.wsMode).toBeUndefined();
145
+ });
146
+
147
+ it('falls back to legacy when only unsupported subprotocols are offered', async () => {
148
+ const stub = makeStub([{ Count: 0 }]);
149
+ const outcome = await handleConnect({
150
+ dynamo: stub.dynamo,
151
+ tables: TABLES,
152
+ connectionId: 'c-unsupported',
153
+ now: 42,
154
+ secWebSocketProtocol: 'chat, superchat.v2',
155
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
156
+ });
157
+ expect(outcome).toEqual({ admitted: true, subprotocol: null });
158
+ const item = stub.lastPutItem as MarshalledConnection | undefined;
159
+ expect(item?.wsMode).toBeUndefined();
160
+ });
161
+
162
+ it('does not negotiate a subprotocol when the admission gate rejects', async () => {
163
+ const stub = makeStub([{ Count: 5 }]);
164
+ const outcome = await handleConnect({
165
+ dynamo: stub.dynamo,
166
+ tables: TABLES,
167
+ connectionId: 'c-full',
168
+ secWebSocketProtocol: 'binary.ircv3.net',
169
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
74
170
  });
75
171
  expect(outcome).toEqual({ admitted: false, statusCode: 429, reason: 'server full' });
76
172
  expect(stub.puts).toBe(0);
@@ -12,12 +12,12 @@ import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
12
12
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
13
  import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
14
  import {
15
- DEFAULT_SERVER_CONFIG,
16
15
  type Nick,
17
16
  type ParsedServerConfig,
18
17
  StaticMotdProvider,
19
18
  createConnection,
20
19
  } from '@serverless-ircd/irc-core';
20
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
21
21
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22
22
  import { AwsRuntime, type PostToConnection, cleanupConnection } from '../src/aws-runtime.js';
23
23
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
@@ -30,12 +30,11 @@ import type { TablesConfig } from '../src/tables.js';
30
30
  const available = process.env.DDB_AVAILABLE === '1';
31
31
  const endpoint = process.env.DYNAMO_ENDPOINT;
32
32
 
33
- const SERVER_CONFIG: ParsedServerConfig = {
34
- ...DEFAULT_SERVER_CONFIG,
33
+ const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
35
34
  serverName: 'irc.example.com',
36
35
  networkName: 'ExampleNet',
37
36
  motdLines: [],
38
- };
37
+ });
39
38
 
40
39
  interface Fixture {
41
40
  tables: TablesConfig;
@@ -2,20 +2,32 @@
2
2
  * Vitest `globalSetup` — boots a DynamoDB Local instance once per test run
3
3
  * and exposes its endpoint via `process.env.DYNAMO_ENDPOINT`.
4
4
  *
5
- * Strategy (in priority order):
5
+ * Strategy selection:
6
6
  *
7
- * 1. **testcontainers** preferred, matches CI. Spins up the canonical
8
- * `amazon/dynamodb-local` Docker image. Requires Docker.
9
- * 2. **DynamoDB Local ZIP + Java** development fallback for hosts
10
- * with Java (e.g. dev laptops without Docker Desktop). Downloads
11
- * the official AWS ZIP once into a per-user cache dir, runs it
12
- * with `-inMemory` so per-run state is isolated by port.
7
+ * - `DDB_LOCAL_STRATEGY=zip` → only the Java ZIP fallback (skips the
8
+ * testcontainers probe). Canonical CI path on the self-hosted Gitea
9
+ * runner, which has a JRE installed via `setup-java` but no
10
+ * privileged container / `docker.sock` mount for Ryuk.
11
+ * - `DDB_LOCAL_STRATEGY=docker` only testcontainers.
12
+ * - unset / `auto` (default) try testcontainers first, then ZIP.
13
+ * Matches developer laptops where either backend may be available.
14
+ *
15
+ * Strategies (in priority order under `auto`):
16
+ *
17
+ * 1. **testcontainers** — preferred. Spins up the canonical
18
+ * `amazon/dynamodb-local` Docker image. Requires Docker with a
19
+ * working Ryuk reaper (privileged runner or `docker.sock` mount).
20
+ * 2. **DynamoDB Local ZIP + Java** — development/CI fallback for hosts
21
+ * with Java (e.g. the CI runner image with a JRE, or dev laptops
22
+ * without Docker Desktop). Downloads the official AWS ZIP once
23
+ * into a per-user cache dir, runs it with `-inMemory` so per-run
24
+ * state is isolated by port.
13
25
  * 3. **Skip** — sets `process.env.DDB_AVAILABLE = '0'`; integration
14
26
  * tests gate themselves on this via `describe.skipIf`.
15
27
  *
16
28
  * The skip path keeps the suite green in environments that legitimately
17
29
  * can't run DynamoDB Local (no Docker, no Java). The acceptance criterion
18
- * is "all 31 scenarios pass when DDB is available", not "every dev box
30
+ * is "all scenarios pass when DDB is available", not "every dev box
19
31
  * runs them".
20
32
  */
21
33
 
@@ -51,34 +63,47 @@ interface DdbHandle {
51
63
  }
52
64
 
53
65
  export default async function globalSetup(): Promise<(() => Promise<void>) | undefined> {
66
+ const strategy = (process.env.DDB_LOCAL_STRATEGY ?? 'auto').toLowerCase();
67
+ // Strategy pin: skip the unused probe to avoid a multi-second timeout
68
+ // on hosts that lack the capability. CI pins `zip`; local dev defaults
69
+ // to `auto` so a Docker Desktop install still gets testcontainers.
70
+ const wantDocker = strategy === 'auto' || strategy === 'docker';
71
+ const wantZip = strategy === 'auto' || strategy === 'zip';
72
+
54
73
  // Strategy 1: testcontainers (Docker).
55
- try {
56
- const handle = await tryTestcontainers();
57
- if (handle !== null) {
58
- process.env.DDB_AVAILABLE = '1';
59
- process.env.DYNAMO_ENDPOINT = handle.endpoint;
60
- console.warn(`[aws-adapter global-setup] DynamoDB Local via Docker at ${handle.endpoint}`);
61
- return async () => {
62
- await handle.stop();
63
- };
74
+ if (wantDocker) {
75
+ try {
76
+ const handle = await tryTestcontainers();
77
+ if (handle !== null) {
78
+ process.env.DDB_AVAILABLE = '1';
79
+ process.env.DYNAMO_ENDPOINT = handle.endpoint;
80
+ console.warn(`[aws-adapter global-setup] DynamoDB Local via Docker at ${handle.endpoint}`);
81
+ return async () => {
82
+ await handle.stop();
83
+ };
84
+ }
85
+ } catch (err: unknown) {
86
+ console.warn('[aws-adapter global-setup] testcontainers unavailable:', renderErr(err));
64
87
  }
65
- } catch (err: unknown) {
66
- console.warn('[aws-adapter global-setup] testcontainers unavailable:', renderErr(err));
67
88
  }
68
89
 
69
90
  // Strategy 2: local ZIP + Java.
70
- try {
71
- const handle = await tryZip();
72
- if (handle !== null) {
73
- process.env.DDB_AVAILABLE = '1';
74
- process.env.DYNAMO_ENDPOINT = handle.endpoint;
75
- console.warn(`[aws-adapter global-setup] DynamoDB Local via Java ZIP at ${handle.endpoint}`);
76
- return async () => {
77
- await handle.stop();
78
- };
91
+ if (wantZip) {
92
+ try {
93
+ const handle = await tryZip();
94
+ if (handle !== null) {
95
+ process.env.DDB_AVAILABLE = '1';
96
+ process.env.DYNAMO_ENDPOINT = handle.endpoint;
97
+ console.warn(
98
+ `[aws-adapter global-setup] DynamoDB Local via Java ZIP at ${handle.endpoint}`,
99
+ );
100
+ return async () => {
101
+ await handle.stop();
102
+ };
103
+ }
104
+ } catch (err: unknown) {
105
+ console.warn('[aws-adapter global-setup] ZIP fallback unavailable:', renderErr(err));
79
106
  }
80
- } catch (err: unknown) {
81
- console.warn('[aws-adapter global-setup] ZIP fallback unavailable:', renderErr(err));
82
107
  }
83
108
 
84
109
  // Strategy 3: skip.
@@ -11,12 +11,8 @@
11
11
  import { GoneException } from '@aws-sdk/client-apigatewaymanagementapi';
12
12
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
13
13
  import { PutCommand } from '@aws-sdk/lib-dynamodb';
14
- import {
15
- DEFAULT_SERVER_CONFIG,
16
- type Nick,
17
- type ParsedServerConfig,
18
- createConnection,
19
- } from '@serverless-ircd/irc-core';
14
+ import { type Nick, type ParsedServerConfig, createConnection } from '@serverless-ircd/irc-core';
15
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
20
16
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
21
17
  import { AwsRuntime, type PostToConnection, cleanupConnection } from '../src/aws-runtime.js';
22
18
  import { TABLE_DEFS } from '../src/cdk-table-defs.js';
@@ -27,12 +23,11 @@ import type { TablesConfig } from '../src/tables.js';
27
23
  const available = process.env.DDB_AVAILABLE === '1';
28
24
  const endpoint = process.env.DYNAMO_ENDPOINT;
29
25
 
30
- const SERVER_CONFIG: ParsedServerConfig = {
31
- ...DEFAULT_SERVER_CONFIG,
26
+ const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
32
27
  serverName: 'irc.example.com',
33
28
  networkName: 'ExampleNet',
34
29
  motdLines: [],
35
- };
30
+ });
36
31
 
37
32
  interface Fixture {
38
33
  tables: TablesConfig;