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,8 +1,15 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
+ CapturingLogger,
3
4
  type Clock,
5
+ ConsoleLogger,
4
6
  FakeClock,
5
7
  type IdFactory,
8
+ LogLevel,
9
+ type LogRecord,
10
+ type Logger,
11
+ NoopLogger,
12
+ NoopLoggerInstance,
6
13
  SequentialIdFactory,
7
14
  SystemClock,
8
15
  UuidIdFactory,
@@ -99,3 +106,259 @@ describe('SequentialIdFactory', () => {
99
106
  expect(f.sessionId()).toBe('session-2');
100
107
  });
101
108
  });
109
+
110
+ describe('IdFactory.traceId', () => {
111
+ it('UuidIdFactory returns unique trace ids', () => {
112
+ const f: IdFactory = new UuidIdFactory();
113
+ expect(typeof f.traceId()).toBe('string');
114
+ expect(f.traceId()).not.toBe(f.traceId());
115
+ });
116
+
117
+ it('SequentialIdFactory returns deterministic trace ids', () => {
118
+ const f = new SequentialIdFactory();
119
+ expect(f.traceId()).toBe('trace-0');
120
+ expect(f.traceId()).toBe('trace-1');
121
+ });
122
+ });
123
+
124
+ describe('LogLevel', () => {
125
+ it('orders levels debug < info < warn < error', () => {
126
+ expect(LogLevel.Debug).toBeLessThan(LogLevel.Info);
127
+ expect(LogLevel.Info).toBeLessThan(LogLevel.Warn);
128
+ expect(LogLevel.Warn).toBeLessThan(LogLevel.Error);
129
+ });
130
+ });
131
+
132
+ describe('NoopLogger', () => {
133
+ it('implements the Logger port without throwing', () => {
134
+ const log: Logger = NoopLoggerInstance;
135
+ expect(() => log.debug('m')).not.toThrow();
136
+ expect(() => log.info('m')).not.toThrow();
137
+ expect(() => log.warn('m')).not.toThrow();
138
+ expect(() => log.error('m')).not.toThrow();
139
+ });
140
+
141
+ it('filters out levels below its threshold', () => {
142
+ const called = 0;
143
+ const log = new NoopLogger(LogLevel.Warn);
144
+ log.on(LogLevel.Debug, 'm');
145
+ log.info('m');
146
+ // The NoopLogger body is empty; the only contract is "no observable
147
+ // side-effect", which the count staying zero demonstrates.
148
+ expect(called).toBe(0);
149
+ });
150
+
151
+ it('child() returns an equivalent no-op logger', () => {
152
+ const child = new NoopLogger().child({ traceId: 't-1' });
153
+ expect(() => child.info('m')).not.toThrow();
154
+ });
155
+ });
156
+
157
+ describe('CapturingLogger', () => {
158
+ it('captures every record in order', () => {
159
+ const log = new CapturingLogger();
160
+ log.info('one', { a: 1 });
161
+ log.warn('two');
162
+ log.error('three', { e: 'boom' });
163
+ expect(log.records).toHaveLength(3);
164
+ expect(log.records[0]?.msg).toBe('one');
165
+ expect(log.records[1]?.level).toBe(LogLevel.Warn);
166
+ expect(log.records[2]?.fields).toEqual({ e: 'boom' });
167
+ });
168
+
169
+ it('stamps each record with the supplied traceId and connectionId', () => {
170
+ const log = new CapturingLogger({ traceId: 't-1', connectionId: 'c-1' });
171
+ log.info('hi');
172
+ const rec = log.records[0];
173
+ expect(rec?.traceId).toBe('t-1');
174
+ expect(rec?.connectionId).toBe('c-1');
175
+ });
176
+
177
+ it('child() inherits the parent traceId and connectionId and merges new fields', () => {
178
+ const log = new CapturingLogger({ traceId: 't-1', connectionId: 'c-1' });
179
+ const child = log.child({ connectionId: 'c-2' });
180
+ child.info('hi');
181
+ expect(log.records).toHaveLength(1);
182
+ expect(log.records[0]?.traceId).toBe('t-1');
183
+ expect(log.records[0]?.connectionId).toBe('c-2');
184
+ });
185
+
186
+ it('respects a minimum level', () => {
187
+ const log = new CapturingLogger(undefined, LogLevel.Warn);
188
+ log.info('skipped');
189
+ log.warn('kept');
190
+ expect(log.records.map((r) => r.msg)).toEqual(['kept']);
191
+ });
192
+
193
+ it('merges ctx fields with per-call fields', () => {
194
+ const log = new CapturingLogger({ traceId: 't-1', connectionId: 'c-1', chan: '#foo' });
195
+ log.info('hi', { lines: 3 });
196
+ expect(log.records[0]?.fields).toEqual({ chan: '#foo', lines: 3 });
197
+ });
198
+
199
+ it('records a debug entry via debug()', () => {
200
+ const log = new CapturingLogger();
201
+ log.debug('dbg');
202
+ expect(log.records[0]?.level).toBe(LogLevel.Debug);
203
+ });
204
+
205
+ it('routes through on() at any level', () => {
206
+ const log = new CapturingLogger();
207
+ log.on(LogLevel.Warn, 'manual');
208
+ expect(log.records[0]?.level).toBe(LogLevel.Warn);
209
+ });
210
+
211
+ it('uses the injected clock for ts', () => {
212
+ const clock = new FakeClock(42_000);
213
+ const log = new CapturingLogger({}, LogLevel.Debug, clock);
214
+ log.info('t');
215
+ expect(log.records[0]?.ts).toBe(42_000);
216
+ });
217
+
218
+ it('per-call traceId/connectionId overrides take precedence over ctx (used by dispatch)', () => {
219
+ // dispatch() calls logger.on(level, msg, { traceId, connectionId, ... })
220
+ // rather than constructing a child logger per call — both ctx-bound and
221
+ // per-call ids surface as top-level record fields, with per-call values
222
+ // winning. This keeps the dispatch path allocation-free while still
223
+ // stamping the correct ids on every record.
224
+ const log = new CapturingLogger({ traceId: 't-1', connectionId: 'c-1' });
225
+ log.info('hi', { traceId: 't-2', connectionId: 'c-2', chan: '#foo' });
226
+ expect(log.records[0]?.traceId).toBe('t-2');
227
+ expect(log.records[0]?.connectionId).toBe('c-2');
228
+ // chan is ordinary structured context → stays in fields.
229
+ expect(log.records[0]?.fields).toEqual({ chan: '#foo' });
230
+ });
231
+ });
232
+
233
+ describe('ConsoleLogger', () => {
234
+ function stubSinks(): {
235
+ sinks: {
236
+ debug(p: string): void;
237
+ info(p: string): void;
238
+ warn(p: string): void;
239
+ error(p: string): void;
240
+ };
241
+ calls: string[];
242
+ } {
243
+ const calls: string[] = [];
244
+ const sinks = {
245
+ debug: (p: string) => {
246
+ calls.push(`debug:${p}`);
247
+ },
248
+ info: (p: string) => {
249
+ calls.push(`info:${p}`);
250
+ },
251
+ warn: (p: string) => {
252
+ calls.push(`warn:${p}`);
253
+ },
254
+ error: (p: string) => {
255
+ calls.push(`error:${p}`);
256
+ },
257
+ };
258
+ return { sinks, calls };
259
+ }
260
+
261
+ it('serializes each record as a single line of JSON to console.<level>', () => {
262
+ const { sinks, calls } = stubSinks();
263
+ const log = new ConsoleLogger({ traceId: 't-1', connectionId: 'c-1' }, sinks);
264
+ log.info('hello', { lines: 1 });
265
+ log.error('boom');
266
+
267
+ expect(calls).toHaveLength(2);
268
+ expect(calls[0]).toMatch(/^info:/);
269
+ const infoLine = calls[0] ?? '';
270
+ const parsed0 = JSON.parse(infoLine.slice('info:'.length)) as Record<string, unknown>;
271
+ expect(parsed0.msg).toBe('hello');
272
+ expect(parsed0.level).toBe('info');
273
+ expect(parsed0.traceId).toBe('t-1');
274
+ expect(parsed0.connectionId).toBe('c-1');
275
+ expect(parsed0.lines).toBe(1);
276
+ expect(parsed0.ts).toEqual(expect.any(String));
277
+ // Single line — every record must be one JSON object on one physical line.
278
+ expect(infoLine.includes('\n')).toBe(false);
279
+ });
280
+
281
+ it('routes debug/warn through their dedicated sinks', () => {
282
+ const { sinks, calls } = stubSinks();
283
+ const log = new ConsoleLogger({ traceId: 't-1' }, sinks);
284
+ log.debug('d');
285
+ log.warn('w');
286
+ expect(calls[0]).toMatch(/^debug:/);
287
+ expect(calls[1]).toMatch(/^warn:/);
288
+ const debugLine = calls[0] ?? '';
289
+ const warnLine = calls[1] ?? '';
290
+ const parsed0 = JSON.parse(debugLine.slice('debug:'.length)) as Record<string, unknown>;
291
+ expect(parsed0.level).toBe('debug');
292
+ const parsed1 = JSON.parse(warnLine.slice('warn:'.length)) as Record<string, unknown>;
293
+ expect(parsed1.level).toBe('warn');
294
+ });
295
+
296
+ it('routes through on() at any level', () => {
297
+ const { sinks, calls } = stubSinks();
298
+ const log = new ConsoleLogger({}, sinks);
299
+ log.on(LogLevel.Warn, 'manual');
300
+ expect(calls[0]).toMatch(/^warn:/);
301
+ });
302
+
303
+ it('respects a minimum level', () => {
304
+ const { sinks, calls } = stubSinks();
305
+ const log = new ConsoleLogger({}, sinks, LogLevel.Error);
306
+ log.info('skipped');
307
+ log.error('kept');
308
+ expect(calls).toHaveLength(1);
309
+ expect(calls[0]).toMatch(/^error:/);
310
+ });
311
+
312
+ it('child inherits traceId/connectionId', () => {
313
+ const { sinks, calls } = stubSinks();
314
+ const log = new ConsoleLogger({ traceId: 't-1', connectionId: 'c-1' }, sinks);
315
+ const child = log.child({ connectionId: 'c-2' });
316
+ child.info('hi');
317
+ expect(calls[0]).toBeDefined();
318
+ const parsed = JSON.parse((calls[0] ?? '').slice('info:'.length)) as Record<string, unknown>;
319
+ expect(parsed.traceId).toBe('t-1');
320
+ expect(parsed.connectionId).toBe('c-2');
321
+ });
322
+
323
+ it('stamps ts from the injected clock', () => {
324
+ const { sinks, calls } = stubSinks();
325
+ const clock = new FakeClock(1_700_000_000_000);
326
+ const log = new ConsoleLogger({}, sinks, LogLevel.Debug, clock);
327
+ log.info('t');
328
+ expect(calls[0]).toBeDefined();
329
+ const parsed = JSON.parse((calls[0] ?? '').slice('info:'.length)) as Record<string, unknown>;
330
+ expect(parsed.ts).toBe(new Date(1_700_000_000_000).toISOString());
331
+ });
332
+
333
+ it('defaults to the global console when no sink is supplied', () => {
334
+ const log: Logger = new ConsoleLogger();
335
+ // Exercise every level so the globalThis.console wrapper's four
336
+ // bound arrows are all hit at least once.
337
+ expect(() => {
338
+ log.debug('d');
339
+ log.info('i');
340
+ log.warn('w');
341
+ log.error('e');
342
+ }).not.toThrow();
343
+ });
344
+
345
+ it('falls back to no-op sinks when globalThis.console is absent', () => {
346
+ const original = (globalThis as { console?: unknown }).console;
347
+ try {
348
+ // Simulate a runtime where `globalThis.console` is undefined.
349
+ (globalThis as { console?: unknown }).console = undefined;
350
+ const log = new ConsoleLogger();
351
+ expect(() => log.info('m')).not.toThrow();
352
+ } finally {
353
+ (globalThis as { console?: unknown }).console = original;
354
+ }
355
+ });
356
+ });
357
+
358
+ // Compile-time assertion: LogRecord includes the required observability fields.
359
+ const _assertLogRecord: LogRecord = {
360
+ ts: 0,
361
+ level: LogLevel.Info,
362
+ msg: '',
363
+ };
364
+ void _assertLogRecord;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-server",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",
@@ -35,39 +35,25 @@ import {
35
35
  EmptyMotdProvider,
36
36
  type IdFactory,
37
37
  type IrcMessage,
38
+ type Logger,
39
+ type MessageStore,
38
40
  type MotdProvider,
41
+ NoopLoggerInstance,
39
42
  Numerics,
40
43
  type RawLine,
41
44
  type ServerConfig,
42
45
  buildCtx,
43
- capReducer,
44
- channelModeReducer,
46
+ chathistoryReducer,
45
47
  handleJoinZero,
46
- inviteReducer,
47
48
  isChannelTarget,
48
- joinReducer,
49
- kickReducer,
50
49
  listReducer,
51
- motdReducer,
52
- namesReducer,
53
- nickReducer,
54
- noticeChannelReducer,
55
- noticeUserReducer,
56
50
  parse,
57
- partReducer,
58
- passReducer,
59
- pingReducer,
60
- pongReducer,
61
- privmsgChannelReducer,
62
- privmsgUserReducer,
63
- quitReducer,
64
- topicReducer,
65
- userModeReducer,
66
- userReducer,
67
51
  whoReducer,
68
52
  whoisReducer,
69
53
  } from '@serverless-ircd/irc-core';
70
54
  import { dispatch } from './dispatch.js';
55
+ import type { DispatchOptions } from './dispatch.js';
56
+ import { type RoutedReducers, buildRoutedReducers } from './routing.js';
71
57
  import type { IrcRuntime } from './runtime.js';
72
58
 
73
59
  /**
@@ -87,6 +73,16 @@ import type { IrcRuntime } from './runtime.js';
87
73
  */
88
74
  export interface ActorChannelAccess {
89
75
  getOrCreateChannel(name: ChanName): ChannelState;
76
+ /**
77
+ * Peek-only channel lookup that does NOT create the channel as a side
78
+ * effect (unlike {@link getOrCreateChannel}). Returns the authoritative
79
+ * {@link ChannelState} when the channel exists, or `undefined` otherwise.
80
+ *
81
+ * Used by query-only commands (CHATHISTORY) that must distinguish "no
82
+ * such channel" from an empty one. Optional: runtimes that have not
83
+ * implemented it fall back to {@link getOrCreateChannel} in the actor.
84
+ */
85
+ getChannel?(name: ChanName): ChannelState | undefined;
90
86
  refreshChannel?(name: ChanName): Promise<void>;
91
87
  }
92
88
 
@@ -98,6 +94,28 @@ export interface ConnectionActorOptions {
98
94
  clock: Clock;
99
95
  ids: IdFactory;
100
96
  motd?: MotdProvider;
97
+ /**
98
+ * Chat-history persistence source (absent when chathistory playback is
99
+ * disabled). When bound, `ctx.messages` is defined so the CHATHISTORY
100
+ * reducer can query backlog and the PRIVMSG/NOTICE channel reducer
101
+ * records accepted messages. Omitted (→ `ctx.messages` undefined)
102
+ * preserves the no-store behaviour for deployments/tests that do not
103
+ * opt in.
104
+ */
105
+ readonly messages?: MessageStore;
106
+ /**
107
+ * Structured-logger port. When omitted the actor runs
108
+ * silently — observability is purely opt-in. When supplied, every
109
+ * frame.receive / frame.parse-error / dispatch record carries the
110
+ * actor's bound `traceId` and `connectionId`.
111
+ */
112
+ readonly logger?: Logger;
113
+ /**
114
+ * Per-request trace id. When omitted the actor allocates
115
+ * one fresh per inbound frame via `ids.traceId()` so every frame is
116
+ * still uniquely identifiable in logs.
117
+ */
118
+ readonly traceId?: string;
101
119
  }
102
120
 
103
121
  export class ConnectionActor {
@@ -108,6 +126,20 @@ export class ConnectionActor {
108
126
  private readonly clock: Clock;
109
127
  private readonly ids: IdFactory;
110
128
  private readonly motd: MotdProvider;
129
+ private readonly messages: MessageStore | undefined;
130
+ private readonly reducers: RoutedReducers;
131
+ /**
132
+ * Bound logger. Defaults to a no-op so the actor pipeline has no
133
+ * observable side effects when callers do not opt in.
134
+ */
135
+ private readonly logger: Logger;
136
+ /**
137
+ * Pinned trace id. When the caller did not supply one the actor generates
138
+ * a fresh one per inbound frame via {@link IdFactory.traceId}; this pinned
139
+ * value is only used in that latter case as a fallback for code paths
140
+ * that need a non-undefined id in test assertions.
141
+ */
142
+ private readonly pinnedTraceId: string | undefined;
111
143
 
112
144
  constructor(opts: ConnectionActorOptions) {
113
145
  this.state = opts.state;
@@ -117,6 +149,20 @@ export class ConnectionActor {
117
149
  this.clock = opts.clock;
118
150
  this.ids = opts.ids;
119
151
  this.motd = opts.motd ?? EmptyMotdProvider;
152
+ this.messages = opts.messages;
153
+ this.logger = opts.logger ?? NoopLoggerInstance;
154
+ this.pinnedTraceId = opts.traceId;
155
+ // Bind the connection id once so every record the actor emits carries
156
+ // the cross-cutting connection identifier. The child inherits traceId
157
+ // and rebinds per-frame; connectionId stays constant for the actor's
158
+ // lifetime.
159
+ if (opts.logger !== undefined) {
160
+ this.logger = opts.logger.child({ connectionId: this.state.id });
161
+ }
162
+ // Build once: every routed reducer is flood-control-wrapped (or the raw
163
+ // reducer when flood control is disabled) so a flooding client is bounded
164
+ // regardless of which authority owns the command's state.
165
+ this.reducers = buildRoutedReducers(opts.serverConfig);
120
166
  }
121
167
 
122
168
  /**
@@ -125,13 +171,17 @@ export class ConnectionActor {
125
171
  * one line emits a `421` and processing continues with the next line.
126
172
  */
127
173
  async receiveTextFrame(text: string): Promise<void> {
128
- for (const line of splitFrameLines(text)) {
174
+ const lines = splitFrameLines(text);
175
+ if (lines.length === 0) return;
176
+ const traceId = this.pinnedTraceId ?? this.ids.traceId();
177
+ const log = this.logger.child({ traceId });
178
+ for (const line of lines) {
129
179
  if (line.length === 0) continue;
130
- await this.processLine(line);
180
+ await this.processLine(line, log, traceId);
131
181
  }
132
182
  }
133
183
 
134
- private async processLine(line: string): Promise<void> {
184
+ private async processLine(line: string, log: Logger, traceId: string): Promise<void> {
135
185
  let msg: IrcMessage;
136
186
  let parseFailed = false;
137
187
  try {
@@ -145,17 +195,30 @@ export class ConnectionActor {
145
195
  msg = { tags: {}, command: '', params: [] };
146
196
  }
147
197
  if (parseFailed) {
198
+ log.warn('frame.parse-error', {
199
+ line,
200
+ token: firstToken(line),
201
+ });
148
202
  const token = firstToken(line);
149
- await dispatch([this.unknownCommandEffect(token)], this.runtime);
203
+ await dispatch(
204
+ [this.unknownCommandEffect(token)],
205
+ this.runtime,
206
+ this.dispatchOpts(log, traceId),
207
+ );
150
208
  return;
151
209
  }
210
+ log.debug('frame.receive', { command: msg.command, lines: 1 });
152
211
  // Pull authoritative channel state for any channel the upcoming reducer
153
212
  // will read. In-memory runtimes no-op; distributed runtimes fetch from
154
213
  // the channel authority (e.g. CF ChannelDO). Done before `route` so the
155
214
  // synchronous reducers observe up-to-date rosters / modes / topics.
156
215
  await this.prefetchChannelState(msg);
157
216
  const effects = await this.route(msg);
158
- await dispatch(effects, this.runtime);
217
+ await dispatch(effects, this.runtime, this.dispatchOpts(log, traceId));
218
+ }
219
+
220
+ private dispatchOpts(log: Logger, traceId: string): DispatchOptions {
221
+ return { logger: log, traceId, connectionId: this.state.id };
159
222
  }
160
223
 
161
224
  /**
@@ -178,26 +241,31 @@ export class ConnectionActor {
178
241
  ids: this.ids,
179
242
  motd: this.motd,
180
243
  connection: this.state,
244
+ ...(this.messages !== undefined ? { messages: this.messages } : {}),
181
245
  });
182
246
 
183
247
  switch (msg.command) {
184
248
  // -------- Connection-authority commands --------
185
249
  case 'PING':
186
- return pingReducer(this.state, msg, ctx).effects;
250
+ return this.reducers.ping(this.state, msg, ctx).effects;
187
251
  case 'PONG':
188
- return pongReducer(this.state, msg, ctx).effects;
252
+ return this.reducers.pong(this.state, msg, ctx).effects;
189
253
  case 'NICK':
190
- return nickReducer(this.state, msg, ctx).effects;
254
+ return this.reducers.nick(this.state, msg, ctx).effects;
191
255
  case 'USER':
192
- return userReducer(this.state, msg, ctx).effects;
256
+ return this.reducers.user(this.state, msg, ctx).effects;
193
257
  case 'PASS':
194
- return passReducer(this.state, msg, ctx).effects;
258
+ return this.reducers.pass(this.state, msg, ctx).effects;
195
259
  case 'QUIT':
196
- return quitReducer(this.state, msg, ctx).effects;
260
+ return this.reducers.quit(this.state, msg, ctx).effects;
197
261
  case 'MOTD':
198
- return motdReducer(this.state, msg, ctx).effects;
262
+ return this.reducers.motd(this.state, msg, ctx).effects;
199
263
  case 'CAP':
200
- return capReducer(this.state, msg, ctx).effects;
264
+ return this.reducers.cap(this.state, msg, ctx).effects;
265
+ case 'AUTHENTICATE':
266
+ return this.reducers.authenticate(this.state, msg, ctx).effects;
267
+ case 'AWAY':
268
+ return this.reducers.away(this.state, msg, ctx).effects;
201
269
 
202
270
  // -------- Channel-authority commands --------
203
271
  case 'JOIN':
@@ -205,32 +273,47 @@ export class ConnectionActor {
205
273
  case 'PART':
206
274
  return this.routeMultiChannel(
207
275
  msg,
208
- (chan, singleParamMsg) => partReducer(chan, singleParamMsg, ctx).effects,
276
+ (chan, singleParamMsg) => this.reducers.part(chan, singleParamMsg, ctx).effects,
209
277
  );
210
278
  case 'TOPIC':
211
- return this.routeSingleChannel(msg, (chan) => topicReducer(chan, msg, ctx).effects);
279
+ return this.routeSingleChannel(msg, (chan) => this.reducers.topic(chan, msg, ctx).effects);
212
280
  case 'NAMES':
213
- return this.routeSingleChannel(msg, (chan) => namesReducer(chan, msg, ctx).effects);
281
+ return this.routeSingleChannel(msg, (chan) => this.reducers.names(chan, msg, ctx).effects);
214
282
  case 'KICK':
215
- return this.routeSingleChannel(msg, (chan) => kickReducer(chan, msg, ctx).effects);
283
+ return this.routeSingleChannel(msg, (chan) => this.reducers.kick(chan, msg, ctx).effects);
216
284
  case 'INVITE':
217
- return this.routeSingleChannel(msg, (chan) => inviteReducer(chan, msg, ctx).effects);
285
+ return this.routeSingleChannel(msg, (chan) => this.reducers.invite(chan, msg, ctx).effects);
218
286
  case 'LIST':
219
287
  return this.routeList(msg, ctx);
220
288
  case 'WHO':
221
289
  return this.routeWho(msg, ctx);
222
290
  case 'WHOIS':
223
291
  return this.routeWhois(msg, ctx);
292
+ case 'CHATHISTORY':
293
+ return this.routeChathistory(msg, ctx);
224
294
 
225
295
  // -------- Target-dependent commands --------
226
296
  case 'PRIVMSG':
227
- return this.routeMessageTarget(msg, privmsgChannelReducer, privmsgUserReducer, ctx);
297
+ return this.routeMessageTarget(
298
+ msg,
299
+ this.reducers.privmsgChannel,
300
+ this.reducers.privmsgUser,
301
+ ctx,
302
+ );
228
303
  case 'NOTICE':
229
- return this.routeMessageTarget(msg, noticeChannelReducer, noticeUserReducer, ctx);
304
+ return this.routeMessageTarget(
305
+ msg,
306
+ this.reducers.noticeChannel,
307
+ this.reducers.noticeUser,
308
+ ctx,
309
+ );
230
310
  case 'MODE':
231
311
  return isChannelTarget(msg.params[0] ?? '')
232
- ? this.routeSingleChannel(msg, (chan) => channelModeReducer(chan, msg, ctx).effects)
233
- : userModeReducer(this.state, msg, ctx).effects;
312
+ ? this.routeSingleChannel(
313
+ msg,
314
+ (chan) => this.reducers.channelMode(chan, msg, ctx).effects,
315
+ )
316
+ : this.reducers.userMode(this.state, msg, ctx).effects;
234
317
 
235
318
  default:
236
319
  return [this.unknownCommandEffect(msg.command)];
@@ -281,6 +364,41 @@ export class ConnectionActor {
281
364
  return whoisReducer(target, channels, msg, ctx).effects;
282
365
  }
283
366
 
367
+ /**
368
+ * Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
369
+ * reducer. The command is query-only and must NOT create a channel as a
370
+ * side effect: the target channel (`msg.params[1]`) is resolved via the
371
+ * peek-only {@link ActorChannelAccess.getChannel} when the runtime
372
+ * implements it, so a non-existent channel yields `undefined` → the
373
+ * reducer emits `403 ERR_NOSUCHCHANNEL`. The `TARGETS` subcommand uses a
374
+ * timestamp in that position; `getChannel` simply returns `undefined`
375
+ * for it, which the reducer ignores (TARGETS never reads the channel).
376
+ *
377
+ * Cap-gating, param validation, and batch framing all live in the pure
378
+ * reducer — this method only resolves the channel and delegates.
379
+ */
380
+ private async routeChathistory(
381
+ msg: IrcMessage,
382
+ ctx: ReturnType<typeof buildCtx>,
383
+ ): Promise<EffectType[]> {
384
+ const target = msg.params[1];
385
+ const channel = target !== undefined ? this.peekChannel(target) : undefined;
386
+ return chathistoryReducer(channel, msg, ctx).effects;
387
+ }
388
+
389
+ /**
390
+ * Resolves a channel by name WITHOUT creating it. Prefers the
391
+ * peek-only {@link ActorChannelAccess.getChannel}; falls back to
392
+ * {@link ActorChannelAccess.getOrCreateChannel} for runtimes that have
393
+ * not implemented the peek.
394
+ */
395
+ private peekChannel(name: ChanName): ChannelState | undefined {
396
+ if (this.channels.getChannel !== undefined) {
397
+ return this.channels.getChannel(name);
398
+ }
399
+ return this.channels.getOrCreateChannel(name);
400
+ }
401
+
284
402
  private routeJoin(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): EffectType[] {
285
403
  if (msg.params[0] === '0') {
286
404
  return handleJoinZero(ctx);
@@ -292,14 +410,14 @@ export class ConnectionActor {
292
410
  // No channel argument: defer to the reducer so it emits the proper
293
411
  // 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
294
412
  const chan = this.channels.getOrCreateChannel('');
295
- return joinReducer(chan, msg, ctx).effects;
413
+ return this.reducers.join(chan, msg, ctx).effects;
296
414
  }
297
415
  for (const [i, name] of names.entries()) {
298
416
  const chan = this.channels.getOrCreateChannel(name);
299
417
  const key = keys[i];
300
418
  const singleParamMsg: IrcMessage =
301
419
  key === undefined ? { ...msg, params: [name] } : { ...msg, params: [name, key] };
302
- effects.push(...joinReducer(chan, singleParamMsg, ctx).effects);
420
+ effects.push(...this.reducers.join(chan, singleParamMsg, ctx).effects);
303
421
  }
304
422
  return effects;
305
423
  }