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
@@ -8,11 +8,11 @@
8
8
 
9
9
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
10
10
  import {
11
- DEFAULT_SERVER_CONFIG,
12
11
  InMemoryAccountStore,
13
12
  type ParsedServerConfig,
14
13
  StaticMotdProvider,
15
14
  } from '@serverless-ircd/irc-core';
15
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
16
16
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
17
17
  import { AwsRuntime } from '../src/aws-runtime.js';
18
18
  import type { PostToConnection } from '../src/aws-runtime.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: ['Welcome to the test IRC server.'],
38
- };
37
+ });
39
38
 
40
39
  const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
41
40
 
@@ -96,6 +95,9 @@ async function makeFixture(): Promise<Fixture> {
96
95
  motd: MOTD,
97
96
  messages: bindMessageStore(SERVER_CONFIG),
98
97
  managementApi: mgmt as never,
98
+ // Bind a REHASH reload source so the $default dispatch path exercises
99
+ // the configLoader wiring (and an oper's REHASH re-fetches config).
100
+ configLoader: async () => SERVER_CONFIG,
99
101
  },
100
102
  cleanup: async () => {
101
103
  for (const logical of Object.keys(TABLE_DEFS)) {
@@ -142,6 +144,27 @@ describe.skipIf(!available)('handlers', () => {
142
144
  expect(info?.registration).toBe('pre-registration');
143
145
  });
144
146
 
147
+ it('echoes the agreed Sec-WebSocket-Protocol header back on a spec-mode connect', async () => {
148
+ const res = await dispatch(
149
+ {
150
+ requestContext: { routeKey: '$connect', connectionId: 'c-sub' },
151
+ headers: { 'Sec-WebSocket-Protocol': 'text.ircv3.net' },
152
+ },
153
+ requireFx().deps,
154
+ );
155
+ expect(res.statusCode).toBe(200);
156
+ expect(res.headers?.['Sec-WebSocket-Protocol']).toBe('text.ircv3.net');
157
+ });
158
+
159
+ it('omits the Sec-WebSocket-Protocol header on a legacy connect', async () => {
160
+ const res = await dispatch(
161
+ { requestContext: { routeKey: '$connect', connectionId: 'c-nosub' } },
162
+ requireFx().deps,
163
+ );
164
+ expect(res.statusCode).toBe(200);
165
+ expect(res.headers?.['Sec-WebSocket-Protocol']).toBeUndefined();
166
+ });
167
+
145
168
  it('is idempotent on APIGW retry (same connectionId overwrites)', async () => {
146
169
  for (let i = 0; i < 2; i++) {
147
170
  await dispatch(
@@ -361,6 +384,95 @@ describe.skipIf(!available)('handlers', () => {
361
384
  expect(sink).toContain('PONG :tok');
362
385
  });
363
386
 
387
+ it('delivers each outbound line as its own postToConnection for a spec-mode connection', async () => {
388
+ await dispatch(
389
+ {
390
+ requestContext: { routeKey: '$connect', connectionId: 'spec1' },
391
+ headers: { 'Sec-WebSocket-Protocol': 'text.ircv3.net' },
392
+ },
393
+ requireFx().deps,
394
+ );
395
+ const calls: string[] = [];
396
+ requireFx().mgmt.register('spec1', (data) => {
397
+ calls.push(data);
398
+ });
399
+ // Spec clients send exactly one IRC message per WebSocket frame.
400
+ await dispatch(
401
+ {
402
+ requestContext: { routeKey: '$default', connectionId: 'spec1' },
403
+ body: 'NICK bob\r\n',
404
+ },
405
+ requireFx().deps,
406
+ );
407
+ await dispatch(
408
+ {
409
+ requestContext: { routeKey: '$default', connectionId: 'spec1' },
410
+ body: 'USER bob 0 * :Bob\r\n',
411
+ },
412
+ requireFx().deps,
413
+ );
414
+ // The welcome block is multiple IRC lines; the IRCv3 spec contract
415
+ // sends exactly one line per WebSocket message (no trailing CRLF).
416
+ expect(calls.length).toBeGreaterThan(1);
417
+ for (const data of calls) {
418
+ expect(data).not.toContain('\r\n');
419
+ }
420
+ expect(calls.some((d) => d.includes(' 001 '))).toBe(true);
421
+ });
422
+
423
+ it('delivers one postToConnection per line for a binary.ircv3.net connection', async () => {
424
+ await dispatch(
425
+ {
426
+ requestContext: { routeKey: '$connect', connectionId: 'spec2' },
427
+ headers: { 'Sec-WebSocket-Protocol': 'binary.ircv3.net' },
428
+ },
429
+ requireFx().deps,
430
+ );
431
+ const calls: string[] = [];
432
+ requireFx().mgmt.register('spec2', (data) => {
433
+ calls.push(data);
434
+ });
435
+ await dispatch(
436
+ {
437
+ requestContext: { routeKey: '$default', connectionId: 'spec2' },
438
+ body: 'NICK binbob\r\n',
439
+ },
440
+ requireFx().deps,
441
+ );
442
+ await dispatch(
443
+ {
444
+ requestContext: { routeKey: '$default', connectionId: 'spec2' },
445
+ body: 'USER binbob 0 * :BinBob\r\n',
446
+ },
447
+ requireFx().deps,
448
+ );
449
+ expect(calls.length).toBeGreaterThan(1);
450
+ for (const data of calls) {
451
+ expect(data).not.toContain('\r\n');
452
+ }
453
+ });
454
+
455
+ it('batches outbound lines into a single postToConnection for a legacy connection', async () => {
456
+ await dispatch(
457
+ { requestContext: { routeKey: '$connect', connectionId: 'leg1' } },
458
+ requireFx().deps,
459
+ );
460
+ const calls: string[] = [];
461
+ requireFx().mgmt.register('leg1', (data) => {
462
+ calls.push(data);
463
+ });
464
+ await dispatch(
465
+ {
466
+ requestContext: { routeKey: '$default', connectionId: 'leg1' },
467
+ body: 'NICK carol\r\nUSER carol 0 * :Carol\r\n',
468
+ },
469
+ requireFx().deps,
470
+ );
471
+ expect(calls.length).toBe(1);
472
+ expect(calls[0]).toContain('\r\n');
473
+ expect(calls[0]).toContain(' 001 ');
474
+ });
475
+
364
476
  it('persists joined channels across $default invocations', async () => {
365
477
  await dispatch(
366
478
  { requestContext: { routeKey: '$connect', connectionId: 'c1' } },
@@ -537,6 +649,128 @@ describe.skipIf(!available)('handlers', () => {
537
649
  // The 500-branch is covered indirectly by GoneException tests.
538
650
  expect(true).toBe(true);
539
651
  });
652
+
653
+ it('runs an oper REHASH through the bound configLoader and emits 382', async () => {
654
+ await dispatch(
655
+ { requestContext: { routeKey: '$connect', connectionId: 'rh1' } },
656
+ requireFx().deps,
657
+ );
658
+ const sink: string[] = [];
659
+ requireFx().mgmt.register('rh1', (data) => {
660
+ for (const line of data.split('\r\n')) {
661
+ if (line.length > 0) sink.push(line);
662
+ }
663
+ });
664
+ // Seed oper creds + a reload source so OPER then REHASH both succeed.
665
+ const depsWithOper: HandlerDeps = {
666
+ ...requireFx().deps,
667
+ serverConfig: { ...SERVER_CONFIG, operCreds: [{ user: 'alice', password: 's3cret' }] },
668
+ configLoader: async () => ({
669
+ ...SERVER_CONFIG,
670
+ operCreds: [{ user: 'alice', password: 'rotated' }],
671
+ }),
672
+ };
673
+ await dispatch(
674
+ {
675
+ requestContext: { routeKey: '$default', connectionId: 'rh1' },
676
+ body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nOPER alice s3cret\r\nREHASH\r\n',
677
+ },
678
+ depsWithOper,
679
+ );
680
+
681
+ // 381 (oper) + 382 (rehashing from the Secrets Manager source) emitted.
682
+ expect(sink.some((l) => l.includes(' 381 '))).toBe(true);
683
+ expect(sink.some((l) => l.includes(' 382 ') && l.includes('Secrets Manager'))).toBe(true);
684
+ // Never a 421 for REHASH.
685
+ expect(sink.some((l) => l.includes(' 421 ') && l.includes('REHASH'))).toBe(false);
686
+ });
687
+
688
+ it('refreshChannel updates cached state when a channel becomes empty (no topic)', async () => {
689
+ // After the last member PARTs, the channel snapshot returns 0
690
+ // members but the meta row persists. The next frame that
691
+ // references the channel triggers refreshChannel, which must
692
+ // take the empty-members + existing-cache path without throwing
693
+ // and leave the cache usable for subsequent joins.
694
+ await dispatch(
695
+ { requestContext: { routeKey: '$connect', connectionId: 'e1' } },
696
+ requireFx().deps,
697
+ );
698
+ requireFx().mgmt.register('e1', () => {});
699
+ await dispatch(
700
+ {
701
+ requestContext: { routeKey: '$default', connectionId: 'e1' },
702
+ body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #empty\r\n',
703
+ },
704
+ requireFx().deps,
705
+ );
706
+ // PART drops the member count to 0 while the meta row survives.
707
+ await dispatch(
708
+ {
709
+ requestContext: { routeKey: '$default', connectionId: 'e1' },
710
+ body: 'PART #empty\r\n',
711
+ },
712
+ requireFx().deps,
713
+ );
714
+ // Re-JOIN forces prefetchChannelState → refreshChannel, hitting
715
+ // the empty-members + existing-cache branch. The JOIN must
716
+ // succeed (no exception), proving the cache is still consistent.
717
+ const res = await dispatch(
718
+ {
719
+ requestContext: { routeKey: '$default', connectionId: 'e1' },
720
+ body: 'JOIN #empty\r\n',
721
+ },
722
+ requireFx().deps,
723
+ );
724
+ expect(res.statusCode).toBe(200);
725
+ });
726
+
727
+ it('refreshChannel preserves an existing topic when the snapshot has one', async () => {
728
+ // Same shape as the previous test, but a TOPIC is set before the
729
+ // PART so the snapshot's `topic` field is defined. This exercises
730
+ // the `if (snap.topic !== undefined) existing.topic = snap.topic`
731
+ // assignment in the empty-members path. The JOIN reducer does not
732
+ // emit 332 itself, so we query the topic explicitly via `TOPIC
733
+ // #chan` (no args) after re-JOIN — a 332 RPL_TOPIC reply proves
734
+ // the cache retained the topic through the empty refresh.
735
+ await dispatch(
736
+ { requestContext: { routeKey: '$connect', connectionId: 't1' } },
737
+ requireFx().deps,
738
+ );
739
+ const sink: string[] = [];
740
+ requireFx().mgmt.register('t1', (data) => {
741
+ for (const line of data.split('\r\n')) {
742
+ if (line.length > 0) sink.push(line);
743
+ }
744
+ });
745
+ await dispatch(
746
+ {
747
+ requestContext: { routeKey: '$default', connectionId: 't1' },
748
+ body: 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #topicchan\r\nTOPIC #topicchan :hello\r\n',
749
+ },
750
+ requireFx().deps,
751
+ );
752
+ const sinkAfterFirstJoin = sink.length;
753
+ await dispatch(
754
+ {
755
+ requestContext: { routeKey: '$default', connectionId: 't1' },
756
+ body: 'PART #topicchan\r\n',
757
+ },
758
+ requireFx().deps,
759
+ );
760
+ // Re-JOIN triggers refreshChannel with snap.topic === 'hello',
761
+ // assigning existing.topic. The subsequent TOPIC query reads from
762
+ // the cache and emits 332 RPL_TOPIC.
763
+ const res = await dispatch(
764
+ {
765
+ requestContext: { routeKey: '$default', connectionId: 't1' },
766
+ body: 'JOIN #topicchan\r\nTOPIC #topicchan\r\n',
767
+ },
768
+ requireFx().deps,
769
+ );
770
+ expect(res.statusCode).toBe(200);
771
+ const newReplies = sink.slice(sinkAfterFirstJoin);
772
+ expect(newReplies.some((l) => l.includes(' 332 ') && l.includes('hello'))).toBe(true);
773
+ });
540
774
  });
541
775
 
542
776
  // -------------------------------------------------------------------------
@@ -7,7 +7,8 @@
7
7
  * store (record → query round-trip) without needing DynamoDB Local.
8
8
  */
9
9
 
10
- import { DEFAULT_SERVER_CONFIG, type StoredMessage } from '@serverless-ircd/irc-core';
10
+ import type { StoredMessage } from '@serverless-ircd/irc-core';
11
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
11
12
  import { describe, expect, it } from 'vitest';
12
13
  import { bindMessageStore } from '../src/message-store.js';
13
14
 
@@ -26,7 +27,7 @@ function sampleMessage(chan: string, text: string): StoredMessage {
26
27
 
27
28
  describe('bindMessageStore', () => {
28
29
  it('returns a store that records and replays a message via LATEST', () => {
29
- const store = bindMessageStore(DEFAULT_SERVER_CONFIG);
30
+ const store = bindMessageStore(makeTestServerConfig());
30
31
  store.record(sampleMessage('#aws', 'hello aws'));
31
32
 
32
33
  const result = store.query({ chan: '#aws', direction: 'latest', limit: 10 });
@@ -35,7 +36,7 @@ describe('bindMessageStore', () => {
35
36
  });
36
37
 
37
38
  it('returns a store scoped per channel (no cross-channel leak)', () => {
38
- const store = bindMessageStore(DEFAULT_SERVER_CONFIG);
39
+ const store = bindMessageStore(makeTestServerConfig());
39
40
  store.record(sampleMessage('#a', 'in a'));
40
41
  store.record(sampleMessage('#b', 'in b'));
41
42
 
@@ -45,8 +46,8 @@ describe('bindMessageStore', () => {
45
46
  });
46
47
 
47
48
  it('returns an independent store instance per call', () => {
48
- const a = bindMessageStore(DEFAULT_SERVER_CONFIG);
49
- const b = bindMessageStore(DEFAULT_SERVER_CONFIG);
49
+ const a = bindMessageStore(makeTestServerConfig());
50
+ const b = bindMessageStore(makeTestServerConfig());
50
51
  a.record(sampleMessage('#aws', 'only in a'));
51
52
 
52
53
  expect(a.query({ chan: '#aws', direction: 'latest', limit: 10 })).toHaveLength(1);
@@ -9,11 +9,8 @@
9
9
  */
10
10
 
11
11
  import { CreateTableCommand, DeleteTableCommand } from '@aws-sdk/client-dynamodb';
12
- import {
13
- DEFAULT_SERVER_CONFIG,
14
- type ParsedServerConfig,
15
- StaticMotdProvider,
16
- } from '@serverless-ircd/irc-core';
12
+ import { type ParsedServerConfig, StaticMotdProvider } from '@serverless-ircd/irc-core';
13
+ import { makeTestServerConfig } from '@serverless-ircd/irc-test-support';
17
14
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
18
15
  import { AwsRuntime } from '../src/aws-runtime.js';
19
16
  import type { PostToConnection } from '../src/aws-runtime.js';
@@ -30,12 +27,11 @@ import type { TablesConfig } from '../src/tables.js';
30
27
  const available = process.env.DDB_AVAILABLE === '1';
31
28
  const endpoint = process.env.DYNAMO_ENDPOINT;
32
29
 
33
- const SERVER_CONFIG: ParsedServerConfig = {
34
- ...DEFAULT_SERVER_CONFIG,
30
+ const SERVER_CONFIG: ParsedServerConfig = makeTestServerConfig({
35
31
  serverName: 'irc.example.com',
36
32
  networkName: 'ExampleNet',
37
33
  motdLines: ['Welcome to the test IRC server.'],
38
- };
34
+ });
39
35
 
40
36
  const MOTD = new StaticMotdProvider(SERVER_CONFIG.motdLines);
41
37
 
@@ -151,6 +147,8 @@ describe.skipIf(!available)('NLB stream handler — byte framing', () => {
151
147
  motd: MOTD,
152
148
  messages: bindMessageStore(SERVER_CONFIG),
153
149
  managementApi: f.mgmt,
150
+ // Bind a reload source so the configLoader wiring is exercised.
151
+ configLoader: async () => SERVER_CONFIG,
154
152
  });
155
153
  expect(res.statusCode).toBe(200);
156
154
 
@@ -459,6 +457,62 @@ describe.skipIf(!available)('NLB stream handler — streaming back', () => {
459
457
  });
460
458
  expect(bSink.some((l) => l.includes('PRIVMSG #room :hello from alice'))).toBe(true);
461
459
  });
460
+
461
+ it('refreshChannel tolerates an empty channel snapshot without throwing', async () => {
462
+ // After the last member PARTs, the channel meta row persists but
463
+ // queryMembers returns no rows → snap.members.size === 0. The next
464
+ // frame referencing the channel triggers refreshChannel on the
465
+ // empty-members + existing-cache path. This test ensures that path
466
+ // does not throw and the channel remains usable for a re-JOIN.
467
+ const f = requireFx();
468
+ const opts = () => ({
469
+ dynamo: f.client,
470
+ tables: f.tables,
471
+ serverConfig: SERVER_CONFIG,
472
+ motd: MOTD,
473
+ messages: bindMessageStore(SERVER_CONFIG),
474
+ managementApi: f.mgmt,
475
+ });
476
+ const a = deriveNlbConnectionId('10.0.0.40', '50040');
477
+ f.mgmt.register(a, () => {});
478
+ // Register + JOIN + PART → cache populated then snapshot empty.
479
+ await handleNlbStream(nlbEvent('10.0.0.40', '50040', ''), opts());
480
+ await handleNlbStream(
481
+ nlbEvent('10.0.0.40', '50040', 'NICK alice\r\nUSER alice 0 * :Alice\r\nJOIN #nlbempty\r\n'),
482
+ opts(),
483
+ );
484
+ await handleNlbStream(nlbEvent('10.0.0.40', '50040', 'PART #nlbempty\r\n'), opts());
485
+ // Re-JOIN hits refreshChannel on the empty-members path; must not throw.
486
+ const res = await handleNlbStream(nlbEvent('10.0.0.40', '50040', 'JOIN #nlbempty\r\n'), opts());
487
+ expect(res.statusCode).toBe(200);
488
+ });
489
+
490
+ it('falls back to "unknown"/"0" when NLB headers are absent', async () => {
491
+ // `readHeader` returns undefined when no header key matches. The
492
+ // caller's `?? 'unknown'` / `?? '0'` fallbacks must apply so the
493
+ // handler does not crash on a malformed event (defensive).
494
+ const f = requireFx();
495
+ const eventWithoutHeaders: NlbStreamEvent = {
496
+ requestContext: {
497
+ elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:::targetgroup/test' },
498
+ },
499
+ version: '2.0',
500
+ body: Buffer.from('PING :tok\r\n', 'utf8').toString('base64'),
501
+ isBase64Encoded: true,
502
+ headers: { 'some-other-header': 'value' },
503
+ };
504
+ const res = await handleNlbStream(eventWithoutHeaders, {
505
+ dynamo: f.client,
506
+ tables: f.tables,
507
+ serverConfig: SERVER_CONFIG,
508
+ motd: MOTD,
509
+ messages: bindMessageStore(SERVER_CONFIG),
510
+ managementApi: f.mgmt,
511
+ });
512
+ expect(res.statusCode).toBe(200);
513
+ // The fallback id (nlb-unknown-0) still received a PONG.
514
+ expect(res.body).toBeDefined();
515
+ });
462
516
  });
463
517
 
464
518
  /**