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
@@ -14,7 +14,6 @@
14
14
  import { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
15
15
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
16
16
  import {
17
- type AccountStore,
18
17
  EmptyMotdProvider,
19
18
  InMemoryNickHistoryStore,
20
19
  type MessageStore,
@@ -25,11 +24,11 @@ import {
25
24
  StaticMotdProvider,
26
25
  SystemClock,
27
26
  } from '@serverless-ircd/irc-core';
28
- import { resolveAccountStore } from '../account-store.js';
29
27
  import { type LambdaConfigEnv, loadServerConfigFromLambdaEnv } from '../config-loader.js';
30
28
  import { type DynamoServicesStore, loadDynamoServicesStore } from '../dynamo-services-store.js';
31
29
  import { createDynamoDocumentClient } from '../dynamo.js';
32
30
  import { bindMessageStore } from '../message-store.js';
31
+ import { parseWebOrigins } from '../origin-allowlist.js';
33
32
  import { TABLE_KEYS, type TablesConfig } from '../tables.js';
34
33
  import { handleConnect } from './connect.js';
35
34
  import { handleDefault } from './default.js';
@@ -105,20 +104,15 @@ export interface HandlerDeps {
105
104
  * DynamoDB-backed variant is a documented follow-up.
106
105
  */
107
106
  messages: MessageStore;
108
- /**
109
- * SASL account verification source bound to every actor. Constructed
110
- * once per cold start (memoised via {@link cachedDeps}) so it survives
111
- * across warm Lambda invocations. `undefined` when no accounts are
112
- * configured (the default) so `ctx.accounts` stays unset.
113
- */
114
- accounts?: AccountStore;
115
107
  /**
116
108
  * Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
117
- * OperServ + read-marker), hydrated once per cold start from the
118
- * `Services` DynamoDB table into a write-behind cache. `undefined` when
119
- * `SERVICES_TABLE` is unset (services unbound → no NickServ/ChanServ
120
- * routing). The handler flushes pending writes after each `$default` /
121
- * NLB frame so a Lambda freeze / evict does not lose services state.
109
+ * OperServ + read-marker + the unified SASL/PASS credential home),
110
+ * hydrated once per cold start from the `Services` DynamoDB table into a
111
+ * write-behind cache. `undefined` when `SERVICES_TABLE` is unset
112
+ * (services unbound no NickServ/ChanServ routing, no SASL/PASS
113
+ * credential verify). The handler flushes pending writes after each
114
+ * `$default` / NLB frame so a Lambda freeze / evict does not lose
115
+ * services state.
122
116
  */
123
117
  services?: DynamoServicesStore;
124
118
  /**
@@ -136,6 +130,16 @@ export interface HandlerDeps {
136
130
  * path applies (error-suffixed `382`).
137
131
  */
138
132
  configLoader?: () => Promise<ServerConfig>;
133
+ /**
134
+ * Parsed `WEB_ORIGINS` allowlist for the `$connect` CSWSH defence.
135
+ * Empty set (the default) disables the check; non-empty causes
136
+ * {@link handleConnect} to reject browser-sent `Origin` headers that
137
+ * are not in the set with `403`. Built once per cold start from
138
+ * `process.env.WEB_ORIGINS` via {@link parseWebOrigins}; non-browser
139
+ * clients (curl, WeeChat, the `tcp-ws-forwarder`) never send `Origin`
140
+ * and always proceed regardless.
141
+ */
142
+ webOrigins?: Set<string> | undefined;
139
143
  }
140
144
 
141
145
  /**
@@ -143,7 +147,12 @@ export interface HandlerDeps {
143
147
  * construct events directly.
144
148
  */
145
149
  export type ConnectEvent = {
146
- requestContext: { routeKey: '$connect'; connectionId: string };
150
+ requestContext: {
151
+ routeKey: '$connect';
152
+ connectionId: string;
153
+ /** Client source IP — feeds the per-IP admission gates. */
154
+ identity?: { sourceIp?: string };
155
+ };
147
156
  headers?: Record<string, string>;
148
157
  };
149
158
  export type DisconnectEvent = {
@@ -196,7 +205,16 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
196
205
  tables: deps.tables,
197
206
  connectionId: connId,
198
207
  serverConfig: deps.serverConfig,
199
- secWebSocketProtocol: event.headers?.['Sec-WebSocket-Protocol'] ?? null,
208
+ secWebSocketProtocol: readHeader(event.headers, 'Sec-WebSocket-Protocol'),
209
+ // APIGW normalises header keys to lowercase, but readHeader
210
+ // tolerates either casing so a future proxy / stage mutation
211
+ // cannot silently bypass the CSWSH defence.
212
+ originHeader: readHeader(event.headers, 'Origin'),
213
+ webOrigins: deps.webOrigins,
214
+ // Per-IP admission: APIGW stamps the client IP on every
215
+ // `$connect` event; absent (older shapes) the per-IP gates
216
+ // stay dormant.
217
+ sourceIp: event.requestContext.identity?.sourceIp,
200
218
  });
201
219
  if (outcome.admitted) {
202
220
  if (outcome.subprotocol !== null) {
@@ -233,7 +251,6 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
233
251
  serverConfig: deps.serverConfig,
234
252
  motd: deps.motd,
235
253
  messages: deps.messages,
236
- ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
237
254
  ...(deps.services !== undefined ? { services: deps.services } : {}),
238
255
  ...(deps.history !== undefined ? { history: deps.history } : {}),
239
256
  ...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
@@ -333,7 +350,24 @@ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStream
333
350
  cachedDeps = await cachedDepsPromise;
334
351
  cachedDepsPromise = undefined;
335
352
  }
336
- const deps = cachedDeps;
353
+ return dispatchNlbStream(event, cachedDeps);
354
+ }
355
+
356
+ /**
357
+ * Pure NLB dispatcher — exported for unit tests that inject deps directly
358
+ * (mirrors {@link dispatch} on the wss path).
359
+ *
360
+ * The CDK stack wires this Lambda target exclusively behind a
361
+ * `Protocol.TLS` NLB listener (an ACM-cert-terminated `irc+tls://`
362
+ * listener — see `aws-stack.ts`), so every connection routed here is
363
+ * TLS-protected at the edge. The secure fact is asserted so the actor
364
+ * surfaces read-only user mode `S`, STS advertises the duration-only
365
+ * TLS form, and SASL EXTERNAL is negotiable.
366
+ */
367
+ export async function dispatchNlbStream(
368
+ event: NlbStreamEvent,
369
+ deps: HandlerDeps,
370
+ ): Promise<NlbStreamResponse> {
337
371
  return handleNlbStream(event, {
338
372
  dynamo: deps.dynamo,
339
373
  tables: deps.tables,
@@ -341,9 +375,11 @@ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStream
341
375
  motd: deps.motd,
342
376
  messages: deps.messages,
343
377
  managementApi: deps.managementApi,
344
- ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
345
378
  ...(deps.services !== undefined ? { services: deps.services } : {}),
346
379
  ...(deps.history !== undefined ? { history: deps.history } : {}),
380
+ // TLS fact: the NLB listener terminates TLS before the target sees
381
+ // the flow (mirrors the wss handler's hard-coded `secure: true`).
382
+ secure: true,
347
383
  });
348
384
  }
349
385
 
@@ -359,10 +395,16 @@ export {
359
395
  * Builds a {@link HandlerDeps} from the current `process.env`.
360
396
  *
361
397
  * Exposed so tests can construct production-shaped deps without re-running
362
- * the env parsing. Async because it scans the `Accounts` DynamoDB table
363
- * (via {@link resolveAccountStore}) to pre-load SASL credentials at cold
364
- * start — the {@link AccountStore} port is synchronous, so the scan must
365
- * complete before any reducer calls `verify`.
398
+ * the env parsing. Async because it scans the `Services` DynamoDB table
399
+ * (via {@link loadDynamoServicesStore}) to pre-load the services snapshot
400
+ * at cold start — the {@link ServicesStore} port is synchronous, so the
401
+ * scan must complete before any reducer calls `verifyNick` /
402
+ * `verifyCertFP`.
403
+ *
404
+ * After the services scan, the `SASL_ACCOUNTS` env-var seed is ingested
405
+ * into the services store via `registerNick` (idempotent — existing
406
+ * registrations, including NickServ-registered nicks, win over the env
407
+ * seed).
366
408
  */
367
409
  export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<HandlerDeps> {
368
410
  const cfg = loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
@@ -378,13 +420,24 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
378
420
  const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
379
421
  const messages = bindMessageStore(cfg);
380
422
  const history = new InMemoryNickHistoryStore(SystemClock);
381
- const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
382
423
  const servicesTable = typeof env.SERVICES_TABLE === 'string' ? env.SERVICES_TABLE : undefined;
383
424
  const services = await loadDynamoServicesStore(dynamo, servicesTable, SystemClock);
425
+ if (services !== undefined) {
426
+ for (const acct of cfg.saslAccounts) {
427
+ services.registerNick(acct.username, acct.password, '');
428
+ }
429
+ }
384
430
  // REHASH reload source: re-read the live env so a rotated oper password
385
431
  // or MOTD takes effect on the next REHASH without a redeploy.
386
432
  const configLoader = async (): Promise<ServerConfig> =>
387
433
  loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
434
+ // CSWSH defence — parsed once per cold start so every `$connect`
435
+ // reuses the same set. Empty set (the default when `WEB_ORIGINS` is
436
+ // unset) disables the check; non-empty denies browser-sent origins
437
+ // that are not in the set. Non-browser clients never send `Origin`.
438
+ const webOrigins = parseWebOrigins(
439
+ typeof env.WEB_ORIGINS === 'string' ? env.WEB_ORIGINS : undefined,
440
+ );
388
441
  return {
389
442
  dynamo,
390
443
  tables,
@@ -394,7 +447,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
394
447
  messages,
395
448
  history,
396
449
  configLoader,
397
- ...(accounts !== undefined ? { accounts } : {}),
450
+ ...(webOrigins.size > 0 ? { webOrigins } : {}),
398
451
  ...(services !== undefined ? { services } : {}),
399
452
  };
400
453
  }
@@ -411,3 +464,23 @@ function tablesConfigFromEnv(env: NodeJS.ProcessEnv): TablesConfig {
411
464
  }
412
465
  return out;
413
466
  }
467
+
468
+ /**
469
+ * Case-insensitive header lookup against an APIGW `event.headers` record.
470
+ *
471
+ * API Gateway WebSocket normalises header keys to lowercase, but HTTP
472
+ * proxies in front of it (ALB, CloudFront, custom domains) can preserve
473
+ * the original case. A case-sensitive lookup would silently miss the
474
+ * header under one of those routes — for the `Origin` CSWSH check that
475
+ * is a security hole, so this helper walks both casings. Returns `null`
476
+ * when absent so callers can pass it straight to handlers that
477
+ * distinguish "header absent" from "header empty".
478
+ */
479
+ function readHeader(headers: Record<string, string> | undefined, name: string): string | null {
480
+ if (headers === undefined) return null;
481
+ const direct = headers[name];
482
+ if (typeof direct === 'string') return direct;
483
+ const lower = headers[name.toLowerCase()];
484
+ if (typeof lower === 'string') return lower;
485
+ return null;
486
+ }
@@ -12,13 +12,21 @@
12
12
  * `x-forwarded-port` headers. {@link deriveNlbConnectionId} maps that tuple
13
13
  * to the existing `connectionId` scheme (`nlb-<ip>-<port>`) so the same
14
14
  * DynamoDB-backed state path serves both the wss and TCP+TLS transports.
15
+ * A flow arriving without both headers in well-formed shape (missing,
16
+ * non-IP, or non-numeric-port — e.g. a misconfigured integration) is
17
+ * rejected with a 400 before any state is read or written; every such
18
+ * flow would otherwise share one `nlb-unknown-0` Connections row.
15
19
  *
16
20
  * Transport buffer persistence: the `TcpByteStreamTransport` buffers
17
21
  * partial lines (a chunk may split an IRC command at an arbitrary byte
18
22
  * boundary). Because each chunk arrives in a fresh Lambda invocation, the
19
23
  * buffered tail is snapshotted to a `transportBuffer` attribute on the
20
24
  * Connections row and rehydrated on the next invocation — no bytes are lost
21
- * across the compute boundary.
25
+ * across the compute boundary. The tail is capped at MAX_BUFFER_BYTES
26
+ * (shared constant from irc-core): a body over the cap is rejected with a
27
+ * 400 before any processing, and an accumulated tail over the cap closes
28
+ * the connection (ERROR line + Connections row deleted) so the buffer can
29
+ * never grow unboundedly or persist oversized.
22
30
  *
23
31
  * Outbound bytes: self-send lines (PONG, welcome block, error numerics, …)
24
32
  * are collected and returned as the Lambda response body (base64-encoded so
@@ -40,15 +48,16 @@
40
48
  * underlying flow may be rebalanced by the NLB at any time.
41
49
  */
42
50
 
51
+ import { isIP } from 'node:net';
43
52
  import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanagementapi';
44
- import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
53
+ import { DeleteCommand, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
45
54
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
46
55
  import {
47
- type AccountStore,
48
56
  type ChannelState,
49
57
  type Clock,
50
58
  type ConnectionState,
51
59
  type IdFactory,
60
+ MAX_BUFFER_BYTES,
52
61
  type MessageStore,
53
62
  type MotdProvider,
54
63
  type NickHistoryStore,
@@ -56,6 +65,7 @@ import {
56
65
  type ServerConfig,
57
66
  SystemClock,
58
67
  UuidIdFactory,
68
+ byteLength,
59
69
  } from '@serverless-ircd/irc-core';
60
70
  import {
61
71
  type ActorChannelAccess,
@@ -65,6 +75,7 @@ import {
65
75
  import { TcpByteStreamTransport } from '@serverless-ircd/irc-server';
66
76
  import { AwsRuntime, type AwsRuntimeHandlers, type PostToConnection } from '../aws-runtime.js';
67
77
  import type { DynamoServicesStore } from '../dynamo-services-store.js';
78
+ import { countConnectionsForIp } from '../ip-admission.js';
68
79
  import type { MarshalledConnection } from '../serialize.js';
69
80
  import { CONNECTION_VERSION } from '../serialize.js';
70
81
  import type { TablesConfig } from '../tables.js';
@@ -125,6 +136,30 @@ export function deriveNlbConnectionId(sourceIp: string, sourcePort: string): str
125
136
  return `${NLB_CONN_PREFIX}${safeIp}-${sourcePort}`;
126
137
  }
127
138
 
139
+ /**
140
+ * Parses and validates the NLB flow headers (`x-forwarded-for` /
141
+ * `x-forwarded-port`) that identify a connection.
142
+ *
143
+ * A flow the NLB cannot identify — missing header, a non-IP
144
+ * `x-forwarded-for` (including a comma-separated proxy list; NLB sends
145
+ * exactly one client IP), or a `x-forwarded-port` that is not a plain
146
+ * decimal in `1..65535` — yields `null`. Such an invocation must be
147
+ * rejected by the caller: admitting it would map every such client onto
148
+ * one shared connection id and a single DynamoDB `Connections` row whose
149
+ * `ConnectionState` they overwrite per frame.
150
+ */
151
+ export function parseNlbFlowHeaders(
152
+ event: NlbStreamEvent,
153
+ ): { sourceIp: string; sourcePort: string } | null {
154
+ const ip = readHeader(event, 'x-forwarded-for');
155
+ if (ip === undefined || isIP(ip) === 0) return null;
156
+ const port = readHeader(event, 'x-forwarded-port');
157
+ if (port === undefined || !/^\d{1,5}$/u.test(port) || Number(port) < 1 || Number(port) > 65535) {
158
+ return null;
159
+ }
160
+ return { sourceIp: ip, sourcePort: port };
161
+ }
162
+
128
163
  // ---------------------------------------------------------------------------
129
164
  // Handler
130
165
  // ---------------------------------------------------------------------------
@@ -137,12 +172,11 @@ export interface NlbStreamParams {
137
172
  motd: MotdProvider;
138
173
  /** Chat-history persistence source (same as the wss `$default` path). */
139
174
  messages?: MessageStore;
140
- /** SASL account verification source. */
141
- accounts?: AccountStore;
142
175
  /**
143
176
  * Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
144
- * OperServ + read-marker). Flushed after each frame so a Lambda freeze
145
- * does not lose services state.
177
+ * OperServ + read-marker + the unified SASL/PASS credential home).
178
+ * Flushed after each frame so a Lambda freeze does not lose services
179
+ * state.
146
180
  */
147
181
  services?: DynamoServicesStore;
148
182
  /** mTLS identity source for SASL EXTERNAL. */
@@ -169,7 +203,9 @@ export interface NlbStreamParams {
169
203
  * Whether the NLB listener terminates TLS for this flow. Set to `true`
170
204
  * when the target group fronts a TLS listener (the `irc+tls://` port) so
171
205
  * the connection surfaces user mode `S`; omit / set `false` for a plain
172
- * TCP listener. The stack code sets this from the listener protocol.
206
+ * TCP listener. The Lambda entry ({@link dispatchNlbStream}) asserts
207
+ * `true` because the stack wires this target exclusively behind a TLS
208
+ * listener.
173
209
  */
174
210
  secure?: boolean;
175
211
  }
@@ -197,9 +233,27 @@ export async function handleNlbStream(
197
233
  ? Buffer.from(event.body, 'base64').toString('utf8')
198
234
  : event.body;
199
235
 
200
- // 2. Derive the connection id from NLB flow metadata.
201
- const sourceIp = readHeader(event, 'x-forwarded-for') ?? 'unknown';
202
- const sourcePort = readHeader(event, 'x-forwarded-port') ?? '0';
236
+ // 1a. Payload pre-check: a body decoding to more than MAX_BUFFER_BYTES
237
+ // can never be legal IRC traffic (the line limit is 512 bytes), so it is
238
+ // rejected with a 400 to the NLB integration BEFORE any row read, actor
239
+ // invocation, or state write — an oversized payload must not even reach
240
+ // the transport buffer. Separate guard from the accumulation cap below:
241
+ // this one bounds a single invocation's input.
242
+ if (byteLength(data) > MAX_BUFFER_BYTES) {
243
+ return { statusCode: 400, body: '', isBase64Encoded: false };
244
+ }
245
+
246
+ // 2. Derive the connection id from NLB flow metadata. A flow missing
247
+ // either header — or carrying a malformed IP / port — cannot be
248
+ // identified: admitting it would collapse every such client onto one
249
+ // shared Connections row (the retired `nlb-unknown-0` fallback) whose
250
+ // state they overwrite per frame. Reject with a 400 before any row
251
+ // read, actor invocation, or state write.
252
+ const flow = parseNlbFlowHeaders(event);
253
+ if (flow === null) {
254
+ return { statusCode: 400, body: '', isBase64Encoded: false };
255
+ }
256
+ const { sourceIp, sourcePort } = flow;
203
257
  const connId = deriveNlbConnectionId(sourceIp, sourcePort);
204
258
 
205
259
  // 3. Load the existing row (if any) and split the transport buffer.
@@ -207,8 +261,30 @@ export async function handleNlbStream(
207
261
  let state: ConnectionState;
208
262
  let transportBuffer: string;
209
263
  if (existing === null) {
264
+ // 3a. Per-IP new-flow rate limit. The NLB path has no `$connect`
265
+ // event — flow establishment IS this first chunk — so the
266
+ // `perIpConnectionRate` budget is enforced here, counting the
267
+ // source IP's in-window establishments against the `sourceIp` GSI
268
+ // (the same index `$connect` consults). Over-budget flows get 429
269
+ // (the NLB closes the TCP flow on a non-200 target response) and NO
270
+ // row is written. Only establishment is budgeted: every subsequent
271
+ // chunk of an established flow is also an invocation, and
272
+ // rate-limiting those would starve an active IRC session.
273
+ if (sourceIp !== 'unknown') {
274
+ const rate = params.serverConfig.perIpConnectionRate;
275
+ const ipCounts = await countConnectionsForIp(
276
+ params.dynamo,
277
+ params.tables.Connections,
278
+ sourceIp,
279
+ now,
280
+ rate.windowMs,
281
+ );
282
+ if (ipCounts.recent >= rate.max) {
283
+ return { statusCode: 429, body: '', isBase64Encoded: false };
284
+ }
285
+ }
210
286
  state = createInitialConnectionState(connId, now);
211
- if (sourceIp !== 'unknown') state.host = sourceIp;
287
+ state.host = sourceIp;
212
288
  transportBuffer = '';
213
289
  } else {
214
290
  state = unmarshalState(existing);
@@ -216,11 +292,39 @@ export async function handleNlbStream(
216
292
  }
217
293
 
218
294
  // 4. Frame the chunk through the persistent TCP transport.
219
- const transport = new TcpByteStreamTransport();
295
+ let overflowed = false;
296
+ const transport = new TcpByteStreamTransport({
297
+ onOverflow: () => {
298
+ overflowed = true;
299
+ },
300
+ });
220
301
  transport.restore(transportBuffer);
221
302
  const lines = transport.feed(data);
222
303
  const newBuffer = transport.getBuffer();
223
304
 
305
+ // 4a. Buffer-cap teardown: the retained tail (restored buffer + this
306
+ // chunk, minus complete lines) exceeded MAX_BUFFER_BYTES. The peer can
307
+ // never produce a legal line from those bytes, so the connection is
308
+ // closed: the ERROR line is streamed back as the response body and the
309
+ // Connections row is DELETED (not persisted) — the oversized buffer
310
+ // never reaches a write, and the flow's bookkeeping is dropped. A fresh
311
+ // chunk from the same 4-tuple starts a clean connection row.
312
+ if (overflowed) {
313
+ await params.dynamo.send(
314
+ new DeleteCommand({
315
+ TableName: params.tables.Connections,
316
+ Key: { connectionId: connId },
317
+ }),
318
+ );
319
+ return {
320
+ statusCode: 200,
321
+ body: Buffer.from('ERROR :Closing link: input buffer overflow\r\n', 'utf8').toString(
322
+ 'base64',
323
+ ),
324
+ isBase64Encoded: true,
325
+ };
326
+ }
327
+
224
328
  // Mark activity.
225
329
  state.lastSeen = now;
226
330
 
@@ -264,7 +368,6 @@ export async function handleNlbStream(
264
368
  // WsTextFrameTransport splits on \r\n (no-op for already-split lines).
265
369
  transport: new WsTextFrameTransport(),
266
370
  ...(params.messages !== undefined ? { messages: params.messages } : {}),
267
- ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
268
371
  ...(params.services !== undefined ? { services: params.services } : {}),
269
372
  ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
270
373
  ...(params.history !== undefined ? { history: params.history } : {}),
@@ -288,6 +391,7 @@ export async function handleNlbStream(
288
391
  state,
289
392
  now,
290
393
  newBuffer,
394
+ sourceIp !== 'unknown' ? sourceIp : undefined,
291
395
  );
292
396
 
293
397
  // Drain any services write-behind ops so a Lambda freeze / evict does
@@ -365,6 +469,14 @@ function unmarshalConnectionTyped(row: MarshalledConnection): ConnectionState {
365
469
  * UpdateCommand. The buffer is written as a plain string attribute — when
366
470
  * empty it is REMOVE'd so stale buffers from a previous chunk do not
367
471
  * linger after the line completes.
472
+ *
473
+ * `connectedSince` + `sourceIp` (when known) are stamped on every write:
474
+ * the upsert creates the row on a flow's first chunk, and both key
475
+ * attributes MUST be present for the row to surface in the `sourceIp`
476
+ * GSI the per-IP admission gates consult. For rows the update creates
477
+ * the establishment timestamp is stable (`state.connectedSince` is only
478
+ * absent on legacy rows written before this stamping shipped, where
479
+ * `now` is the best available fallback).
368
480
  */
369
481
  async function persistStateAndBuffer(
370
482
  dynamo: DynamoDBDocumentClient,
@@ -373,6 +485,7 @@ async function persistStateAndBuffer(
373
485
  state: ConnectionState,
374
486
  now: number,
375
487
  transportBuffer: string,
488
+ sourceIp?: string,
376
489
  ): Promise<void> {
377
490
  // Build the standard persist expression (same fields as handleDefault's
378
491
  // buildPersistUpdate, minus the joinedChannels which the membership
@@ -382,19 +495,27 @@ async function persistStateAndBuffer(
382
495
  ':capN': state.capNegotiating,
383
496
  ':caps': [...state.caps],
384
497
  ':um': { ...state.userModes },
498
+ ':sec': state.secure,
385
499
  ':ls': state.lastSeen,
386
500
  ':is': now,
387
501
  ':v': CONNECTION_VERSION,
502
+ ':cs': state.connectedSince ?? now,
388
503
  };
389
504
  const setNames = [
390
505
  'registration = :reg',
391
506
  'capNegotiating = :capN',
392
507
  'caps = :caps',
393
508
  'userModes = :um',
509
+ 'secure = :sec',
394
510
  'lastSeen = :ls',
395
511
  'idleSince = :is',
396
512
  'version = :v',
513
+ 'connectedSince = :cs',
397
514
  ];
515
+ if (sourceIp !== undefined) {
516
+ values[':sip'] = sourceIp;
517
+ setNames.push('sourceIp = :sip');
518
+ }
398
519
  const removeNames: string[] = [];
399
520
  const nameMap: Record<string, string> = {};
400
521
 
@@ -16,17 +16,15 @@ export { cleanupConnection } from './aws-runtime.js';
16
16
  export { TABLE_KEYS, TABLE_NAMES, tablesConfigFromNames } from './tables.js';
17
17
  export type { TableName, TablesConfig } from './tables.js';
18
18
  export { createDynamoDocumentClient } from './dynamo.js';
19
- export {
20
- DynamoAccountStore,
21
- type HashedAccountCredential,
22
- hashAccountCredential,
23
- loadDynamoAccountStore,
24
- } from './dynamo-account-store.js';
25
- export { bindAccountStore, putAccountCredential, resolveAccountStore } from './account-store.js';
26
19
  export {
27
20
  DynamoServicesStore,
28
21
  loadDynamoServicesStore,
29
22
  } from './dynamo-services-store.js';
23
+ export {
24
+ type HashedAccountCredential,
25
+ hashAccountCredential,
26
+ verifyHashedPassword,
27
+ } from '@serverless-ircd/irc-core';
30
28
  export { AwsStats } from './stats.js';
31
29
  export type { AwsStatsOptions } from './stats.js';
32
30
  export {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Per-IP connection counting against the `Connections` table's
3
+ * `sourceIp` GSI (`sourceIp` partition + `connectedSince` sort).
4
+ *
5
+ * Feeds the per-IP admission gates on both AWS transports:
6
+ * - `$connect`: simultaneous per-IP cap (`maxConnectionsPerIp`) and
7
+ * the per-IP connection-rate window (`perIpConnectionRate`).
8
+ * - NLB streaming: the rate window for new flows (flow establishment
9
+ * is the first chunk — there is no `$connect` event on that path).
10
+ *
11
+ * One paginated Query per call, projecting only `connectedSince`; the
12
+ * walk computes both counts (total + recent-inside-window) in a single
13
+ * pass. Per-IP partitions are bounded in steady state by the caps the
14
+ * gate itself enforces, so the walk is a handful of items in practice.
15
+ *
16
+ * Consistency caveat: GSI reads are eventually consistent (DynamoDB does
17
+ * not support strongly-consistent secondary-index reads), so a burst of
18
+ * concurrent connects from one IP can race past the per-IP caps — the
19
+ * same best-effort TOCTOU contract as the global connection counter.
20
+ *
21
+ * Legacy rows: items appear in the GSI only when BOTH key attributes are
22
+ * present. Rows written before this field shipped carry no `sourceIp`,
23
+ * so they are invisible to the index — the caps apply to connections
24
+ * registered from this change forward.
25
+ */
26
+
27
+ import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
28
+ import { QueryCommand } from '@aws-sdk/lib-dynamodb';
29
+ import { SOURCE_IP_INDEX_NAME } from './tables.js';
30
+
31
+ /** Per-IP counts derived from the GSI walk. */
32
+ export interface IpConnectionCounts {
33
+ /** Live connections currently attributed to the IP (GSI item count). */
34
+ readonly total: number;
35
+ /** Connections whose `connectedSince` falls inside the window. */
36
+ readonly recent: number;
37
+ }
38
+
39
+ /**
40
+ * Counts a source IP's connections: total (simultaneous) and recent
41
+ * (established within the last `windowMs`, i.e. still inside the
42
+ * sliding-window rate budget). Admissions exactly `windowMs` old still
43
+ * count (`connectedSince >= now - windowMs`), mirroring irc-core's
44
+ * `recentAdmissions` boundary.
45
+ */
46
+ export async function countConnectionsForIp(
47
+ dynamo: DynamoDBDocumentClient,
48
+ tableName: string,
49
+ sourceIp: string,
50
+ now: number,
51
+ windowMs: number,
52
+ ): Promise<IpConnectionCounts> {
53
+ const cutoff = now - windowMs;
54
+ let total = 0;
55
+ let recent = 0;
56
+ let exclusiveStartKey: Record<string, unknown> | undefined = undefined;
57
+ do {
58
+ const result = await dynamo.send(
59
+ new QueryCommand({
60
+ TableName: tableName,
61
+ IndexName: SOURCE_IP_INDEX_NAME,
62
+ KeyConditionExpression: '#ip = :ip',
63
+ ExpressionAttributeNames: { '#ip': 'sourceIp' },
64
+ ExpressionAttributeValues: { ':ip': sourceIp },
65
+ ProjectionExpression: 'connectedSince',
66
+ ...(exclusiveStartKey !== undefined ? { ExclusiveStartKey: exclusiveStartKey } : {}),
67
+ }),
68
+ );
69
+ for (const item of result.Items ?? []) {
70
+ total++;
71
+ const since = (item as { connectedSince?: unknown }).connectedSince;
72
+ if (typeof since === 'number' && since >= cutoff) {
73
+ recent++;
74
+ }
75
+ }
76
+ exclusiveStartKey = result.LastEvaluatedKey as Record<string, unknown> | undefined;
77
+ } while (exclusiveStartKey !== undefined);
78
+ return { total, recent };
79
+ }