serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -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 +177 -52
  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 +267 -92
  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/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serverless-ircd",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Serverless IRC daemon with a platform-agnostic core and Cloudflare Workers + AWS adapters",
6
6
  "license": "BSD-3-Clause",
@@ -17,7 +17,7 @@
17
17
  "typescript": "^5.9.3",
18
18
  "vite": "^7.3.6",
19
19
  "vitest": "^4.1.10",
20
- "@serverless-ircd/aws-adapter": "0.8.0"
20
+ "@serverless-ircd/aws-adapter": "0.10.0"
21
21
  },
22
22
  "scripts": {
23
23
  "build": "turbo run build",
@@ -32,13 +32,10 @@
32
32
  "mutation:commands": "pnpm --filter @serverless-ircd/irc-core mutation:commands",
33
33
  "mutation": "pnpm run mutation:protocol && pnpm run mutation:commands",
34
34
  "clean": "turbo run clean && rimraf node_modules",
35
- "deploy:cf:staging": "pnpm --filter @serverless-ircd/cf-worker deploy:staging",
36
- "deploy:cf:prod": "pnpm --filter @serverless-ircd/cf-worker deploy:prod",
37
- "smoke:cf:staging": "pnpm --filter @serverless-ircd/cf-worker smoke:staging",
38
- "deploy:cf-tcp:staging": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:staging",
39
- "deploy:cf-tcp:prod": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container deploy:prod",
40
- "deploy:aws:staging": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:staging",
41
- "deploy:aws:prod": "pnpm build && pnpm --filter @serverless-ircd/aws-stack deploy:prod",
42
- "smoke:aws:staging": "pnpm --filter @serverless-ircd/aws-stack smoke:staging"
35
+ "deploy:cf": "pnpm --filter @serverless-ircd/cf-worker run deploy",
36
+ "smoke:cf": "pnpm --filter @serverless-ircd/cf-worker run smoke",
37
+ "deploy:cf-tcp": "pnpm build && pnpm --filter @serverless-ircd/cf-tcp-container run deploy",
38
+ "deploy:aws": "pnpm build && pnpm --filter @serverless-ircd/aws-stack run deploy",
39
+ "smoke:aws": "pnpm --filter @serverless-ircd/aws-stack run smoke"
43
40
  }
44
41
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-adapter",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "description": "AWS Lambda + DynamoDB adapter: AwsRuntime implementing IrcRuntime + $connect/$disconnect/$default handlers",
6
6
  "license": "BSD-3-Clause",
@@ -459,10 +459,15 @@ export class AwsRuntime implements IrcRuntime {
459
459
  // -------------------------------------------------------------------------
460
460
 
461
461
  private async readConnectionRow(conn: ConnId): Promise<MarshalledConnection | null> {
462
+ // Strongly consistent: peer lookups (WHOIS, roster hydration) read a
463
+ // row that may have just been written by another frame. An eventually-
464
+ // consistent Get could present a stale peer snapshot under replication
465
+ // lag.
462
466
  const result = await this.dynamo.send(
463
467
  new GetCommand({
464
468
  TableName: this.tables.Connections,
465
469
  Key: { connectionId: conn },
470
+ ConsistentRead: true,
466
471
  }),
467
472
  );
468
473
  if (result.Item === undefined) return null;
@@ -723,7 +728,16 @@ export async function cleanupConnection(
723
728
  quitMessage = 'Client Quit',
724
729
  ): Promise<void> {
725
730
  const connRow = await dynamo.send(
726
- new GetCommand({ TableName: tables.Connections, Key: { connectionId: connId } }),
731
+ // Strongly consistent: $disconnect (and the send()/broadcast()
732
+ // GoneException path) can fire on the very next frame after the
733
+ // connection's own row was last written/updated. An eventually-
734
+ // consistent Get can miss that just-written row under DynamoDB
735
+ // replication lag and skip nick release + roster fanout.
736
+ new GetCommand({
737
+ TableName: tables.Connections,
738
+ Key: { connectionId: connId },
739
+ ConsistentRead: true,
740
+ }),
727
741
  );
728
742
  const item = connRow.Item as unknown as MarshalledConnection | undefined;
729
743
  if (item === undefined) return; // idempotent — already cleaned up
@@ -18,11 +18,11 @@
18
18
  * so a future `TransactWriteItems` can span Connections ↔ membership
19
19
  * atomically. (Atomicity across Connections/ChannelMembers lands in the
20
20
  * membership-transaction follow-up.)
21
- * • `Accounts` stores SASL PLAIN credentials as scrypt
22
- * `HashedAccountCredential` rows (`{ account, algorithm, salt,
23
- * hash }`, base64) never plaintext. Loaded at Lambda cold start
24
- * by `loadDynamoAccountStore` and seeded via
25
- * `putAccountCredential`.
21
+ * • SASL / NickServ credentials live in the `Services` table under the
22
+ * `NICK:<fold>` partition. The standalone `Accounts` table was dropped
23
+ * — `ServicesStore` (`registerNick` / `verifyNick` / `verifyCertFP`)
24
+ * is the single credential home for SASL PLAIN, SASL EXTERNAL,
25
+ * PASS-auth, and NickServ IDENTIFY.
26
26
  * • `Connections.idleSince` is the DynamoDB TTL attribute; idle connections
27
27
  * are reaped automatically in addition to the explicit sweeper.
28
28
  */
@@ -32,7 +32,7 @@ import type { TableProps } from 'aws-cdk-lib/aws-dynamodb';
32
32
  import type { TableName } from './tables.js';
33
33
 
34
34
  /**
35
- * All five tables keyed by their logical construct id. The id doubles as the
35
+ * All four tables keyed by their logical construct id. The id doubles as the
36
36
  * CloudFormation logical id; the physical `TableName` is set on each entry.
37
37
  *
38
38
  * NOTE: the deployed physical name is **environment-prefixed** by
@@ -65,11 +65,6 @@ export const TABLE_DEFS: Record<TableName, TableProps> = {
65
65
  partitionKey: { name: 'nickLower', type: AttributeType.STRING },
66
66
  billingMode: BillingMode.PAY_PER_REQUEST,
67
67
  },
68
- Accounts: {
69
- tableName: 'Accounts',
70
- partitionKey: { name: 'account', type: AttributeType.STRING },
71
- billingMode: BillingMode.PAY_PER_REQUEST,
72
- },
73
68
  Services: {
74
69
  tableName: 'Services',
75
70
  partitionKey: { name: 'pk', type: AttributeType.STRING },
@@ -53,10 +53,27 @@ export interface LambdaConfigEnv {
53
53
  /**
54
54
  * SASL PLAIN accounts. Newline-delimited `username:password` pairs
55
55
  * (same convention as the CF adapter's `SASL_ACCOUNTS`). Parsed into
56
- * the `saslAccounts` config field so {@link bindAccountStore} can seed
57
- * an `InMemoryAccountStore` at boot.
56
+ * the `saslAccounts` config field so the boot path can seed the
57
+ * `ServicesStore` via `registerNick`.
58
58
  */
59
59
  SASL_ACCOUNTS?: string;
60
+ /**
61
+ * Comma-separated allowlist of web origins permitted to open
62
+ * WebSocket upgrades (Cross-Site WebSocket Hijacking defence —
63
+ * browsers send `Origin` on every cross-origin upgrade; WebSocket
64
+ * upgrades do not otherwise enforce same-origin, and API Gateway
65
+ * WebSocket has no built-in Origin check). Mirrors the CF Worker's
66
+ * `WEB_ORIGINS` env.
67
+ *
68
+ * Only read by the `$connect` handler at
69
+ * {@link buildDepsFromEnv} — it is NOT threaded through the shared
70
+ * `ServerConfig` schema (it is an adapter-level operational concern,
71
+ * not a server-config knob). When unset/empty, the defence is
72
+ * disabled and every Origin proceeds (the default — opt-in).
73
+ * Non-browser clients (curl, WeeChat, the `tcp-ws-forwarder`) never
74
+ * send `Origin` and always pass through regardless.
75
+ */
76
+ WEB_ORIGINS?: string;
60
77
  }
61
78
 
62
79
  /**
@@ -198,6 +198,7 @@ export class DynamoServicesStore extends PersistentServicesStore {
198
198
  email: row.email,
199
199
  createdAt: row.createdAt,
200
200
  enforce: row.enforce,
201
+ certSubjects: row.certSubjects ?? [],
201
202
  algorithm: row.credential.algorithm,
202
203
  salt: row.credential.salt,
203
204
  hash: row.credential.hash,
@@ -536,12 +537,18 @@ function coerceNick(item: Record<string, unknown>): ServicesNickRow | undefined
536
537
  ) {
537
538
  return undefined;
538
539
  }
540
+ // certSubjects is additive — older rows written before the column existed
541
+ // load as []. Accepts a JSON-array or a string[]; anything else → [].
542
+ const certSubjects = Array.isArray(item.certSubjects)
543
+ ? item.certSubjects.filter((s): s is string => typeof s === 'string')
544
+ : [];
539
545
  return {
540
546
  nick: item.nick,
541
547
  account: item.account,
542
548
  email: item.email,
543
549
  createdAt: item.createdAt,
544
550
  enforce: item.enforce as NickEnforcePolicy,
551
+ certSubjects,
545
552
  credential: {
546
553
  account: item.account,
547
554
  algorithm: 'scrypt',
@@ -24,6 +24,7 @@ import {
24
24
  } from '@serverless-ircd/irc-core';
25
25
  import { type AdmissionOutcome, decideConnectAdmission } from '../admission.js';
26
26
  import { decrementConnectionCount, incrementConnectionCount } from '../connection-counter.js';
27
+ import { decideConnectOrigin } from '../origin-allowlist.js';
27
28
  import { marshalConnection } from '../serialize.js';
28
29
  import type { TablesConfig } from '../tables.js';
29
30
  import { createInitialConnectionState } from './state.js';
@@ -44,6 +45,22 @@ export interface ConnectParams {
44
45
  * none match or the header is absent).
45
46
  */
46
47
  secWebSocketProtocol?: string | null;
48
+ /**
49
+ * Raw `Origin` header from `event.headers.Origin` (case-insensitive).
50
+ * When {@link ConnectParams.webOrigins} is non-empty and this is a
51
+ * browser-sent origin that is not on the allowlist, the upgrade is
52
+ * rejected with `403` (Cross-Site WebSocket Hijacking defence —
53
+ * API Gateway has no built-in Origin check). `null` / `undefined`
54
+ * (non-browser clients: curl, WeeChat, the `tcp-ws-forwarder`)
55
+ * always proceed; the defence is opt-in (only fires when the
56
+ * allowlist is non-empty).
57
+ */
58
+ originHeader?: string | null | undefined;
59
+ /**
60
+ * Parsed `WEB_ORIGINS` allowlist. Empty set (or `undefined`) disables
61
+ * the Origin check (the default — defence is opt-in).
62
+ */
63
+ webOrigins?: Set<string> | undefined;
47
64
  }
48
65
 
49
66
  /**
@@ -83,6 +100,15 @@ export async function handleConnect(params: ConnectParams): Promise<ConnectOutco
83
100
  const now = params.now ?? Date.now();
84
101
  const maxClients = params.serverConfig.maxClients;
85
102
 
103
+ // CSWSH defence — Origin allowlist. Runs before the admission counter
104
+ // so a hostile-origin flood cannot consume `maxClients` slots. Only
105
+ // fires when `WEB_ORIGINS` is set (defence is opt-in); a missing
106
+ // `Origin` header (non-browser clients) always proceeds.
107
+ const originDecision = decideConnectOrigin(params.originHeader, params.webOrigins ?? new Set());
108
+ if (originDecision === 'deny') {
109
+ return { admitted: false, statusCode: 403, reason: 'origin not allowed' };
110
+ }
111
+
86
112
  // Reserve a slot atomically. The returned value includes this reservation,
87
113
  // so the pre-existing count (`total`, as expected by the policy) is one
88
114
  // less than the post-increment value.
@@ -12,7 +12,6 @@ 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,
@@ -63,19 +62,14 @@ export interface DefaultParams {
63
62
  * behaviour of callers that have not opted in.
64
63
  */
65
64
  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
65
  /**
73
66
  * 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.
67
+ * OperServ + read-marker + the unified SASL/PASS credential home). When
68
+ * omitted the actor runs with `ctx.services` undefined so
69
+ * `PRIVMSG NickServ` / ChanServ routing is disabled and every SASL/PASS
70
+ * credential verify fails preserves the behaviour of callers that
71
+ * have not opted in. On success the handler flushes pending
72
+ * write-behind ops after the actor run.
79
73
  */
80
74
  services?: DynamoServicesStore;
81
75
  /**
@@ -128,113 +122,144 @@ export interface DefaultParams {
128
122
  export async function handleDefault(params: DefaultParams): Promise<{ statusCode: number }> {
129
123
  const clock = params.clock ?? SystemClock;
130
124
  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
-
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
125
 
164
- // Build the runtime + handlers.
126
+ // Optimistic-concurrency retry loop. API Gateway dispatches a client's
127
+ // rapid frames (CAP END, NICK, USER; or two PRIVMSG) as CONCURRENT
128
+ // Lambda invocations that each read-modify-write the same Connections
129
+ // row. Each attempt:
130
+ // 1. Strongly-consistent read (closes the $connect→$default window).
131
+ // 2. Run the actor over the frame against the fresh state.
132
+ // 3. Conditional write requiring `revision = :expected`; on conflict
133
+ // (another invocation wrote first) re-read and re-run.
134
+ // Membership writes (TransactWriteItems + ADD/DELETE on a String Set)
135
+ // are idempotent by design, so a retried frame never doubles roster
136
+ // state. NOTE: peer `postToConnection` calls issued inside the actor
137
+ // run MAY double-deliver on the rare retry; registration (the deadlock
138
+ // this fixes) has no peer sends, and a duplicated PRIVMSG on a rare
139
+ // retry is strictly better than the total state-loss this loop fixes.
140
+ // Full peer-send buffering is a documented follow-up.
141
+ const MAX_RETRIES = 8;
165
142
  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
- });
192
-
193
- const channelAccess = new LambdaChannelAccess(runtime, clock.now());
143
+ // Captured from the committed attempt so the post-loop outbound flush
144
+ // knows the connection's frame mode (spec vs legacy line framing).
145
+ let wsModeHolder: WsFrameMode = 'legacy';
194
146
 
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({
147
+ /** Builds a fresh actor for `state` and runs this invocation's frame. */
148
+ const runFrame = async (state: ConnectionState, wsMode: WsFrameMode): Promise<void> => {
149
+ const handlers: AwsRuntimeHandlers = {
150
+ send: (lines) => {
151
+ // Self-send: APIGW echoes back to the caller's open socket via the
152
+ // management API in production; in tests we collect for assertions.
153
+ for (const l of lines) outbound.push(l.text);
154
+ },
155
+ disconnect: () => {
156
+ // No-op: a Lambda `$default` invocation cannot close its own APIGW
157
+ // socket. The canonical teardown is the `$disconnect` route (which
158
+ // fires when APIGW observes the close) plus the sweeper/ping-checker
159
+ // for connections that vanish without one. The actor's state
160
+ // mutations (nick release, roster deltas) are persisted below before
161
+ // this invocation returns, so the eventual `$disconnect` completes
162
+ // the fanout. See docs/AWS-Deployment.md §14.1.
163
+ },
164
+ snapshot: () => state,
165
+ };
166
+ const runtime = new AwsRuntime({
210
167
  dynamo: params.dynamo,
211
168
  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 {
169
+ connId: params.connectionId,
170
+ handlers,
171
+ managementApi: params.managementApi,
172
+ clock,
173
+ ...(params.configLoader !== undefined ? { configLoader: params.configLoader } : {}),
174
+ });
175
+ const channelAccess = new LambdaChannelAccess(runtime, clock.now());
176
+ const actor = new ConnectionActor({
177
+ state,
178
+ runtime,
179
+ channels: channelAccess,
180
+ serverConfig: params.serverConfig,
181
+ configSource: 'Secrets Manager',
182
+ clock,
183
+ ids,
184
+ motd: params.motd,
185
+ transport: { feed: (chunk) => frameToLines(chunk, wsMode) },
186
+ // AwsStats Scans Connections + ChannelMeta for LUSERS/STATS.
187
+ // `uptimeStartedAt` is captured at the Lambda's cold start so `STATS u`
188
+ // reports per-isolate uptime; a deployment-wide uptime would require a
189
+ // persistent record (future work).
190
+ stats: new AwsStats({
191
+ dynamo: params.dynamo,
192
+ tables: params.tables,
193
+ uptimeStartedAt: LAMBDA_STARTUP_AT,
194
+ }),
195
+ ...(params.messages !== undefined ? { messages: params.messages } : {}),
196
+ ...(params.services !== undefined ? { services: params.services } : {}),
197
+ ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
198
+ ...(params.history !== undefined ? { history: params.history } : {}),
199
+ // API Gateway terminates TLS before the Lambda is invoked, so every
200
+ // WebSocket connection is secure → user mode `S`.
201
+ secure: true,
202
+ });
225
203
  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 };
204
+ };
205
+
206
+ for (let attempt = 0; ; attempt++) {
207
+ // 1. Strongly-consistent read.
208
+ const result = await params.dynamo.send(
209
+ new GetCommand({
210
+ TableName: params.tables.Connections,
211
+ Key: { connectionId: params.connectionId },
212
+ ConsistentRead: true,
213
+ }),
214
+ );
215
+ if (result.Item === undefined) {
216
+ // Row gone (client reconnected elsewhere, swept, or stale connId).
217
+ // Not retriable: surface the 410 so the client reconnects.
218
+ console.warn(
219
+ `[irc-handler] $default connId=${params.connectionId} wsMode=? row=missing -> 410`,
220
+ );
221
+ return { statusCode: 410 };
222
+ }
223
+ const persisted = result.Item as unknown as MarshalledConnection;
224
+ const state = unmarshalConnection(persisted);
225
+ const expectedRevision = persisted.revision ?? 0;
226
+ const wsMode: WsFrameMode = persisted.wsMode ?? 'legacy';
227
+ wsModeHolder = wsMode;
228
+
229
+ // 2. Mark activity + run the actor. Self-sends reset per attempt.
230
+ state.lastSeen = clock.now();
231
+ outbound.length = 0;
232
+ try {
233
+ await runFrame(state, wsMode);
234
+ } catch (err: unknown) {
235
+ console.error('[irc-handler] actor.receiveTextFrame failed', err);
236
+ // Best-effort persist of partial state (swallow a conflict here —
237
+ // the actor already failed, no point retrying).
238
+ await persistState(params.dynamo, params.tables, state, clock.now(), expectedRevision).catch(
239
+ () => {},
240
+ );
241
+ await params.services?.flush();
242
+ return { statusCode: 500 };
243
+ }
244
+
245
+ // 3. Conditional persist.
246
+ try {
247
+ await persistState(params.dynamo, params.tables, state, clock.now(), expectedRevision);
248
+ break; // committed — exit the retry loop.
249
+ } catch (err: unknown) {
250
+ if (isConditionalCheckFailed(err) && attempt < MAX_RETRIES) {
251
+ // Another invocation wrote first; re-read + re-run against fresh state.
252
+ continue;
253
+ }
254
+ // Exhausted retries or an unrelated DynamoDB error.
255
+ console.error('[irc-handler] persist failed (retries exhausted or unrecoverable)', err);
256
+ await params.services?.flush();
257
+ return { statusCode: 500 };
258
+ }
232
259
  }
233
260
 
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.
261
+ // Persist committed. Drain any services write-behind ops (NickServ/ChanServ/...)
262
+ // so a Lambda freeze / evict does not lose state.
238
263
  await params.services?.flush();
239
264
 
240
265
  // In production, deliver outbound bytes back to the caller via APIGW.
@@ -243,7 +268,7 @@ export async function handleDefault(params: DefaultParams): Promise<{ statusCode
243
268
  // receive every line CRLF-joined in a single message.
244
269
  if (params.managementApi !== null && outbound.length > 0) {
245
270
  try {
246
- await postOutbound(params.managementApi, params.connectionId, outbound, wsMode);
271
+ await postOutbound(params.managementApi, params.connectionId, outbound, wsModeHolder);
247
272
  } catch (err) {
248
273
  // Surface the failure (gone connection, stale MANAGEMENT_URL, …)
249
274
  // so it shows in CloudWatch instead of vanishing. The handler still
@@ -280,28 +305,34 @@ async function postOutbound(
280
305
  /**
281
306
  * Writes the actor's mutated `state` back to the Connections row.
282
307
  *
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.
308
+ * Uses a conditional `UpdateCommand` that SETs every actor-owned field
309
+ * EXCEPT `joinedChannels`, bumps the `revision` counter, and requires
310
+ * the row's `revision` to still equal `expectedRevision` (the value read
311
+ * at the start of this invocation). A `ConditionalCheckFailedException`
312
+ * means another concurrent `$default` for the same connection wrote in
313
+ * between; {@link handleDefault} catches it and retries the whole
314
+ * read run persist cycle against the fresh row. The membership
315
+ * transaction (see `AwsRuntime`) owns `joinedChannels` atomically and
316
+ * is idempotent (`ADD`/`DELETE` on a String Set), so a retried frame
317
+ * never doubles roster state. Optional fields (nick, user, …) are SET
318
+ * when present and REMOVE'd when absent so a cleared value does not
319
+ * linger from a previous frame.
290
320
  */
291
321
  async function persistState(
292
322
  dynamo: DynamoDBDocumentClient,
293
323
  tables: TablesConfig,
294
- _connId: string,
295
324
  state: ConnectionState,
296
325
  now: number,
326
+ expectedRevision: number,
297
327
  ): Promise<void> {
298
- const expr = buildPersistUpdate(state, now);
328
+ const expr = buildPersistUpdate(state, now, expectedRevision);
299
329
  const hasNames = Object.keys(expr.expressionAttributeNames).length > 0;
300
330
  await dynamo.send(
301
331
  new UpdateCommand({
302
332
  TableName: tables.Connections,
303
333
  Key: { connectionId: state.id },
304
334
  UpdateExpression: expr.updateExpression,
335
+ ConditionExpression: expr.conditionExpression,
305
336
  ExpressionAttributeValues: expr.expressionAttributeValues,
306
337
  ...(hasNames ? { ExpressionAttributeNames: expr.expressionAttributeNames } : {}),
307
338
  }),
@@ -326,12 +357,27 @@ const OPTIONAL_FIELDS = [
326
357
  * the required fields; SETs optional fields that are present and
327
358
  * REMOVEs those that are absent. `joinedChannels` is intentionally
328
359
  * excluded — owned by the membership transaction.
360
+ *
361
+ * **Optimistic concurrency:** the expression also `ADD`s `1` to the
362
+ * `revision` counter and attaches a `ConditionExpression` requiring
363
+ * `revision = :expectedRev` (or `attribute_not_exists(#rev)` for rows
364
+ * written before the field shipped). {@link handleDefault} captures the
365
+ * row's revision at read time and passes it as `expectedRevision`; a
366
+ * `ConditionalCheckFailedException` means another concurrent invocation
367
+ * for the same connection wrote in between → the caller re-reads and
368
+ * re-runs the frame (see {@link handleDefault}'s retry loop). Without
369
+ * this guard, two concurrent `$default` invocations (API Gateway
370
+ * dispatches a client's rapid frames as overlapping Lambdas) clobber
371
+ * each other's state — the registration deadlock where `CAP END`'s
372
+ * `capNegotiating=false` was overwritten by `NICK`/`USER`'s stale read.
329
373
  */
330
- function buildPersistUpdate(
374
+ export function buildPersistUpdate(
331
375
  state: ConnectionState,
332
376
  now: number,
377
+ expectedRevision: number,
333
378
  ): {
334
379
  updateExpression: string;
380
+ conditionExpression: string;
335
381
  expressionAttributeNames: Record<string, string>;
336
382
  expressionAttributeValues: Record<string, unknown>;
337
383
  } {
@@ -343,6 +389,10 @@ function buildPersistUpdate(
343
389
  ':ls': state.lastSeen,
344
390
  ':is': now,
345
391
  ':v': CONNECTION_VERSION,
392
+ // Optimistic-concurrency: increment the revision counter, gated by
393
+ // a condition that the row is still at the revision we read.
394
+ ':revInc': 1,
395
+ ':expectedRev': expectedRevision,
346
396
  };
347
397
  const setNames = [
348
398
  'registration = :reg',
@@ -354,7 +404,7 @@ function buildPersistUpdate(
354
404
  'version = :v',
355
405
  ];
356
406
  const removeNames: string[] = [];
357
- const nameMap: Record<string, string> = {};
407
+ const nameMap: Record<string, string> = { '#rev': 'revision' };
358
408
 
359
409
  for (const field of OPTIONAL_FIELDS) {
360
410
  const placeholder = `:${field}`;
@@ -373,13 +423,30 @@ function buildPersistUpdate(
373
423
  if (removeNames.length > 0) {
374
424
  parts.push(`REMOVE ${removeNames.join(', ')}`);
375
425
  }
426
+ // Bump the optimistic-concurrency counter atomically with the state write.
427
+ parts.push('ADD #rev :revInc');
376
428
  return {
377
429
  updateExpression: parts.join(' '),
430
+ // `attribute_not_exists(#rev)` lets the first write after deploy land on
431
+ // pre-existing rows that predate the column (treats them as revision 0).
432
+ conditionExpression: 'attribute_not_exists(#rev) OR #rev = :expectedRev',
378
433
  expressionAttributeNames: nameMap,
379
434
  expressionAttributeValues: values,
380
435
  };
381
436
  }
382
437
 
438
+ /**
439
+ * Returns true when a DynamoDB error is a conditional-check failure
440
+ * (the row's `revision` no longer matches the value the invocation
441
+ * read). Such failures are retried by {@link handleDefault}'s
442
+ * optimistic-concurrency loop after re-reading and re-running the frame.
443
+ */
444
+ export function isConditionalCheckFailed(err: unknown): boolean {
445
+ if (err === null || typeof err !== 'object') return false;
446
+ const name = (err as { name?: string }).name;
447
+ return name === 'ConditionalCheckFailedException';
448
+ }
449
+
383
450
  /**
384
451
  * `ActorChannelAccess` backed by the per-invocation {@link AwsRuntime}.
385
452
  *