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
@@ -29,9 +29,10 @@ import {
29
29
  type RawLine,
30
30
  createConnection,
31
31
  } from '@serverless-ircd/irc-core';
32
- import { afterAll, afterEach, describe, expect, it } from 'vitest';
32
+ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
33
33
  import { type CfConnectionHandlers, CfRuntime } from '../src/cf-runtime';
34
34
  import type { Env, RegistryRpc } from '../src/env';
35
+ import { serialize } from '../src/serialize';
35
36
  import { registryKeyForNick } from '../src/sharding';
36
37
 
37
38
  declare global {
@@ -45,6 +46,8 @@ declare global {
45
46
  CHANNEL_DO_REAL: DurableObjectNamespace;
46
47
  CHANNEL_DO_STUB: DurableObjectNamespace;
47
48
  CHANNEL_REGISTRY_DO: DurableObjectNamespace;
49
+ COUNTER_DO: DurableObjectNamespace;
50
+ RATE_LIMIT_DO: DurableObjectNamespace;
48
51
  }
49
52
  }
50
53
  }
@@ -64,6 +67,8 @@ function envWithRealDOs(): Env {
64
67
  REGISTRY_DO: env.REGISTRY_DO_REAL,
65
68
  CHANNEL_DO: env.CHANNEL_DO_REAL,
66
69
  CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
70
+ COUNTER_DO: env.COUNTER_DO,
71
+ RATE_LIMIT_DO: env.RATE_LIMIT_DO,
67
72
  SERVER_NAME: 'irc.example.com',
68
73
  NETWORK_NAME: 'ExampleNet',
69
74
  MOTD_LINES: '',
@@ -81,6 +86,8 @@ function envWithChannelStub(): Env {
81
86
  REGISTRY_DO: env.REGISTRY_DO_REAL,
82
87
  CHANNEL_DO: env.CHANNEL_DO_STUB,
83
88
  CHANNEL_REGISTRY_DO: env.CHANNEL_REGISTRY_DO,
89
+ COUNTER_DO: env.COUNTER_DO,
90
+ RATE_LIMIT_DO: env.RATE_LIMIT_DO,
84
91
  SERVER_NAME: 'irc.example.com',
85
92
  NETWORK_NAME: 'ExampleNet',
86
93
  MOTD_LINES: '',
@@ -659,41 +666,54 @@ describe('CfRuntime — reloadConfig (REHASH)', () => {
659
666
 
660
667
  /**
661
668
  * Opens a ConnectionDO, registers it with the given nick, and optionally
662
- * flips user mode `w` on via a direct state mutation (avoids driving the
663
- * full MODE handshake through the actor). Returns the live WebSocket and
664
- * the connection's canonical hex id.
669
+ * flips user mode `w`/`o` on via a direct state mutation (avoids driving
670
+ * the full MODE/OPER handshake through the actor). Returns the live
671
+ * WebSocket and the connection's canonical hex id.
665
672
  */
666
673
  async function openWallopsCapableConn(
667
674
  name: string,
668
- opts: { wallops: boolean },
675
+ opts: { wallops?: boolean; oper?: boolean },
669
676
  ): Promise<{ ws: WebSocket; hexId: string; received: string[] }> {
670
677
  const ws = await openConnection(name);
671
678
  await new Promise<void>((r) => setTimeout(r, 20));
672
679
  ws.send(`NICK ${name}\r\n`);
673
- ws.send(`USER ${name} 0 * :${name}\r\n`);
680
+ // Fixed short username: the server caps USER at 16 bytes, and several
681
+ // connection names in these suites are longer — interpolating the name
682
+ // here made the server reject USER and close the link before the
683
+ // wallops/oper fan-out ever ran.
684
+ ws.send('USER chat 0 * :chat\r\n');
674
685
  // Drain the registration numerics so they don't interfere with the
675
- // assertions on wallops delivery below.
686
+ // assertions on wallops/oper-notice delivery below.
676
687
  await new Promise<void>((r) => setTimeout(r, 100));
677
688
 
678
689
  const hexId = await getConnectionHexId(name);
679
690
  const received = drainSocket(ws);
680
691
 
681
- if (opts.wallops) {
682
- // Flip the +w mode directly on the live cached state. The DO is the
683
- // authority for this state; broadcastWallops reads it back via
684
- // getConnState() so what we mutate here is exactly what the fan-out
685
- // will see.
692
+ if (opts.wallops === true || opts.oper === true) {
693
+ // Flip the mode(s) directly on the live cached state. The DO is the
694
+ // authority for this state; broadcastWallops/broadcastOperNotice read
695
+ // it back via getConnState() so what we mutate here is exactly what
696
+ // the fan-out will see.
686
697
  const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromString(hexId));
687
698
  await runInDurableObject(stub, async (instance: unknown) => {
688
699
  const doi = instance as {
689
700
  __peekState?: () => Promise<
690
701
  import('@serverless-ircd/irc-core').ConnectionState | undefined
691
702
  >;
692
- __pokeState?: (s: import('@serverless-ircd/irc-core').ConnectionState) => void;
693
703
  };
694
704
  const state = await doi.__peekState?.();
695
- if (state !== undefined) state.userModes.wallops = true;
705
+ if (state !== undefined) {
706
+ if (opts.wallops === true) state.userModes.wallops = true;
707
+ if (opts.oper === true) state.userModes.oper = true;
708
+ }
696
709
  });
710
+ // Persist the mutated mode: the DO persists state only after a frame
711
+ // dispatches, and a DO eviction between here and the fan-out would
712
+ // otherwise reload the unmutated persisted copy (the cause of a
713
+ // pre-existing intermittent failure in the excepted-originator
714
+ // tests). One client PING forces the persist.
715
+ ws.send('PING :pin\r\n');
716
+ await new Promise<void>((r) => setTimeout(r, 150));
697
717
  }
698
718
 
699
719
  // Reserve the nick in the REAL registry under its hex id so
@@ -751,7 +771,7 @@ describe('CfRuntime — broadcastWallops', () => {
751
771
  await rt.broadcastWallops([{ text: ':oper!u@h WALLOPS :except me' }], sender.hexId);
752
772
 
753
773
  await new Promise<void>((resolve) => {
754
- const deadline = Date.now() + 1000;
774
+ const deadline = Date.now() + 4000;
755
775
  const tick = (): void => {
756
776
  if (other.received.some((l) => l.includes('except me'))) {
757
777
  resolve();
@@ -801,7 +821,7 @@ describe('CfRuntime — broadcastWallops', () => {
801
821
  ).resolves.toBeUndefined();
802
822
 
803
823
  await new Promise<void>((resolve) => {
804
- const deadline = Date.now() + 1000;
824
+ const deadline = Date.now() + 4000;
805
825
  const tick = (): void => {
806
826
  if (other.received.some((l) => l.includes('ghost friendly'))) {
807
827
  resolve();
@@ -821,6 +841,76 @@ describe('CfRuntime — broadcastWallops', () => {
821
841
  });
822
842
  });
823
843
 
844
+ // ---------------------------------------------------------------------------
845
+ // Tests — broadcastOperNotice (global +o fan-out for oper-only notices)
846
+ // ---------------------------------------------------------------------------
847
+
848
+ describe('CfRuntime — broadcastOperNotice', () => {
849
+ it('delivers to every +o connection and skips non-opers', async () => {
850
+ const realEnv = envWithRealDOs();
851
+
852
+ const oper = await openWallopsCapableConn('opernote-oper', { oper: true });
853
+ // +w without +o must NOT receive an oper notice.
854
+ const wallopsOnly = await openWallopsCapableConn('opernote-wallops', { wallops: true });
855
+
856
+ const rt = new CfRuntime(realEnv, 'conn-opernote-sender', recordingHandlers());
857
+ await rt.broadcastOperNotice([{ text: ':server NOTICE * :*** oper lockout tripped' }]);
858
+
859
+ await new Promise<void>((resolve) => {
860
+ const deadline = Date.now() + 1500;
861
+ const tick = (): void => {
862
+ if (oper.received.some((l) => l.includes('oper lockout tripped'))) {
863
+ resolve();
864
+ return;
865
+ }
866
+ if (Date.now() > deadline) {
867
+ resolve();
868
+ return;
869
+ }
870
+ setTimeout(tick, 20);
871
+ };
872
+ tick();
873
+ });
874
+
875
+ expect(oper.received.some((l) => l.includes('oper lockout tripped'))).toBe(true);
876
+ expect(wallopsOnly.received.some((l) => l.includes('oper lockout tripped'))).toBe(false);
877
+
878
+ oper.ws.close();
879
+ wallopsOnly.ws.close();
880
+ });
881
+
882
+ it('skips the excepted originator', async () => {
883
+ const realEnv = envWithRealDOs();
884
+ const sender = await openWallopsCapableConn('opernote-except-sender', { oper: true });
885
+ const other = await openWallopsCapableConn('opernote-except-other', { oper: true });
886
+
887
+ const rt = new CfRuntime(realEnv, sender.hexId, recordingHandlers());
888
+ await rt.broadcastOperNotice([{ text: ':server NOTICE * :*** except me' }], sender.hexId);
889
+
890
+ await new Promise<void>((resolve) => {
891
+ const deadline = Date.now() + 4000;
892
+ const tick = (): void => {
893
+ if (other.received.some((l) => l.includes('except me'))) {
894
+ resolve();
895
+ return;
896
+ }
897
+ if (Date.now() > deadline) {
898
+ resolve();
899
+ return;
900
+ }
901
+ setTimeout(tick, 20);
902
+ };
903
+ tick();
904
+ });
905
+
906
+ expect(other.received.some((l) => l.includes('except me'))).toBe(true);
907
+ expect(sender.received.some((l) => l.includes('except me'))).toBe(false);
908
+
909
+ sender.ws.close();
910
+ other.ws.close();
911
+ });
912
+ });
913
+
824
914
  // ---------------------------------------------------------------------------
825
915
  // Tests — changeNick shard routing + failure paths
826
916
  // ---------------------------------------------------------------------------
@@ -921,3 +1011,102 @@ describe('CfRuntime — getConnection and getChannelConnections branches', () =>
921
1011
  member.ws.close();
922
1012
  });
923
1013
  });
1014
+
1015
+ // ---------------------------------------------------------------------------
1016
+ // Tests — pure helper seams for defensive null-DTO branches
1017
+ // ---------------------------------------------------------------------------
1018
+
1019
+ describe('CfRuntime — defensive DTO helper seams', () => {
1020
+ it('deserializeConnState returns null when the DTO is null', () => {
1021
+ const stubEnv = envWithChannelStub();
1022
+ const rt = new CfRuntime(stubEnv, 'conn-dsz-null', recordingHandlers());
1023
+ expect(rt.deserializeConnState(null)).toBeNull();
1024
+ });
1025
+
1026
+ it('deserializeConnState rehydrates a populated DTO', () => {
1027
+ const stubEnv = envWithChannelStub();
1028
+ const rt = new CfRuntime(stubEnv, 'conn-dsz-real', recordingHandlers());
1029
+ const live = createConnection({ id: 'conn-dsz-real', connectedSince: 0 });
1030
+ live.nick = 'dz';
1031
+ // Round-trip through serialize so the DTO matches what a real
1032
+ // ConnectionDO.getConnState would return.
1033
+ const dto = serialize(live);
1034
+ const out = rt.deserializeConnState(dto);
1035
+ expect(out?.nick).toBe('dz');
1036
+ expect(out?.id).toBe('conn-dsz-real');
1037
+ });
1038
+
1039
+ it('collectChannelMember skips a null DTO without mutating the map', () => {
1040
+ const stubEnv = envWithChannelStub();
1041
+ const rt = new CfRuntime(stubEnv, 'conn-ccm-null', recordingHandlers());
1042
+ const out = new Map();
1043
+ rt.collectChannelMember(out, 'ghost', null);
1044
+ expect(out.size).toBe(0);
1045
+ });
1046
+
1047
+ it('collectChannelMember rehydrates and inserts a populated DTO', () => {
1048
+ const stubEnv = envWithChannelStub();
1049
+ const rt = new CfRuntime(stubEnv, 'conn-ccm-real', recordingHandlers());
1050
+ const live = createConnection({ id: 'conn-ccm-real', connectedSince: 0 });
1051
+ live.nick = 'ccm';
1052
+ const out = new Map();
1053
+ rt.collectChannelMember(out, 'conn-ccm-real', serialize(live));
1054
+ expect(out.size).toBe(1);
1055
+ expect(out.get('conn-ccm-real')?.nick).toBe('ccm');
1056
+ });
1057
+
1058
+ it('deliverWallopsToCandidate is a no-op when the DTO is null', async () => {
1059
+ const stubEnv = envWithChannelStub();
1060
+ const rt = new CfRuntime(stubEnv, 'conn-dwc-null', recordingHandlers());
1061
+ const deliver = vi.fn();
1062
+ await rt.deliverWallopsToCandidate({ deliver }, null, [{ text: 'x' }]);
1063
+ expect(deliver).not.toHaveBeenCalled();
1064
+ });
1065
+
1066
+ it('deliverWallopsToCandidate skips a candidate whose +w mode is off', async () => {
1067
+ const stubEnv = envWithChannelStub();
1068
+ const rt = new CfRuntime(stubEnv, 'conn-dwc-now', recordingHandlers());
1069
+ const live = createConnection({ id: 'conn-dwc-now', connectedSince: 0 });
1070
+ live.userModes.wallops = false;
1071
+ const deliver = vi.fn();
1072
+ await rt.deliverWallopsToCandidate({ deliver }, serialize(live), [{ text: 'x' }]);
1073
+ expect(deliver).not.toHaveBeenCalled();
1074
+ });
1075
+
1076
+ it('deliverWallopsToCandidate delivers to a +w candidate', async () => {
1077
+ const stubEnv = envWithChannelStub();
1078
+ const rt = new CfRuntime(stubEnv, 'conn-dwc-yes', recordingHandlers());
1079
+ const live = createConnection({ id: 'conn-dwc-yes', connectedSince: 0 });
1080
+ live.userModes.wallops = true;
1081
+ const calls: RawLine[][] = [];
1082
+ const deliver = (lines: RawLine[]): Promise<void> => {
1083
+ calls.push(lines);
1084
+ return Promise.resolve();
1085
+ };
1086
+ await rt.deliverWallopsToCandidate({ deliver }, serialize(live), [
1087
+ { text: ':oper!u@h WALLOPS :hi' },
1088
+ ]);
1089
+ expect(calls).toEqual([[{ text: ':oper!u@h WALLOPS :hi' }]]);
1090
+ });
1091
+ });
1092
+
1093
+ // ---------------------------------------------------------------------------
1094
+ // Tests — getChannelSnapshot topic branch (the { topic } arm of the spread)
1095
+ // ---------------------------------------------------------------------------
1096
+
1097
+ describe('CfRuntime — getChannelSnapshot topic branch', () => {
1098
+ it('rehydrates the topic onto ChanSnapshot when the ChannelDO reports one', async () => {
1099
+ // Existing coverage exercises the `: {}` arm of the topic spread; this
1100
+ // case drives the `{ topic: dto.topic }` arm by applying a topic
1101
+ // through the real ChannelDO and reading it back via CfRuntime.
1102
+ const realEnv = envWithRealDOs();
1103
+ const rt = new CfRuntime(realEnv, 'conn-topic-caller', recordingHandlers());
1104
+ const topic = { text: 'news', setter: 'alice', setAt: 1 };
1105
+ await rt.applyChannelDelta('#topic-branch', {
1106
+ memberships: [{ type: 'add' as const, conn: 'conn-topic-caller', nick: 'alice' }],
1107
+ topic,
1108
+ });
1109
+ const snap = await rt.getChannelSnapshot('#topic-branch');
1110
+ expect(snap?.topic).toEqual(topic);
1111
+ });
1112
+ });
@@ -18,7 +18,7 @@
18
18
  import { env, runInDurableObject } from 'cloudflare:test';
19
19
  import type { ChannelDelta, ChannelTopic, ConnId, RosterEntry } from '@serverless-ircd/irc-core';
20
20
  import { describe, expect, it } from 'vitest';
21
- import type { ChannelSnapshotDto } from '../src/channel-do';
21
+ import { type ChannelSnapshotDto, deserializeChannel, toDto } from '../src/channel-do';
22
22
 
23
23
  declare global {
24
24
  namespace Cloudflare {
@@ -595,3 +595,120 @@ describe('ChannelDO — broadcast + cache edge cases', () => {
595
595
  await alice.client.close();
596
596
  });
597
597
  });
598
+
599
+ // ---------------------------------------------------------------------------
600
+ // Tests — ChannelDO instantiated without a name (idFromString)
601
+ // ---------------------------------------------------------------------------
602
+
603
+ describe('ChannelDO — id.name fallback', () => {
604
+ it('falls back to ctx.id.toString() when the DO was created via idFromString', async () => {
605
+ // ChannelDOs in production are always keyed by lowercased name
606
+ // (idFromName), but the DO storage-creation path still needs to
607
+ // handle the no-name case. `newUniqueId()` returns a valid hex DO id
608
+ // whose `id.name` is null, forcing the `??` fallback to `toString()`.
609
+ const hexId = env.CHANNEL_DO_REAL.newUniqueId().toString();
610
+ const stub = env.CHANNEL_DO_REAL.get(env.CHANNEL_DO_REAL.idFromString(hexId));
611
+ const rpc = stub as unknown as ChannelRpc;
612
+ await rpc.applyChannelDelta({
613
+ memberships: [{ type: 'add', conn: 'c-id-fallback', nick: 'alice' }],
614
+ });
615
+ const snap = await rpc.getChannelSnapshot();
616
+ // The DO used the hex id as the storage key, so nameLower resolves
617
+ // to the hex string (not a lowercased channel name).
618
+ expect(snap?.nameLower).toBe(hexId);
619
+ expect(snap?.name).toBe(hexId);
620
+ });
621
+ });
622
+
623
+ // ---------------------------------------------------------------------------
624
+ // Tests — pure deserializeChannel / toDto helpers (legacy-shape arms)
625
+ // ---------------------------------------------------------------------------
626
+
627
+ describe('deserializeChannel — legacy record defensive arms', () => {
628
+ it('coalesces missing op/voice to false for legacy persisted members', () => {
629
+ // Pre-feature records may have been written without `op`/`voice`
630
+ // fields (or with them explicitly undefined). The loader's `?? false`
631
+ // arms must catch both so the rehydrated RosterEntry always carries
632
+ // a boolean. The PersistedChannelState interface types op/voice as
633
+ // optional, but legacy on-disk rows predate the field — cast through
634
+ // unknown to simulate the storage layer's untyped payload.
635
+ const record = {
636
+ name: '#legacy',
637
+ nameLower: '#legacy',
638
+ modes: {
639
+ inviteOnly: false,
640
+ topicLock: false,
641
+ noExternal: false,
642
+ moderated: false,
643
+ secret: false,
644
+ private: false,
645
+ registered: false,
646
+ blockUnidentified: false,
647
+ moderatedIdent: false,
648
+ },
649
+ banMasks: [],
650
+ members: [{ conn: 'c-1', nick: 'alice' }],
651
+ pendingInvites: [],
652
+ createdAt: 0,
653
+ } as unknown as Parameters<typeof deserializeChannel>[0];
654
+ const chan = deserializeChannel(record);
655
+ const entry = chan.members.get('c-1');
656
+ expect(entry?.op).toBe(false);
657
+ expect(entry?.voice).toBe(false);
658
+ });
659
+
660
+ it('leaves topic undefined when the record carries none', () => {
661
+ // The `if (record.topic !== undefined)` else arm: a record written
662
+ // before any TOPIC was set has no topic field, and deserializeChannel
663
+ // must leave chan.topic undefined.
664
+ const record = {
665
+ name: '#notopic',
666
+ nameLower: '#notopic',
667
+ modes: {
668
+ inviteOnly: false,
669
+ topicLock: false,
670
+ noExternal: false,
671
+ moderated: false,
672
+ secret: false,
673
+ private: false,
674
+ registered: false,
675
+ blockUnidentified: false,
676
+ moderatedIdent: false,
677
+ },
678
+ banMasks: [],
679
+ members: [],
680
+ pendingInvites: [],
681
+ createdAt: 0,
682
+ } as unknown as Parameters<typeof deserializeChannel>[0];
683
+ expect(deserializeChannel(record).topic).toBeUndefined();
684
+ });
685
+ });
686
+
687
+ describe('toDto — defensive Set/Map arms', () => {
688
+ it('returns empty arrays when ChannelState fields are missing', () => {
689
+ // Channels constructed without going through createChannel (e.g. a
690
+ // hand-built test fixture or a future storage migration) may lack
691
+ // the `Set`/`Map` fields. The `?:` arms in toDto must degrade to
692
+ // empty arrays so the DTO never carries undefined to callers.
693
+ const partial = {
694
+ name: '#partial',
695
+ nameLower: '#partial',
696
+ modes: {
697
+ inviteOnly: false,
698
+ topicLock: false,
699
+ noExternal: false,
700
+ moderated: false,
701
+ secret: false,
702
+ private: false,
703
+ registered: false,
704
+ blockUnidentified: false,
705
+ moderatedIdent: false,
706
+ },
707
+ createdAt: 0,
708
+ } as unknown as Parameters<typeof toDto>[0];
709
+ const dto = toDto(partial);
710
+ expect(dto.banMasks).toEqual([]);
711
+ expect(dto.members).toEqual([]);
712
+ expect(dto.pendingInvites).toEqual([]);
713
+ });
714
+ });
@@ -17,6 +17,18 @@ interface CfEnvLike {
17
17
  CHANNEL_PREFIXES?: string;
18
18
  OPER_USER?: string;
19
19
  OPER_PASSWORD?: string;
20
+ /**
21
+ * Scrypt salt (base64) for the hashed oper credential form. When set
22
+ * alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
23
+ * `{user, salt, hash}` cred instead of the legacy plaintext
24
+ * `{user, password}` shape.
25
+ */
26
+ OPER_SALT?: string;
27
+ /**
28
+ * Scrypt hash (base64) for the hashed oper credential form. See
29
+ * `OPER_SALT` for the full set of vars.
30
+ */
31
+ OPER_HASH?: string;
20
32
  /**
21
33
  * Server-password gate. Treat as a wrangler secret in production
22
34
  * (never a plaintext `[vars]` entry); the loader threads it through
@@ -30,6 +42,8 @@ interface CfEnvLike {
30
42
  TOPIC_LEN?: string;
31
43
  MAX_LIST_ENTRIES?: string;
32
44
  QUIT_MESSAGE?: string;
45
+ /** SASL EXTERNAL operator opt-in ('true'/'1' enable; default off). */
46
+ EXTERNAL_ENABLED?: string;
33
47
  }
34
48
 
35
49
  function baseEnv(): CfEnvLike {
@@ -82,6 +96,49 @@ describe('loadServerConfigFromCfEnv — valid env', () => {
82
96
  expect(cfg.operCreds).toEqual([{ user: 'admin', password: 'hunter2' }]);
83
97
  });
84
98
 
99
+ it('builds a hashed operCred from OPER_USER + OPER_SALT + OPER_HASH', () => {
100
+ const cfg = loadServerConfigFromCfEnv({
101
+ ...baseEnv(),
102
+ OPER_USER: 'admin',
103
+ OPER_SALT: 'c2FsdA==',
104
+ OPER_HASH: 'aGFzaA==',
105
+ });
106
+ expect(cfg.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
107
+ });
108
+
109
+ it('prefers the hashed form when both OPER_PASSWORD and OPER_HASH/OPER_SALT are set', () => {
110
+ // Hashed form takes precedence so operators can rotate to it without
111
+ // removing the legacy OPER_PASSWORD var in the same deploy.
112
+ const cfg = loadServerConfigFromCfEnv({
113
+ ...baseEnv(),
114
+ OPER_USER: 'admin',
115
+ OPER_PASSWORD: 'legacy',
116
+ OPER_SALT: 'c2FsdA==',
117
+ OPER_HASH: 'aGFzaA==',
118
+ });
119
+ expect(cfg.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
120
+ });
121
+
122
+ it('throws when OPER_USER + OPER_SALT are set but OPER_HASH is missing', () => {
123
+ expect(() =>
124
+ loadServerConfigFromCfEnv({
125
+ ...baseEnv(),
126
+ OPER_USER: 'admin',
127
+ OPER_SALT: 'c2FsdA==',
128
+ }),
129
+ ).toThrowError(/hash/u);
130
+ });
131
+
132
+ it('throws when OPER_USER + OPER_HASH are set but OPER_SALT is missing', () => {
133
+ expect(() =>
134
+ loadServerConfigFromCfEnv({
135
+ ...baseEnv(),
136
+ OPER_USER: 'admin',
137
+ OPER_HASH: 'aGFzaA==',
138
+ }),
139
+ ).toThrowError(/salt/u);
140
+ });
141
+
85
142
  it('threads SERVER_PASSWORD through to serverPassword', () => {
86
143
  const cfg = loadServerConfigFromCfEnv({
87
144
  ...baseEnv(),
@@ -237,3 +294,105 @@ describe('loadServerConfigFromCfEnv — SASL_ACCOUNTS parsing', () => {
237
294
  expect(cfg.createdAt).toBe(huge);
238
295
  });
239
296
  });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Adapter frame-rate limit (per-connection inbound frame window).
300
+ // ---------------------------------------------------------------------------
301
+
302
+ describe('loadServerConfigFromCfEnv — adapter frame-rate limit', () => {
303
+ it('defaults the adapter frame window when the vars are unset', () => {
304
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
305
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 5 });
306
+ });
307
+
308
+ it('maps MAX_FRAMES_PER_WINDOW + FRAME_WINDOW_SECONDS to the adapter section', () => {
309
+ const cfg = loadServerConfigFromCfEnv({
310
+ ...baseEnv(),
311
+ MAX_FRAMES_PER_WINDOW: '10',
312
+ FRAME_WINDOW_SECONDS: '2',
313
+ });
314
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 10, frameWindowSeconds: 2 });
315
+ });
316
+
317
+ it('fills the unset knob with its schema default when only one var is set', () => {
318
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_FRAMES_PER_WINDOW: '25' });
319
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 25, frameWindowSeconds: 5 });
320
+ });
321
+
322
+ it('throws when MAX_FRAMES_PER_WINDOW is non-numeric', () => {
323
+ expect(() =>
324
+ loadServerConfigFromCfEnv({ ...baseEnv(), MAX_FRAMES_PER_WINDOW: 'lots' }),
325
+ ).toThrowError(/maxFramesPerWindow/u);
326
+ });
327
+
328
+ it('throws when FRAME_WINDOW_SECONDS is non-positive', () => {
329
+ expect(() =>
330
+ loadServerConfigFromCfEnv({ ...baseEnv(), FRAME_WINDOW_SECONDS: '0' }),
331
+ ).toThrowError(/frameWindowSeconds/u);
332
+ });
333
+ });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // EXTERNAL_ENABLED — SASL EXTERNAL operator opt-in (default off).
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe('loadServerConfigFromCfEnv — EXTERNAL_ENABLED (SASL EXTERNAL opt-in)', () => {
340
+ it('leaves SASL EXTERNAL off when EXTERNAL_ENABLED is unset', () => {
341
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
342
+ expect(cfg.sasl?.externalEnabled ?? false).toBe(false);
343
+ });
344
+
345
+ it('maps EXTERNAL_ENABLED=true to sasl.externalEnabled', () => {
346
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'true' });
347
+ expect(cfg.sasl?.externalEnabled).toBe(true);
348
+ });
349
+
350
+ it('maps EXTERNAL_ENABLED=1 to sasl.externalEnabled', () => {
351
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: '1' });
352
+ expect(cfg.sasl?.externalEnabled).toBe(true);
353
+ });
354
+
355
+ it('maps EXTERNAL_ENABLED=false (and any other value) to false', () => {
356
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'false' });
357
+ expect(cfg.sasl?.externalEnabled).toBe(false);
358
+ const junk = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'yes please' });
359
+ expect(junk.sasl?.externalEnabled ?? false).toBe(false);
360
+ });
361
+ });
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Per-IP admission knobs — edge throttling is operator-tunable via env vars.
365
+ // ---------------------------------------------------------------------------
366
+
367
+ describe('loadServerConfigFromCfEnv — per-IP admission knobs', () => {
368
+ it('defaults maxConnectionsPerIp / perIpConnectionRate from the schema', () => {
369
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
370
+ expect(cfg.maxConnectionsPerIp).toBe(10);
371
+ expect(cfg.perIpConnectionRate).toEqual({ max: 5, windowMs: 60_000 });
372
+ });
373
+
374
+ it('parses MAX_CONNECTIONS_PER_IP as an integer', () => {
375
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CONNECTIONS_PER_IP: '3' });
376
+ expect(cfg.maxConnectionsPerIp).toBe(3);
377
+ });
378
+
379
+ it('parses PER_IP_CONNECTION_RATE_MAX / _WINDOW_MS into the rate knob', () => {
380
+ const cfg = loadServerConfigFromCfEnv({
381
+ ...baseEnv(),
382
+ PER_IP_CONNECTION_RATE_MAX: '10',
383
+ PER_IP_CONNECTION_RATE_WINDOW_MS: '30000',
384
+ });
385
+ expect(cfg.perIpConnectionRate).toEqual({ max: 10, windowMs: 30000 });
386
+ });
387
+
388
+ it('fills the unset half of the rate knob with the schema default', () => {
389
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), PER_IP_CONNECTION_RATE_MAX: '9' });
390
+ expect(cfg.perIpConnectionRate).toEqual({ max: 9, windowMs: 60_000 });
391
+ });
392
+
393
+ it('rejects a non-positive rate budget', () => {
394
+ expect(() =>
395
+ loadServerConfigFromCfEnv({ ...baseEnv(), PER_IP_CONNECTION_RATE_MAX: '0' }),
396
+ ).toThrowError(/perIpConnectionRate/u);
397
+ });
398
+ });