serverless-ircd 0.1.0 → 0.3.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 (204) 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/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /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.3.0",
4
4
  "private": true,
5
5
  "description": "Orchestration layer: IrcRuntime port, dispatch interpreter, ConnectionActor",
6
6
  "license": "BSD-3-Clause",
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import {
29
+ type AccountStore,
29
30
  type ChanName,
30
31
  type ChannelState,
31
32
  type Clock,
@@ -35,39 +36,25 @@ import {
35
36
  EmptyMotdProvider,
36
37
  type IdFactory,
37
38
  type IrcMessage,
39
+ type Logger,
40
+ type MessageStore,
38
41
  type MotdProvider,
42
+ NoopLoggerInstance,
39
43
  Numerics,
40
44
  type RawLine,
41
45
  type ServerConfig,
42
46
  buildCtx,
43
- capReducer,
44
- channelModeReducer,
47
+ chathistoryReducer,
45
48
  handleJoinZero,
46
- inviteReducer,
47
49
  isChannelTarget,
48
- joinReducer,
49
- kickReducer,
50
50
  listReducer,
51
- motdReducer,
52
- namesReducer,
53
- nickReducer,
54
- noticeChannelReducer,
55
- noticeUserReducer,
56
51
  parse,
57
- partReducer,
58
- passReducer,
59
- pingReducer,
60
- pongReducer,
61
- privmsgChannelReducer,
62
- privmsgUserReducer,
63
- quitReducer,
64
- topicReducer,
65
- userModeReducer,
66
- userReducer,
67
52
  whoReducer,
68
53
  whoisReducer,
69
54
  } from '@serverless-ircd/irc-core';
70
55
  import { dispatch } from './dispatch.js';
56
+ import type { DispatchOptions } from './dispatch.js';
57
+ import { type RoutedReducers, buildRoutedReducers } from './routing.js';
71
58
  import type { IrcRuntime } from './runtime.js';
72
59
 
73
60
  /**
@@ -87,6 +74,16 @@ import type { IrcRuntime } from './runtime.js';
87
74
  */
88
75
  export interface ActorChannelAccess {
89
76
  getOrCreateChannel(name: ChanName): ChannelState;
77
+ /**
78
+ * Peek-only channel lookup that does NOT create the channel as a side
79
+ * effect (unlike {@link getOrCreateChannel}). Returns the authoritative
80
+ * {@link ChannelState} when the channel exists, or `undefined` otherwise.
81
+ *
82
+ * Used by query-only commands (CHATHISTORY) that must distinguish "no
83
+ * such channel" from an empty one. Optional: runtimes that have not
84
+ * implemented it fall back to {@link getOrCreateChannel} in the actor.
85
+ */
86
+ getChannel?(name: ChanName): ChannelState | undefined;
90
87
  refreshChannel?(name: ChanName): Promise<void>;
91
88
  }
92
89
 
@@ -98,6 +95,38 @@ export interface ConnectionActorOptions {
98
95
  clock: Clock;
99
96
  ids: IdFactory;
100
97
  motd?: MotdProvider;
98
+ /**
99
+ * Chat-history persistence source (absent when chathistory playback is
100
+ * disabled). When bound, `ctx.messages` is defined so the CHATHISTORY
101
+ * reducer can query backlog and the PRIVMSG/NOTICE channel reducer
102
+ * records accepted messages. Omitted (→ `ctx.messages` undefined)
103
+ * preserves the no-store behaviour for deployments/tests that do not
104
+ * opt in.
105
+ */
106
+ readonly messages?: MessageStore;
107
+ /**
108
+ * SASL account verification source (absent when no accounts are
109
+ * configured). When bound, `ctx.accounts` is defined so the
110
+ * `AUTHENTICATE` reducer can verify PLAIN credentials end-to-end.
111
+ * Omitted (→ `ctx.accounts` undefined) preserves the existing
112
+ * behaviour for callers/tests that do not opt in: a client completing
113
+ * `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL` because no store
114
+ * can vouch for the credentials.
115
+ */
116
+ readonly accounts?: AccountStore;
117
+ /**
118
+ * Structured-logger port. When omitted the actor runs
119
+ * silently — observability is purely opt-in. When supplied, every
120
+ * frame.receive / frame.parse-error / dispatch record carries the
121
+ * actor's bound `traceId` and `connectionId`.
122
+ */
123
+ readonly logger?: Logger;
124
+ /**
125
+ * Per-request trace id. When omitted the actor allocates
126
+ * one fresh per inbound frame via `ids.traceId()` so every frame is
127
+ * still uniquely identifiable in logs.
128
+ */
129
+ readonly traceId?: string;
101
130
  }
102
131
 
103
132
  export class ConnectionActor {
@@ -108,6 +137,21 @@ export class ConnectionActor {
108
137
  private readonly clock: Clock;
109
138
  private readonly ids: IdFactory;
110
139
  private readonly motd: MotdProvider;
140
+ private readonly messages: MessageStore | undefined;
141
+ private readonly accounts: AccountStore | undefined;
142
+ private readonly reducers: RoutedReducers;
143
+ /**
144
+ * Bound logger. Defaults to a no-op so the actor pipeline has no
145
+ * observable side effects when callers do not opt in.
146
+ */
147
+ private readonly logger: Logger;
148
+ /**
149
+ * Pinned trace id. When the caller did not supply one the actor generates
150
+ * a fresh one per inbound frame via {@link IdFactory.traceId}; this pinned
151
+ * value is only used in that latter case as a fallback for code paths
152
+ * that need a non-undefined id in test assertions.
153
+ */
154
+ private readonly pinnedTraceId: string | undefined;
111
155
 
112
156
  constructor(opts: ConnectionActorOptions) {
113
157
  this.state = opts.state;
@@ -117,6 +161,21 @@ export class ConnectionActor {
117
161
  this.clock = opts.clock;
118
162
  this.ids = opts.ids;
119
163
  this.motd = opts.motd ?? EmptyMotdProvider;
164
+ this.messages = opts.messages;
165
+ this.accounts = opts.accounts;
166
+ this.logger = opts.logger ?? NoopLoggerInstance;
167
+ this.pinnedTraceId = opts.traceId;
168
+ // Bind the connection id once so every record the actor emits carries
169
+ // the cross-cutting connection identifier. The child inherits traceId
170
+ // and rebinds per-frame; connectionId stays constant for the actor's
171
+ // lifetime.
172
+ if (opts.logger !== undefined) {
173
+ this.logger = opts.logger.child({ connectionId: this.state.id });
174
+ }
175
+ // Build once: every routed reducer is flood-control-wrapped (or the raw
176
+ // reducer when flood control is disabled) so a flooding client is bounded
177
+ // regardless of which authority owns the command's state.
178
+ this.reducers = buildRoutedReducers(opts.serverConfig);
120
179
  }
121
180
 
122
181
  /**
@@ -125,13 +184,17 @@ export class ConnectionActor {
125
184
  * one line emits a `421` and processing continues with the next line.
126
185
  */
127
186
  async receiveTextFrame(text: string): Promise<void> {
128
- for (const line of splitFrameLines(text)) {
187
+ const lines = splitFrameLines(text);
188
+ if (lines.length === 0) return;
189
+ const traceId = this.pinnedTraceId ?? this.ids.traceId();
190
+ const log = this.logger.child({ traceId });
191
+ for (const line of lines) {
129
192
  if (line.length === 0) continue;
130
- await this.processLine(line);
193
+ await this.processLine(line, log, traceId);
131
194
  }
132
195
  }
133
196
 
134
- private async processLine(line: string): Promise<void> {
197
+ private async processLine(line: string, log: Logger, traceId: string): Promise<void> {
135
198
  let msg: IrcMessage;
136
199
  let parseFailed = false;
137
200
  try {
@@ -145,17 +208,30 @@ export class ConnectionActor {
145
208
  msg = { tags: {}, command: '', params: [] };
146
209
  }
147
210
  if (parseFailed) {
211
+ log.warn('frame.parse-error', {
212
+ line,
213
+ token: firstToken(line),
214
+ });
148
215
  const token = firstToken(line);
149
- await dispatch([this.unknownCommandEffect(token)], this.runtime);
216
+ await dispatch(
217
+ [this.unknownCommandEffect(token)],
218
+ this.runtime,
219
+ this.dispatchOpts(log, traceId),
220
+ );
150
221
  return;
151
222
  }
223
+ log.debug('frame.receive', { command: msg.command, lines: 1 });
152
224
  // Pull authoritative channel state for any channel the upcoming reducer
153
225
  // will read. In-memory runtimes no-op; distributed runtimes fetch from
154
226
  // the channel authority (e.g. CF ChannelDO). Done before `route` so the
155
227
  // synchronous reducers observe up-to-date rosters / modes / topics.
156
228
  await this.prefetchChannelState(msg);
157
229
  const effects = await this.route(msg);
158
- await dispatch(effects, this.runtime);
230
+ await dispatch(effects, this.runtime, this.dispatchOpts(log, traceId));
231
+ }
232
+
233
+ private dispatchOpts(log: Logger, traceId: string): DispatchOptions {
234
+ return { logger: log, traceId, connectionId: this.state.id };
159
235
  }
160
236
 
161
237
  /**
@@ -178,26 +254,34 @@ export class ConnectionActor {
178
254
  ids: this.ids,
179
255
  motd: this.motd,
180
256
  connection: this.state,
257
+ ...(this.messages !== undefined ? { messages: this.messages } : {}),
258
+ ...(this.accounts !== undefined ? { accounts: this.accounts } : {}),
181
259
  });
182
260
 
183
261
  switch (msg.command) {
184
262
  // -------- Connection-authority commands --------
185
263
  case 'PING':
186
- return pingReducer(this.state, msg, ctx).effects;
264
+ return this.reducers.ping(this.state, msg, ctx).effects;
187
265
  case 'PONG':
188
- return pongReducer(this.state, msg, ctx).effects;
266
+ return this.reducers.pong(this.state, msg, ctx).effects;
189
267
  case 'NICK':
190
- return nickReducer(this.state, msg, ctx).effects;
268
+ return this.reducers.nick(this.state, msg, ctx).effects;
191
269
  case 'USER':
192
- return userReducer(this.state, msg, ctx).effects;
270
+ return this.reducers.user(this.state, msg, ctx).effects;
193
271
  case 'PASS':
194
- return passReducer(this.state, msg, ctx).effects;
272
+ return this.reducers.pass(this.state, msg, ctx).effects;
195
273
  case 'QUIT':
196
- return quitReducer(this.state, msg, ctx).effects;
274
+ return this.reducers.quit(this.state, msg, ctx).effects;
197
275
  case 'MOTD':
198
- return motdReducer(this.state, msg, ctx).effects;
276
+ return this.reducers.motd(this.state, msg, ctx).effects;
277
+ case 'OPER':
278
+ return this.reducers.oper(this.state, msg, ctx).effects;
199
279
  case 'CAP':
200
- return capReducer(this.state, msg, ctx).effects;
280
+ return this.reducers.cap(this.state, msg, ctx).effects;
281
+ case 'AUTHENTICATE':
282
+ return this.reducers.authenticate(this.state, msg, ctx).effects;
283
+ case 'AWAY':
284
+ return this.reducers.away(this.state, msg, ctx).effects;
201
285
 
202
286
  // -------- Channel-authority commands --------
203
287
  case 'JOIN':
@@ -205,32 +289,54 @@ export class ConnectionActor {
205
289
  case 'PART':
206
290
  return this.routeMultiChannel(
207
291
  msg,
208
- (chan, singleParamMsg) => partReducer(chan, singleParamMsg, ctx).effects,
292
+ (chan, singleParamMsg) => this.reducers.part(chan, singleParamMsg, ctx).effects,
209
293
  );
210
294
  case 'TOPIC':
211
- return this.routeSingleChannel(msg, (chan) => topicReducer(chan, msg, ctx).effects);
295
+ return this.routeSingleChannel(msg, (chan) => this.reducers.topic(chan, msg, ctx).effects);
212
296
  case 'NAMES':
213
- return this.routeSingleChannel(msg, (chan) => namesReducer(chan, msg, ctx).effects);
297
+ return this.routeSingleChannel(msg, (chan) => this.reducers.names(chan, msg, ctx).effects);
214
298
  case 'KICK':
215
- return this.routeSingleChannel(msg, (chan) => kickReducer(chan, msg, ctx).effects);
299
+ return this.routeSingleChannel(msg, (chan) => this.reducers.kick(chan, msg, ctx).effects);
216
300
  case 'INVITE':
217
- return this.routeSingleChannel(msg, (chan) => inviteReducer(chan, msg, ctx).effects);
301
+ return this.routeSingleChannel(msg, (chan) => this.reducers.invite(chan, msg, ctx).effects);
218
302
  case 'LIST':
219
303
  return this.routeList(msg, ctx);
220
304
  case 'WHO':
221
305
  return this.routeWho(msg, ctx);
222
306
  case 'WHOIS':
223
307
  return this.routeWhois(msg, ctx);
308
+ case 'CHATHISTORY':
309
+ return this.routeChathistory(msg, ctx);
224
310
 
225
311
  // -------- Target-dependent commands --------
226
312
  case 'PRIVMSG':
227
- return this.routeMessageTarget(msg, privmsgChannelReducer, privmsgUserReducer, ctx);
313
+ return this.routeMessageTarget(
314
+ msg,
315
+ this.reducers.privmsgChannel,
316
+ this.reducers.privmsgUser,
317
+ ctx,
318
+ );
228
319
  case 'NOTICE':
229
- return this.routeMessageTarget(msg, noticeChannelReducer, noticeUserReducer, ctx);
320
+ return this.routeMessageTarget(
321
+ msg,
322
+ this.reducers.noticeChannel,
323
+ this.reducers.noticeUser,
324
+ ctx,
325
+ );
326
+ case 'TAGMSG':
327
+ return this.routeMessageTarget(
328
+ msg,
329
+ this.reducers.tagmsgChannel,
330
+ this.reducers.tagmsgUser,
331
+ ctx,
332
+ );
230
333
  case 'MODE':
231
334
  return isChannelTarget(msg.params[0] ?? '')
232
- ? this.routeSingleChannel(msg, (chan) => channelModeReducer(chan, msg, ctx).effects)
233
- : userModeReducer(this.state, msg, ctx).effects;
335
+ ? this.routeSingleChannel(
336
+ msg,
337
+ (chan) => this.reducers.channelMode(chan, msg, ctx).effects,
338
+ )
339
+ : this.reducers.userMode(this.state, msg, ctx).effects;
234
340
 
235
341
  default:
236
342
  return [this.unknownCommandEffect(msg.command)];
@@ -281,6 +387,41 @@ export class ConnectionActor {
281
387
  return whoisReducer(target, channels, msg, ctx).effects;
282
388
  }
283
389
 
390
+ /**
391
+ * Routes `CHATHISTORY <sub> <target> [<args>...]` to the chathistory
392
+ * reducer. The command is query-only and must NOT create a channel as a
393
+ * side effect: the target channel (`msg.params[1]`) is resolved via the
394
+ * peek-only {@link ActorChannelAccess.getChannel} when the runtime
395
+ * implements it, so a non-existent channel yields `undefined` → the
396
+ * reducer emits `403 ERR_NOSUCHCHANNEL`. The `TARGETS` subcommand uses a
397
+ * timestamp in that position; `getChannel` simply returns `undefined`
398
+ * for it, which the reducer ignores (TARGETS never reads the channel).
399
+ *
400
+ * Cap-gating, param validation, and batch framing all live in the pure
401
+ * reducer — this method only resolves the channel and delegates.
402
+ */
403
+ private async routeChathistory(
404
+ msg: IrcMessage,
405
+ ctx: ReturnType<typeof buildCtx>,
406
+ ): Promise<EffectType[]> {
407
+ const target = msg.params[1];
408
+ const channel = target !== undefined ? this.peekChannel(target) : undefined;
409
+ return chathistoryReducer(channel, msg, ctx).effects;
410
+ }
411
+
412
+ /**
413
+ * Resolves a channel by name WITHOUT creating it. Prefers the
414
+ * peek-only {@link ActorChannelAccess.getChannel}; falls back to
415
+ * {@link ActorChannelAccess.getOrCreateChannel} for runtimes that have
416
+ * not implemented the peek.
417
+ */
418
+ private peekChannel(name: ChanName): ChannelState | undefined {
419
+ if (this.channels.getChannel !== undefined) {
420
+ return this.channels.getChannel(name);
421
+ }
422
+ return this.channels.getOrCreateChannel(name);
423
+ }
424
+
284
425
  private routeJoin(msg: IrcMessage, ctx: ReturnType<typeof buildCtx>): EffectType[] {
285
426
  if (msg.params[0] === '0') {
286
427
  return handleJoinZero(ctx);
@@ -292,14 +433,14 @@ export class ConnectionActor {
292
433
  // No channel argument: defer to the reducer so it emits the proper
293
434
  // 461 ERR_NEEDMOREPARAMS rather than the actor silently doing nothing.
294
435
  const chan = this.channels.getOrCreateChannel('');
295
- return joinReducer(chan, msg, ctx).effects;
436
+ return this.reducers.join(chan, msg, ctx).effects;
296
437
  }
297
438
  for (const [i, name] of names.entries()) {
298
439
  const chan = this.channels.getOrCreateChannel(name);
299
440
  const key = keys[i];
300
441
  const singleParamMsg: IrcMessage =
301
442
  key === undefined ? { ...msg, params: [name] } : { ...msg, params: [name, key] };
302
- effects.push(...joinReducer(chan, singleParamMsg, ctx).effects);
443
+ effects.push(...this.reducers.join(chan, singleParamMsg, ctx).effects);
303
444
  }
304
445
  return effects;
305
446
  }
@@ -439,6 +580,7 @@ function channelTargetsForMessage(msg: IrcMessage): ChanName[] {
439
580
  case 'MODE':
440
581
  case 'PRIVMSG':
441
582
  case 'NOTICE':
583
+ case 'TAGMSG':
442
584
  return splitCsv(msg.params[0] ?? '').filter(isChannelTarget) as ChanName[];
443
585
  case 'INVITE':
444
586
  // INVITE <nick> <channel> — the channel is the second param.