serverless-ircd 0.1.0 → 0.2.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 (198) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -1,4 +1,5 @@
1
1
  import {
2
+ CapturingLogger,
2
3
  type ChanName,
3
4
  type ChanSnapshot,
4
5
  type ChannelDelta,
@@ -8,10 +9,17 @@ import {
8
9
  type ConnectionState,
9
10
  EmptyMotdProvider,
10
11
  FakeClock,
12
+ type FloodControlConfig,
13
+ InMemoryMessageStore,
14
+ LogLevel,
15
+ type Logger,
16
+ type MessageStore,
11
17
  type Nick,
12
18
  Numerics,
13
19
  type RawLine,
14
20
  SequentialIdFactory,
21
+ type ServerConfig,
22
+ type StoredMessage,
15
23
  applyChannelDelta as applyDelta,
16
24
  createChannel,
17
25
  createConnection,
@@ -56,6 +64,10 @@ class FakeRuntime implements IrcRuntime {
56
64
  return chan;
57
65
  }
58
66
 
67
+ getChannel(name: ChanName): ChannelState | undefined {
68
+ return this.channels.get(name.toLowerCase());
69
+ }
70
+
59
71
  async send(to: ConnId, lines: RawLine[]): Promise<void> {
60
72
  this.calls.push({ method: 'send', args: [to, lines] });
61
73
  }
@@ -138,6 +150,10 @@ class FakeRuntime implements IrcRuntime {
138
150
  function makeActor(opts: {
139
151
  conn?: Partial<ConnectionState>;
140
152
  clock?: FakeClock;
153
+ floodControl?: FloodControlConfig | null;
154
+ logger?: Logger;
155
+ traceId?: string;
156
+ messages?: MessageStore;
141
157
  }): { actor: ConnectionActor; runtime: FakeRuntime; conn: ConnectionState } {
142
158
  const clock = opts.clock ?? new FakeClock(1_000);
143
159
  const conn = createConnection({ id: 'c1', connectedSince: 0 });
@@ -149,25 +165,45 @@ function makeActor(opts: {
149
165
  if (opts.conn) Object.assign(conn, opts.conn);
150
166
 
151
167
  const runtime = new FakeRuntime(clock);
152
- const actor = new ConnectionActor({
168
+ // Build in one expression: `floodControl` is readonly, and only stamp it
169
+ // when the caller supplied a value so `undefined` (omit → default) stays
170
+ // distinct from `null` (explicit disable).
171
+ const serverConfig: ServerConfig = {
172
+ serverName: 'irc.example.com',
173
+ networkName: 'ExampleNet',
174
+ maxChannelsPerUser: 30,
175
+ maxTargetsPerCommand: 10,
176
+ maxListEntries: 50,
177
+ nickLen: 30,
178
+ channelLen: 50,
179
+ topicLen: 390,
180
+ quitMessage: 'Client Quit',
181
+ ...(opts.floodControl !== undefined ? { floodControl: opts.floodControl } : {}),
182
+ };
183
+ const ids = new SequentialIdFactory();
184
+ type ActorOpts = ConstructorParameters<typeof ConnectionActor>[0];
185
+ // Strip readonly modifiers so we can conditionally assign `logger` /
186
+ // `traceId` without tripping the readonly guards on `ConnectionActorOptions`.
187
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
188
+ const finalOpts: Mutable<ActorOpts> = {
153
189
  state: conn,
154
190
  runtime,
155
191
  channels: runtime,
156
- serverConfig: {
157
- serverName: 'irc.example.com',
158
- networkName: 'ExampleNet',
159
- maxChannelsPerUser: 30,
160
- maxTargetsPerCommand: 10,
161
- maxListEntries: 50,
162
- nickLen: 30,
163
- channelLen: 50,
164
- topicLen: 390,
165
- quitMessage: 'Client Quit',
166
- },
192
+ serverConfig,
167
193
  clock,
168
- ids: new SequentialIdFactory(),
194
+ ids,
169
195
  motd: EmptyMotdProvider,
170
- });
196
+ };
197
+ if (opts.logger !== undefined) {
198
+ finalOpts.logger = opts.logger;
199
+ }
200
+ if (opts.traceId !== undefined) {
201
+ finalOpts.traceId = opts.traceId;
202
+ }
203
+ if (opts.messages !== undefined) {
204
+ finalOpts.messages = opts.messages;
205
+ }
206
+ const actor = new ConnectionActor(finalOpts);
171
207
  return { actor, runtime, conn };
172
208
  }
173
209
 
@@ -563,6 +599,119 @@ describe('ConnectionActor — additional routing', () => {
563
599
  }
564
600
  });
565
601
 
602
+ it('routes AUTHENTICATE PLAIN (post CAP REQ sasl) through authenticateReducer, never 421', async () => {
603
+ // Pre-registration: client has opened CAP negotiation and requested sasl.
604
+ // AUTHENTICATE must reach the SASL reducer, not the 421 fallthrough.
605
+ const { actor, runtime, conn } = makeActor({
606
+ conn: {
607
+ registration: 'pre-registration',
608
+ capNegotiating: true,
609
+ caps: new Set<string>(['sasl']),
610
+ },
611
+ });
612
+ // The connection needs a nick for the reducer's `nickOf(ctx)` fallback.
613
+ conn.nick = 'alice';
614
+ runtime.calls.length = 0;
615
+
616
+ await actor.receiveTextFrame('AUTHENTICATE PLAIN\r\n');
617
+
618
+ const sends = runtime.calls.filter((c) => c.method === 'send');
619
+ expect(sends).toHaveLength(1);
620
+ const lines = sends[0]?.args[1] as RawLine[];
621
+ // SASL reducer responds with `AUTHENTICATE +` to request the payload.
622
+ expect(lines[0]?.text).toBe('AUTHENTICATE +');
623
+ // Critical: never 421 for AUTHENTICATE.
624
+ for (const s of sends) {
625
+ for (const line of s.args[1] as RawLine[]) {
626
+ expect(line.text).not.toContain(' 421 ');
627
+ }
628
+ }
629
+ });
630
+
631
+ it('routes AUTHENTICATE * (abort) through authenticateReducer, never 421', async () => {
632
+ const { actor, runtime } = makeActor({
633
+ conn: {
634
+ registration: 'pre-registration',
635
+ capNegotiating: true,
636
+ caps: new Set<string>(['sasl']),
637
+ saslMech: 'PLAIN',
638
+ },
639
+ });
640
+ runtime.calls.length = 0;
641
+
642
+ await actor.receiveTextFrame('AUTHENTICATE *\r\n');
643
+
644
+ const sends = runtime.calls.filter((c) => c.method === 'send');
645
+ expect(sends).toHaveLength(1);
646
+ const lines = sends[0]?.args[1] as RawLine[];
647
+ // Abort → 906 ERR_SASLABORT.
648
+ expect(lines[0]?.text).toContain(' 906 ');
649
+ for (const s of sends) {
650
+ for (const line of s.args[1] as RawLine[]) {
651
+ expect(line.text).not.toContain(' 421 ');
652
+ }
653
+ }
654
+ });
655
+
656
+ it('routes AWAY :reason through awayReducer, emitting 306 RPL_NOWAWAY (never 421)', async () => {
657
+ const { actor, runtime } = makeActor({});
658
+ runtime.calls.length = 0;
659
+
660
+ await actor.receiveTextFrame('AWAY :gone\r\n');
661
+
662
+ const sends = runtime.calls.filter((c) => c.method === 'send');
663
+ expect(sends).toHaveLength(1);
664
+ const lines = sends[0]?.args[1] as RawLine[];
665
+ expect(lines[0]?.text).toBe(':irc.example.com 306 alice :You have been marked as being away');
666
+ // Critical: never 421 for AWAY.
667
+ for (const s of sends) {
668
+ for (const line of s.args[1] as RawLine[]) {
669
+ expect(line.text).not.toContain(' 421 ');
670
+ }
671
+ }
672
+ });
673
+
674
+ it('routes AWAY with no params through awayReducer, emitting 305 RPL_UNAWAY (never 421)', async () => {
675
+ const { actor, runtime, conn } = makeActor({});
676
+ // Start from an away state so the unset path is observable.
677
+ conn.away = 'previously away';
678
+ runtime.calls.length = 0;
679
+
680
+ await actor.receiveTextFrame('AWAY\r\n');
681
+
682
+ const sends = runtime.calls.filter((c) => c.method === 'send');
683
+ expect(sends).toHaveLength(1);
684
+ const lines = sends[0]?.args[1] as RawLine[];
685
+ expect(lines[0]?.text).toBe(
686
+ ':irc.example.com 305 alice :You are no longer marked as being away',
687
+ );
688
+ expect(conn.away).toBeUndefined();
689
+ for (const s of sends) {
690
+ for (const line of s.args[1] as RawLine[]) {
691
+ expect(line.text).not.toContain(' 421 ');
692
+ }
693
+ }
694
+ });
695
+
696
+ it('routes AWAY with an empty reason through awayReducer, emitting 305 RPL_UNAWAY (never 421)', async () => {
697
+ const { actor, runtime, conn } = makeActor({});
698
+ conn.away = 'previously away';
699
+ runtime.calls.length = 0;
700
+
701
+ await actor.receiveTextFrame('AWAY :\r\n');
702
+
703
+ const sends = runtime.calls.filter((c) => c.method === 'send');
704
+ expect(sends).toHaveLength(1);
705
+ const lines = sends[0]?.args[1] as RawLine[];
706
+ expect(lines[0]?.text).toContain(' 305 ');
707
+ expect(conn.away).toBeUndefined();
708
+ for (const s of sends) {
709
+ for (const line of s.args[1] as RawLine[]) {
710
+ expect(line.text).not.toContain(' 421 ');
711
+ }
712
+ }
713
+ });
714
+
566
715
  it('emits 421 with empty command when routeSingleChannel gets no channel (TOPIC)', async () => {
567
716
  const { actor, runtime } = makeActor({});
568
717
  await actor.receiveTextFrame('TOPIC\r\n');
@@ -814,3 +963,310 @@ describe('ConnectionActor — WHO', () => {
814
963
  expect(lines[0]?.text).toBe(':irc.example.com 403 alice badname :No such channel');
815
964
  });
816
965
  });
966
+
967
+ describe('ConnectionActor — flood control', () => {
968
+ // Pre-add the connection to #foo so under-limit PRIVMSG frames broadcast
969
+ // (makes the pass-through vs. disconnect contrast observable).
970
+ async function joinFoo(runtime: FakeRuntime): Promise<void> {
971
+ await runtime.applyChannelDelta('#foo', {
972
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
973
+ });
974
+ runtime.calls.length = 0;
975
+ }
976
+
977
+ it('disconnects a client bursting PRIVMSG past the bucket capacity with "Excess Flood"', async () => {
978
+ const { actor, runtime } = makeActor({});
979
+ await joinFoo(runtime);
980
+
981
+ // Default bucket (capacity 10, threshold 0): the first 10 PRIVMSG pass,
982
+ // the 11th onward trip the Disconnect path.
983
+ for (let i = 0; i < 20; i++) {
984
+ await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
985
+ }
986
+
987
+ const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
988
+ expect(disconnects.length).toBeGreaterThan(0);
989
+ expect(disconnects[0]?.args[1]).toBe('Excess Flood');
990
+ // The close path targets the actor's own connection.
991
+ expect(disconnects[0]?.args[0]).toBe('c1');
992
+ });
993
+
994
+ it('lets the same burst pass through unchanged when flood control is disabled (floodControl: null)', async () => {
995
+ const { actor, runtime } = makeActor({ floodControl: null });
996
+ await joinFoo(runtime);
997
+
998
+ for (let i = 0; i < 20; i++) {
999
+ await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
1000
+ }
1001
+
1002
+ // Disabled → every frame reaches the inner reducer and broadcasts; the
1003
+ // close path is never invoked.
1004
+ expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
1005
+ expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(20);
1006
+ });
1007
+
1008
+ it('does not let an interleaved PING advance the bucket (control-plane exemption plumbed through)', async () => {
1009
+ const { actor, runtime } = makeActor({});
1010
+ await joinFoo(runtime);
1011
+
1012
+ // 10 PRIVMSG drain the default bucket exactly to zero (no disconnect yet).
1013
+ for (let i = 0; i < 10; i++) {
1014
+ await actor.receiveTextFrame('PRIVMSG #foo :flood\r\n');
1015
+ }
1016
+ expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
1017
+
1018
+ // A PING is control-plane traffic: it must not decrement the bucket, so
1019
+ // it neither disconnects here nor consumes the last token. (A flat-cost
1020
+ // wrapper would trip the Disconnect on this very frame.)
1021
+ await actor.receiveTextFrame('PING :keepalive\r\n');
1022
+ expect(runtime.calls.filter((c) => c.method === 'disconnect')).toHaveLength(0);
1023
+
1024
+ // The bucket is still at zero, so the next PRIVMSG is the one that trips
1025
+ // the disconnect — proving the PING was free.
1026
+ await actor.receiveTextFrame('PRIVMSG #foo :one-more\r\n');
1027
+ const disconnects = runtime.calls.filter((c) => c.method === 'disconnect');
1028
+ expect(disconnects).toHaveLength(1);
1029
+ expect(disconnects[0]?.args[1]).toBe('Excess Flood');
1030
+ });
1031
+ });
1032
+
1033
+ // ===========================================================================
1034
+ // Observability: structured logs + traceId/connectionId
1035
+ // ===========================================================================
1036
+
1037
+ describe('ConnectionActor — observability', () => {
1038
+ it('emits a structured log record with traceId and connectionId for a processed frame', async () => {
1039
+ const logger = new CapturingLogger();
1040
+ const { actor } = makeActor({ logger, traceId: 'trace-xyz' });
1041
+
1042
+ await actor.receiveTextFrame('PING :tok\r\n');
1043
+
1044
+ const received = logger.records.filter((r) => r.msg === 'frame.receive');
1045
+ expect(received).toHaveLength(1);
1046
+ const rec = received[0];
1047
+ expect(rec?.level).toBe(LogLevel.Debug);
1048
+ expect(rec?.traceId).toBe('trace-xyz');
1049
+ expect(rec?.connectionId).toBe('c1');
1050
+ expect(rec?.fields?.command).toBe('PING');
1051
+ expect(rec?.fields?.lines).toBe(1);
1052
+ });
1053
+
1054
+ it('stamps the same traceId on every record emitted while processing one frame', async () => {
1055
+ const logger = new CapturingLogger();
1056
+ const { actor } = makeActor({ logger, traceId: 'trace-abc' });
1057
+
1058
+ // A joined frame exercises the per-line path; a successful reducer
1059
+ // followed by a parse failure exercises both frame.receive and
1060
+ // frame.parse-error records on the same trace.
1061
+ await actor.receiveTextFrame('PING :tok\r\nBADLINE_7_DIGITS\r\n');
1062
+
1063
+ expect(logger.records.length).toBeGreaterThan(0);
1064
+ for (const rec of logger.records) {
1065
+ expect(rec.traceId).toBe('trace-abc');
1066
+ expect(rec.connectionId).toBe('c1');
1067
+ }
1068
+ });
1069
+
1070
+ it('emits a warn-level frame.parse-error record on a malformed line', async () => {
1071
+ const logger = new CapturingLogger();
1072
+ const { actor } = makeActor({ logger, traceId: 't1' });
1073
+
1074
+ // 5-digit numeric prefix is not a legal IRC command → parse fails.
1075
+ await actor.receiveTextFrame('12345\r\n');
1076
+
1077
+ const errs = logger.records.filter((r) => r.msg === 'frame.parse-error');
1078
+ expect(errs).toHaveLength(1);
1079
+ expect(errs[0]?.level).toBe(LogLevel.Warn);
1080
+ expect(errs[0]?.traceId).toBe('t1');
1081
+ expect(typeof errs[0]?.fields?.line).toBe('string');
1082
+ });
1083
+
1084
+ it('falls back to a NoopLogger and unknown traceId when omitted', async () => {
1085
+ // The actor must still function when no logger/traceId is supplied — the
1086
+ // codebase's default. No exceptions, no observable output.
1087
+ const { actor, runtime } = makeActor({});
1088
+ await actor.receiveTextFrame('PING :tok\r\n');
1089
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1090
+ expect(sends).toHaveLength(1);
1091
+ });
1092
+
1093
+ it('allocates a fresh traceId per receiveTextFrame call when none was supplied', async () => {
1094
+ // Each inbound frame gets its own trace id; the actor generates one
1095
+ // itself via IdFactory.traceId() when the caller did not pin one. Two
1096
+ // frames → two distinct ids.
1097
+ const logger = new CapturingLogger();
1098
+ const { actor } = makeActor({ logger });
1099
+
1100
+ await actor.receiveTextFrame('PING :a\r\n');
1101
+ await actor.receiveTextFrame('PING :b\r\n');
1102
+
1103
+ const ids = new Set(logger.records.map((r) => r.traceId));
1104
+ expect(ids.size).toBe(2);
1105
+ });
1106
+
1107
+ it('logs dispatch effects with the bound traceId', async () => {
1108
+ const logger = new CapturingLogger();
1109
+ const { actor, runtime } = makeActor({ logger, traceId: 'trace-d1' });
1110
+ await runtime.applyChannelDelta('#foo', {
1111
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
1112
+ });
1113
+
1114
+ await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
1115
+
1116
+ const dispatchLogs = logger.records.filter((r) => r.msg === 'dispatch');
1117
+ expect(dispatchLogs.length).toBeGreaterThan(0);
1118
+ for (const rec of dispatchLogs) {
1119
+ expect(rec.traceId).toBe('trace-d1');
1120
+ expect(rec.fields?.effects).toEqual(expect.any(Array));
1121
+ }
1122
+ });
1123
+ });
1124
+
1125
+ // ===========================================================================
1126
+ // CHATHISTORY — dispatcher routing + MessageStore plumbing
1127
+ // ===========================================================================
1128
+
1129
+ describe('ConnectionActor — CHATHISTORY', () => {
1130
+ /** The full cap set a chathistory-capable client negotiates. */
1131
+ const CHATHISTORY_CAPS = new Set<string>([
1132
+ 'draft/chathistory',
1133
+ 'server-time',
1134
+ 'message-tags',
1135
+ 'batch',
1136
+ ]);
1137
+
1138
+ /** Records `count` chronological PRIVMSG messages into `store` for `chan`. */
1139
+ function seedMessages(store: MessageStore, chan: string, count: number): StoredMessage[] {
1140
+ const out: StoredMessage[] = [];
1141
+ for (let i = 1; i <= count; i++) {
1142
+ const m: StoredMessage = {
1143
+ msgid: `m${i}`,
1144
+ time: i * 1_000,
1145
+ chan: chan.toLowerCase(),
1146
+ command: 'PRIVMSG',
1147
+ nick: 'bob',
1148
+ user: 'bob',
1149
+ host: 'ex.org',
1150
+ text: `msg ${i}`,
1151
+ };
1152
+ store.record(m);
1153
+ out.push(m);
1154
+ }
1155
+ return out;
1156
+ }
1157
+
1158
+ it('routes CHATHISTORY LATEST to the reducer, emitting a BATCH chathistory frame (never 421)', async () => {
1159
+ const store = new InMemoryMessageStore();
1160
+ const { actor, runtime } = makeActor({
1161
+ conn: { caps: CHATHISTORY_CAPS },
1162
+ messages: store,
1163
+ });
1164
+ // Create the channel so the peek resolves it (no creation side-effect
1165
+ // during the query itself).
1166
+ runtime.getOrCreateChannel('#foo');
1167
+ seedMessages(store, '#foo', 2);
1168
+ runtime.calls.length = 0;
1169
+
1170
+ await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
1171
+
1172
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1173
+ expect(sends).toHaveLength(1);
1174
+ const lines = sends[0]?.args[1] as RawLine[];
1175
+ // BATCH +id chathistory #foo … BATCH -id (id from the shared id factory).
1176
+ expect(lines[0]?.text).toMatch(/^BATCH \+batch-\d+ chathistory #foo$/);
1177
+ const batchId = lines[0]?.text.match(/^BATCH \+batch-(\d+)/)?.[1];
1178
+ expect(lines[lines.length - 1]?.text).toBe(`BATCH -batch-${batchId}`);
1179
+ // Two replay lines between the batch markers.
1180
+ expect(lines).toHaveLength(4);
1181
+ expect(lines[1]?.text).toContain('PRIVMSG #foo :msg 1');
1182
+ expect(lines[2]?.text).toContain('PRIVMSG #foo :msg 2');
1183
+ // Critical: never 421 for a cap-enabled CHATHISTORY query.
1184
+ for (const line of lines) {
1185
+ expect(line.text).not.toContain(' 421 ');
1186
+ }
1187
+ });
1188
+
1189
+ it('still emits 421 for CHATHISTORY when the cap is not negotiated (reducer cap-gating)', async () => {
1190
+ const store = new InMemoryMessageStore();
1191
+ const { actor, runtime } = makeActor({
1192
+ conn: { caps: new Set<string>() },
1193
+ messages: store,
1194
+ });
1195
+ runtime.getOrCreateChannel('#foo');
1196
+ seedMessages(store, '#foo', 2);
1197
+ runtime.calls.length = 0;
1198
+
1199
+ await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
1200
+
1201
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1202
+ expect(sends).toHaveLength(1);
1203
+ const lines = sends[0]?.args[1] as RawLine[];
1204
+ expect(lines[0]?.text).toContain(' 421 ');
1205
+ expect(lines[0]?.text).toContain('CHATHISTORY');
1206
+ });
1207
+
1208
+ it('emits 403 ERR_NOSUCHCHANNEL for an unknown channel target', async () => {
1209
+ const store = new InMemoryMessageStore();
1210
+ const { actor, runtime } = makeActor({
1211
+ conn: { caps: CHATHISTORY_CAPS },
1212
+ messages: store,
1213
+ });
1214
+ // Deliberately do NOT create #nope.
1215
+ runtime.calls.length = 0;
1216
+
1217
+ await actor.receiveTextFrame('CHATHISTORY LATEST #nope * 2\r\n');
1218
+
1219
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1220
+ expect(sends).toHaveLength(1);
1221
+ const lines = sends[0]?.args[1] as RawLine[];
1222
+ expect(lines[0]?.text).toContain(' 403 ');
1223
+ expect(lines[0]?.text).toContain('#nope');
1224
+ });
1225
+
1226
+ it('does not crash and emits nothing when no store is bound (ctx.messages undefined)', async () => {
1227
+ const { actor, runtime } = makeActor({
1228
+ conn: { caps: CHATHISTORY_CAPS },
1229
+ // No `messages` option → ctx.messages is undefined.
1230
+ });
1231
+ runtime.getOrCreateChannel('#foo');
1232
+ runtime.calls.length = 0;
1233
+
1234
+ await actor.receiveTextFrame('CHATHISTORY LATEST #foo * 2\r\n');
1235
+
1236
+ // No store → the reducer's query returns [] → empty batch elided → no
1237
+ // Send effects. Proves the no-store path is a graceful no-op.
1238
+ const sends = runtime.calls.filter((c) => c.method === 'send');
1239
+ expect(sends).toHaveLength(0);
1240
+ });
1241
+
1242
+ it('records a channel PRIVMSG into the bound store (JOIN auto-playback data source)', async () => {
1243
+ const store = new InMemoryMessageStore();
1244
+ const { actor, runtime } = makeActor({ messages: store });
1245
+ await runtime.applyChannelDelta('#foo', {
1246
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
1247
+ });
1248
+ runtime.calls.length = 0;
1249
+
1250
+ await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
1251
+
1252
+ const recorded = store.query({ chan: '#foo', direction: 'latest', limit: 10 });
1253
+ expect(recorded).toHaveLength(1);
1254
+ expect(recorded[0]?.text).toBe('hello');
1255
+ expect(recorded[0]?.command).toBe('PRIVMSG');
1256
+ expect(recorded[0]?.nick).toBe('alice');
1257
+ });
1258
+
1259
+ it('does not record a PRIVMSG when no store is bound (unchanged behaviour)', async () => {
1260
+ const { actor, runtime } = makeActor({});
1261
+ await runtime.applyChannelDelta('#foo', {
1262
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
1263
+ });
1264
+ runtime.calls.length = 0;
1265
+
1266
+ await actor.receiveTextFrame('PRIVMSG #foo :hello\r\n');
1267
+
1268
+ // Broadcast still happens (the message is delivered live)…
1269
+ expect(runtime.calls.filter((c) => c.method === 'broadcast')).toHaveLength(1);
1270
+ // …but there is no store to record into.
1271
+ });
1272
+ });
@@ -1,4 +1,5 @@
1
1
  import { Effect } from '@serverless-ircd/irc-core';
2
+ import { CapturingLogger, LogLevel } from '@serverless-ircd/irc-core';
2
3
  import type { ChannelDelta, ConnId, RawLine } from '@serverless-ircd/irc-core';
3
4
  import { describe, expect, it } from 'vitest';
4
5
  import { dispatch } from '../src/dispatch';
@@ -510,3 +511,86 @@ describe('dispatch — Broadcast cap / capLines', () => {
510
511
  expect(sends.map((s) => s.to)).toEqual(['c1']);
511
512
  });
512
513
  });
514
+
515
+ // ===========================================================================
516
+ // dispatch observability
517
+ // ===========================================================================
518
+ describe('dispatch — observability', () => {
519
+ it('emits a `dispatch` debug record with traceId/connectionId and effect tags', async () => {
520
+ const calls: RecordedCall[] = [];
521
+ const runtime = fakeRuntime(calls);
522
+ const logger = new CapturingLogger();
523
+
524
+ await dispatch(
525
+ [Effect.send('c1', [L('PING :1')]), Effect.send('c1', [L('PONG :1')])],
526
+ runtime,
527
+ { logger, traceId: 't-1', connectionId: 'c1' },
528
+ );
529
+
530
+ const records = logger.records.filter((r) => r.msg === 'dispatch');
531
+ expect(records).toHaveLength(1);
532
+ const rec = records[0];
533
+ expect(rec?.level).toBe(LogLevel.Debug);
534
+ expect(rec?.traceId).toBe('t-1');
535
+ expect(rec?.connectionId).toBe('c1');
536
+ expect(rec?.fields?.effects).toEqual(['Send', 'Send']);
537
+ expect(rec?.fields?.histogram).toEqual({ Send: 2 });
538
+ });
539
+
540
+ it('is silent when no logger is supplied (backwards-compatible default)', async () => {
541
+ const calls: RecordedCall[] = [];
542
+ const runtime = fakeRuntime(calls);
543
+ // Default silent call signature still works.
544
+ await expect(dispatch([Effect.send('c1', [L('x')])], runtime)).resolves.toBeUndefined();
545
+ });
546
+
547
+ it('emits a dispatch.error warn record and rethrows when a handler rejects', async () => {
548
+ const runtime: IrcRuntime = {
549
+ ...fakeRuntime([]),
550
+ async send() {
551
+ throw new Error('boom');
552
+ },
553
+ };
554
+ const logger = new CapturingLogger();
555
+
556
+ await expect(
557
+ dispatch([Effect.send('c1', [L('x')])], runtime, {
558
+ logger,
559
+ traceId: 't-2',
560
+ connectionId: 'c1',
561
+ }),
562
+ ).rejects.toThrow('boom');
563
+
564
+ const errs = logger.records.filter((r) => r.msg === 'dispatch.error');
565
+ expect(errs).toHaveLength(1);
566
+ expect(errs[0]?.level).toBe(LogLevel.Warn);
567
+ expect(errs[0]?.traceId).toBe('t-2');
568
+ expect(errs[0]?.connectionId).toBe('c1');
569
+ expect(errs[0]?.fields?.effect).toBe('Send');
570
+ const err = errs[0]?.fields?.err as { name: string; message: string };
571
+ expect(err.name).toBe('Error');
572
+ expect(err.message).toBe('boom');
573
+ });
574
+
575
+ it('stringifies non-Error rejections in the dispatch.error record', async () => {
576
+ const runtime: IrcRuntime = {
577
+ ...fakeRuntime([]),
578
+ async send() {
579
+ throw 'string-error';
580
+ },
581
+ };
582
+ const logger = new CapturingLogger();
583
+
584
+ await expect(
585
+ dispatch([Effect.send('c1', [L('x')])], runtime, {
586
+ logger,
587
+ traceId: 't-3',
588
+ connectionId: 'c1',
589
+ }),
590
+ ).rejects.toBe('string-error');
591
+
592
+ const errs = logger.records.filter((r) => r.msg === 'dispatch.error');
593
+ expect(errs).toHaveLength(1);
594
+ expect(errs[0]?.fields?.err).toBe('string-error');
595
+ });
596
+ });