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,4 +1,6 @@
1
1
  import {
2
+ type AdmissionConfig,
3
+ type AdmissionDecision,
2
4
  type ChannelDelta,
3
5
  type ConnectionState,
4
6
  FakeClock,
@@ -225,6 +227,14 @@ describe('InMemoryRuntime — applyChannelDelta / getChannelSnapshot', () => {
225
227
  expect(snap?.members.has('c1')).toBe(true);
226
228
  });
227
229
 
230
+ it('treats rfc1459 special chars [ \\ ] ^ as equal to { | } ~', async () => {
231
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
232
+ const a = rt.getOrCreateChannel('#[Foo]');
233
+ const b = rt.getOrCreateChannel('#{foo}');
234
+ expect(b).toBe(a);
235
+ expect(b.nameLower).toBe('#{foo}');
236
+ });
237
+
228
238
  it('returns null from getChannelSnapshot for an unknown channel', async () => {
229
239
  const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
230
240
  expect(await rt.getChannelSnapshot('#nope')).toBeNull();
@@ -500,3 +510,127 @@ describe('InMemoryRuntime — connection lifecycle', () => {
500
510
  expect(rt.getOrCreateChannel('#foo')).toBe(chan);
501
511
  });
502
512
  });
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Admission gate
516
+ // ---------------------------------------------------------------------------
517
+
518
+ /** Strict per-IP cap, generous everything else, for isolated per-IP tests. */
519
+ const IP_CAP_ONLY: AdmissionConfig = {
520
+ maxConnectionsPerIp: 2,
521
+ maxConnectionsPerUser: 100,
522
+ perIpConnectionRate: { max: 1_000, windowMs: 10_000 },
523
+ };
524
+
525
+ /** Strict per-user cap, generous everything else, for isolated per-user tests. */
526
+ const USER_CAP_ONLY: AdmissionConfig = {
527
+ maxConnectionsPerIp: 100,
528
+ maxConnectionsPerUser: 2,
529
+ perIpConnectionRate: { max: 1_000, windowMs: 10_000 },
530
+ };
531
+
532
+ /** Strict rate limit, generous caps, for isolated rate-limit tests. */
533
+ const RATE_ONLY: AdmissionConfig = {
534
+ maxConnectionsPerIp: 100,
535
+ maxConnectionsPerUser: 100,
536
+ perIpConnectionRate: { max: 2, windowMs: 10_000 },
537
+ };
538
+
539
+ describe('InMemoryRuntime — admission gate', () => {
540
+ it('admits the first connection from an IP when a policy is configured', async () => {
541
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
542
+ const decision = rt.admitConnection('1.2.3.4', undefined);
543
+ expect(decision.ok).toBe(true);
544
+ if (decision.ok) rt.commitAdmission('1.2.3.4', undefined, decision.recordId);
545
+ });
546
+
547
+ it('rejects a connection exceeding the per-IP cap', async () => {
548
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
549
+ for (let i = 0; i < 2; i++) {
550
+ const d = rt.admitConnection('1.1.1.1', `u${i}`);
551
+ if (d.ok) rt.commitAdmission('1.1.1.1', `u${i}`, d.recordId);
552
+ }
553
+ const third = rt.admitConnection('1.1.1.1', 'u2');
554
+ expect(third.ok).toBe(false);
555
+ if (!third.ok) expect(third.reason).toBe('max_connections_per_ip');
556
+ });
557
+
558
+ it('rejects a connection exceeding the per-user cap', async () => {
559
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: USER_CAP_ONLY });
560
+ for (let i = 0; i < 2; i++) {
561
+ const d = rt.admitConnection(`10.0.0.${i}`, 'alice');
562
+ if (d.ok) rt.commitAdmission(`10.0.0.${i}`, 'alice', d.recordId);
563
+ }
564
+ const third = rt.admitConnection('10.0.0.9', 'alice');
565
+ expect(third.ok).toBe(false);
566
+ if (!third.ok) expect(third.reason).toBe('max_connections_per_user');
567
+ });
568
+
569
+ it('rejects a connection exceeding the per-IP rate cap', async () => {
570
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: RATE_ONLY });
571
+ for (let i = 0; i < 2; i++) {
572
+ const d = rt.admitConnection('5.5.5.5', `u${i}`);
573
+ if (d.ok) rt.commitAdmission('5.5.5.5', `u${i}`, d.recordId);
574
+ }
575
+ const third = rt.admitConnection('5.5.5.5', 'u2');
576
+ expect(third.ok).toBe(false);
577
+ if (!third.ok) expect(third.reason).toBe('rate_limited_per_ip');
578
+ });
579
+
580
+ it('releaseAdmission decrements counters so subsequent admissions succeed', () => {
581
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
582
+ const d1 = rt.admitConnection('1.1.1.1', 'alice');
583
+ expect(d1.ok).toBe(true);
584
+ if (!d1.ok) return;
585
+ rt.commitAdmission('1.1.1.1', 'alice', d1.recordId);
586
+ const d2 = rt.admitConnection('1.1.1.1', 'alice');
587
+ expect(d2.ok).toBe(true);
588
+ if (!d2.ok) return;
589
+ rt.commitAdmission('1.1.1.1', 'alice', d2.recordId);
590
+ expect(rt.admitConnection('1.1.1.1', 'alice').ok).toBe(false);
591
+ rt.releaseAdmission(d1.recordId);
592
+ expect(rt.admitConnection('1.1.1.1', 'alice').ok).toBe(true);
593
+ });
594
+
595
+ it('unregisterConnection releases the admission record so the IP slot is freed', async () => {
596
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0), admission: IP_CAP_ONLY });
597
+ const conn = makeConn('c1', 'alice');
598
+ // Simulate the local-CLI admission flow: admit, commit, register, and
599
+ // pair the record id with the connection for later release.
600
+ const d = rt.admitConnection('9.9.9.9', 'alice');
601
+ expect(d.ok).toBe(true);
602
+ if (!d.ok) return;
603
+ rt.commitAdmission('9.9.9.9', 'alice', d.recordId);
604
+ rt.registerConnection(conn, { send: () => {}, disconnect: () => {} }, d.recordId);
605
+ // IP slot now holds one connection.
606
+ const second = rt.admitConnection('9.9.9.9', 'bob');
607
+ expect(second.ok).toBe(true);
608
+ if (second.ok) rt.commitAdmission('9.9.9.9', 'bob', second.recordId);
609
+ // Third would exceed per-IP cap of 2.
610
+ expect(rt.admitConnection('9.9.9.9', 'carol').ok).toBe(false);
611
+ // Unregistering c1 frees its slot (the runtime releases the paired record).
612
+ rt.unregisterConnection('c1');
613
+ const fourth = rt.admitConnection('9.9.9.9', 'carol');
614
+ expect(fourth.ok).toBe(true);
615
+ });
616
+
617
+ it('returns an admit decision when no admission policy is configured (gate disabled)', () => {
618
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
619
+ const decisions: AdmissionDecision[] = [];
620
+ for (let i = 0; i < 100; i++) {
621
+ decisions.push(rt.admitConnection('1.1.1.1', 'alice'));
622
+ }
623
+ expect(decisions.every((d) => d.ok)).toBe(true);
624
+ });
625
+
626
+ it('returns a stable recordId when admission is disabled (no per-call minting)', () => {
627
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
628
+ const d1 = rt.admitConnection('1.1.1.1', 'alice');
629
+ const d2 = rt.admitConnection('2.2.2.2', 'bob');
630
+ expect(d1.ok).toBe(true);
631
+ expect(d2.ok).toBe(true);
632
+ if (d1.ok && d2.ok) {
633
+ expect(d1.recordId).toBe(d2.recordId);
634
+ }
635
+ });
636
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-core",
3
- "version": "0.1.0",
3
+ "version": "0.3.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,18 @@
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
+ "vitest": "^4.1.0"
39
+ }
29
40
  }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Connection-admission gate — pure policy + stateful counter.
3
+ *
4
+ * Guards the connection acceptance path against three classes of abuse:
5
+ *
6
+ * 1. **Per-IP connection cap** (`maxConnectionsPerIp`): caps how many
7
+ * simultaneous connections a single source IP may hold. Distinct
8
+ * from per-connection flood control: this is the "how many sockets
9
+ *. at once" gate, not the "how many commands per socket" gate.
10
+ *
11
+ * 2. **Per-user connection cap** (`maxConnectionsPerUser`): same idea,
12
+ * keyed by SASL account name when available, falling back to the
13
+ * source IP when the connection has not authenticated.
14
+ *
15
+ * 3. **Per-IP connection-rate limit** (`perIpConnectionRate`): caps how
16
+ * fast a single source IP may open new connections within a sliding
17
+ * window. Defends against connection-flood attacks that recycle
18
+ * sockets to evade the per-IP cap.
19
+ *
20
+ * The policy itself is a pure function {@link decideAdmission}; the
21
+ * accounting state lives in {@link AdmissionStats}, which is injected so
22
+ * tests can pin the timeline via {@link FakeClock} and inspect state in
23
+ * isolation. Both adapters (in-memory runtime, CF / AWS) construct one
24
+ * `AdmissionStats` per process / per worker invocation and share it
25
+ * across every connection acceptance decision.
26
+ *
27
+ * Determinism: same Clock + same call stream → same decisions. There are
28
+ * no ambient inputs.
29
+ */
30
+
31
+ import type { Clock } from './ports.js';
32
+
33
+ /**
34
+ * Static policy consulted by {@link decideAdmission}. Adapters source the
35
+ * values from `ServerConfig` but the gate consumes this narrow shape so
36
+ * the unit tests do not need to construct a full config.
37
+ */
38
+ export interface AdmissionConfig {
39
+ /** Maximum simultaneous connections from a single source IP. */
40
+ readonly maxConnectionsPerIp: number;
41
+ /** Maximum simultaneous connections from a single authenticated user. */
42
+ readonly maxConnectionsPerUser: number;
43
+ /** Per-IP connection-rate limit (sliding window). */
44
+ readonly perIpConnectionRate: { readonly max: number; readonly windowMs: number };
45
+ }
46
+
47
+ /** Positive admission outcome — caller should {@link AdmissionStats.recordAdmission}. */
48
+ export interface AdmitDecision {
49
+ readonly ok: true;
50
+ /**
51
+ * Opaque record id handed back to {@link AdmissionStats.release} when the
52
+ * connection terminates. Unique per admission.
53
+ */
54
+ readonly recordId: string;
55
+ }
56
+
57
+ /** Negative admission outcome — the caller MUST close the connection. */
58
+ export interface RejectDecision {
59
+ readonly ok: false;
60
+ /** Machine-readable reason code for logs / metrics / `Disconnect` effect. */
61
+ readonly reason: AdmissionRejectReason;
62
+ }
63
+
64
+ export type AdmissionRejectReason =
65
+ | 'max_connections_per_ip'
66
+ | 'max_connections_per_user'
67
+ | 'rate_limited_per_ip';
68
+
69
+ export type AdmissionDecision = AdmitDecision | RejectDecision;
70
+
71
+ /** Per-IP / per-user / rate counters. Owned by the runtime / adapter. */
72
+ export class AdmissionStats {
73
+ private readonly clock: Clock;
74
+ private readonly perIpCount = new Map<string, number>();
75
+ private readonly perUserCount = new Map<string, number>();
76
+ /**
77
+ * Per-IP timestamps of recent admissions, used by the rate-limit check.
78
+ * Stored ascending; entries older than the window are pruned lazily.
79
+ */
80
+ private readonly perIpAdmitTimes = new Map<string, number[]>();
81
+ /** Reverse index: recordId → { ip, user }, used by {@link release}. */
82
+ private readonly records = new Map<string, { ip: string; user: string }>();
83
+ private nextRecordId = 1;
84
+
85
+ constructor(clock: Clock) {
86
+ this.clock = clock;
87
+ }
88
+
89
+ /**
90
+ * Returns the current per-IP connection count. Exposed for diagnostics
91
+ * and tests; not used by {@link decideAdmission}.
92
+ */
93
+ countForIp(ip: string): number {
94
+ return this.perIpCount.get(ip) ?? 0;
95
+ }
96
+
97
+ /** Returns the current per-user connection count. */
98
+ countForUser(user: string): number {
99
+ return this.perUserCount.get(user) ?? 0;
100
+ }
101
+
102
+ /**
103
+ * Records an accepted admission. MUST be called exactly once per
104
+ * successful {@link decideAdmission} so the counters stay accurate.
105
+ */
106
+ recordAdmission(ip: string, user: string | undefined, recordId: string): void {
107
+ const effectiveUser = user ?? ip;
108
+ this.perIpCount.set(ip, (this.perIpCount.get(ip) ?? 0) + 1);
109
+ this.perUserCount.set(effectiveUser, (this.perUserCount.get(effectiveUser) ?? 0) + 1);
110
+ const now = this.clock.now();
111
+ const times = this.perIpAdmitTimes.get(ip) ?? [];
112
+ times.push(now);
113
+ this.perIpAdmitTimes.set(ip, times);
114
+ this.records.set(recordId, { ip, user: effectiveUser });
115
+ }
116
+
117
+ /**
118
+ * Releases a previously-recorded admission, decrementing the relevant
119
+ * counters. Idempotent: releasing an unknown id is a no-op.
120
+ */
121
+ release(recordId: string): void {
122
+ const rec = this.records.get(recordId);
123
+ if (rec === undefined) return;
124
+ this.records.delete(recordId);
125
+
126
+ const ipCount = this.perIpCount.get(rec.ip);
127
+ if (ipCount !== undefined) {
128
+ const next = ipCount - 1;
129
+ if (next <= 0) this.perIpCount.delete(rec.ip);
130
+ else this.perIpCount.set(rec.ip, next);
131
+ }
132
+ const userCount = this.perUserCount.get(rec.user);
133
+ if (userCount !== undefined) {
134
+ const next = userCount - 1;
135
+ if (next <= 0) this.perUserCount.delete(rec.user);
136
+ else this.perUserCount.set(rec.user, next);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Returns the number of admissions recorded for `ip` within the last
142
+ * `windowMs` milliseconds (inclusive of the moment returned by
143
+ * `clock.now()`). Stale entries are pruned as a side effect.
144
+ */
145
+ recentAdmissions(ip: string, windowMs: number): number {
146
+ const times = this.perIpAdmitTimes.get(ip);
147
+ if (times === undefined || times.length === 0) return 0;
148
+ const cutoff = this.clock.now() - windowMs;
149
+ let writeIdx = 0;
150
+ for (let i = 0; i < times.length; i++) {
151
+ const t = times[i];
152
+ if (t === undefined) continue;
153
+ if (t >= cutoff) {
154
+ times[writeIdx++] = t;
155
+ }
156
+ }
157
+ if (writeIdx === 0) {
158
+ this.perIpAdmitTimes.delete(ip);
159
+ return 0;
160
+ }
161
+ times.length = writeIdx;
162
+ return writeIdx;
163
+ }
164
+
165
+ /**
166
+ * Allocates the next unique record id. Public so {@link decideAdmission}
167
+ * can mint ids without breaking encapsulation.
168
+ */
169
+ nextRecord(): string {
170
+ const id = this.nextRecordId.toString(36);
171
+ this.nextRecordId += 1;
172
+ return id;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Decides whether a new connection should be admitted.
178
+ *
179
+ * The check is performed in a fixed precedence order so the rejection
180
+ * reason is deterministic:
181
+ * 1. Per-IP connection-rate limit (cheapest to test).
182
+ * 2. Per-IP simultaneous connection cap.
183
+ * 3. Per-user simultaneous connection cap.
184
+ *
185
+ * On success the caller MUST call {@link AdmissionStats.recordAdmission}
186
+ * with the returned `recordId` before admitting any further connection;
187
+ * on failure the caller MUST close the connection.
188
+ *
189
+ * The decision is pure with respect to `(ip, user, stats, config)` — same
190
+ * inputs yield the same outcome, including the assigned `recordId`.
191
+ */
192
+ export function decideAdmission(
193
+ ip: string,
194
+ user: string | undefined,
195
+ stats: AdmissionStats,
196
+ config: AdmissionConfig,
197
+ ): AdmissionDecision {
198
+ // 1. Rate limit (per-IP, sliding window).
199
+ const recent = stats.recentAdmissions(ip, config.perIpConnectionRate.windowMs);
200
+ if (recent >= config.perIpConnectionRate.max) {
201
+ return { ok: false, reason: 'rate_limited_per_ip' };
202
+ }
203
+
204
+ // 2. Per-IP cap.
205
+ if (stats.countForIp(ip) >= config.maxConnectionsPerIp) {
206
+ return { ok: false, reason: 'max_connections_per_ip' };
207
+ }
208
+
209
+ // 3. Per-user cap (falls back to IP when no SASL identity is present).
210
+ const effectiveUser = user ?? ip;
211
+ if (stats.countForUser(effectiveUser) >= config.maxConnectionsPerUser) {
212
+ return { ok: false, reason: 'max_connections_per_user' };
213
+ }
214
+
215
+ return { ok: true, recordId: stats.nextRecord() };
216
+ }
@@ -26,6 +26,7 @@ export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
26
26
  { name: 'away-notify' },
27
27
  { name: 'batch' },
28
28
  { name: 'chghost' },
29
+ { name: 'draft/chathistory' },
29
30
  { name: 'echo-message' },
30
31
  { name: 'extended-join' },
31
32
  { name: 'invite-notify' },
@@ -0,0 +1,64 @@
1
+ /**
2
+ * IRC case-mapping (case folding) for nick and channel-name comparison.
3
+ *
4
+ * IRC servers compare nicks and channel names case-insensitively, but the
5
+ * exact set of characters that fold together is advertised to clients via
6
+ * the `005 RPL_ISUPPORT` `CASEMAPPING=` token. This module implements the
7
+ * two relevant mappings:
8
+ *
9
+ * - `ascii` — only `A`–`Z` fold to `a`–`z`.
10
+ * - `rfc1459` — `ascii` plus the four characters `[ \ ] ^` folding to
11
+ * `{ | } ~` respectively (per RFC 1459 §2.2). The targets
12
+ * `{ | } ~` do NOT fold further, so the fold is idempotent:
13
+ * `fold(fold(s)) === fold(s)`.
14
+ *
15
+ * The fold operates on one character at a time and leaves every other byte
16
+ * (including all non-ASCII) unchanged. It is pure and dependency-free so it
17
+ * runs unchanged on every runtime (Node, Workers, Lambda).
18
+ *
19
+ * Advertised via {@link CaseMapping} and consumed by the command reducers
20
+ * and the reference runtime so that the advertised case-mapping and the
21
+ * actual folding never diverge.
22
+ */
23
+
24
+ /** Case-mapping algorithms supported for IRC-name comparison. */
25
+ export type CaseMapping = 'rfc1459' | 'ascii';
26
+
27
+ /**
28
+ * Lowercases `s` according to `mapping`, returning the canonical key used
29
+ * for case-insensitive nick and channel comparison.
30
+ *
31
+ * @param mapping `rfc1459` (default behaviour everywhere in this server) or
32
+ * `ascii` (a strict subset — useful for strict-ASCII tests).
33
+ * @param s The nick or channel name to fold.
34
+ */
35
+ export function caseFold(mapping: CaseMapping, s: string): string {
36
+ let out = '';
37
+ for (let i = 0; i < s.length; i += 1) {
38
+ out += foldChar(mapping, s.charCodeAt(i));
39
+ }
40
+ return out;
41
+ }
42
+
43
+ /** Folds a single UTF-16 code unit per the active mapping. */
44
+ function foldChar(mapping: CaseMapping, code: number): string {
45
+ if (code >= 0x41 && code <= 0x5a) {
46
+ // A–Z → a–z
47
+ return String.fromCharCode(code + 0x20);
48
+ }
49
+ if (mapping === 'rfc1459') {
50
+ switch (code) {
51
+ case 0x5b:
52
+ return '{'; // [ → {
53
+ case 0x5c:
54
+ return '|'; // \ → |
55
+ case 0x5d:
56
+ return '}'; // ] → }
57
+ case 0x5e:
58
+ return '~'; // ^ → ~
59
+ default:
60
+ break;
61
+ }
62
+ }
63
+ return String.fromCharCode(code);
64
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Hostmask cloaking — replaces a client's real source IP with a deterministic
3
+ * opaque identifier derived from the deployment secret + the IP.
4
+ *
5
+ * Goals (matching mainstream IRC daemon behaviour — charybdis / UnrealIRCd):
6
+ * - The real IP is never revealed on the wire (in WHOIS, hostmasks, etc.).
7
+ * - The same IP + secret always yields the same cloak (so per-IP
8
+ * reputation/line enforcement still works).
9
+ * - Two different IPs almost never collide.
10
+ * - The cloak fits in a single DNS-label-safe segment.
11
+ *
12
+ * Implementation note: this uses a self-contained FNV-1a 32-bit hash plus a
13
+ * short base32 encoding so the module stays pure-TS and runs unchanged on
14
+ * Node 20, Cloudflare Workers, and Lambda. It is NOT a cryptographic MAC:
15
+ * an attacker who learns one cloak cannot recover the secret, but a
16
+ * determined attacker with the secret can forge cloaks. Deployments that
17
+ * need stronger unforgeability should override this module.
18
+ */
19
+
20
+ import type { CloakingConfig } from './types.js';
21
+
22
+ /**
23
+ * Returns the visible host for `realHost` after applying the deployment's
24
+ * cloaking policy.
25
+ *
26
+ * - When `config.enabled === false`, `realHost` is returned verbatim.
27
+ * - When `config.enabled === true`, a deterministic cloak of the form
28
+ * `<hash>.<suffix>` is returned. The `<hash>` segment is derived from
29
+ * `secret` + `realHost`; the `<suffix>` defaults to `defaultSuffix`
30
+ * (typically the deployment's `networkName` lowercased) and may be
31
+ * overridden via `config.cloakedSuffix`.
32
+ *
33
+ * The function is pure — same inputs always yield the same output. There
34
+ * is no ambient randomness or clock.
35
+ *
36
+ * @param realHost The connection's real source IP (or any host string).
37
+ * @param config Cloaking policy. See {@link CloakingConfig}.
38
+ * @param defaultSuffix Suffix used when `config.cloakedSuffix` is absent.
39
+ */
40
+ export function cloakHost(realHost: string, config: CloakingConfig, defaultSuffix: string): string {
41
+ if (!config.enabled) return realHost;
42
+ const suffix = config.cloakedSuffix ?? defaultSuffix;
43
+ // 64-bit hash assembled from two FNV-1a passes with different seeds so
44
+ // distinct IPs collide with probability ~ 1 / 2^64.
45
+ const hi = fnv1a32(`a:${config.secret}:${realHost}`);
46
+ const lo = fnv1a32(`b:${config.secret}:${realHost}`);
47
+ return `${base32(hi)}${base32(lo)}.${suffix}`;
48
+ }
49
+
50
+ /**
51
+ * FNV-1a 32-bit hash. Self-contained: no Node `crypto`, works on every
52
+ * serverless runtime. Output is an unsigned 32-bit integer.
53
+ */
54
+ function fnv1a32(input: string): number {
55
+ let h = 0x811c9dc5;
56
+ for (let i = 0; i < input.length; i++) {
57
+ h ^= input.charCodeAt(i);
58
+ // 32-bit multiply by FNV prime using Math.imul to stay in u32 space.
59
+ h = Math.imul(h, 0x01000193);
60
+ }
61
+ // Force unsigned.
62
+ return h >>> 0;
63
+ }
64
+
65
+ /**
66
+ * Lowercase base32 (RFC 4648) encoding without padding, suitable for a
67
+ * DNS-safe identifier. Encodes 5 bits per character.
68
+ */
69
+ const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
70
+
71
+ function base32(value: number): string {
72
+ // 32 bits -> 7 base32 chars (ceil(32/5) = 7 with one bit of padding,
73
+ // but we don't emit padding so 7 chars is the canonical length here).
74
+ let out = '';
75
+ let v = value;
76
+ for (let i = 0; i < 7; i++) {
77
+ out += BASE32_ALPHABET[v & 0x1f];
78
+ v = Math.floor(v / 32);
79
+ }
80
+ return out;
81
+ }
@@ -199,8 +199,7 @@ function handleReq(
199
199
  */
200
200
  function handleEnd(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionState> {
201
201
  state.capNegotiating = false;
202
- const welcome = emitWelcomeIfReady(state, ctx);
203
- return { state, effects: welcome !== null ? [welcome] : [] };
202
+ return { state, effects: emitWelcomeIfReady(state, ctx) };
204
203
  }
205
204
 
206
205
  /**