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
@@ -12,10 +12,10 @@ import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanageme
12
12
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
13
13
  import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
14
14
  import {
15
- type AccountStore,
16
15
  type ChannelState,
17
16
  type Clock,
18
17
  type ConnectionState,
18
+ FRAME_FLOOD_ERROR_LINE,
19
19
  type IdFactory,
20
20
  type MessageStore,
21
21
  type MotdProvider,
@@ -26,6 +26,7 @@ import {
26
26
  SystemClock,
27
27
  UuidIdFactory,
28
28
  type WsFrameMode,
29
+ admitFrameWindow,
29
30
  createConnection,
30
31
  frameToLines,
31
32
  } from '@serverless-ircd/irc-core';
@@ -63,19 +64,14 @@ export interface DefaultParams {
63
64
  * behaviour of callers that have not opted in.
64
65
  */
65
66
  messages?: MessageStore;
66
- /**
67
- * SASL account verification source. When omitted the actor runs with
68
- * `ctx.accounts` undefined so `AUTHENTICATE PLAIN` fails with `904`
69
- * — preserves the behaviour of callers that have not opted in.
70
- */
71
- accounts?: AccountStore;
72
67
  /**
73
68
  * Persistent services store (NickServ / ChanServ / HostServ / MemoServ /
74
- * OperServ + read-marker). When omitted the actor runs with
75
- * `ctx.services` undefined so `PRIVMSG NickServ` / ChanServ routing is
76
- * disabled preserves the behaviour of callers that have not opted in.
77
- * On success the handler flushes pending write-behind ops after the
78
- * actor run.
69
+ * OperServ + read-marker + the unified SASL/PASS credential home). When
70
+ * omitted the actor runs with `ctx.services` undefined so
71
+ * `PRIVMSG NickServ` / ChanServ routing is disabled and every SASL/PASS
72
+ * credential verify fails preserves the behaviour of callers that
73
+ * have not opted in. On success the handler flushes pending
74
+ * write-behind ops after the actor run.
79
75
  */
80
76
  services?: DynamoServicesStore;
81
77
  /**
@@ -128,113 +124,194 @@ export interface DefaultParams {
128
124
  export async function handleDefault(params: DefaultParams): Promise<{ statusCode: number }> {
129
125
  const clock = params.clock ?? SystemClock;
130
126
  const ids = params.ids ?? new UuidIdFactory();
131
- // Load persisted state. Strongly consistent: $connect writes this row and
132
- // $default reads it on the very next frame — an eventually-consistent Get
133
- // can miss the just-written row (DynamoDB replication lag) and silently
134
- // drop the client's first frame as a 410. Strong consistency closes that
135
- // read-after-write window.
136
- const result = await params.dynamo.send(
137
- new GetCommand({
138
- TableName: params.tables.Connections,
139
- Key: { connectionId: params.connectionId },
140
- ConsistentRead: true,
141
- }),
142
- );
143
- if (result.Item === undefined) {
144
- // Previously this branch returned silently, making a missing-row
145
- // $default invisible to operators (client sees nothing, logs show
146
- // nothing). Warn so the 410 path is diagnosable.
147
- console.warn(
148
- `[irc-handler] $default connId=${params.connectionId} wsMode=? row=missing -> 410`,
149
- );
150
- return { statusCode: 410 };
151
- }
152
- const persisted = result.Item as unknown as MarshalledConnection;
153
- const state = unmarshalConnection(persisted);
154
127
 
155
- // Recover the IRCv3 WebSocket frame mode persisted at `$connect`.
156
- // Absent = legacy (no subprotocol negotiated). Drives both the inbound
157
- // line-framing transport (spec: one IRC message per frame, never split)
158
- // and the outbound delivery shape (spec: one postToConnection per line).
159
- const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
160
-
161
- // Mark this frame's activity.
162
- state.lastSeen = clock.now();
163
-
164
- // Build the runtime + handlers.
128
+ // Optimistic-concurrency retry loop. API Gateway dispatches a client's
129
+ // rapid frames (CAP END, NICK, USER; or two PRIVMSG) as CONCURRENT
130
+ // Lambda invocations that each read-modify-write the same Connections
131
+ // row. Each attempt:
132
+ // 1. Strongly-consistent read (closes the $connect→$default window).
133
+ // 2. Run the actor over the frame against the fresh state.
134
+ // 3. Conditional write requiring `revision = :expected`; on conflict
135
+ // (another invocation wrote first) re-read and re-run.
136
+ // Membership writes (TransactWriteItems + ADD/DELETE on a String Set)
137
+ // are idempotent by design, so a retried frame never doubles roster
138
+ // state. NOTE: peer `postToConnection` calls issued inside the actor
139
+ // run MAY double-deliver on the rare retry; registration (the deadlock
140
+ // this fixes) has no peer sends, and a duplicated PRIVMSG on a rare
141
+ // retry is strictly better than the total state-loss this loop fixes.
142
+ // Full peer-send buffering is a documented follow-up.
143
+ const MAX_RETRIES = 8;
165
144
  const outbound: string[] = [];
166
- const handlers: AwsRuntimeHandlers = {
167
- send: (lines) => {
168
- // Self-send: APIGW echoes back to the caller's open socket via the
169
- // management API in production; in tests we collect for assertions.
170
- for (const l of lines) outbound.push(l.text);
171
- },
172
- disconnect: () => {
173
- // No-op: a Lambda `$default` invocation cannot close its own APIGW
174
- // socket. The canonical teardown is the `$disconnect` route (which
175
- // fires when APIGW observes the close) plus the sweeper/ping-checker
176
- // for connections that vanish without one. The actor's state
177
- // mutations (nick release, roster deltas) are persisted below before
178
- // this invocation returns, so the eventual `$disconnect` completes
179
- // the fanout. See docs/AWS-Deployment.md §14.1.
180
- },
181
- snapshot: () => state,
182
- };
183
- const runtime = new AwsRuntime({
184
- dynamo: params.dynamo,
185
- tables: params.tables,
186
- connId: params.connectionId,
187
- handlers,
188
- managementApi: params.managementApi,
189
- clock,
190
- ...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
191
- });
145
+ // Captured from the committed attempt so the post-loop outbound flush
146
+ // knows the connection's frame mode (spec vs legacy line framing).
147
+ let wsModeHolder: WsFrameMode = 'legacy';
192
148
 
193
- const channelAccess = new LambdaChannelAccess(runtime, clock.now());
194
-
195
- const actor = new ConnectionActor({
196
- state,
197
- runtime,
198
- channels: channelAccess,
199
- serverConfig: params.serverConfig,
200
- configSource: 'Secrets Manager',
201
- clock,
202
- ids,
203
- motd: params.motd,
204
- transport: { feed: (chunk) => frameToLines(chunk, wsMode) },
205
- // AwsStats Scans Connections + ChannelMeta for LUSERS/STATS.
206
- // `uptimeStartedAt` is captured at the Lambda's cold start so `STATS u`
207
- // reports per-isolate uptime; a deployment-wide uptime would require a
208
- // persistent record (future work).
209
- stats: new AwsStats({
149
+ /** Builds a fresh actor for `state` and runs this invocation's frame. */
150
+ const runFrame = async (state: ConnectionState, wsMode: WsFrameMode): Promise<void> => {
151
+ const handlers: AwsRuntimeHandlers = {
152
+ send: (lines) => {
153
+ // Self-send: APIGW echoes back to the caller's open socket via the
154
+ // management API in production; in tests we collect for assertions.
155
+ for (const l of lines) outbound.push(l.text);
156
+ },
157
+ disconnect: () => {
158
+ // No-op: a Lambda `$default` invocation cannot close its own APIGW
159
+ // socket. The canonical teardown is the `$disconnect` route (which
160
+ // fires when APIGW observes the close) plus the sweeper/ping-checker
161
+ // for connections that vanish without one. The actor's state
162
+ // mutations (nick release, roster deltas) are persisted below before
163
+ // this invocation returns, so the eventual `$disconnect` completes
164
+ // the fanout. See docs/AWS-Deployment.md §14.1.
165
+ },
166
+ snapshot: () => state,
167
+ };
168
+ const runtime = new AwsRuntime({
210
169
  dynamo: params.dynamo,
211
170
  tables: params.tables,
212
- uptimeStartedAt: LAMBDA_STARTUP_AT,
213
- }),
214
- ...(params.messages !== undefined ? { messages: params.messages } : {}),
215
- ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
216
- ...(params.services !== undefined ? { services: params.services } : {}),
217
- ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
218
- ...(params.history !== undefined ? { history: params.history } : {}),
219
- // API Gateway terminates TLS before the Lambda is invoked, so every
220
- // WebSocket connection is secure → user mode `S`.
221
- secure: true,
222
- });
223
-
224
- try {
171
+ connId: params.connectionId,
172
+ handlers,
173
+ managementApi: params.managementApi,
174
+ clock,
175
+ ...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
176
+ });
177
+ const channelAccess = new LambdaChannelAccess(runtime, clock.now());
178
+ const actor = new ConnectionActor({
179
+ state,
180
+ runtime,
181
+ channels: channelAccess,
182
+ serverConfig: params.serverConfig,
183
+ configSource: 'Secrets Manager',
184
+ clock,
185
+ ids,
186
+ motd: params.motd,
187
+ transport: { feed: (chunk) => frameToLines(chunk, wsMode) },
188
+ // AwsStats Scans Connections + ChannelMeta for LUSERS/STATS.
189
+ // `uptimeStartedAt` is captured at the Lambda's cold start so `STATS u`
190
+ // reports per-isolate uptime; a deployment-wide uptime would require a
191
+ // persistent record (future work).
192
+ stats: new AwsStats({
193
+ dynamo: params.dynamo,
194
+ tables: params.tables,
195
+ uptimeStartedAt: LAMBDA_STARTUP_AT,
196
+ }),
197
+ ...(params.messages !== undefined ? { messages: params.messages } : {}),
198
+ ...(params.services !== undefined ? { services: params.services } : {}),
199
+ ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
200
+ ...(params.history !== undefined ? { history: params.history } : {}),
201
+ // API Gateway terminates TLS before the Lambda is invoked, so every
202
+ // WebSocket connection is secure → user mode `S`.
203
+ secure: true,
204
+ });
225
205
  await actor.receiveTextFrame(params.body);
226
- } catch (err: unknown) {
227
- console.error('[irc-handler] actor.receiveTextFrame failed', err);
228
- // Still persist the state partial mutations are valuable for debug.
229
- await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
230
- await params.services?.flush();
231
- return { statusCode: 500 };
206
+ };
207
+
208
+ for (let attempt = 0; ; attempt++) {
209
+ // 1. Strongly-consistent read.
210
+ const result = await params.dynamo.send(
211
+ new GetCommand({
212
+ TableName: params.tables.Connections,
213
+ Key: { connectionId: params.connectionId },
214
+ ConsistentRead: true,
215
+ }),
216
+ );
217
+ if (result.Item === undefined) {
218
+ // Row gone (client reconnected elsewhere, swept, or stale connId).
219
+ // Not retriable: surface the 410 so the client reconnects.
220
+ console.warn(
221
+ `[irc-handler] $default connId=${params.connectionId} wsMode=? row=missing -> 410`,
222
+ );
223
+ return { statusCode: 410 };
224
+ }
225
+ const persisted = result.Item as unknown as MarshalledConnection & { frameTimes?: unknown };
226
+ const state = unmarshalConnection(persisted);
227
+ const expectedRevision = persisted.revision ?? 0;
228
+ const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
229
+ wsModeHolder = wsMode;
230
+
231
+ // 1a. Adapter-boundary frame-rate gate. Each frame is a billed read +
232
+ // write (and the actor run); a hostile client must not be able to
233
+ // drive that cost unboundedly with low-cost commands. The sliding
234
+ // window persists on the row (admitted frames persist it atomically
235
+ // with the state write below), so the limit holds across
236
+ // invocations. An over-limit frame never reaches the actor: the
237
+ // canonical ERROR line is delivered, the socket is closed via
238
+ // DeleteConnection (which fires the $disconnect cleanup), and the
239
+ // invocation returns without a write.
240
+ const frameDecision = admitFrameWindow(
241
+ readFrameTimes(persisted),
242
+ clock.now(),
243
+ params.serverConfig.adapter.maxFramesPerWindow,
244
+ params.serverConfig.adapter.frameWindowSeconds * 1000,
245
+ );
246
+ if (!frameDecision.admitted) {
247
+ console.warn(
248
+ `[irc-handler] $default connId=${params.connectionId} frame limit exceeded ` +
249
+ `(${params.serverConfig.adapter.maxFramesPerWindow}/${params.serverConfig.adapter.frameWindowSeconds}s) -> closing`,
250
+ );
251
+ if (params.managementApi !== null) {
252
+ try {
253
+ await postOutbound(
254
+ params.managementApi,
255
+ params.connectionId,
256
+ [FRAME_FLOOD_ERROR_LINE],
257
+ wsMode,
258
+ );
259
+ await closeConnectionIfSupported(params.managementApi, params.connectionId);
260
+ } catch (err) {
261
+ // The socket may already be gone (close raced a concurrent
262
+ // over-limit frame) — surface for observability, nothing to retry.
263
+ console.error('[irc-handler] flood close post failed', err);
264
+ }
265
+ }
266
+ return { statusCode: 200 };
267
+ }
268
+
269
+ // 2. Mark activity + run the actor. Self-sends reset per attempt.
270
+ state.lastSeen = clock.now();
271
+ outbound.length = 0;
272
+ try {
273
+ await runFrame(state, wsMode);
274
+ } catch (err: unknown) {
275
+ console.error('[irc-handler] actor.receiveTextFrame failed', err);
276
+ // Best-effort persist of partial state (swallow a conflict here —
277
+ // the actor already failed, no point retrying).
278
+ await persistState(
279
+ params.dynamo,
280
+ params.tables,
281
+ state,
282
+ clock.now(),
283
+ expectedRevision,
284
+ frameDecision.times,
285
+ ).catch(() => {});
286
+ await params.services?.flush();
287
+ return { statusCode: 500 };
288
+ }
289
+
290
+ // 3. Conditional persist.
291
+ try {
292
+ await persistState(
293
+ params.dynamo,
294
+ params.tables,
295
+ state,
296
+ clock.now(),
297
+ expectedRevision,
298
+ frameDecision.times,
299
+ );
300
+ break; // committed — exit the retry loop.
301
+ } catch (err: unknown) {
302
+ if (isConditionalCheckFailed(err) && attempt < MAX_RETRIES) {
303
+ // Another invocation wrote first; re-read + re-run against fresh state.
304
+ continue;
305
+ }
306
+ // Exhausted retries or an unrelated DynamoDB error.
307
+ console.error('[irc-handler] persist failed (retries exhausted or unrecoverable)', err);
308
+ await params.services?.flush();
309
+ return { statusCode: 500 };
310
+ }
232
311
  }
233
312
 
234
- // Persist the mutated state and flush outbound bytes.
235
- await persistState(params.dynamo, params.tables, params.connectionId, state, clock.now());
236
- // Drain any services write-behind ops (NickServ/ChanServ/...) so a
237
- // Lambda freeze / evict does not lose state.
313
+ // Persist committed. Drain any services write-behind ops (NickServ/ChanServ/...)
314
+ // so a Lambda freeze / evict does not lose state.
238
315
  await params.services?.flush();
239
316
 
240
317
  // In production, deliver outbound bytes back to the caller via APIGW.
@@ -243,7 +320,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
243
320
  // receive every line CRLF-joined in a single message.
244
321
  if (params.managementApi !== null && outbound.length > 0) {
245
322
  try {
246
- await postOutbound(params.managementApi, params.connectionId, outbound, wsMode);
323
+ await postOutbound(params.managementApi, params.connectionId, outbound, wsModeHolder);
247
324
  } catch (err) {
248
325
  // Surface the failure (gone connection, stale MANAGEMENT_URL, …)
249
326
  // so it shows in CloudWatch instead of vanishing. The handler still
@@ -255,6 +332,35 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
255
332
  return { statusCode: 200 };
256
333
  }
257
334
 
335
+ /**
336
+ * Reads the persisted inbound frame-window timestamps off a Connections
337
+ * row. Rows written before the frame gate shipped (or by paths that do
338
+ * not track frames, e.g. NLB chunks) carry no attribute — an empty
339
+ * window starts the connection fresh.
340
+ */
341
+ function readFrameTimes(row: MarshalledConnection & { frameTimes?: unknown }): number[] {
342
+ const raw = row.frameTimes;
343
+ if (!Array.isArray(raw)) return [];
344
+ return raw as number[];
345
+ }
346
+
347
+ /**
348
+ * Closes the client's APIGW WebSocket via the management API
349
+ * (`DeleteConnection` — API Gateway observes the close and fires the
350
+ * `$disconnect` route, which runs the canonical QUIT cleanup). The
351
+ * `PostToConnection` test-double arm has no delete method; the close is
352
+ * skipped there (the ERROR post alone still informs the client).
353
+ */
354
+ async function closeConnectionIfSupported(
355
+ api: ApiGatewayManagementApi | PostToConnection,
356
+ connectionId: string,
357
+ ): Promise<void> {
358
+ const deleter = api as Partial<Pick<ApiGatewayManagementApi, 'deleteConnection'>>;
359
+ if (typeof deleter.deleteConnection === 'function') {
360
+ await deleter.deleteConnection({ ConnectionId: connectionId });
361
+ }
362
+ }
363
+
258
364
  /**
259
365
  * Delivers `lines` to `connectionId` via APIGW according to `mode`:
260
366
  * - `legacy` — all lines joined with `\r\n` in a single `postToConnection`.
@@ -280,28 +386,35 @@ async function postOutbound(
280
386
  /**
281
387
  * Writes the actor's mutated `state` back to the Connections row.
282
388
  *
283
- * Uses an `UpdateCommand` that SETs every actor-owned field EXCEPT
284
- * `joinedChannels`. The membership transaction (see `AwsRuntime`)
285
- * owns `joinedChannels` atomically; if this frame's full-state write
286
- * touched it, a concurrent `$default` for the same connection would
287
- * lose the other frame's JOIN/PART. Optional fields (nick, user, …)
288
- * are SET when present and REMOVE'd when absent so a cleared value
289
- * does not linger from a previous frame.
389
+ * Uses a conditional `UpdateCommand` that SETs every actor-owned field
390
+ * EXCEPT `joinedChannels`, bumps the `revision` counter, and requires
391
+ * the row's `revision` to still equal `expectedRevision` (the value read
392
+ * at the start of this invocation). A `ConditionalCheckFailedException`
393
+ * means another concurrent `$default` for the same connection wrote in
394
+ * between; {@link handleDefault} catches it and retries the whole
395
+ * read run persist cycle against the fresh row. The membership
396
+ * transaction (see `AwsRuntime`) owns `joinedChannels` atomically and
397
+ * is idempotent (`ADD`/`DELETE` on a String Set), so a retried frame
398
+ * never doubles roster state. Optional fields (nick, user, …) are SET
399
+ * when present and REMOVE'd when absent so a cleared value does not
400
+ * linger from a previous frame.
290
401
  */
291
402
  async function persistState(
292
403
  dynamo: DynamoDBDocumentClient,
293
404
  tables: TablesConfig,
294
- _connId: string,
295
405
  state: ConnectionState,
296
406
  now: number,
407
+ expectedRevision: number,
408
+ frameTimes: readonly number[],
297
409
  ): Promise<void> {
298
- const expr = buildPersistUpdate(state, now);
410
+ const expr = buildPersistUpdate(state, now, expectedRevision, frameTimes);
299
411
  const hasNames = Object.keys(expr.expressionAttributeNames).length > 0;
300
412
  await dynamo.send(
301
413
  new UpdateCommand({
302
414
  TableName: tables.Connections,
303
415
  Key: { connectionId: state.id },
304
416
  UpdateExpression: expr.updateExpression,
417
+ ConditionExpression: expr.conditionExpression,
305
418
  ExpressionAttributeValues: expr.expressionAttributeValues,
306
419
  ...(hasNames ? { ExpressionAttributeNames: expr.expressionAttributeNames } : {}),
307
420
  }),
@@ -326,12 +439,28 @@ const OPTIONAL_FIELDS = [
326
439
  * the required fields; SETs optional fields that are present and
327
440
  * REMOVEs those that are absent. `joinedChannels` is intentionally
328
441
  * excluded — owned by the membership transaction.
442
+ *
443
+ * **Optimistic concurrency:** the expression also `ADD`s `1` to the
444
+ * `revision` counter and attaches a `ConditionExpression` requiring
445
+ * `revision = :expectedRev` (or `attribute_not_exists(#rev)` for rows
446
+ * written before the field shipped). {@link handleDefault} captures the
447
+ * row's revision at read time and passes it as `expectedRevision`; a
448
+ * `ConditionalCheckFailedException` means another concurrent invocation
449
+ * for the same connection wrote in between → the caller re-reads and
450
+ * re-runs the frame (see {@link handleDefault}'s retry loop). Without
451
+ * this guard, two concurrent `$default` invocations (API Gateway
452
+ * dispatches a client's rapid frames as overlapping Lambdas) clobber
453
+ * each other's state — the registration deadlock where `CAP END`'s
454
+ * `capNegotiating=false` was overwritten by `NICK`/`USER`'s stale read.
329
455
  */
330
- function buildPersistUpdate(
456
+ export function buildPersistUpdate(
331
457
  state: ConnectionState,
332
458
  now: number,
459
+ expectedRevision: number,
460
+ frameTimes: readonly number[],
333
461
  ): {
334
462
  updateExpression: string;
463
+ conditionExpression: string;
335
464
  expressionAttributeNames: Record<string, string>;
336
465
  expressionAttributeValues: Record<string, unknown>;
337
466
  } {
@@ -343,6 +472,15 @@ function buildPersistUpdate(
343
472
  ':ls': state.lastSeen,
344
473
  ':is': now,
345
474
  ':v': CONNECTION_VERSION,
475
+ // Sliding-window timestamps for the adapter-boundary frame limit.
476
+ // Committed atomically with the state write (same revision
477
+ // condition), so each frame is counted exactly once even when
478
+ // concurrent invocations race through the retry loop.
479
+ ':ft': [...frameTimes],
480
+ // Optimistic-concurrency: increment the revision counter, gated by
481
+ // a condition that the row is still at the revision we read.
482
+ ':revInc': 1,
483
+ ':expectedRev': expectedRevision,
346
484
  };
347
485
  const setNames = [
348
486
  'registration = :reg',
@@ -352,9 +490,10 @@ function buildPersistUpdate(
352
490
  'lastSeen = :ls',
353
491
  'idleSince = :is',
354
492
  'version = :v',
493
+ 'frameTimes = :ft',
355
494
  ];
356
495
  const removeNames: string[] = [];
357
- const nameMap: Record<string, string> = {};
496
+ const nameMap: Record<string, string> = { '#rev': 'revision' };
358
497
 
359
498
  for (const field of OPTIONAL_FIELDS) {
360
499
  const placeholder = `:${field}`;
@@ -373,13 +512,30 @@ function buildPersistUpdate(
373
512
  if (removeNames.length > 0) {
374
513
  parts.push(`REMOVE ${removeNames.join(', ')}`);
375
514
  }
515
+ // Bump the optimistic-concurrency counter atomically with the state write.
516
+ parts.push('ADD #rev :revInc');
376
517
  return {
377
518
  updateExpression: parts.join(' '),
519
+ // `attribute_not_exists(#rev)` lets the first write after deploy land on
520
+ // pre-existing rows that predate the column (treats them as revision 0).
521
+ conditionExpression: 'attribute_not_exists(#rev) OR #rev = :expectedRev',
378
522
  expressionAttributeNames: nameMap,
379
523
  expressionAttributeValues: values,
380
524
  };
381
525
  }
382
526
 
527
+ /**
528
+ * Returns true when a DynamoDB error is a conditional-check failure
529
+ * (the row's `revision` no longer matches the value the invocation
530
+ * read). Such failures are retried by {@link handleDefault}'s
531
+ * optimistic-concurrency loop after re-reading and re-running the frame.
532
+ */
533
+ export function isConditionalCheckFailed(err: unknown): boolean {
534
+ if (err === null || typeof err !== 'object') return false;
535
+ const name = (err as { name?: string }).name;
536
+ return name === 'ConditionalCheckFailedException';
537
+ }
538
+
383
539
  /**
384
540
  * `ActorChannelAccess` backed by the per-invocation {@link AwsRuntime}.
385
541
  *