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
@@ -0,0 +1,94 @@
1
+ /**
2
+ * WebSocket `Origin` policy for the AWS adapter's `$connect` Lambda —
3
+ * Cross-Site WebSocket Hijacking (CSWSH) defence.
4
+ *
5
+ * Browsers send an `Origin` header on every cross-origin WebSocket
6
+ * upgrade (RFC 6454); WebSocket upgrades do not otherwise enforce
7
+ * same-origin. Without a check, a malicious page can open a WebSocket
8
+ * to the IRC server from the victim's browser and drive the session
9
+ * with the victim's credentials. API Gateway WebSocket has no
10
+ * built-in `Origin` validation — the `$connect` Lambda receives
11
+ * `event.headers.Origin` and must enforce the allowlist itself.
12
+ *
13
+ * This module mirrors the canonical Cloudflare Worker policy
14
+ * (`apps/cf-worker/src/origin-allowlist.ts`) for the parsing
15
+ * semantics (comma-separated, trimmed, lowercased) and the
16
+ * "missing `Origin` = non-browser → allow" branch.
17
+ *
18
+ * **Deliberate divergence from the CF policy:** the AWS adapter
19
+ * supports ONLY the explicit-allowlist mode. The CF Worker's Mode 2
20
+ * (auto-derive the expected origin from the request's own `Host`
21
+ * header — same-origin enforcement) does not apply on AWS, because
22
+ * the SPA is served from a different CloudFront origin than the API
23
+ * Gateway WebSocket endpoint (`wss://{api-id}.execute-api.…`), so
24
+ * the request's own host is never the SPA's origin. The CSWSH
25
+ * defence is therefore opt-in: it only fires when `WEB_ORIGINS` is
26
+ * set. Unset → no Origin policy (existing AWS deployments without a
27
+ * web frontend are unchanged on upgrade).
28
+ */
29
+
30
+ /**
31
+ * Outcome of evaluating a `$connect` upgrade's `Origin` header.
32
+ *
33
+ * • `'allow'` — proceed: the origin is allowlisted, or the
34
+ * allowlist is empty (defence disabled), or the
35
+ * header is absent (non-browser client).
36
+ * • `'deny'` — reject (`403`): a browser sent an `Origin` that
37
+ * is not in the configured allowlist.
38
+ */
39
+ export type ConnectOriginDecision = 'allow' | 'deny';
40
+
41
+ /**
42
+ * Parse the comma-separated `WEB_ORIGINS` env var into a normalised
43
+ * set of lowercased origins. Whitespace-only and empty entries are
44
+ * dropped so trailing commas / stray spaces do not pollute the set.
45
+ * An empty/undefined var yields an empty set, which
46
+ * {@link decideConnectOrigin} treats as "defence disabled" (NOT
47
+ * deny-all).
48
+ *
49
+ * Origins are lowercased wholesale for case-insensitive matching;
50
+ * the scheme and host are case-insensitive per RFC 3986, and
51
+ * lowercasing the whole string is the simplest normalisation that
52
+ * handles the real-world browser-emitted form
53
+ * (`https://Example.com` → `https://example.com`). Port numbers —
54
+ * the only case-sensitive trailing component per the URI spec — are
55
+ * always digits in practice, so lowercasing is a no-op on them.
56
+ */
57
+ export function parseWebOrigins(raw: string | undefined): Set<string> {
58
+ return new Set(
59
+ (raw ?? '')
60
+ .split(',')
61
+ .map((entry) => entry.trim().toLowerCase())
62
+ .filter((entry) => entry.length > 0),
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Decide whether a `$connect` upgrade's `Origin` header is permitted.
68
+ *
69
+ * @param originHeader the raw `Origin` header value from
70
+ * `event.headers.Origin` (`null` / `undefined` /
71
+ * `''` when absent — non-browser clients).
72
+ * @param allowlist the parsed {@link parseWebOrigins} set. When
73
+ * empty, the defence is disabled and every
74
+ * upgrade is allowed regardless of its `Origin`.
75
+ * @returns `'allow'` to proceed with the upgrade, or `'deny'` to
76
+ * return `403` so API Gateway closes the connection.
77
+ */
78
+ export function decideConnectOrigin(
79
+ originHeader: string | null | undefined,
80
+ allowlist: Set<string>,
81
+ ): ConnectOriginDecision {
82
+ // Missing Origin = non-browser client (curl, WeeChat, tcp-ws-forwarder).
83
+ if (originHeader === null || originHeader === undefined || originHeader === '') {
84
+ return 'allow';
85
+ }
86
+
87
+ // Defence disabled — WEB_ORIGINS unset/empty. Every Origin proceeds.
88
+ if (allowlist.size === 0) {
89
+ return 'allow';
90
+ }
91
+
92
+ const origin = originHeader.trim().toLowerCase();
93
+ return allowlist.has(origin) ? 'allow' : 'deny';
94
+ }
@@ -84,6 +84,20 @@ export interface MarshalledConnection {
84
84
  away?: string;
85
85
  saslMech?: string;
86
86
  saslBuffer?: string;
87
+ /**
88
+ * Optimistic-concurrency revision counter. Incremented on every
89
+ * `$default`/NLB state write (conditional `UpdateItem` with
90
+ * `ConditionExpression: revision = :expected`) so two concurrent
91
+ * Lambda invocations for the same connection cannot silently
92
+ * clobber each other's state (the registration deadlock where
93
+ * `CAP END`'s `capNegotiating=false` got overwritten by a later
94
+ * frame's stale read). Absent on rows written before this field
95
+ * shipped; the persist path treats a missing attribute as revision
96
+ * `0` via an `attribute_not_exists(#rev) OR #rev = :expected`
97
+ * condition, so the migration is seamless. Seeded to `0` by
98
+ * {@link marshalConnection} at `$connect`.
99
+ */
100
+ revision?: number;
87
101
  /**
88
102
  * Negotiated IRCv3 WebSocket frame mode persisted at `$connect` time.
89
103
  * Absent (`undefined`) on legacy connections (no subprotocol agreed) so
@@ -93,6 +107,14 @@ export interface MarshalledConnection {
93
107
  * subsequent frame.
94
108
  */
95
109
  wsMode?: WsFrameMode;
110
+ /**
111
+ * Source IP of the connecting client, persisted at `$connect` (and on
112
+ * NLB flow creation) so the `sourceIp` GSI (`sourceIp-connectedSince`)
113
+ * carries the row for the per-IP admission walks. Absent on rows
114
+ * written before this field shipped — those are invisible to the
115
+ * index (a GSI item requires both key attributes).
116
+ */
117
+ sourceIp?: string;
96
118
  }
97
119
 
98
120
  /**
@@ -116,6 +138,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
116
138
  connectedSince: state.connectedSince,
117
139
  idleSince,
118
140
  version: CONNECTION_VERSION,
141
+ revision: 0,
119
142
  };
120
143
  if (state.joinedChannels.size > 0) {
121
144
  // Copy so later mutations to `state` don't alias the stored value.
@@ -14,13 +14,7 @@
14
14
  */
15
15
 
16
16
  /** Logical table names — also used as CDK construct ids. */
17
- export type TableName =
18
- | 'Connections'
19
- | 'ChannelMeta'
20
- | 'ChannelMembers'
21
- | 'Nicks'
22
- | 'Accounts'
23
- | 'Services';
17
+ export type TableName = 'Connections' | 'ChannelMeta' | 'ChannelMembers' | 'Nicks' | 'Services';
24
18
 
25
19
  /** Logical table names in stable order. */
26
20
  export const TABLE_NAMES: readonly TableName[] = [
@@ -28,7 +22,6 @@ export const TABLE_NAMES: readonly TableName[] = [
28
22
  'ChannelMeta',
29
23
  'ChannelMembers',
30
24
  'Nicks',
31
- 'Accounts',
32
25
  'Services',
33
26
  ];
34
27
 
@@ -47,7 +40,6 @@ export const TABLE_KEYS = {
47
40
  ChannelMeta: { pk: 'channelName' },
48
41
  ChannelMembers: { pk: 'channelName', sk: 'connectionId' },
49
42
  Nicks: { pk: 'nickLower' },
50
- Accounts: { pk: 'account' },
51
43
  } as const;
52
44
 
53
45
  /**
@@ -58,14 +50,13 @@ export const TABLE_KEYS = {
58
50
  * `Services` is optional: the services-persistence table is provisioned by
59
51
  * {@link TABLE_DEFS} but the runtime reads `SERVICES_TABLE` directly and
60
52
  * tolerates an unset var (services unbound). A `TablesConfig` built from
61
- * the required-five env vars still type-checks.
53
+ * the required-four env vars still type-checks.
62
54
  */
63
55
  export type TablesConfig = {
64
56
  Connections: string;
65
57
  ChannelMeta: string;
66
58
  ChannelMembers: string;
67
59
  Nicks: string;
68
- Accounts: string;
69
60
  Services?: string;
70
61
  };
71
62
 
@@ -75,9 +66,17 @@ export function tablesConfigFromNames(names: {
75
66
  ChannelMeta: string;
76
67
  ChannelMembers: string;
77
68
  Nicks: string;
78
- Accounts: string;
79
69
  Services?: string;
80
70
  }): TablesConfig {
81
71
  const out: TablesConfig = { ...names };
82
72
  return out;
83
73
  }
74
+
75
+ /**
76
+ * Name of the per-IP admission GSI on the `Connections` table
77
+ * (partition key `sourceIp`, sort key `connectedSince`). Consulted by the
78
+ * `$connect` and NLB handlers' per-IP admission gates; declared on the
79
+ * deployed table by `TABLE_DEFS` in `cdk-table-defs.ts`. Shared here so
80
+ * the runtime (this module) and the CDK definition reference one name.
81
+ */
82
+ export const SOURCE_IP_INDEX_NAME = 'sourceIp-connectedSince';
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Pure unit tests for the AWS `$connect` admission policy
3
3
  * (`src/admission.ts`). No DynamoDB — these exercise every branch of
4
- * {@link decideConnectAdmission} directly so the per-IP branch is covered
5
- * even though the AWS handler does not yet feed it a `perIp` count.
4
+ * {@link decideConnectAdmission} directly so the per-IP branches are
5
+ * covered without standing up DynamoDB Local.
6
6
  */
7
7
 
8
8
  import { describe, expect, it } from 'vitest';
@@ -67,4 +67,62 @@ describe('decideConnectAdmission', () => {
67
67
  reason: 'server full',
68
68
  });
69
69
  });
70
+
71
+ it('admits when a rate budget is set but no recent count is supplied', () => {
72
+ expect(
73
+ decideConnectAdmission(
74
+ { total: 0 },
75
+ { maxClients: 10, perIpRate: { max: 5, windowMs: 60_000 } },
76
+ ),
77
+ ).toEqual({ admitted: true });
78
+ });
79
+
80
+ it('admits when the recent per-IP count is below the rate budget', () => {
81
+ expect(
82
+ decideConnectAdmission(
83
+ { total: 0, recentPerIp: 4 },
84
+ { maxClients: 10, perIpRate: { max: 5, windowMs: 60_000 } },
85
+ ),
86
+ ).toEqual({ admitted: true });
87
+ });
88
+
89
+ it('rejects with 429 once the recent per-IP count reaches the rate budget', () => {
90
+ expect(
91
+ decideConnectAdmission(
92
+ { total: 0, recentPerIp: 5 },
93
+ { maxClients: 10, perIpRate: { max: 5, windowMs: 60_000 } },
94
+ ),
95
+ ).toEqual({
96
+ admitted: false,
97
+ statusCode: 429,
98
+ reason: 'connection rate exceeded for this IP',
99
+ });
100
+ });
101
+
102
+ it('checks the per-IP simultaneous cap before the rate budget', () => {
103
+ // A caller supplying both over-budget counts gets the simultaneous-
104
+ // cap reason: the more specific signal for an already-connected IP.
105
+ expect(
106
+ decideConnectAdmission(
107
+ { total: 0, perIp: 3, recentPerIp: 5 },
108
+ { maxClients: 10, maxConnectionsPerIp: 3, perIpRate: { max: 5, windowMs: 60_000 } },
109
+ ),
110
+ ).toEqual({
111
+ admitted: false,
112
+ statusCode: 429,
113
+ reason: 'too many connections from this IP',
114
+ });
115
+ });
116
+
117
+ it('ignores a stale recent count (window decayed) and admits', () => {
118
+ // Window-reset behaviour at the policy seam: a recent count of 0
119
+ // (every prior admission aged out of the window) never trips the
120
+ // rate branch even at a budget of 1.
121
+ expect(
122
+ decideConnectAdmission(
123
+ { total: 0, recentPerIp: 0 },
124
+ { maxClients: 10, perIpRate: { max: 1, windowMs: 60_000 } },
125
+ ),
126
+ ).toEqual({ admitted: true });
127
+ });
70
128
  });
@@ -97,7 +97,6 @@ export class AwsHarness implements IrcHarness {
97
97
  ChannelMeta: `${this.prefix}ChannelMeta`,
98
98
  ChannelMembers: `${this.prefix}ChannelMembers`,
99
99
  Nicks: `${this.prefix}Nicks`,
100
- Accounts: `${this.prefix}Accounts`,
101
100
  };
102
101
  this.client = createDynamoDocumentClient({ endpoint });
103
102
  }
@@ -212,7 +211,7 @@ export class AwsHarness implements IrcHarness {
212
211
  const pkName = props.partitionKey?.name;
213
212
  const skName = props.sortKey?.name;
214
213
  if (pkName === undefined) throw new Error(`table ${logical} missing partitionKey`);
215
- const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' }> = [
214
+ const attributeDefinitions: Array<{ AttributeName: string; AttributeType: 'S' | 'N' }> = [
216
215
  { AttributeName: pkName, AttributeType: 'S' },
217
216
  ];
218
217
  const keySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
@@ -222,11 +221,33 @@ export class AwsHarness implements IrcHarness {
222
221
  attributeDefinitions.push({ AttributeName: skName, AttributeType: 'S' });
223
222
  keySchema.push({ AttributeName: skName, KeyType: 'RANGE' });
224
223
  }
224
+ // Mirror the CDK-declared secondary indexes (the Connections
225
+ // per-IP admission GSI) so the fixture matches the deployed schema.
226
+ const globalSecondaryIndexes = (props.globalSecondaryIndexes ?? []).map((g) => {
227
+ const gpk = g.partitionKey;
228
+ if (gpk === undefined) throw new Error(`index ${g.indexName} missing partitionKey`);
229
+ const gsiKeySchema: Array<{ AttributeName: string; KeyType: 'HASH' | 'RANGE' }> = [
230
+ { AttributeName: gpk.name, KeyType: 'HASH' },
231
+ ];
232
+ attributeDefinitions.push({ AttributeName: gpk.name, AttributeType: 'S' });
233
+ if (g.sortKey !== undefined) {
234
+ gsiKeySchema.push({ AttributeName: g.sortKey.name, KeyType: 'RANGE' });
235
+ attributeDefinitions.push({ AttributeName: g.sortKey.name, AttributeType: 'N' });
236
+ }
237
+ return {
238
+ IndexName: g.indexName,
239
+ KeySchema: gsiKeySchema,
240
+ Projection: { ProjectionType: 'ALL' as const },
241
+ };
242
+ });
225
243
  await this.client.send(
226
244
  new CreateTableCommand({
227
245
  TableName: `${this.prefix}${logical}`,
228
246
  AttributeDefinitions: attributeDefinitions,
229
247
  KeySchema: keySchema,
248
+ ...(globalSecondaryIndexes.length > 0
249
+ ? { GlobalSecondaryIndexes: globalSecondaryIndexes }
250
+ : {}),
230
251
  BillingMode: 'PAY_PER_REQUEST',
231
252
  }),
232
253
  );
@@ -471,6 +471,70 @@ describe.skipIf(!available)('AwsRuntime', () => {
471
471
  });
472
472
  });
473
473
 
474
+ // -------------------------------------------------------------------------
475
+ // broadcastOperNotice — Scan-based fan-out to every +o connection (the
476
+ // OPER lockout gate's oper notice)
477
+ // -------------------------------------------------------------------------
478
+
479
+ describe('broadcastOperNotice', () => {
480
+ it('delivers to every +o connection and skips the except id', async () => {
481
+ const receivedA: string[] = [];
482
+ const receivedB: string[] = [];
483
+ const stateA = createConnection({ id: 'o-a', connectedSince: 0 });
484
+ stateA.userModes.oper = true;
485
+ const stateB = createConnection({ id: 'o-b', connectedSince: 0 });
486
+ stateB.userModes.oper = true;
487
+
488
+ const mgmt = new LocalPostToConnection();
489
+ mgmt.register('o-a', (data) => {
490
+ for (const line of data.split('\r\n')) {
491
+ if (line.length > 0) receivedA.push(line);
492
+ }
493
+ });
494
+ mgmt.register('o-b', (data) => {
495
+ for (const line of data.split('\r\n')) {
496
+ if (line.length > 0) receivedB.push(line);
497
+ }
498
+ });
499
+
500
+ const rtA = makeRuntime('o-a', handlersSink(receivedA, stateA), undefined, mgmt);
501
+ const rtB = makeRuntime('o-b', handlersSink(receivedB, stateB), undefined, mgmt);
502
+
503
+ await rtA.persistConnectionState(stateA);
504
+ await rtB.persistConnectionState(stateB);
505
+
506
+ // Caller 'o-a' is excepted; only 'o-b' should receive the notice.
507
+ await rtA.broadcastOperNotice(
508
+ [{ text: ':srv NOTICE * :OPER lockout triggered for h' }],
509
+ 'o-a',
510
+ );
511
+ expect(receivedA).toEqual([]);
512
+ expect(receivedB).toEqual([':srv NOTICE * :OPER lockout triggered for h']);
513
+ });
514
+
515
+ it('does not deliver to a +w-only connection (the gate is +o)', async () => {
516
+ const received: string[] = [];
517
+ const state = createConnection({ id: 'o-wonly', connectedSince: 0 });
518
+ state.userModes.wallops = true;
519
+ const rt = makeRuntime('o-caller', handlersSink(received, state), undefined, null);
520
+ await rt.persistConnectionState(state);
521
+ await rt.broadcastOperNotice([{ text: ':srv NOTICE * :hi' }]);
522
+ expect(received).toEqual([]);
523
+ });
524
+
525
+ it('delivers to the bound connection via in-process handlers when not excepted', async () => {
526
+ // The bound connection's own +o delivery goes through handlers.send,
527
+ // not APIGW. With no `except`, the bound oper conn is a recipient.
528
+ const received: string[] = [];
529
+ const state = createConnection({ id: 'self', connectedSince: 0 });
530
+ state.userModes.oper = true;
531
+ const rt = makeRuntime('self', handlersSink(received, state), undefined, null);
532
+ await rt.persistConnectionState(state);
533
+ await rt.broadcastOperNotice([{ text: ':srv NOTICE * :hi' }]);
534
+ expect(received).toEqual([':srv NOTICE * :hi']);
535
+ });
536
+ });
537
+
474
538
  // -------------------------------------------------------------------------
475
539
  // Nick registry — error propagation
476
540
  // -------------------------------------------------------------------------
@@ -51,6 +51,35 @@ describe('buildLambdaConfigInput', () => {
51
51
  expect(out.operCreds).toEqual([{ user: '', password: 'pw' }]);
52
52
  });
53
53
 
54
+ it('builds a hashed operCred when OPER_USER + OPER_SALT + OPER_HASH are set', () => {
55
+ const out = buildLambdaConfigInput({
56
+ OPER_USER: 'admin',
57
+ OPER_SALT: 'c2FsdA==',
58
+ OPER_HASH: 'aGFzaA==',
59
+ });
60
+ expect(out.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
61
+ });
62
+
63
+ it('prefers the hashed form over plaintext when both are set', () => {
64
+ const out = buildLambdaConfigInput({
65
+ OPER_USER: 'admin',
66
+ OPER_PASSWORD: 'legacy',
67
+ OPER_SALT: 'c2FsdA==',
68
+ OPER_HASH: 'aGFzaA==',
69
+ });
70
+ expect(out.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
71
+ });
72
+
73
+ it('does NOT build operCreds when only OPER_SALT + OPER_HASH are set (no OPER_USER)', () => {
74
+ // Without OPER_USER the loader has no `user` field to thread; the
75
+ // hashed form requires a user name. The schema's superRefine would
76
+ // catch a missing user too, but the loader opts to omit operCreds
77
+ // entirely so the no-op (vs. `491 ERR_NOOPERHOST`) behaviour stays
78
+ // consistent with the legacy "no OPER_USER" path.
79
+ const out = buildLambdaConfigInput({ OPER_SALT: 'c2FsdA==', OPER_HASH: 'aGFzaA==' });
80
+ expect('operCreds' in out).toBe(false);
81
+ });
82
+
54
83
  it('threads SERVER_PASSWORD through to serverPassword', () => {
55
84
  expect(buildLambdaConfigInput({ SERVER_PASSWORD: 'hunter2' }).serverPassword).toBe('hunter2');
56
85
  });
@@ -155,6 +184,72 @@ describe('buildLambdaConfigInput', () => {
155
184
  const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ' alice:secret ' });
156
185
  expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
157
186
  });
187
+
188
+ it('does NOT thread WEB_ORIGINS into the ServerConfig input', () => {
189
+ // WEB_ORIGINS is an adapter-level operational concern (consumed by
190
+ // `$connect`'s CSWSH defence), NOT a server-config knob. Routing
191
+ // it through the shared Zod schema would either be silently
192
+ // stripped (the schema is non-strict) or pollute the canonical
193
+ // `ServerConfig` type with an AWS-only field. The buildLambdaConfigInput
194
+ // function therefore accepts the typed env field but never emits
195
+ // it — `buildDepsFromEnv` parses it directly via `parseWebOrigins`.
196
+ const out = buildLambdaConfigInput({ WEB_ORIGINS: 'https://app.example.com' });
197
+ expect('webOrigins' in out).toBe(false);
198
+ expect('WEB_ORIGINS' in out).toBe(false);
199
+ });
200
+
201
+ it('types WEB_ORIGINS as an optional string field on LambdaConfigEnv', () => {
202
+ // Compile-time assertion: the env interface accepts the field.
203
+ const env: LambdaConfigEnv = { WEB_ORIGINS: 'https://a.example.com,https://b.example.com' };
204
+ expect(env.WEB_ORIGINS).toBe('https://a.example.com,https://b.example.com');
205
+ });
206
+ });
207
+
208
+ describe('buildDepsFromEnv — WEB_ORIGINS wiring', () => {
209
+ // Build a minimal env shape that satisfies `tablesConfigFromEnv` and the
210
+ // shared config schema, pointing DynamoDB at an unreachable endpoint so
211
+ // `resolveAccountStore` / `loadDynamoServicesStore` fall through to
212
+ // defaults rather than scanning a live table. We are only asserting the
213
+ // env→deps wiring for `WEB_ORIGINS`.
214
+ function makeBaseEnv(): NodeJS.ProcessEnv {
215
+ return {
216
+ SERVER_NAME: 'irc.test',
217
+ NETWORK_NAME: 'TestNet',
218
+ CONNECTIONS_TABLE: 'Connections',
219
+ CHANNELMETA_TABLE: 'ChannelMeta',
220
+ CHANNELMEMBERS_TABLE: 'ChannelMembers',
221
+ NICKS_TABLE: 'Nicks',
222
+ ACCOUNTS_TABLE: 'Accounts',
223
+ DYNAMO_ENDPOINT: 'http://127.0.0.1:1',
224
+ };
225
+ }
226
+
227
+ it('parses WEB_ORIGINS into a Set on the returned deps', async () => {
228
+ // The env→deps wiring is the production path: `WEB_ORIGINS` is read
229
+ // once at cold start, parsed via `parseWebOrigins`, and stored on
230
+ // `HandlerDeps.webOrigins` so every `$connect` invocation reuses the
231
+ // same set.
232
+ const { buildDepsFromEnv } = await import('../src/handlers/index.js');
233
+ const deps = await buildDepsFromEnv({
234
+ ...makeBaseEnv(),
235
+ WEB_ORIGINS: 'https://app.example.com, https://staging.app.example.com',
236
+ });
237
+ expect(deps.webOrigins).toEqual(
238
+ new Set(['https://app.example.com', 'https://staging.app.example.com']),
239
+ );
240
+ });
241
+
242
+ it('omits webOrigins from deps when WEB_ORIGINS is unset', async () => {
243
+ const { buildDepsFromEnv } = await import('../src/handlers/index.js');
244
+ const deps = await buildDepsFromEnv(makeBaseEnv());
245
+ expect(deps.webOrigins).toBeUndefined();
246
+ });
247
+
248
+ it('omits webOrigins from deps when WEB_ORIGINS is an empty string', async () => {
249
+ const { buildDepsFromEnv } = await import('../src/handlers/index.js');
250
+ const deps = await buildDepsFromEnv({ ...makeBaseEnv(), WEB_ORIGINS: '' });
251
+ expect(deps.webOrigins).toBeUndefined();
252
+ });
158
253
  });
159
254
 
160
255
  describe('loadServerConfigFromLambdaEnv', () => {
@@ -248,3 +343,125 @@ describe('loadServerConfigFromLambdaEnv', () => {
248
343
  ]);
249
344
  });
250
345
  });
346
+
347
+ describe('loadServerConfigFromLambdaEnv — EXTERNAL_ENABLED (SASL EXTERNAL opt-in)', () => {
348
+ it('leaves SASL EXTERNAL off when EXTERNAL_ENABLED is unset', () => {
349
+ const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
350
+ expect(cfg.sasl?.externalEnabled ?? false).toBe(false);
351
+ });
352
+
353
+ it('maps EXTERNAL_ENABLED=true to sasl.externalEnabled', () => {
354
+ const cfg = loadServerConfigFromLambdaEnv({
355
+ SERVER_NAME: 's',
356
+ NETWORK_NAME: 'n',
357
+ EXTERNAL_ENABLED: 'true',
358
+ });
359
+ expect(cfg.sasl?.externalEnabled).toBe(true);
360
+ });
361
+
362
+ it('maps EXTERNAL_ENABLED=1 to sasl.externalEnabled', () => {
363
+ const cfg = loadServerConfigFromLambdaEnv({
364
+ SERVER_NAME: 's',
365
+ NETWORK_NAME: 'n',
366
+ EXTERNAL_ENABLED: '1',
367
+ });
368
+ expect(cfg.sasl?.externalEnabled).toBe(true);
369
+ });
370
+
371
+ it('maps EXTERNAL_ENABLED=false (and any other value) to false', () => {
372
+ const cfg = loadServerConfigFromLambdaEnv({
373
+ SERVER_NAME: 's',
374
+ NETWORK_NAME: 'n',
375
+ EXTERNAL_ENABLED: 'false',
376
+ });
377
+ expect(cfg.sasl?.externalEnabled).toBe(false);
378
+ const junk = loadServerConfigFromLambdaEnv({
379
+ SERVER_NAME: 's',
380
+ NETWORK_NAME: 'n',
381
+ EXTERNAL_ENABLED: 'yes please',
382
+ });
383
+ expect(junk.sasl?.externalEnabled ?? false).toBe(false);
384
+ });
385
+ });
386
+
387
+ describe('loadServerConfigFromLambdaEnv — adapter frame-rate limit', () => {
388
+ it('defaults the adapter frame window when the vars are unset', () => {
389
+ const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
390
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 5 });
391
+ });
392
+
393
+ it('maps MAX_FRAMES_PER_WINDOW + FRAME_WINDOW_SECONDS to the adapter section', () => {
394
+ const cfg = loadServerConfigFromLambdaEnv({
395
+ SERVER_NAME: 's',
396
+ NETWORK_NAME: 'n',
397
+ MAX_FRAMES_PER_WINDOW: '20',
398
+ FRAME_WINDOW_SECONDS: '10',
399
+ });
400
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 20, frameWindowSeconds: 10 });
401
+ });
402
+
403
+ it('fills the unset knob with its schema default when only one var is set', () => {
404
+ const cfg = loadServerConfigFromLambdaEnv({
405
+ SERVER_NAME: 's',
406
+ NETWORK_NAME: 'n',
407
+ FRAME_WINDOW_SECONDS: '30',
408
+ });
409
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 30 });
410
+ });
411
+
412
+ it('throws when MAX_FRAMES_PER_WINDOW is non-numeric', () => {
413
+ expect(() =>
414
+ loadServerConfigFromLambdaEnv({
415
+ SERVER_NAME: 's',
416
+ NETWORK_NAME: 'n',
417
+ MAX_FRAMES_PER_WINDOW: 'many',
418
+ }),
419
+ ).toThrowError(/maxFramesPerWindow/u);
420
+ });
421
+
422
+ it('throws when FRAME_WINDOW_SECONDS is non-positive', () => {
423
+ expect(() =>
424
+ loadServerConfigFromLambdaEnv({
425
+ SERVER_NAME: 's',
426
+ NETWORK_NAME: 'n',
427
+ FRAME_WINDOW_SECONDS: '-1',
428
+ }),
429
+ ).toThrowError(/frameWindowSeconds/u);
430
+ });
431
+ });
432
+
433
+ describe('loadServerConfigFromLambdaEnv — per-IP admission knobs', () => {
434
+ it('defaults maxConnectionsPerIp / perIpConnectionRate from the schema', () => {
435
+ const cfg = loadServerConfigFromLambdaEnv({ SERVER_NAME: 's', NETWORK_NAME: 'n' });
436
+ expect(cfg.maxConnectionsPerIp).toBe(10);
437
+ expect(cfg.perIpConnectionRate).toEqual({ max: 5, windowMs: 60_000 });
438
+ });
439
+
440
+ it('parses MAX_CONNECTIONS_PER_IP as an integer', () => {
441
+ const cfg = loadServerConfigFromLambdaEnv({
442
+ SERVER_NAME: 's',
443
+ NETWORK_NAME: 'n',
444
+ MAX_CONNECTIONS_PER_IP: '4',
445
+ });
446
+ expect(cfg.maxConnectionsPerIp).toBe(4);
447
+ });
448
+
449
+ it('parses PER_IP_CONNECTION_RATE_MAX / _WINDOW_MS into the rate knob', () => {
450
+ const cfg = loadServerConfigFromLambdaEnv({
451
+ SERVER_NAME: 's',
452
+ NETWORK_NAME: 'n',
453
+ PER_IP_CONNECTION_RATE_MAX: '10',
454
+ PER_IP_CONNECTION_RATE_WINDOW_MS: '30000',
455
+ });
456
+ expect(cfg.perIpConnectionRate).toEqual({ max: 10, windowMs: 30_000 });
457
+ });
458
+
459
+ it('fills the unset half of the rate knob with the schema default', () => {
460
+ const cfg = loadServerConfigFromLambdaEnv({
461
+ SERVER_NAME: 's',
462
+ NETWORK_NAME: 'n',
463
+ PER_IP_CONNECTION_RATE_WINDOW_MS: '10000',
464
+ });
465
+ expect(cfg.perIpConnectionRate).toEqual({ max: 5, windowMs: 10_000 });
466
+ });
467
+ });