serverless-ircd 0.9.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 (134) hide show
  1. package/.github/workflows/deploy-aws.yml +156 -32
  2. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  3. package/.github/workflows/deploy-cf.yml +14 -14
  4. package/CHANGELOG.md +305 -0
  5. package/README.md +134 -41
  6. package/apps/aws-stack/README.md +3 -5
  7. package/apps/aws-stack/bin/aws.ts +82 -9
  8. package/apps/aws-stack/cdk.json +0 -3
  9. package/apps/aws-stack/package.json +3 -4
  10. package/apps/aws-stack/src/aws-stack.ts +177 -52
  11. package/apps/aws-stack/src/static-site.ts +323 -0
  12. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  13. package/apps/aws-stack/tests/stack.test.ts +267 -92
  14. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  15. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  16. package/apps/cf-tcp-container/package.json +2 -3
  17. package/apps/cf-tcp-container/src/container-server.ts +12 -9
  18. package/apps/cf-tcp-container/wrangler.toml +1 -10
  19. package/apps/cf-worker/package.json +3 -4
  20. package/apps/cf-worker/wrangler.toml +12 -71
  21. package/apps/local-cli/package.json +1 -1
  22. package/apps/local-cli/src/server.ts +21 -17
  23. package/apps/web/landing/favicon.ico +0 -0
  24. package/apps/web/landing/index.html +1 -0
  25. package/apps/web/package.json +2 -2
  26. package/apps/web/scripts/build.mjs +66 -4
  27. package/apps/web/src/build-env.ts +125 -4
  28. package/apps/web/src/config-schema.ts +20 -6
  29. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  30. package/apps/web/tests/build-env.test.ts +210 -9
  31. package/apps/web/tests/build-smoke.test.ts +2 -2
  32. package/apps/web/tests/config-schema.test.ts +149 -25
  33. package/docs/AWS-Deployment.md +670 -96
  34. package/docs/AWS-TCP-Deployment.md +20 -45
  35. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  36. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  37. package/docs/Release-Process.md +27 -23
  38. package/docs/Services.md +69 -22
  39. package/docs/WebClientGuide.md +35 -26
  40. package/package.json +7 -10
  41. package/packages/aws-adapter/package.json +1 -1
  42. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  43. package/packages/aws-adapter/src/config-loader.ts +19 -2
  44. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  45. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  46. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  47. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +3 -6
  49. package/packages/aws-adapter/src/index.ts +5 -7
  50. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  51. package/packages/aws-adapter/src/serialize.ts +15 -0
  52. package/packages/aws-adapter/src/tables.ts +2 -12
  53. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  54. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  55. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  56. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  57. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  58. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  59. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  60. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  62. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  63. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  64. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  65. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  66. package/packages/cf-adapter/package.json +1 -5
  67. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  68. package/packages/cf-adapter/src/channel-do.ts +13 -3
  69. package/packages/cf-adapter/src/connection-do.ts +266 -109
  70. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  71. package/packages/cf-adapter/src/env.ts +11 -10
  72. package/packages/cf-adapter/src/index.ts +0 -6
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  74. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  75. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +95 -54
  77. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  78. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  79. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  80. package/packages/in-memory-runtime/package.json +1 -1
  81. package/packages/irc-core/package.json +1 -1
  82. package/packages/irc-core/src/account-migration.ts +140 -0
  83. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  84. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  85. package/packages/irc-core/src/commands/nickserv.ts +122 -11
  86. package/packages/irc-core/src/commands/registration.ts +2 -2
  87. package/packages/irc-core/src/commands/sasl.ts +22 -31
  88. package/packages/irc-core/src/config.ts +35 -9
  89. package/packages/irc-core/src/credential-hashing.ts +11 -54
  90. package/packages/irc-core/src/index.ts +1 -0
  91. package/packages/irc-core/src/ports.ts +159 -179
  92. package/packages/irc-core/src/types.ts +38 -10
  93. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  94. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  95. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  96. package/packages/irc-core/tests/commands/nickserv.test.ts +237 -1
  97. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  98. package/packages/irc-core/tests/commands/registration.test.ts +140 -133
  99. package/packages/irc-core/tests/commands/sasl.test.ts +159 -178
  100. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  101. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  102. package/packages/irc-core/tests/message-store.test.ts +5 -0
  103. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  104. package/packages/irc-core/tests/ports.test.ts +71 -0
  105. package/packages/irc-core/tests/services-store.test.ts +204 -0
  106. package/packages/irc-core/vitest.config.ts +6 -1
  107. package/packages/irc-server/package.json +1 -1
  108. package/packages/irc-server/src/actor.ts +0 -14
  109. package/packages/irc-server/tests/actor.test.ts +19 -47
  110. package/packages/irc-test-support/package.json +1 -1
  111. package/pnpm-workspace.yaml +1 -0
  112. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  113. package/scripts/deploy-web-aws.mjs +290 -0
  114. package/scripts/package.json +23 -0
  115. package/scripts/tsconfig.test.json +12 -0
  116. package/scripts/vitest.config.ts +19 -0
  117. package/tools/ci-hardening/package.json +1 -1
  118. package/tools/ci-hardening/src/index.ts +2 -0
  119. package/tools/ci-hardening/src/validate.ts +57 -0
  120. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  121. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  122. package/tools/load-test/package.json +1 -1
  123. package/tools/migrate-accounts-to-services.ts +270 -0
  124. package/tools/package.json +2 -1
  125. package/tools/seed-aws-accounts.ts +35 -10
  126. package/tools/seed-cf-accounts.ts +42 -9
  127. package/tools/tcp-ws-forwarder/package.json +1 -1
  128. package/packages/aws-adapter/src/account-store.ts +0 -121
  129. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  130. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  131. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  132. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  133. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  134. 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
  /**
@@ -196,7 +200,12 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
196
200
  tables: deps.tables,
197
201
  connectionId: connId,
198
202
  serverConfig: deps.serverConfig,
199
- secWebSocketProtocol: event.headers?.['Sec-WebSocket-Protocol'] ?? null,
203
+ secWebSocketProtocol: readHeader(event.headers, 'Sec-WebSocket-Protocol'),
204
+ // APIGW normalises header keys to lowercase, but readHeader
205
+ // tolerates either casing so a future proxy / stage mutation
206
+ // cannot silently bypass the CSWSH defence.
207
+ originHeader: readHeader(event.headers, 'Origin'),
208
+ webOrigins: deps.webOrigins,
200
209
  });
201
210
  if (outcome.admitted) {
202
211
  if (outcome.subprotocol !== null) {
@@ -233,7 +242,6 @@ export async function dispatch(event: WebSocketEvent, deps: HandlerDeps): Promis
233
242
  serverConfig: deps.serverConfig,
234
243
  motd: deps.motd,
235
244
  messages: deps.messages,
236
- ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
237
245
  ...(deps.services !== undefined ? { services: deps.services } : {}),
238
246
  ...(deps.history !== undefined ? { history: deps.history } : {}),
239
247
  ...(deps.configLoader !== undefined ? { configLoader: deps.configLoader } : {}),
@@ -341,7 +349,6 @@ export async function nlbStreamHandler(event: NlbStreamEvent): Promise<NlbStream
341
349
  motd: deps.motd,
342
350
  messages: deps.messages,
343
351
  managementApi: deps.managementApi,
344
- ...(deps.accounts !== undefined ? { accounts: deps.accounts } : {}),
345
352
  ...(deps.services !== undefined ? { services: deps.services } : {}),
346
353
  ...(deps.history !== undefined ? { history: deps.history } : {}),
347
354
  });
@@ -359,10 +366,16 @@ export {
359
366
  * Builds a {@link HandlerDeps} from the current `process.env`.
360
367
  *
361
368
  * 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`.
369
+ * the env parsing. Async because it scans the `Services` DynamoDB table
370
+ * (via {@link loadDynamoServicesStore}) to pre-load the services snapshot
371
+ * at cold start — the {@link ServicesStore} port is synchronous, so the
372
+ * scan must complete before any reducer calls `verifyNick` /
373
+ * `verifyCertFP`.
374
+ *
375
+ * After the services scan, the `SASL_ACCOUNTS` env-var seed is ingested
376
+ * into the services store via `registerNick` (idempotent — existing
377
+ * registrations, including NickServ-registered nicks, win over the env
378
+ * seed).
366
379
  */
367
380
  export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<HandlerDeps> {
368
381
  const cfg = loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
@@ -378,13 +391,24 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
378
391
  const motd = cfg.motdLines.length > 0 ? new StaticMotdProvider(cfg.motdLines) : EmptyMotdProvider;
379
392
  const messages = bindMessageStore(cfg);
380
393
  const history = new InMemoryNickHistoryStore(SystemClock);
381
- const accounts = await resolveAccountStore(dynamo, tables.Accounts, cfg);
382
394
  const servicesTable = typeof env.SERVICES_TABLE === 'string' ? env.SERVICES_TABLE : undefined;
383
395
  const services = await loadDynamoServicesStore(dynamo, servicesTable, SystemClock);
396
+ if (services !== undefined) {
397
+ for (const acct of cfg.saslAccounts) {
398
+ services.registerNick(acct.username, acct.password, '');
399
+ }
400
+ }
384
401
  // REHASH reload source: re-read the live env so a rotated oper password
385
402
  // or MOTD takes effect on the next REHASH without a redeploy.
386
403
  const configLoader = async (): Promise<ServerConfig> =>
387
404
  loadServerConfigFromLambdaEnv(env as LambdaConfigEnv);
405
+ // CSWSH defence — parsed once per cold start so every `$connect`
406
+ // reuses the same set. Empty set (the default when `WEB_ORIGINS` is
407
+ // unset) disables the check; non-empty denies browser-sent origins
408
+ // that are not in the set. Non-browser clients never send `Origin`.
409
+ const webOrigins = parseWebOrigins(
410
+ typeof env.WEB_ORIGINS === 'string' ? env.WEB_ORIGINS : undefined,
411
+ );
388
412
  return {
389
413
  dynamo,
390
414
  tables,
@@ -394,7 +418,7 @@ export async function buildDepsFromEnv(env: NodeJS.ProcessEnv = process.env): Pr
394
418
  messages,
395
419
  history,
396
420
  configLoader,
397
- ...(accounts !== undefined ? { accounts } : {}),
421
+ ...(webOrigins.size > 0 ? { webOrigins } : {}),
398
422
  ...(services !== undefined ? { services } : {}),
399
423
  };
400
424
  }
@@ -411,3 +435,23 @@ function tablesConfigFromEnv(env: NodeJS.ProcessEnv): TablesConfig {
411
435
  }
412
436
  return out;
413
437
  }
438
+
439
+ /**
440
+ * Case-insensitive header lookup against an APIGW `event.headers` record.
441
+ *
442
+ * API Gateway WebSocket normalises header keys to lowercase, but HTTP
443
+ * proxies in front of it (ALB, CloudFront, custom domains) can preserve
444
+ * the original case. A case-sensitive lookup would silently miss the
445
+ * header under one of those routes — for the `Origin` CSWSH check that
446
+ * is a security hole, so this helper walks both casings. Returns `null`
447
+ * when absent so callers can pass it straight to handlers that
448
+ * distinguish "header absent" from "header empty".
449
+ */
450
+ function readHeader(headers: Record<string, string> | undefined, name: string): string | null {
451
+ if (headers === undefined) return null;
452
+ const direct = headers[name];
453
+ if (typeof direct === 'string') return direct;
454
+ const lower = headers[name.toLowerCase()];
455
+ if (typeof lower === 'string') return lower;
456
+ return null;
457
+ }
@@ -44,7 +44,6 @@ import type { ApiGatewayManagementApi } from '@aws-sdk/client-apigatewaymanageme
44
44
  import { GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
45
45
  import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
46
46
  import {
47
- type AccountStore,
48
47
  type ChannelState,
49
48
  type Clock,
50
49
  type ConnectionState,
@@ -137,12 +136,11 @@ export interface NlbStreamParams {
137
136
  motd: MotdProvider;
138
137
  /** Chat-history persistence source (same as the wss `$default` path). */
139
138
  messages?: MessageStore;
140
- /** SASL account verification source. */
141
- accounts?: AccountStore;
142
139
  /**
143
140
  * 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.
141
+ * OperServ + read-marker + the unified SASL/PASS credential home).
142
+ * Flushed after each frame so a Lambda freeze does not lose services
143
+ * state.
146
144
  */
147
145
  services?: DynamoServicesStore;
148
146
  /** mTLS identity source for SASL EXTERNAL. */
@@ -264,7 +262,6 @@ export async function handleNlbStream(
264
262
  // WsTextFrameTransport splits on \r\n (no-op for already-split lines).
265
263
  transport: new WsTextFrameTransport(),
266
264
  ...(params.messages !== undefined ? { messages: params.messages } : {}),
267
- ...(params.accounts !== undefined ? { accounts: params.accounts } : {}),
268
265
  ...(params.services !== undefined ? { services: params.services } : {}),
269
266
  ...(params.mtlsIdentity !== undefined ? { mtlsIdentity: params.mtlsIdentity } : {}),
270
267
  ...(params.history !== undefined ? { history: params.history } : {}),
@@ -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,94 @@
1
+ /**
2
+ * WebSocket `Origin` policy for the AWS adapter's `$connect` Lambda —
3
+ * Cross-Site WebSocket Hijacking (CSWSH) defence.
4
+ *
5
+ * Browsers send an `Origin` header on every cross-origin WebSocket
6
+ * upgrade (RFC 6454); WebSocket upgrades do not otherwise enforce
7
+ * same-origin. Without a check, a malicious page can open a WebSocket
8
+ * to the IRC server from the victim's browser and drive the session
9
+ * with the victim's credentials. API Gateway WebSocket has no
10
+ * built-in `Origin` validation — the `$connect` Lambda receives
11
+ * `event.headers.Origin` and must enforce the allowlist itself.
12
+ *
13
+ * This module mirrors the canonical Cloudflare Worker policy
14
+ * (`apps/cf-worker/src/origin-allowlist.ts`) for the parsing
15
+ * semantics (comma-separated, trimmed, lowercased) and the
16
+ * "missing `Origin` = non-browser → allow" branch.
17
+ *
18
+ * **Deliberate divergence from the CF policy:** the AWS adapter
19
+ * supports ONLY the explicit-allowlist mode. The CF Worker's Mode 2
20
+ * (auto-derive the expected origin from the request's own `Host`
21
+ * header — same-origin enforcement) does not apply on AWS, because
22
+ * the SPA is served from a different CloudFront origin than the API
23
+ * Gateway WebSocket endpoint (`wss://{api-id}.execute-api.…`), so
24
+ * the request's own host is never the SPA's origin. The CSWSH
25
+ * defence is therefore opt-in: it only fires when `WEB_ORIGINS` is
26
+ * set. Unset → no Origin policy (existing AWS deployments without a
27
+ * web frontend are unchanged on upgrade).
28
+ */
29
+
30
+ /**
31
+ * Outcome of evaluating a `$connect` upgrade's `Origin` header.
32
+ *
33
+ * • `'allow'` — proceed: the origin is allowlisted, or the
34
+ * allowlist is empty (defence disabled), or the
35
+ * header is absent (non-browser client).
36
+ * • `'deny'` — reject (`403`): a browser sent an `Origin` that
37
+ * is not in the configured allowlist.
38
+ */
39
+ export type ConnectOriginDecision = 'allow' | 'deny';
40
+
41
+ /**
42
+ * Parse the comma-separated `WEB_ORIGINS` env var into a normalised
43
+ * set of lowercased origins. Whitespace-only and empty entries are
44
+ * dropped so trailing commas / stray spaces do not pollute the set.
45
+ * An empty/undefined var yields an empty set, which
46
+ * {@link decideConnectOrigin} treats as "defence disabled" (NOT
47
+ * deny-all).
48
+ *
49
+ * Origins are lowercased wholesale for case-insensitive matching;
50
+ * the scheme and host are case-insensitive per RFC 3986, and
51
+ * lowercasing the whole string is the simplest normalisation that
52
+ * handles the real-world browser-emitted form
53
+ * (`https://Example.com` → `https://example.com`). Port numbers —
54
+ * the only case-sensitive trailing component per the URI spec — are
55
+ * always digits in practice, so lowercasing is a no-op on them.
56
+ */
57
+ export function parseWebOrigins(raw: string | undefined): Set<string> {
58
+ return new Set(
59
+ (raw ?? '')
60
+ .split(',')
61
+ .map((entry) => entry.trim().toLowerCase())
62
+ .filter((entry) => entry.length > 0),
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Decide whether a `$connect` upgrade's `Origin` header is permitted.
68
+ *
69
+ * @param originHeader the raw `Origin` header value from
70
+ * `event.headers.Origin` (`null` / `undefined` /
71
+ * `''` when absent — non-browser clients).
72
+ * @param allowlist the parsed {@link parseWebOrigins} set. When
73
+ * empty, the defence is disabled and every
74
+ * upgrade is allowed regardless of its `Origin`.
75
+ * @returns `'allow'` to proceed with the upgrade, or `'deny'` to
76
+ * return `403` so API Gateway closes the connection.
77
+ */
78
+ export function decideConnectOrigin(
79
+ originHeader: string | null | undefined,
80
+ allowlist: Set<string>,
81
+ ): ConnectOriginDecision {
82
+ // Missing Origin = non-browser client (curl, WeeChat, tcp-ws-forwarder).
83
+ if (originHeader === null || originHeader === undefined || originHeader === '') {
84
+ return 'allow';
85
+ }
86
+
87
+ // Defence disabled — WEB_ORIGINS unset/empty. Every Origin proceeds.
88
+ if (allowlist.size === 0) {
89
+ return 'allow';
90
+ }
91
+
92
+ const origin = originHeader.trim().toLowerCase();
93
+ return allowlist.has(origin) ? 'allow' : 'deny';
94
+ }
@@ -84,6 +84,20 @@ export interface MarshalledConnection {
84
84
  away?: string;
85
85
  saslMech?: string;
86
86
  saslBuffer?: string;
87
+ /**
88
+ * Optimistic-concurrency revision counter. Incremented on every
89
+ * `$default`/NLB state write (conditional `UpdateItem` with
90
+ * `ConditionExpression: revision = :expected`) so two concurrent
91
+ * Lambda invocations for the same connection cannot silently
92
+ * clobber each other's state (the registration deadlock where
93
+ * `CAP END`'s `capNegotiating=false` got overwritten by a later
94
+ * frame's stale read). Absent on rows written before this field
95
+ * shipped; the persist path treats a missing attribute as revision
96
+ * `0` via an `attribute_not_exists(#rev) OR #rev = :expected`
97
+ * condition, so the migration is seamless. Seeded to `0` by
98
+ * {@link marshalConnection} at `$connect`.
99
+ */
100
+ revision?: number;
87
101
  /**
88
102
  * Negotiated IRCv3 WebSocket frame mode persisted at `$connect` time.
89
103
  * Absent (`undefined`) on legacy connections (no subprotocol agreed) so
@@ -116,6 +130,7 @@ export function marshalConnection(state: ConnectionState, idleSince: number): Ma
116
130
  connectedSince: state.connectedSince,
117
131
  idleSince,
118
132
  version: CONNECTION_VERSION,
133
+ revision: 0,
119
134
  };
120
135
  if (state.joinedChannels.size > 0) {
121
136
  // Copy so later mutations to `state` don't alias the stored value.
@@ -14,13 +14,7 @@
14
14
  */
15
15
 
16
16
  /** Logical table names — also used as CDK construct ids. */
17
- export type TableName =
18
- | 'Connections'
19
- | 'ChannelMeta'
20
- | 'ChannelMembers'
21
- | 'Nicks'
22
- | 'Accounts'
23
- | 'Services';
17
+ export type TableName = 'Connections' | 'ChannelMeta' | 'ChannelMembers' | 'Nicks' | 'Services';
24
18
 
25
19
  /** Logical table names in stable order. */
26
20
  export const TABLE_NAMES: readonly TableName[] = [
@@ -28,7 +22,6 @@ export const TABLE_NAMES: readonly TableName[] = [
28
22
  'ChannelMeta',
29
23
  'ChannelMembers',
30
24
  'Nicks',
31
- 'Accounts',
32
25
  'Services',
33
26
  ];
34
27
 
@@ -47,7 +40,6 @@ export const TABLE_KEYS = {
47
40
  ChannelMeta: { pk: 'channelName' },
48
41
  ChannelMembers: { pk: 'channelName', sk: 'connectionId' },
49
42
  Nicks: { pk: 'nickLower' },
50
- Accounts: { pk: 'account' },
51
43
  } as const;
52
44
 
53
45
  /**
@@ -58,14 +50,13 @@ export const TABLE_KEYS = {
58
50
  * `Services` is optional: the services-persistence table is provisioned by
59
51
  * {@link TABLE_DEFS} but the runtime reads `SERVICES_TABLE` directly and
60
52
  * tolerates an unset var (services unbound). A `TablesConfig` built from
61
- * the required-five env vars still type-checks.
53
+ * the required-four env vars still type-checks.
62
54
  */
63
55
  export type TablesConfig = {
64
56
  Connections: string;
65
57
  ChannelMeta: string;
66
58
  ChannelMembers: string;
67
59
  Nicks: string;
68
- Accounts: string;
69
60
  Services?: string;
70
61
  };
71
62
 
@@ -75,7 +66,6 @@ export function tablesConfigFromNames(names: {
75
66
  ChannelMeta: string;
76
67
  ChannelMembers: string;
77
68
  Nicks: string;
78
- Accounts: string;
79
69
  Services?: string;
80
70
  }): TablesConfig {
81
71
  const out: TablesConfig = { ...names };
@@ -97,7 +97,6 @@ export class AwsHarness implements IrcHarness {
97
97
  ChannelMeta: `${this.prefix}ChannelMeta`,
98
98
  ChannelMembers: `${this.prefix}ChannelMembers`,
99
99
  Nicks: `${this.prefix}Nicks`,
100
- Accounts: `${this.prefix}Accounts`,
101
100
  };
102
101
  this.client = createDynamoDocumentClient({ endpoint });
103
102
  }
@@ -155,6 +155,72 @@ describe('buildLambdaConfigInput', () => {
155
155
  const out = buildLambdaConfigInput({ SASL_ACCOUNTS: ' alice:secret ' });
156
156
  expect(out.saslAccounts).toEqual([{ username: 'alice', password: 'secret' }]);
157
157
  });
158
+
159
+ it('does NOT thread WEB_ORIGINS into the ServerConfig input', () => {
160
+ // WEB_ORIGINS is an adapter-level operational concern (consumed by
161
+ // `$connect`'s CSWSH defence), NOT a server-config knob. Routing
162
+ // it through the shared Zod schema would either be silently
163
+ // stripped (the schema is non-strict) or pollute the canonical
164
+ // `ServerConfig` type with an AWS-only field. The buildLambdaConfigInput
165
+ // function therefore accepts the typed env field but never emits
166
+ // it — `buildDepsFromEnv` parses it directly via `parseWebOrigins`.
167
+ const out = buildLambdaConfigInput({ WEB_ORIGINS: 'https://app.example.com' });
168
+ expect('webOrigins' in out).toBe(false);
169
+ expect('WEB_ORIGINS' in out).toBe(false);
170
+ });
171
+
172
+ it('types WEB_ORIGINS as an optional string field on LambdaConfigEnv', () => {
173
+ // Compile-time assertion: the env interface accepts the field.
174
+ const env: LambdaConfigEnv = { WEB_ORIGINS: 'https://a.example.com,https://b.example.com' };
175
+ expect(env.WEB_ORIGINS).toBe('https://a.example.com,https://b.example.com');
176
+ });
177
+ });
178
+
179
+ describe('buildDepsFromEnv — WEB_ORIGINS wiring', () => {
180
+ // Build a minimal env shape that satisfies `tablesConfigFromEnv` and the
181
+ // shared config schema, pointing DynamoDB at an unreachable endpoint so
182
+ // `resolveAccountStore` / `loadDynamoServicesStore` fall through to
183
+ // defaults rather than scanning a live table. We are only asserting the
184
+ // env→deps wiring for `WEB_ORIGINS`.
185
+ function makeBaseEnv(): NodeJS.ProcessEnv {
186
+ return {
187
+ SERVER_NAME: 'irc.test',
188
+ NETWORK_NAME: 'TestNet',
189
+ CONNECTIONS_TABLE: 'Connections',
190
+ CHANNELMETA_TABLE: 'ChannelMeta',
191
+ CHANNELMEMBERS_TABLE: 'ChannelMembers',
192
+ NICKS_TABLE: 'Nicks',
193
+ ACCOUNTS_TABLE: 'Accounts',
194
+ DYNAMO_ENDPOINT: 'http://127.0.0.1:1',
195
+ };
196
+ }
197
+
198
+ it('parses WEB_ORIGINS into a Set on the returned deps', async () => {
199
+ // The env→deps wiring is the production path: `WEB_ORIGINS` is read
200
+ // once at cold start, parsed via `parseWebOrigins`, and stored on
201
+ // `HandlerDeps.webOrigins` so every `$connect` invocation reuses the
202
+ // same set.
203
+ const { buildDepsFromEnv } = await import('../src/handlers/index.js');
204
+ const deps = await buildDepsFromEnv({
205
+ ...makeBaseEnv(),
206
+ WEB_ORIGINS: 'https://app.example.com, https://staging.app.example.com',
207
+ });
208
+ expect(deps.webOrigins).toEqual(
209
+ new Set(['https://app.example.com', 'https://staging.app.example.com']),
210
+ );
211
+ });
212
+
213
+ it('omits webOrigins from deps when WEB_ORIGINS is unset', async () => {
214
+ const { buildDepsFromEnv } = await import('../src/handlers/index.js');
215
+ const deps = await buildDepsFromEnv(makeBaseEnv());
216
+ expect(deps.webOrigins).toBeUndefined();
217
+ });
218
+
219
+ it('omits webOrigins from deps when WEB_ORIGINS is an empty string', async () => {
220
+ const { buildDepsFromEnv } = await import('../src/handlers/index.js');
221
+ const deps = await buildDepsFromEnv({ ...makeBaseEnv(), WEB_ORIGINS: '' });
222
+ expect(deps.webOrigins).toBeUndefined();
223
+ });
158
224
  });
159
225
 
160
226
  describe('loadServerConfigFromLambdaEnv', () => {
@@ -17,7 +17,6 @@ const TABLES: TablesConfig = {
17
17
  ChannelMeta: 'ChannelMeta',
18
18
  ChannelMembers: 'ChannelMembers',
19
19
  Nicks: 'Nicks',
20
- Accounts: 'Accounts',
21
20
  };
22
21
 
23
22
  interface Stub {
@@ -298,3 +297,127 @@ describe('handleConnect — IRCv3 WebSocket subprotocol negotiation', () => {
298
297
  expect(stub.puts).toBe(0);
299
298
  });
300
299
  });
300
+
301
+ describe('handleConnect — Origin allowlist (CSWSH defence)', () => {
302
+ // The same lowercase / trim / comma-split semantics as the CF Worker's
303
+ // policy (`apps/cf-worker/src/origin-allowlist.ts`); the parsed set is
304
+ // built once per cold start and injected via `ConnectParams.webOrigins`.
305
+ const allowlist = new Set(['https://app.example.com']);
306
+
307
+ it('denies a disallowed Origin with 403 before touching the counter', async () => {
308
+ // A denied upgrade must NOT consume an admission slot — the Origin
309
+ // check runs before the counter increment so a CSWSH flood from a
310
+ // hostile origin cannot exhaust the `maxClients` cap.
311
+ const stub = makeStub(0);
312
+ const outcome = await handleConnect({
313
+ dynamo: stub.dynamo,
314
+ tables: TABLES,
315
+ connectionId: 'c-cswsh',
316
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
317
+ originHeader: 'https://attacker.example.com',
318
+ webOrigins: allowlist,
319
+ });
320
+ expect(outcome).toEqual({
321
+ admitted: false,
322
+ statusCode: 403,
323
+ reason: expect.stringMatching(/origin/iu),
324
+ });
325
+ expect(stub.puts).toBe(0);
326
+ expect(stub.increments).toBe(0);
327
+ expect(stub.decrements).toBe(0);
328
+ });
329
+
330
+ it('admits an allowed Origin (existing connect path)', async () => {
331
+ const stub = makeStub(0);
332
+ const outcome = await handleConnect({
333
+ dynamo: stub.dynamo,
334
+ tables: TABLES,
335
+ connectionId: 'c-ok',
336
+ now: 42,
337
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
338
+ originHeader: 'https://app.example.com',
339
+ webOrigins: allowlist,
340
+ });
341
+ expect(outcome).toEqual({ admitted: true, subprotocol: null });
342
+ expect(stub.puts).toBe(1);
343
+ });
344
+
345
+ it('case-insensitively matches a mixed-case allowed Origin', async () => {
346
+ const stub = makeStub(0);
347
+ const outcome = await handleConnect({
348
+ dynamo: stub.dynamo,
349
+ tables: TABLES,
350
+ connectionId: 'c-case',
351
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
352
+ originHeader: 'https://APP.Example.COM',
353
+ webOrigins: allowlist,
354
+ });
355
+ expect(outcome.admitted).toBe(true);
356
+ });
357
+
358
+ it('admits a missing Origin (non-browser client: curl, WeeChat, tcp-ws-forwarder)', async () => {
359
+ // The defence only fires when `Origin` is present; non-browser
360
+ // tooling never sends it and must pass through unchanged.
361
+ const stub = makeStub(0);
362
+ const outcome = await handleConnect({
363
+ dynamo: stub.dynamo,
364
+ tables: TABLES,
365
+ connectionId: 'c-cli',
366
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
367
+ originHeader: null,
368
+ webOrigins: allowlist,
369
+ });
370
+ expect(outcome.admitted).toBe(true);
371
+ expect(stub.puts).toBe(1);
372
+ });
373
+
374
+ it('admits a missing Origin when the header key is absent from the event', async () => {
375
+ // APIGW may omit the header entirely on some non-browser flows;
376
+ // `undefined` is treated the same as `null`.
377
+ const stub = makeStub(0);
378
+ const outcome = await handleConnect({
379
+ dynamo: stub.dynamo,
380
+ tables: TABLES,
381
+ connectionId: 'c-noheader',
382
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
383
+ originHeader: undefined,
384
+ webOrigins: allowlist,
385
+ });
386
+ expect(outcome.admitted).toBe(true);
387
+ });
388
+
389
+ it('skips the check when WEB_ORIGINS is unset (defence is opt-in)', async () => {
390
+ // An empty allowlist means the deployment has not opted in; every
391
+ // Origin (including an obviously hostile one) must proceed so
392
+ // existing AWS deployments without a web frontend are unchanged
393
+ // on upgrade.
394
+ const stub = makeStub(0);
395
+ const outcome = await handleConnect({
396
+ dynamo: stub.dynamo,
397
+ tables: TABLES,
398
+ connectionId: 'c-unset',
399
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
400
+ originHeader: 'https://attacker.example.com',
401
+ webOrigins: new Set(),
402
+ });
403
+ expect(outcome.admitted).toBe(true);
404
+ expect(stub.puts).toBe(1);
405
+ });
406
+
407
+ it('denies an Origin that matches a listed origin except for the scheme', async () => {
408
+ // Origins are case-sensitive on scheme+host+port; an `http://`
409
+ // form MUST NOT be admitted against an `https://`-only allowlist
410
+ // (defends against a stripped-TLS attacker on the same hostname).
411
+ const stub = makeStub(0);
412
+ const outcome = await handleConnect({
413
+ dynamo: stub.dynamo,
414
+ tables: TABLES,
415
+ connectionId: 'c-scheme',
416
+ serverConfig: makeTestServerConfig({ maxClients: 5 }),
417
+ originHeader: 'http://app.example.com',
418
+ webOrigins: allowlist,
419
+ });
420
+ expect(outcome.admitted).toBe(false);
421
+ expect(outcome.admitted === false && outcome.statusCode).toBe(403);
422
+ });
423
+ });