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
@@ -34,7 +34,12 @@ import {
34
34
  type Clock,
35
35
  type ConnId,
36
36
  type ConnectionState,
37
+ ConsoleLogger,
37
38
  type IdFactory,
39
+ InMemoryMessageStore,
40
+ LogLevel,
41
+ type Logger,
42
+ type MessageStore,
38
43
  type MotdProvider,
39
44
  type RawLine,
40
45
  type ServerConfig,
@@ -90,6 +95,15 @@ export class ConnectionDO extends DurableObject<Env> {
90
95
  * should seed `createdAt` with its receive time.
91
96
  */
92
97
  private channelAccess: PassthroughChannelAccess | undefined;
98
+ /**
99
+ * Per-connection chat-history store backing `draft/chathistory`
100
+ * playback. The minimum-viable in-worker ring buffer is scoped to this
101
+ * connection: messages this connection sends are recorded and replayable
102
+ * by the same connection. A Durable-Object-backed persistent variant
103
+ * (shared across connections, keyed by channel) is a documented
104
+ * follow-up. Lazily constructed so the first frame seeds it.
105
+ */
106
+ private messages: MessageStore | undefined;
93
107
  private readonly clock: Clock = SystemClock;
94
108
  private readonly ids: IdFactory = new UuidIdFactory();
95
109
  private readonly pingIntervalMs: number = DEFAULT_PING_INTERVAL_MS;
@@ -293,6 +307,10 @@ export class ConnectionDO extends DurableObject<Env> {
293
307
  if (this.channelAccess === undefined) {
294
308
  this.channelAccess = new PassthroughChannelAccess(this.clock.now(), this.env);
295
309
  }
310
+ // Workers Observability ingests `console.*` directly.
311
+ // Bound `connectionId` so every record the actor emits is filterable
312
+ // per-connection in the Workers dashboard.
313
+ const logger: Logger = new ConsoleLogger({ connectionId: state.id }, undefined, LogLevel.Info);
296
314
  return new ConnectionActor({
297
315
  state,
298
316
  runtime,
@@ -301,6 +319,8 @@ export class ConnectionDO extends DurableObject<Env> {
301
319
  clock: this.clock,
302
320
  ids: this.ids,
303
321
  motd,
322
+ messages: this.messageStore(),
323
+ logger,
304
324
  });
305
325
  }
306
326
 
@@ -351,6 +371,19 @@ export class ConnectionDO extends DurableObject<Env> {
351
371
  return { lines: () => raw.split('\n') };
352
372
  }
353
373
 
374
+ /**
375
+ * Lazily constructs the per-connection chat-history store. The ring
376
+ * buffer default cap matches the shared `InMemoryMessageStore`; a
377
+ * future persistent variant (DO/KV-backed, keyed by channel) will
378
+ * replace this without touching the actor wiring.
379
+ */
380
+ private messageStore(): MessageStore {
381
+ if (this.messages === undefined) {
382
+ this.messages = new InMemoryMessageStore();
383
+ }
384
+ return this.messages;
385
+ }
386
+
354
387
  // -------------------------------------------------------------------------
355
388
  // Test hooks (only invoked from `runInDurableObject` in tests)
356
389
  // -------------------------------------------------------------------------
@@ -394,6 +427,7 @@ export class ConnectionDO extends DurableObject<Env> {
394
427
  clock: this.clock,
395
428
  ids: this.ids,
396
429
  motd: this.motdProvider(),
430
+ messages: this.messageStore(),
397
431
  });
398
432
  await actor.receiveTextFrame('QUIT\r\n');
399
433
  await this.releaseAndPersist(state);
@@ -20,7 +20,7 @@ import type {
20
20
  IrcHarness,
21
21
  IrcHarnessFactory,
22
22
  SpawnClientOptions,
23
- } from './integration/harness.js';
23
+ } from '@serverless-ircd/irc-test-support';
24
24
 
25
25
  /** Default tick for the polling waitFor* implementations (ms). */
26
26
  const POLL_MS = 10;
@@ -57,7 +57,7 @@ export interface CfHarnessEnv {
57
57
  *
58
58
  * ```ts
59
59
  * import { env } from 'cloudflare:test';
60
- * import { runIrcScenarios } from './integration/scenarios.js';
60
+ * import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
61
61
  * import { makeCfHarnessFactory } from './cf-harness';
62
62
  *
63
63
  * runIrcScenarios([makeCfHarnessFactory({ env })]);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Parametrized IRC scenarios against the Cloudflare runtime — 036.
3
3
  *
4
- * Reuses the scenario runner from `./integration/scenarios.js`
4
+ * Reuses the scenario runner from `@serverless-ircd/irc-test-support`
5
5
  * (032) and registers the CF harness factory built on top of the
6
6
  * real ConnectionDO / RegistryDO / ChannelDO worker bindings. Each
7
7
  * scenario spawns one or more real WebSockets and drives the full
@@ -26,9 +26,9 @@
26
26
  */
27
27
 
28
28
  import { env, reset } from 'cloudflare:test';
29
+ import { runIrcScenarios } from '@serverless-ircd/irc-test-support';
29
30
  import { afterAll, afterEach, beforeAll } from 'vitest';
30
31
  import { makeCfHarnessFactory } from './cf-harness';
31
- import { runIrcScenarios } from './integration/scenarios.js';
32
32
 
33
33
  declare global {
34
34
  namespace Cloudflare {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Config loading for the cf-adapter.
3
+ *
4
+ * Verifies the shared `ServerConfigSchema` (from `irc-core`) is consumed
5
+ * by the Cloudflare adapter — both adapters (CF + AWS) build the same
6
+ * shape from their platform-native sources.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+ import { loadServerConfigFromCfEnv } from '../src/config-loader';
11
+
12
+ interface CfEnvLike {
13
+ SERVER_NAME?: string;
14
+ NETWORK_NAME?: string;
15
+ MOTD_LINES?: string;
16
+ MAX_CLIENTS?: string;
17
+ CHANNEL_PREFIXES?: string;
18
+ OPER_USER?: string;
19
+ OPER_PASSWORD?: string;
20
+ MAX_CHANNELS_PER_USER?: string;
21
+ MAX_TARGETS_PER_COMMAND?: string;
22
+ NICK_LEN?: string;
23
+ CHANNEL_LEN?: string;
24
+ TOPIC_LEN?: string;
25
+ MAX_LIST_ENTRIES?: string;
26
+ QUIT_MESSAGE?: string;
27
+ }
28
+
29
+ function baseEnv(): CfEnvLike {
30
+ return { SERVER_NAME: 'irc.cf.example.com', NETWORK_NAME: 'CfNet' };
31
+ }
32
+
33
+ describe('loadServerConfigFromCfEnv — valid env', () => {
34
+ it('returns a parsed config derived from the minimal required vars', () => {
35
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
36
+ expect(cfg.serverName).toBe('irc.cf.example.com');
37
+ expect(cfg.networkName).toBe('CfNet');
38
+ // Defaults still apply.
39
+ expect(cfg.channelPrefixes.length).toBeGreaterThan(0);
40
+ expect(cfg.maxClients).toBeGreaterThan(0);
41
+ expect(cfg.operCreds).toEqual([]);
42
+ });
43
+
44
+ it('splits MOTD_LINES on newlines into motdLines', () => {
45
+ const cfg = loadServerConfigFromCfEnv({
46
+ ...baseEnv(),
47
+ MOTD_LINES: 'line 1\nline 2\nline 3',
48
+ });
49
+ expect(cfg.motdLines).toEqual(['line 1', 'line 2', 'line 3']);
50
+ });
51
+
52
+ it('treats an undefined MOTD_LINES as the default empty MOTD', () => {
53
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
54
+ expect(cfg.motdLines).toEqual([]);
55
+ });
56
+
57
+ it('parses MAX_CLIENTS as an integer', () => {
58
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CLIENTS: '4242' });
59
+ expect(cfg.maxClients).toBe(4242);
60
+ });
61
+
62
+ it('parses CHANNEL_PREFIXES verbatim', () => {
63
+ const cfg = loadServerConfigFromCfEnv({
64
+ ...baseEnv(),
65
+ CHANNEL_PREFIXES: '#&+',
66
+ });
67
+ expect(cfg.channelPrefixes).toBe('#&+');
68
+ });
69
+
70
+ it('builds operCreds from OPER_USER + OPER_PASSWORD when both set', () => {
71
+ const cfg = loadServerConfigFromCfEnv({
72
+ ...baseEnv(),
73
+ OPER_USER: 'admin',
74
+ OPER_PASSWORD: 'hunter2',
75
+ });
76
+ expect(cfg.operCreds).toEqual([{ user: 'admin', password: 'hunter2' }]);
77
+ });
78
+
79
+ it('parses numeric limit overrides from string env vars', () => {
80
+ const cfg = loadServerConfigFromCfEnv({
81
+ ...baseEnv(),
82
+ MAX_CHANNELS_PER_USER: '12',
83
+ MAX_TARGETS_PER_COMMAND: '3',
84
+ NICK_LEN: '20',
85
+ CHANNEL_LEN: '40',
86
+ TOPIC_LEN: '300',
87
+ MAX_LIST_ENTRIES: '7',
88
+ });
89
+ expect(cfg.maxChannelsPerUser).toBe(12);
90
+ expect(cfg.maxTargetsPerCommand).toBe(3);
91
+ expect(cfg.nickLen).toBe(20);
92
+ expect(cfg.channelLen).toBe(40);
93
+ expect(cfg.topicLen).toBe(300);
94
+ expect(cfg.maxListEntries).toBe(7);
95
+ });
96
+
97
+ it('honours a custom QUIT_MESSAGE', () => {
98
+ const cfg = loadServerConfigFromCfEnv({
99
+ ...baseEnv(),
100
+ QUIT_MESSAGE: 'Bye from CF',
101
+ });
102
+ expect(cfg.quitMessage).toBe('Bye from CF');
103
+ });
104
+ });
105
+
106
+ describe('loadServerConfigFromCfEnv — failure modes', () => {
107
+ it('throws when SERVER_NAME is missing', () => {
108
+ expect(() => loadServerConfigFromCfEnv({ NETWORK_NAME: 'CfNet' })).toThrowError(/serverName/u);
109
+ });
110
+
111
+ it('throws when NETWORK_NAME is missing', () => {
112
+ expect(() => loadServerConfigFromCfEnv({ SERVER_NAME: 's' })).toThrowError(/networkName/u);
113
+ });
114
+
115
+ it('throws when MAX_CLIENTS is non-numeric', () => {
116
+ expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CLIENTS: 'lots' })).toThrowError(
117
+ /maxClients/u,
118
+ );
119
+ });
120
+
121
+ it('throws when OPER_USER is set but OPER_PASSWORD is missing', () => {
122
+ expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), OPER_USER: 'admin' })).toThrowError(
123
+ /password/u,
124
+ );
125
+ });
126
+
127
+ it('throws when OPER_PASSWORD is set but OPER_USER is missing', () => {
128
+ expect(() => loadServerConfigFromCfEnv({ ...baseEnv(), OPER_PASSWORD: 'p' })).toThrowError(
129
+ /user/u,
130
+ );
131
+ });
132
+
133
+ it('aggregates multiple errors into a single readable Error', () => {
134
+ try {
135
+ loadServerConfigFromCfEnv({
136
+ MAX_CLIENTS: 'oops',
137
+ OPER_USER: 'admin',
138
+ });
139
+ expect.fail('expected throw');
140
+ } catch (err) {
141
+ expect(err).toBeInstanceOf(Error);
142
+ const msg = (err as Error).message;
143
+ expect(msg).toMatch(/invalid server config/iu);
144
+ // Both bad fields are surfaced.
145
+ expect(msg).toMatch(/serverName/u);
146
+ expect(msg).toMatch(/maxClients/u);
147
+ }
148
+ });
149
+ });
@@ -123,6 +123,23 @@ async function register(client: IrcWsClient, nick: string): Promise<void> {
123
123
  await client.waitForMessage((l) => l.includes(' 001 '));
124
124
  }
125
125
 
126
+ /**
127
+ * Negotiates the chathistory cap set then registers. Mirrors the
128
+ * pre-registration `CAP REQ` → `NICK`/`USER` → `CAP END` flow a real
129
+ * client uses.
130
+ */
131
+ async function registerWithChathistory(client: IrcWsClient, nick: string): Promise<void> {
132
+ // `echo-message` is included so tests can deterministically wait for a
133
+ // PRIVMSG to be recorded before issuing a follow-up CHATHISTORY query
134
+ // (otherwise the two async webSocketMessage handlers race).
135
+ client.send('CAP REQ :draft/chathistory server-time message-tags batch echo-message\r\n');
136
+ await client.waitForMessage((l) => / CAP \S+ ACK :/.test(l));
137
+ client.send(`NICK ${nick}\r\n`);
138
+ client.send(`USER ${nick} 0 * :${nick}\r\n`);
139
+ client.send('CAP END\r\n');
140
+ await client.waitForMessage((l) => l.includes(' 001 '));
141
+ }
142
+
126
143
  /**
127
144
  * Forces the ConnectionDO to drop its in-memory cache of the
128
145
  * {@link ConnectionState}, simulating wake-from-hibernation. The next
@@ -324,6 +341,31 @@ describe('ConnectionDO — alarms', () => {
324
341
  });
325
342
  });
326
343
 
344
+ describe('ConnectionDO — chathistory end-to-end', () => {
345
+ it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
346
+ const client = await connect('conn-chathist-1');
347
+ await registerWithChathistory(client, 'cf-alice');
348
+
349
+ client.send('JOIN #cf-chathist\r\n');
350
+ await client.waitForMessage((l) => l.includes(' 366 ') && l.includes('#cf-chathist'));
351
+
352
+ client.send('PRIVMSG #cf-chathist :recorded via cf\r\n');
353
+ // Wait for the echo (echo-message cap) so the PRIVMSG is recorded
354
+ // before the CHATHISTORY query arrives as a separate frame.
355
+ await client.waitForMessage((l) => l.includes('PRIVMSG #cf-chathist :recorded via cf'));
356
+
357
+ client.send('CHATHISTORY LATEST #cf-chathist * 10\r\n');
358
+ const batchOpen = await client.waitForMessage((l) =>
359
+ /^BATCH \+\S+ chathistory #cf-chathist/.test(l),
360
+ );
361
+ expect(batchOpen).toMatch(/^BATCH \+\S+ chathistory #cf-chathist/);
362
+
363
+ await client.waitForMessage((l) => l.includes('PRIVMSG #cf-chathist :recorded via cf'));
364
+
365
+ client.ws.close();
366
+ });
367
+ });
368
+
327
369
  // ---------------------------------------------------------------------------
328
370
  // End of file
329
371
  // ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "description": "Single-process reference implementation of the IrcRuntime port, backed by Maps",
6
6
  "license": "BSD-3-Clause",
@@ -13,6 +13,9 @@
13
13
  */
14
14
 
15
15
  import {
16
+ type AdmissionConfig,
17
+ type AdmissionDecision,
18
+ AdmissionStats,
16
19
  type ChanName,
17
20
  type ChanSnapshot,
18
21
  type ChannelDelta,
@@ -25,6 +28,7 @@ import {
25
28
  type RawLine,
26
29
  applyChannelDelta as applyDelta,
27
30
  createChannel,
31
+ decideAdmission,
28
32
  toChannelSnapshot,
29
33
  toSnapshot as toConnSnapshot,
30
34
  } from '@serverless-ircd/irc-core';
@@ -41,20 +45,93 @@ export interface ConnectionHandlers {
41
45
  interface TrackedConnection {
42
46
  state: ConnectionState;
43
47
  handlers: ConnectionHandlers;
48
+ /**
49
+ * Optional admission record id paired with this connection at register
50
+ * time. When set, {@link InMemoryRuntime.unregisterConnection} releases
51
+ * it so per-IP / per-user connection counters stay accurate.
52
+ */
53
+ admissionRecordId?: string;
44
54
  }
45
55
 
46
56
  function lower(s: string): string {
47
57
  return s.toLowerCase();
48
58
  }
49
59
 
60
+ let disabledRecordCounter = 0;
61
+ /**
62
+ * Returns a unique record id for the "admission disabled" path. The id is
63
+ * never consulted (no {@link AdmissionStats} exists), but emitting one
64
+ * keeps {@link admitConnection}'s return shape uniform.
65
+ */
66
+ function _disabledRecordId(): string {
67
+ disabledRecordCounter += 1;
68
+ return `disabled-${disabledRecordCounter}`;
69
+ }
70
+
71
+ export interface InMemoryRuntimeOptions {
72
+ clock: Clock;
73
+ /**
74
+ * Connection-admission policy. When omitted the admission gate is
75
+ * disabled — every {@link InMemoryRuntime.admitConnection} call returns
76
+ * an admit decision. Adapters that want to enforce per-IP / per-user /
77
+ * rate limits pass the same {@link AdmissionConfig} derived from the
78
+ * deployment's `ServerConfig`.
79
+ */
80
+ readonly admission?: AdmissionConfig;
81
+ }
82
+
50
83
  export class InMemoryRuntime implements IrcRuntime {
51
84
  private readonly clock: Clock;
52
85
  private readonly connections = new Map<ConnId, TrackedConnection>();
53
86
  private readonly channels = new Map<string, ChannelState>();
54
87
  private readonly nicks = new Map<string, ConnId>();
88
+ private readonly admissionStats: AdmissionStats | undefined;
89
+ private readonly admissionConfig: AdmissionConfig | undefined;
55
90
 
56
- constructor(opts: { clock: Clock }) {
91
+ constructor(opts: InMemoryRuntimeOptions) {
57
92
  this.clock = opts.clock;
93
+ if (opts.admission !== undefined) {
94
+ this.admissionConfig = opts.admission;
95
+ this.admissionStats = new AdmissionStats(this.clock);
96
+ }
97
+ }
98
+
99
+ // ------------------------------------------------------------------
100
+ // Admission gate (NOT part of the IrcRuntime port — adapter only)
101
+ // ------------------------------------------------------------------
102
+
103
+ /**
104
+ * Decides whether a new connection should be admitted under the
105
+ * configured {@link AdmissionConfig}. Returns `{ ok: true, recordId }`
106
+ * when the connection may proceed; the caller MUST then call
107
+ * {@link commitAdmission} exactly once with the returned recordId.
108
+ *
109
+ * Returns `{ ok: false, reason }` when the connection must be refused.
110
+ *
111
+ * When no admission policy was supplied at construction, this method
112
+ * unconditionally admits (the gate is disabled).
113
+ */
114
+ admitConnection(ip: string, user: string | undefined): AdmissionDecision {
115
+ if (this.admissionConfig === undefined || this.admissionStats === undefined) {
116
+ return { ok: true, recordId: _disabledRecordId() };
117
+ }
118
+ return decideAdmission(ip, user, this.admissionStats, this.admissionConfig);
119
+ }
120
+
121
+ /**
122
+ * Records an accepted admission. MUST be called exactly once per
123
+ * successful {@link admitConnection}. No-op when admission is disabled.
124
+ */
125
+ commitAdmission(ip: string, user: string | undefined, recordId: string): void {
126
+ this.admissionStats?.recordAdmission(ip, user, recordId);
127
+ }
128
+
129
+ /**
130
+ * Releases a previously-committed admission record. Safe to call with
131
+ * an unknown id. No-op when admission is disabled.
132
+ */
133
+ releaseAdmission(recordId: string): void {
134
+ this.admissionStats?.release(recordId);
58
135
  }
59
136
 
60
137
  // ------------------------------------------------------------------
@@ -65,13 +142,31 @@ export class InMemoryRuntime implements IrcRuntime {
65
142
  * Tracks a connection and its transport handlers. The runtime keeps a
66
143
  * reference to `state`; the actor may continue mutating it in place and
67
144
  * the runtime will observe the changes via {@link getConnectionInfo}.
145
+ *
146
+ * @param admissionRecordId Optional record id returned by
147
+ * {@link admitConnection} + {@link commitAdmission}. When supplied,
148
+ * {@link unregisterConnection} releases it automatically so the
149
+ * per-IP / per-user connection counters stay accurate without the
150
+ * adapter remembering to call {@link releaseAdmission} itself.
68
151
  */
69
- registerConnection(state: ConnectionState, handlers: ConnectionHandlers): void {
70
- this.connections.set(state.id, { state, handlers });
152
+ registerConnection(
153
+ state: ConnectionState,
154
+ handlers: ConnectionHandlers,
155
+ admissionRecordId?: string,
156
+ ): void {
157
+ const tracked: TrackedConnection =
158
+ admissionRecordId !== undefined
159
+ ? { state, handlers, admissionRecordId }
160
+ : { state, handlers };
161
+ this.connections.set(state.id, tracked);
71
162
  }
72
163
 
73
164
  /** Stops tracking a connection. Idempotent. Does NOT release its nick. */
74
165
  unregisterConnection(conn: ConnId): void {
166
+ const tracked = this.connections.get(conn);
167
+ if (tracked !== undefined && tracked.admissionRecordId !== undefined) {
168
+ this.releaseAdmission(tracked.admissionRecordId);
169
+ }
75
170
  this.connections.delete(conn);
76
171
  }
77
172
 
@@ -102,6 +197,16 @@ export class InMemoryRuntime implements IrcRuntime {
102
197
  return created;
103
198
  }
104
199
 
200
+ /**
201
+ * Peek-only channel lookup that does NOT create the channel. Returns the
202
+ * authoritative {@link ChannelState} when it exists, or `undefined`.
203
+ * Used by query-only commands (CHATHISTORY) that must distinguish a
204
+ * missing channel from an empty one.
205
+ */
206
+ getChannel(name: ChanName): ChannelState | undefined {
207
+ return this.channels.get(lower(name));
208
+ }
209
+
105
210
  // ------------------------------------------------------------------
106
211
  // IrcRuntime — transport
107
212
  // ------------------------------------------------------------------
@@ -6,4 +6,4 @@
6
6
  * and AWS runtimes.
7
7
  */
8
8
  export { InMemoryRuntime } from './in-memory-runtime.js';
9
- export type { ConnectionHandlers } from './in-memory-runtime.js';
9
+ export type { ConnectionHandlers, InMemoryRuntimeOptions } from './in-memory-runtime.js';
@@ -1,4 +1,6 @@
1
1
  import {
2
+ type AdmissionConfig,
3
+ type AdmissionDecision,
2
4
  type ChannelDelta,
3
5
  type ConnectionState,
4
6
  FakeClock,
@@ -500,3 +502,116 @@ describe('InMemoryRuntime — connection lifecycle', () => {
500
502
  expect(rt.getOrCreateChannel('#foo')).toBe(chan);
501
503
  });
502
504
  });
505
+
506
+ // ---------------------------------------------------------------------------
507
+ // Admission gate
508
+ // ---------------------------------------------------------------------------
509
+
510
+ /** Strict per-IP cap, generous everything else, for isolated per-IP tests. */
511
+ const IP_CAP_ONLY: AdmissionConfig = {
512
+ maxConnectionsPerIp: 2,
513
+ maxConnectionsPerUser: 100,
514
+ perIpConnectionRate: { max: 1_000, windowMs: 10_000 },
515
+ };
516
+
517
+ /** Strict per-user cap, generous everything else, for isolated per-user tests. */
518
+ const USER_CAP_ONLY: AdmissionConfig = {
519
+ maxConnectionsPerIp: 100,
520
+ maxConnectionsPerUser: 2,
521
+ perIpConnectionRate: { max: 1_000, windowMs: 10_000 },
522
+ };
523
+
524
+ /** Strict rate limit, generous caps, for isolated rate-limit tests. */
525
+ const RATE_ONLY: AdmissionConfig = {
526
+ maxConnectionsPerIp: 100,
527
+ maxConnectionsPerUser: 100,
528
+ perIpConnectionRate: { max: 2, windowMs: 10_000 },
529
+ };
530
+
531
+ describe('InMemoryRuntime — admission gate', () => {
532
+ it('admits the first connection from an IP when a policy is configured', async () => {
533
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
534
+ const decision = rt.admitConnection('1.2.3.4', undefined);
535
+ expect(decision.ok).toBe(true);
536
+ if (decision.ok) rt.commitAdmission('1.2.3.4', undefined, decision.recordId);
537
+ });
538
+
539
+ it('rejects a connection exceeding the per-IP cap', async () => {
540
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
541
+ for (let i = 0; i < 2; i++) {
542
+ const d = rt.admitConnection('1.1.1.1', `u${i}`);
543
+ if (d.ok) rt.commitAdmission('1.1.1.1', `u${i}`, d.recordId);
544
+ }
545
+ const third = rt.admitConnection('1.1.1.1', 'u2');
546
+ expect(third.ok).toBe(false);
547
+ if (!third.ok) expect(third.reason).toBe('max_connections_per_ip');
548
+ });
549
+
550
+ it('rejects a connection exceeding the per-user cap', async () => {
551
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: USER_CAP_ONLY });
552
+ for (let i = 0; i < 2; i++) {
553
+ const d = rt.admitConnection(`10.0.0.${i}`, 'alice');
554
+ if (d.ok) rt.commitAdmission(`10.0.0.${i}`, 'alice', d.recordId);
555
+ }
556
+ const third = rt.admitConnection('10.0.0.9', 'alice');
557
+ expect(third.ok).toBe(false);
558
+ if (!third.ok) expect(third.reason).toBe('max_connections_per_user');
559
+ });
560
+
561
+ it('rejects a connection exceeding the per-IP rate cap', async () => {
562
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: RATE_ONLY });
563
+ for (let i = 0; i < 2; i++) {
564
+ const d = rt.admitConnection('5.5.5.5', `u${i}`);
565
+ if (d.ok) rt.commitAdmission('5.5.5.5', `u${i}`, d.recordId);
566
+ }
567
+ const third = rt.admitConnection('5.5.5.5', 'u2');
568
+ expect(third.ok).toBe(false);
569
+ if (!third.ok) expect(third.reason).toBe('rate_limited_per_ip');
570
+ });
571
+
572
+ it('releaseAdmission decrements counters so subsequent admissions succeed', () => {
573
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
574
+ const d1 = rt.admitConnection('1.1.1.1', 'alice');
575
+ expect(d1.ok).toBe(true);
576
+ if (!d1.ok) return;
577
+ rt.commitAdmission('1.1.1.1', 'alice', d1.recordId);
578
+ const d2 = rt.admitConnection('1.1.1.1', 'alice');
579
+ expect(d2.ok).toBe(true);
580
+ if (!d2.ok) return;
581
+ rt.commitAdmission('1.1.1.1', 'alice', d2.recordId);
582
+ expect(rt.admitConnection('1.1.1.1', 'alice').ok).toBe(false);
583
+ rt.releaseAdmission(d1.recordId);
584
+ expect(rt.admitConnection('1.1.1.1', 'alice').ok).toBe(true);
585
+ });
586
+
587
+ it('unregisterConnection releases the admission record so the IP slot is freed', async () => {
588
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
589
+ const conn = makeConn('c1', 'alice');
590
+ // Simulate the local-CLI admission flow: admit, commit, register, and
591
+ // pair the record id with the connection for later release.
592
+ const d = rt.admitConnection('9.9.9.9', 'alice');
593
+ expect(d.ok).toBe(true);
594
+ if (!d.ok) return;
595
+ rt.commitAdmission('9.9.9.9', 'alice', d.recordId);
596
+ rt.registerConnection(conn, { send: () => {}, disconnect: () => {} }, d.recordId);
597
+ // IP slot now holds one connection.
598
+ const second = rt.admitConnection('9.9.9.9', 'bob');
599
+ expect(second.ok).toBe(true);
600
+ if (second.ok) rt.commitAdmission('9.9.9.9', 'bob', second.recordId);
601
+ // Third would exceed per-IP cap of 2.
602
+ expect(rt.admitConnection('9.9.9.9', 'carol').ok).toBe(false);
603
+ // Unregistering c1 frees its slot (the runtime releases the paired record).
604
+ rt.unregisterConnection('c1');
605
+ const fourth = rt.admitConnection('9.9.9.9', 'carol');
606
+ expect(fourth.ok).toBe(true);
607
+ });
608
+
609
+ it('returns an admit decision when no admission policy is configured (gate disabled)', () => {
610
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
611
+ const decisions: AdmissionDecision[] = [];
612
+ for (let i = 0; i < 100; i++) {
613
+ decisions.push(rt.admitConnection('1.1.1.1', 'alice'));
614
+ }
615
+ expect(decisions.every((d) => d.ok)).toBe(true);
616
+ });
617
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": true,
5
5
  "description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
6
6
  "license": "BSD-3-Clause",
@@ -23,7 +23,17 @@
23
23
  "test": "vitest run",
24
24
  "test:watch": "vitest",
25
25
  "coverage": "vitest run --coverage",
26
+ "mutation:protocol": "stryker run stryker.protocol.conf.json",
27
+ "mutation:commands": "stryker run stryker.commands.conf.json",
28
+ "mutation": "pnpm run mutation:protocol && pnpm run mutation:commands",
26
29
  "clean": "rimraf dist coverage .tsbuildinfo .turbo"
27
30
  },
28
- "files": ["dist", "src", "README.md"]
31
+ "files": ["dist", "src", "README.md"],
32
+ "dependencies": {
33
+ "zod": "^3.25.0"
34
+ },
35
+ "devDependencies": {
36
+ "@stryker-mutator/core": "^9.6.1",
37
+ "@stryker-mutator/vitest-runner": "^9.6.1"
38
+ }
29
39
  }