serverless-ircd 0.9.0 → 0.11.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -95,6 +95,18 @@ describe('Effect constructors', () => {
95
95
  });
96
96
  });
97
97
 
98
+ it('BroadcastOperNotice carries lines and optional except', () => {
99
+ expect(Effect.broadcastOperNotice([line(':s NOTICE * :lockout')])).toEqual<EffectType>({
100
+ tag: 'BroadcastOperNotice',
101
+ lines: [{ text: ':s NOTICE * :lockout' }],
102
+ });
103
+ expect(Effect.broadcastOperNotice([line(':s NOTICE * :lockout')], 'c1')).toEqual<EffectType>({
104
+ tag: 'BroadcastOperNotice',
105
+ lines: [{ text: ':s NOTICE * :lockout' }],
106
+ except: 'c1',
107
+ });
108
+ });
109
+
98
110
  it('EnforceNick carries conn, nick, policy, and graceMs', () => {
99
111
  expect(Effect.enforceNick('c1', 'alice', 'kill', 0)).toEqual<EffectType>({
100
112
  tag: 'EnforceNick',
@@ -125,6 +137,7 @@ describe('Effect tag set', () => {
125
137
  'ApplyChannelDelta',
126
138
  'SendToNick',
127
139
  'BroadcastWallops',
140
+ 'BroadcastOperNotice',
128
141
  'EnforceNick',
129
142
  ];
130
143
 
@@ -139,6 +152,7 @@ describe('Effect tag set', () => {
139
152
  ApplyChannelDelta: Effect.applyChannelDelta('#c', { memberships: [] }),
140
153
  SendToNick: Effect.sendToNick('n', 'c', []),
141
154
  BroadcastWallops: Effect.broadcastWallops([]),
155
+ BroadcastOperNotice: Effect.broadcastOperNotice([]),
142
156
  EnforceNick: Effect.enforceNick('c', 'n', 'none', 0),
143
157
  };
144
158
  expect(constructors[tag].tag).toBe(tag);
@@ -286,13 +286,41 @@ describe('wrapWithFloodControl — generic over inner state', () => {
286
286
 
287
287
  describe('wrapWithFloodControl — per-command cost (commandCost)', () => {
288
288
  it('defaultCommandCost returns 0 for the control-plane exempt set and 1 otherwise', () => {
289
- for (const command of ['PING', 'PONG', 'CAP', 'AUTHENTICATE', 'QUIT']) {
289
+ for (const command of ['PING', 'PONG', 'CAP', 'QUIT']) {
290
290
  expect(defaultCommandCost(command)).toBe(0);
291
291
  }
292
292
  expect(defaultCommandCost('PRIVMSG')).toBe(1);
293
293
  expect(defaultCommandCost('JOIN')).toBe(1);
294
294
  });
295
295
 
296
+ it('AUTHENTICATE is NOT exempt — it consumes the default flood bucket', () => {
297
+ // SASL brute-force hardening: an attacker spamming AUTHENTICATE must
298
+ // not get a free pass. The per-connection SASL failure tracker in the
299
+ // SASL reducer is the second, orthogonal defense.
300
+ expect(defaultCommandCost('AUTHENTICATE')).toBe(1);
301
+ });
302
+
303
+ it('an AUTHENTICATE burst drains the bucket like PRIVMSG would', () => {
304
+ const state = makeState();
305
+ const clock = new FakeClock(1_000);
306
+ const ctx = makeCtx(state, clock);
307
+ const inner = countingReducer();
308
+ const wrapped = wrapWithFloodControl(inner.reducer, {
309
+ capacity: 2,
310
+ refillRatePerSecond: 1,
311
+ disconnectThreshold: 0,
312
+ commandCost: defaultCommandCost,
313
+ });
314
+
315
+ wrapped(state, ircMessage('AUTHENTICATE', ['PLAIN']), ctx);
316
+ wrapped(state, ircMessage('AUTHENTICATE', ['dGVzdA==']), ctx);
317
+ expect(inner.getCalls()).toBe(2);
318
+
319
+ const out = wrapped(state, ircMessage('AUTHENTICATE', ['*']), ctx);
320
+ expect(out.effects).toEqual<EffectType[]>([Effect.disconnect('c1', EXCESS_FLOOD_REASON)]);
321
+ expect(inner.getCalls()).toBe(2);
322
+ });
323
+
296
324
  it('delegates a cost-0 PING to the inner reducer even when the bucket is exhausted', () => {
297
325
  const state = makeState();
298
326
  const clock = new FakeClock(1_000);
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Per-connection inbound frame-rate window (`admitFrameWindow`).
3
+ *
4
+ * Pure sliding-window admission policy shared by the adapters' inbound
5
+ * frame gates: a connection may deliver at most `maxFrames`
6
+ * frames inside any `windowMs` window; the frame that would exceed the
7
+ * budget is rejected so the adapter can close the connection before the
8
+ * actor / storage write runs.
9
+ *
10
+ * Coverage matrix: under-threshold, at-threshold,
11
+ * over-threshold, and the sliding-window decay.
12
+ */
13
+
14
+ import { describe, expect, it } from 'vitest';
15
+ import {
16
+ FRAME_FLOOD_CLOSE_CODE,
17
+ FRAME_FLOOD_ERROR_LINE,
18
+ admitFrameWindow,
19
+ } from '../src/frame-rate-limit';
20
+
21
+ describe('admitFrameWindow', () => {
22
+ it('admits a frame while the window holds fewer than maxFrames entries (under-threshold)', () => {
23
+ const decision = admitFrameWindow([1_000, 2_000], 3_000, 50, 5_000);
24
+ expect(decision.admitted).toBe(true);
25
+ expect(decision.times).toEqual([1_000, 2_000, 3_000]);
26
+ });
27
+
28
+ it('admits an empty window (first frame of a connection)', () => {
29
+ const decision = admitFrameWindow([], 42, 50, 5_000);
30
+ expect(decision.admitted).toBe(true);
31
+ expect(decision.times).toEqual([42]);
32
+ });
33
+
34
+ it('admits exactly the maxFrames-th frame in the window (at-threshold)', () => {
35
+ const times = Array.from({ length: 49 }, (_, i) => i + 1);
36
+ const decision = admitFrameWindow(times, 50, 50, 5_000);
37
+ expect(decision.admitted).toBe(true);
38
+ expect(decision.times.length).toBe(50);
39
+ });
40
+
41
+ it('rejects the maxFrames+1-th frame within the window (over-threshold)', () => {
42
+ const times = Array.from({ length: 50 }, (_, i) => i + 1);
43
+ const decision = admitFrameWindow(times, 51, 50, 5_000);
44
+ expect(decision.admitted).toBe(false);
45
+ // The rejected frame is NOT recorded; the caller keeps the pruned view.
46
+ expect(decision.times.length).toBe(50);
47
+ });
48
+
49
+ it('ages entries out of the window so old frames stop counting (sliding-window decay)', () => {
50
+ // 50 frames at t=0..49; 6 s after the first frame every entry has
51
+ // aged out of the 5 s window, so the connection starts fresh.
52
+ const times = Array.from({ length: 50 }, (_, i) => i);
53
+ const decision = admitFrameWindow(times, 6_049, 50, 5_000);
54
+ expect(decision.admitted).toBe(true);
55
+ expect(decision.times).toEqual([6_049]);
56
+ });
57
+
58
+ it('partially decays a window (entries inside the window still count)', () => {
59
+ // Frames at t=0 and t=4_000; at t=5_500 only the t=0 entry has aged
60
+ // out of the 5 s window — the recent one still counts, so this frame
61
+ // fits a max=2 budget.
62
+ const decision = admitFrameWindow([0, 4_000], 5_500, 2, 5_000);
63
+ expect(decision.admitted).toBe(true);
64
+ expect(decision.times).toEqual([4_000, 5_500]);
65
+ });
66
+
67
+ it('keeps an entry at exactly now - windowMs inside the window (inclusive boundary)', () => {
68
+ const decision = admitFrameWindow([1_000], 6_000, 1, 5_000);
69
+ // 1_000 >= 6_000 - 5_000 → still counted, so this frame exceeds max=1.
70
+ expect(decision.admitted).toBe(false);
71
+ expect(decision.times).toEqual([1_000]);
72
+ });
73
+
74
+ it('drops entries older than the window even when the frame is rejected', () => {
75
+ // t=0 has aged out at now=6_100 (5 s window); the two recent entries
76
+ // fill the max=2 budget → rejected, and the decayed entry is gone
77
+ // from the retained window.
78
+ const decision = admitFrameWindow([0, 6_000, 6_050], 6_100, 2, 5_000);
79
+ expect(decision.admitted).toBe(false);
80
+ expect(decision.times).toEqual([6_000, 6_050]);
81
+ });
82
+
83
+ it('does not mutate the caller-supplied times array (pure)', () => {
84
+ const times = [1, 2, 3];
85
+ admitFrameWindow(times, 4, 50, 5_000);
86
+ expect(times).toEqual([1, 2, 3]);
87
+ });
88
+ });
89
+
90
+ describe('frame-flood close constants', () => {
91
+ it('uses RFC 6455 close code 1008 (Policy Violation)', () => {
92
+ expect(FRAME_FLOOD_CLOSE_CODE).toBe(1008);
93
+ });
94
+
95
+ it('delivers the canonical flooding ERROR line before the close', () => {
96
+ expect(FRAME_FLOOD_ERROR_LINE).toBe('ERROR :Closing link (flooding)');
97
+ });
98
+ });
@@ -590,4 +590,9 @@ describe('InMemoryMessageStore — getMsg (msgid -> message)', () => {
590
590
  const store = seededStore();
591
591
  expect(store.getMsg('#FoO', 'm2')?.msgid).toBe('m2');
592
592
  });
593
+
594
+ it('returns undefined for a channel with no recorded messages', () => {
595
+ const store = seededStore();
596
+ expect(store.getMsg('#empty', 'm1')).toBeUndefined();
597
+ });
593
598
  });
@@ -0,0 +1,60 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { matchOperCred, resetOperLegacyPlaintextWarning } from '../src/commands/oper';
3
+ import { hashAccountCredential } from '../src/credential-hashing';
4
+ import { type OperCredHashed, hashOperCredential } from '../src/oper-hashing';
5
+
6
+ describe('hashOperCredential', () => {
7
+ afterEach(() => {
8
+ resetOperLegacyPlaintextWarning();
9
+ });
10
+
11
+ it('emits a {user, salt, hash} triple with non-empty base64 fields', () => {
12
+ const out = hashOperCredential('admin', 's3cret');
13
+ expect(out.user).toBe('admin');
14
+ expect(out.salt.length).toBeGreaterThan(0);
15
+ expect(out.hash.length).toBeGreaterThan(0);
16
+ // base64 alphabet only (no whitespace, no plaintext leakage).
17
+ expect(out.salt).toMatch(/^[A-Za-z0-9+/]+=*$/u);
18
+ expect(out.hash).toMatch(/^[A-Za-z0-9+/]+=*$/u);
19
+ });
20
+
21
+ it('never stores the plaintext password in any field', () => {
22
+ const out = hashOperCredential('admin', 'hunter2-secret');
23
+ expect(out.user).not.toContain('hunter2');
24
+ expect(out.salt).not.toContain('hunter2');
25
+ expect(out.hash).not.toContain('hunter2');
26
+ });
27
+
28
+ it('produces a fresh random salt per call (no two salts collide)', () => {
29
+ const a = hashOperCredential('admin', 's3cret');
30
+ const b = hashOperCredential('admin', 's3cret');
31
+ expect(a.salt).not.toBe(b.salt);
32
+ expect(a.hash).not.toBe(b.hash);
33
+ });
34
+
35
+ it('honours a fixed salt for deterministic test scenarios', () => {
36
+ const salt = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
37
+ const a = hashOperCredential('admin', 's3cret', { salt });
38
+ const b = hashOperCredential('admin', 's3cret', { salt });
39
+ expect(a.hash).toBe(b.hash);
40
+ });
41
+
42
+ it('round-trips via matchOperCred: the hashed output verifies the original password', () => {
43
+ const cred = hashOperCredential('admin', 's3cret-password');
44
+ expect(matchOperCred([cred], 'admin', 's3cret-password')).toBe(true);
45
+ expect(matchOperCred([cred], 'admin', 'wrong')).toBe(false);
46
+ });
47
+
48
+ it('round-trips via verifyHashedPassword: the underlying scrypt entry is well-formed', () => {
49
+ const cred = hashOperCredential('admin', 's3cret');
50
+ const entry = hashAccountCredential('admin', 's3cret', {
51
+ salt: Uint8Array.from(Buffer.from(cred.salt, 'base64')),
52
+ });
53
+ expect(entry.hash).toBe(cred.hash);
54
+ });
55
+
56
+ it('returns an object assignable to the OperCredHashed type', () => {
57
+ const cred: OperCredHashed = hashOperCredential('admin', 's3cret');
58
+ expect(cred.user).toBe('admin');
59
+ });
60
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Unit tests for {@link OperFailureStats} — the runtime-owned per-IP
3
+ * failed-OPER counter (sliding window, AdmissionStats shape).
4
+ */
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+ import { OperFailureStats } from '../src/oper-lockout';
8
+ import { FakeClock } from '../src/ports';
9
+
10
+ describe('OperFailureStats', () => {
11
+ it('reports zero recent failures for an unknown IP', () => {
12
+ const clock = new FakeClock(5_000);
13
+ const stats = new OperFailureStats(clock);
14
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
15
+ });
16
+
17
+ it('counts recorded failures within the window', () => {
18
+ const clock = new FakeClock(5_000);
19
+ const stats = new OperFailureStats(clock);
20
+ stats.recordFailure('10.0.0.1');
21
+ clock.advance(1_000);
22
+ stats.recordFailure('10.0.0.1');
23
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(2);
24
+ });
25
+
26
+ it('prunes failures older than the window (sliding)', () => {
27
+ const clock = new FakeClock(5_000);
28
+ const stats = new OperFailureStats(clock);
29
+ stats.recordFailure('10.0.0.1'); // t = 5_000
30
+ clock.advance(10_000);
31
+ stats.recordFailure('10.0.0.1'); // t = 15_000
32
+
33
+ // Window that excludes the first failure keeps only the second.
34
+ expect(stats.recentFailures('10.0.0.1', 9_000)).toBe(1);
35
+ // The prune is sticky: the stale entry is gone on the next read.
36
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(1);
37
+ });
38
+
39
+ it('deletes the per-IP entry when every recorded failure is stale', () => {
40
+ const clock = new FakeClock(5_000);
41
+ const stats = new OperFailureStats(clock);
42
+ stats.recordFailure('10.0.0.1');
43
+ clock.advance(301_000);
44
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
45
+ // A later failure for the same IP starts from a fresh entry.
46
+ stats.recordFailure('10.0.0.1');
47
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(1);
48
+ });
49
+
50
+ it('keeps per-IP accounting independent between IPs', () => {
51
+ const clock = new FakeClock(5_000);
52
+ const stats = new OperFailureStats(clock);
53
+ stats.recordFailure('10.0.0.1');
54
+ stats.recordFailure('10.0.0.1');
55
+ expect(stats.recentFailures('10.0.0.2', 300_000)).toBe(0);
56
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(2);
57
+ });
58
+
59
+ it('reset clears every recorded failure for the IP', () => {
60
+ const clock = new FakeClock(5_000);
61
+ const stats = new OperFailureStats(clock);
62
+ stats.recordFailure('10.0.0.1');
63
+ stats.recordFailure('10.0.0.1');
64
+ stats.reset('10.0.0.1');
65
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
66
+ });
67
+
68
+ it('reset is a no-op for an IP with no recorded failures', () => {
69
+ const clock = new FakeClock(5_000);
70
+ const stats = new OperFailureStats(clock);
71
+ expect(() => stats.reset('10.0.0.1')).not.toThrow();
72
+ expect(stats.recentFailures('10.0.0.1', 300_000)).toBe(0);
73
+ });
74
+ });
@@ -1,11 +1,13 @@
1
1
  import fc from 'fast-check';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import type { RawLine } from '../src/effects';
4
+ import { byteLength } from '../src/protocol/bytes';
4
5
  import {
5
6
  MAX_LINE_BYTES,
6
7
  applyServerTime,
7
8
  enforceLineLimit,
8
9
  formatServerTime,
10
+ sanitizeLineText,
9
11
  } from '../src/protocol/outbound';
10
12
  import { parse } from '../src/protocol/parser';
11
13
  import { serialize } from '../src/protocol/serializer';
@@ -243,6 +245,152 @@ describe('enforceLineLimit — WsFrameMode budget', () => {
243
245
  });
244
246
  });
245
247
 
248
+ // ============================================================================
249
+ // enforceLineLimit — byte (not UTF-16 code-unit) accounting
250
+ // ============================================================================
251
+
252
+ describe('enforceLineLimit — multi-byte (UTF-8 byte) accounting', () => {
253
+ const TEXT_BUDGET = MAX_LINE_BYTES - 2;
254
+
255
+ it('truncates a 353 NAMES-style line that fits 510 UTF-16 units but exceeds 510 bytes', () => {
256
+ // Prefix is 23 ASCII bytes; 260 'é' nicks/text = 260 units but 520 bytes.
257
+ // Total: 283 code units (inside the old unit budget), 543 UTF-8 bytes.
258
+ const prefix = ':srv 353 alice = #chan :';
259
+ const line: RawLine = L(`${prefix}${'é'.repeat(260)}`);
260
+ expect(line.text.length).toBeLessThanOrEqual(TEXT_BUDGET);
261
+ expect(byteLength(line.text)).toBeGreaterThan(TEXT_BUDGET);
262
+
263
+ const out = enforceLineLimit(line, new Set<string>());
264
+ expect(out).not.toBe(line);
265
+ expect(out.text.startsWith(prefix)).toBe(true);
266
+ expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
267
+ });
268
+
269
+ it('passes a multi-byte line whose UTF-8 length is exactly 510 bytes through unchanged', () => {
270
+ // "PRIVMSG #c :" (12 ASCII bytes) + 249 'é' (498 bytes) = 510 bytes in
271
+ // 261 code units.
272
+ const line: RawLine = L(`PRIVMSG #c :${'é'.repeat(249)}`);
273
+ expect(line.text.length).toBeLessThan(TEXT_BUDGET);
274
+ expect(byteLength(line.text)).toBe(TEXT_BUDGET);
275
+ expect(enforceLineLimit(line, new Set<string>())).toBe(line);
276
+ });
277
+
278
+ it('keeps the truncated output within the byte budget for a long multi-byte body', () => {
279
+ const line: RawLine = L(`:alice PRIVMSG #foo :${'é'.repeat(600)}`);
280
+ const out = enforceLineLimit(line, new Set<string>());
281
+ expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
282
+ expect(out.text.startsWith(':alice PRIVMSG #foo :')).toBe(true);
283
+ });
284
+
285
+ it('never splits a surrogate pair when truncating multi-byte content', () => {
286
+ const prefix = ':alice PRIVMSG #foo :';
287
+ const line: RawLine = L(`${prefix}${'😀'.repeat(400)}`);
288
+ const out = enforceLineLimit(line, new Set<string>());
289
+ expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
290
+ // A split pair would leave a half emoji at the end; the last code point
291
+ // must be a complete emoji.
292
+ expect(out.text.endsWith('😀')).toBe(true);
293
+ });
294
+
295
+ it('preserves the tag section verbatim and truncates the multi-byte body by bytes', () => {
296
+ const tagPrefix = '@time=2024-01-15T12:34:56.789Z '; // 31 bytes
297
+ const prefix = ':srv 353 alice = #chan :';
298
+ const line: RawLine = L(`${tagPrefix}${prefix}${'é'.repeat(300)}`);
299
+ const out = enforceLineLimit(line, new Set<string>(['message-tags']));
300
+ expect(out.text.startsWith(`${tagPrefix}${prefix}`)).toBe(true);
301
+ expect(byteLength(out.text)).toBeLessThanOrEqual(TEXT_BUDGET);
302
+ });
303
+
304
+ it('truncates the multi-byte body by bytes in spec-text mode too', () => {
305
+ const line: RawLine = L(`:alice PRIVMSG #foo :${'é'.repeat(400)}`);
306
+ const out = enforceLineLimit(line, new Set<string>(), 'spec-text');
307
+ expect(byteLength(out.text)).toBeLessThanOrEqual(MAX_WS_MESSAGE_BYTES);
308
+ });
309
+ });
310
+
311
+ // ============================================================================
312
+ // sanitizeLineText — defense-in-depth CR stripping for RawLine.text
313
+ // ============================================================================
314
+
315
+ describe('sanitizeLineText', () => {
316
+ it('removes a bare CR embedded in the line text', () => {
317
+ // The 311 RPL_WHOISREALNAME shape: a realname carrying a CR would echo
318
+ // the control character verbatim into a client's frame stream.
319
+ const out = sanitizeLineText(L(':srv 311 me alice u h * :real\rname'));
320
+ expect(out.text).toBe(':srv 311 me alice u h * :realname');
321
+ });
322
+
323
+ it('removes every CR in the line text, not just the first', () => {
324
+ const out = sanitizeLineText(L('TOPIC #foo :a\rb\rc'));
325
+ expect(out.text).toBe('TOPIC #foo :abc');
326
+ expect(out.text.includes('\r')).toBe(false);
327
+ });
328
+
329
+ it('returns the same object when the text contains no CR', () => {
330
+ const line: RawLine = L(':alice PRIVMSG #foo :hi');
331
+ expect(sanitizeLineText(line)).toBe(line);
332
+ });
333
+
334
+ it('leaves LF untouched (CR-only hardening)', () => {
335
+ const out = sanitizeLineText(L('PRIVMSG #foo :a\nb'));
336
+ expect(out.text).toBe('PRIVMSG #foo :a\nb');
337
+ });
338
+
339
+ it('does not mutate the input RawLine', () => {
340
+ const original: RawLine = L('PRIVMSG #foo :a\rb');
341
+ sanitizeLineText(original);
342
+ expect(original.text).toBe('PRIVMSG #foo :a\rb');
343
+ });
344
+ });
345
+
346
+ // ============================================================================
347
+ // enforceLineLimit — CR sanitization composes with the byte budget
348
+ // ============================================================================
349
+
350
+ describe('enforceLineLimit — CR sanitization before budget enforcement', () => {
351
+ const TEXT_BUDGET = MAX_LINE_BYTES - 2;
352
+
353
+ it('strips a bare CR from an otherwise in-budget line', () => {
354
+ const line: RawLine = L(':srv 311 me alice u h * :real\rname');
355
+ const out = enforceLineLimit(line, new Set<string>());
356
+ expect(out.text).toBe(':srv 311 me alice u h * :realname');
357
+ });
358
+
359
+ it('strips CR before measuring the budget so a line that fits after stripping is not truncated', () => {
360
+ // 21-byte prefix + 488 'x' + CR + 1 'x' = 511 units/bytes; stripping the
361
+ // single CR byte lands exactly on the 510 budget — no truncation.
362
+ const prefix = ':alice PRIVMSG #foo :';
363
+ const xs = 'x'.repeat(TEXT_BUDGET - prefix.length - 1);
364
+ const line: RawLine = L(`${prefix}${xs}\rx`);
365
+ expect(byteLength(line.text)).toBe(TEXT_BUDGET + 1);
366
+ const out = enforceLineLimit(line, new Set<string>());
367
+ expect(out.text).toBe(`${prefix}${xs}x`);
368
+ expect(byteLength(out.text)).toBe(TEXT_BUDGET);
369
+ });
370
+
371
+ it('keeps the output within budget when a CR-bearing line also needs truncation', () => {
372
+ const line: RawLine = L(`:alice PRIVMSG #foo :a\rb${'x'.repeat(600)}`);
373
+ const out = enforceLineLimit(line, new Set<string>());
374
+ expect(out.text.includes('\r')).toBe(false);
375
+ expect(byteLength(out.text)).toBe(TEXT_BUDGET);
376
+ expect(out.text.startsWith(':alice PRIVMSG #foo :ab')).toBe(true);
377
+ });
378
+
379
+ it('preserves the tag section while sanitizing CR from a tagged, over-budget line', () => {
380
+ const tagPrefix = '@time=2024-01-15T12:34:56.789Z ';
381
+ const line: RawLine = L(`${tagPrefix}:alice PRIVMSG #foo :a\rb${'x'.repeat(600)}`);
382
+ const out = enforceLineLimit(line, new Set<string>(['message-tags']));
383
+ expect(out.text.startsWith(tagPrefix)).toBe(true);
384
+ expect(out.text.includes('\r')).toBe(false);
385
+ expect(byteLength(out.text)).toBe(TEXT_BUDGET);
386
+ });
387
+
388
+ it('returns a CR-free line object unchanged (identity preserved for the common case)', () => {
389
+ const line: RawLine = L(':alice PRIVMSG #foo :hi');
390
+ expect(enforceLineLimit(line, new Set<string>())).toBe(line);
391
+ });
392
+ });
393
+
246
394
  // ============================================================================
247
395
  // Round-trip through serialize -> applyServerTime -> parse
248
396
  // ============================================================================