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
@@ -1,8 +1,5 @@
1
1
  {
2
2
  "app": "tsx bin/aws.ts",
3
- "context": {
4
- "environmentName": "staging"
5
- },
6
3
  "watch": {
7
4
  "include": ["src/**", "bin/**"],
8
5
  "exclude": ["tests/**", "dist/**", "node_modules/**"]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/aws-stack",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "AWS CDK v2 stack: API Gateway v2 WebSocket API, Lambda, DynamoDB tables, least-privilege IAM",
6
6
  "license": "BSD-3-Clause",
@@ -14,9 +14,8 @@
14
14
  "clean": "rimraf dist coverage .tsbuildinfo .turbo cdk.out",
15
15
  "cdk": "cdk",
16
16
  "cdk:synth": "cdk synth",
17
- "deploy:staging": "cdk deploy --all --require-approval never",
18
- "deploy:prod": "cdk deploy --all --require-approval never",
19
- "smoke:staging": "node scripts/smoke.mjs"
17
+ "deploy": "cdk deploy --all --require-approval never",
18
+ "smoke": "node scripts/smoke.mjs"
20
19
  },
21
20
  "devDependencies": {
22
21
  "aws-cdk": "^2.262.0",
@@ -2,7 +2,7 @@
2
2
  * `apps/aws-stack` CDK stack — the AWS substrate for ServerlessIRCd (PLAN §6.2).
3
3
  *
4
4
  * • 5 DynamoDB tables sourced from `@serverless-ircd/aws-adapter/cdk-table-defs`
5
- * (Connections, ChannelMeta, ChannelMembers, Nicks, Accounts).
5
+ * (Connections, ChannelMeta, ChannelMembers, Nicks, Services).
6
6
  * • ONE Node 24 `NodejsFunction` bundling the AWS Lambda handler
7
7
  * (`$connect` / `$disconnect` / `$default` dispatched on
8
8
  * `event.requestContext.routeKey`).
@@ -34,7 +34,7 @@ import {
34
34
  } from 'aws-cdk-lib/aws-apigatewayv2';
35
35
  import { WebSocketLambdaIntegration } from 'aws-cdk-lib/aws-apigatewayv2-integrations';
36
36
  import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
37
- import { Table } from 'aws-cdk-lib/aws-dynamodb';
37
+ import { Table, type TableProps } from 'aws-cdk-lib/aws-dynamodb';
38
38
  import { Vpc } from 'aws-cdk-lib/aws-ec2';
39
39
  import {
40
40
  NetworkListener,
@@ -50,7 +50,9 @@ import { PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
50
50
  import { Code, Function as Lambda, Runtime } from 'aws-cdk-lib/aws-lambda';
51
51
  import { NodejsFunction, type NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs';
52
52
  import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
53
+ import { CfnWebACL, CfnWebACLAssociation } from 'aws-cdk-lib/aws-wafv2';
53
54
  import type { Construct } from 'constructs';
55
+ import { StaticSite, type StaticSiteProps } from './static-site.js';
54
56
 
55
57
  /** Stage name served by the auto-deploying WebSocket stage. */
56
58
  const STAGE_NAME = 'prod';
@@ -60,7 +62,14 @@ const IRC_TLS_PORT = 6697;
60
62
 
61
63
  /** Fixed rate for the gone-connection sweeper (matches the architecture doc). */
62
64
  const SWEEPER_SCHEDULE_RATE = Duration.minutes(5);
63
-
65
+ /**
66
+ * Logical id of the one ephemeral DynamoDB table. `Connections` holds
67
+ * per-connection rows (TTL-reaped, sweeper-cleaned) and is the ONLY table
68
+ * kept on `RemovalPolicy.DESTROY` with no PITR and no deletion protection;
69
+ * every other table is state-bearing and protected (see the table-creation
70
+ * block below).
71
+ */
72
+ const EPHEMERAL_TABLE_LOGICAL_ID = 'Connections';
64
73
  /**
65
74
  * Fixed rate for the idle / PING checker. One minute is the finest
66
75
  * granularity EventBridge `rate(...)` expressions support, which
@@ -68,13 +77,6 @@ const SWEEPER_SCHEDULE_RATE = Duration.minutes(5);
68
77
  */
69
78
  const PING_CHECKER_SCHEDULE_RATE = Duration.minutes(1);
70
79
 
71
- /**
72
- * Environment assumed when {@link IrcStackProps.environmentName} is
73
- * omitted. Keeps the existing deploy pipeline green without requiring
74
- * callers to pass the flag.
75
- */
76
- const DEFAULT_ENVIRONMENT_NAME = 'staging';
77
-
78
80
  /**
79
81
  * Fallback server identity, used when the corresponding prop is omitted.
80
82
  * Deliberately not `irc.example.com` (the retired placeholder) so a
@@ -109,21 +111,10 @@ const TEST_NO_BUNDLE_FLAG = 'IRC_AWS_STACK_TEST_NO_BUNDLE';
109
111
 
110
112
  /**
111
113
  * Construct props for {@link IrcAwsStack}. Acts as the single
112
- * configuration bag extended by deploy-time knobs (environment name,
113
- * server identity, …) so the prop surface grows in one place.
114
+ * configuration bag extended by deploy-time knobs (server identity, …)
115
+ * so the prop surface grows in one place.
114
116
  */
115
117
  export interface IrcStackProps extends StackProps {
116
- /**
117
- * Logical deployment environment (e.g. `staging`, `production`).
118
- *
119
- * Prefixes every physical DynamoDB table name
120
- * (`<Env><LogicalId>` — first letter capitalised, e.g.
121
- * `StagingConnections`) and is intended to be folded into the
122
- * CloudFormation stack id by the caller
123
- * (`IrcAwsStack-<environmentName>`). Defaults to `staging`.
124
- */
125
- readonly environmentName?: string;
126
-
127
118
  /**
128
119
  * Server name advertised to clients (`SERVER_NAME` / the `001`
129
120
  * welcome source). Falls back to `irc.localhost` when omitted; production
@@ -158,13 +149,148 @@ export interface IrcStackProps extends StackProps {
158
149
  * (WebSocket) transport is deployed.
159
150
  */
160
151
  readonly tcpTlsDomainName?: string;
152
+
153
+ /**
154
+ * Web client hosting substrate (S3 + CloudFront + OAC).
155
+ *
156
+ * When provided, the stack provisions a {@link StaticSite} that serves
157
+ * the shared `apps/web/dist` build artifact over CloudFront. The S3
158
+ * origin bucket is private and grants the distribution via Origin
159
+ * Access Control (never public-read); SPA client-side routing under
160
+ * `/app/*` is handled by a CloudFront custom error response mapping
161
+ * 403/404 → `200 /app/index.html`. Pure infrastructure — the per-env
162
+ * config bake and the s3-sync deploy pipeline are separate. Pass the
163
+ * custom-domain fields for prod; omit them for the default
164
+ * `*.cloudfront.net` URL (staging). When omitted entirely, no web
165
+ * hosting resources are provisioned.
166
+ */
167
+ readonly webSite?: StaticSiteProps;
168
+
169
+ /**
170
+ * Comma-separated allowlist of web origins permitted to open
171
+ * WebSocket upgrades (CSWSH defence — API Gateway WebSocket has no
172
+ * built-in Origin check). Mirrors the CF Worker's `WEB_ORIGINS` env.
173
+ *
174
+ * When provided (non-empty), the value is injected as the
175
+ * `WEB_ORIGINS` Lambda env var on the wss `$connect` handler. The
176
+ * `$connect` Lambda then denies any browser-sent `Origin` not in
177
+ * the list with `403`, so API Gateway closes the upgrade. Unset /
178
+ * empty (the default) disables the check — existing deployments
179
+ * without a web frontend are unchanged on upgrade. Non-browser
180
+ * clients (curl, WeeChat, the `tcp-ws-forwarder`, NLB TCP+TLS)
181
+ * never send `Origin` and pass through regardless.
182
+ *
183
+ * Example: `'https://app.example.com, https://staging.app.example.com'`
184
+ */
185
+ readonly webOrigins?: string;
186
+
187
+ /**
188
+ * Escape hatch that re-enables API Gateway full-frame body tracing
189
+ * (`DataTraceEnabled = true`) on the WebSocket stage. Defaults to
190
+ * `false` (safe: no frame bodies are logged).
191
+ *
192
+ * ⚠️ Setting this to `true` alone REFUSES synthesis — it must be
193
+ * accompanied by {@link iUnderstandThisLeaksCredentials} = `true`.
194
+ * This double opt-in is deliberate: since the staging/prod split was
195
+ * collapsed (every synth is effectively a prod synth; there is no
196
+ * `environmentName` context), there is no longer a "non-prod" branch
197
+ * to quietly enable tracing on. The only safe default is OFF, and the
198
+ * only way back on is an explicit, two-step acknowledgement.
199
+ *
200
+ * For a WebSocket IRC endpoint, body tracing writes the FULL payload
201
+ * of every frame — `PASS <password>`, `AUTHENTICATE <base64-SASL-PLAIN>`,
202
+ * `JOIN #chan <channel-key>`, and every `PRIVMSG`/`NOTICE` — to the
203
+ * APIGW execution-log CloudWatch group. Anyone with `logs:GetLogEvents`
204
+ * (operators, SREs, Logpush destinations, compromised CI creds)
205
+ * recovers IRC credentials verbatim. Use ONLY in a throwaway sandbox
206
+ * account for live debugging, and tear it down immediately after.
207
+ *
208
+ * Set via CDK context: `-c allowDataTrace=true`.
209
+ */
210
+ readonly allowDataTrace?: boolean;
211
+
212
+ /**
213
+ * Mandatory confirmation that accompanies {@link allowDataTrace} = `true`.
214
+ * Has NO effect on its own; it only unlocks the `allowDataTrace` escape
215
+ * hatch. Defaults to `false`. The awkward name is intentional: it cannot
216
+ * be set by accident, and a synth error names this prop when it is
217
+ * missing so the operator is forced to read the credential-leak warning.
218
+ *
219
+ * Set via CDK context: `-c iUnderstandThisLeaksCredentials=true`.
220
+ */
221
+ readonly iUnderstandThisLeaksCredentials?: boolean;
222
+
223
+ /**
224
+ * Escape hatch that allows `cdk destroy` to tear down the state-bearing
225
+ * DynamoDB tables (Nicks, Services, ChannelMeta, ChannelMembers).
226
+ *
227
+ * Defaults to `false` (safe): state tables are `RemovalPolicy.RETAIN`'d
228
+ * and carry `deletionProtection: true`, so a mistaken `cdk destroy`, a
229
+ * stack-name collision, or a stray `aws dynamodb delete-table` from a
230
+ * compromised credential CANNOT wipe user accounts. The Connections
231
+ * table is always DESTROY regardless (its rows are ephemeral per-
232
+ * connection records, TTL-reaped and sweeper-cleaned).
233
+ *
234
+ * Set to `true` ONLY for a deliberate teardown (`-c allowTableDeletion=true`):
235
+ * every state table flips to `RemovalPolicy.DESTROY` with
236
+ * `deletionProtection: false` so CloudFormation can delete them. The data
237
+ * is irreversibly lost — there is no DynamoDB snapshot-on-delete (tables
238
+ * do not snapshot); `RETAIN` + PITR is the only protection, so flipping
239
+ * this off is a one-way operation. {@link enablePitr} is independent and
240
+ * stays on by default even in teardown mode.
241
+ */
242
+ readonly allowTableDeletion?: boolean;
243
+
244
+ /**
245
+ * Toggles point-in-time recovery (PITR) on the state-bearing DynamoDB
246
+ * tables (Nicks, Services, ChannelMeta, ChannelMembers). Defaults to
247
+ * `true` — PITR enables continuous, per-table point-in-time restore
248
+ * (within the last ~35 days) against accidental writes or deletes that
249
+ * RETAIN + deletion protection do not cover (e.g. a buggy deploy that
250
+ * overwrites rows, or an `UpdateItem` with the wrong key).
251
+ *
252
+ * The Connections table NEVER carries PITR regardless of this flag: its
253
+ * rows are ephemeral and the restore cost is unjustified.
254
+ *
255
+ * Set `-c enablePitr=false` to disable on all tables. PITR is billed
256
+ * continuously at ~$0.20/GB-month per table (see docs/AWS-Deployment.md);
257
+ * RETAIN and deletionProtection are free.
258
+ */
259
+ readonly enablePitr?: boolean;
260
+
261
+ /**
262
+ * Optional WAFv2 per-IP edge rate limit for the WebSocket API's
263
+ * `$connect` route (requests per 5-minute window per client IP).
264
+ *
265
+ * When set, the stack emits a `AWS::WAFv2::WebACL` (REGIONAL scope,
266
+ * default action Allow) whose single rate-based rule BLOCKS an IP once
267
+ * it exceeds the limit, associated with the WebSocket stage. WAF on a
268
+ * WebSocket API only inspects the initial HTTP upgrade — i.e. the
269
+ * `$connect` route; established-connection frames ride the upgrade and
270
+ * are never re-inspected — so the rule is effectively scoped to
271
+ * connection establishment without any route matching.
272
+ *
273
+ * This is the EDGE tier of the three-layer connect throttling (WAF at
274
+ * the edge → APIGW stage throttling → the per-IP admission gates in
275
+ * the `$connect` handler); it blocks a flood before a Lambda is
276
+ * invoked at all. Unset (the default) provisions no WAF resources.
277
+ *
278
+ * Set via CDK context: `-c wafConnectRateLimit=2000`.
279
+ */
280
+ readonly wafConnectRateLimit?: number;
161
281
  }
162
282
 
163
283
  export class IrcAwsStack extends Stack {
164
284
  constructor(scope: Construct, id: string, props: IrcStackProps = {}) {
165
285
  super(scope, id, props);
166
286
 
167
- const environmentName = props.environmentName ?? DEFAULT_ENVIRONMENT_NAME;
287
+ // Fail-fast on the credential-leak escape hatch BEFORE any resource is
288
+ // constructed: if `allowDataTrace` was requested without the explicit
289
+ // `iUnderstandThisLeaksCredentials` confirmation, refuse synthesis here so
290
+ // the operator sees the credential-leak error immediately (and so the
291
+ // throw is not masked by downstream asset bundling). The resolved value
292
+ // is threaded into `defaultRouteSettings.dataTraceEnabled` below.
293
+ const dataTraceEnabled = resolveDataTraceEnabled(props);
168
294
 
169
295
  // Server identity is exposed as CloudFormation Parameters so it can be
170
296
  // overridden at deploy time (`cdk deploy --parameters ServerName=...`,
@@ -209,6 +335,34 @@ export class IrcAwsStack extends Stack {
209
335
  default: 'INFO',
210
336
  allowedValues: ['OFF', 'INFO', 'ERROR'],
211
337
  });
338
+
339
+ // Stage-level throttling: the global backstop BEHIND the per-IP
340
+ // admission gates (the `sourceIp` GSI counts in the `$connect`
341
+ // handler and the per-IP rate knobs). Caps the aggregate
342
+ // requests/second (and burst) APIGW forwards to ANY route — a
343
+ // connect flood from many IPs hits this even when each single IP
344
+ // stays under its per-IP budget. Defaults (100 rps / burst 200) are
345
+ // generous for an IRC deployment sized by `maxClients`; tune at
346
+ // deploy time via `--parameters ApiThrottlingRateLimit=...`. For
347
+ // per-IP rate limiting at the EDGE, deploy a WAFv2 rate-based rule
348
+ // scoped to the `$connect` route (operator-side; see
349
+ // docs/AWS-Deployment.md § throttling).
350
+ const apiThrottlingRateParam = new CfnParameter(this, 'ApiThrottlingRateLimit', {
351
+ type: 'Number',
352
+ description:
353
+ 'API Gateway stage throttling: max requests per second across all routes ' +
354
+ '(global backstop behind the per-IP admission gates). ' +
355
+ 'Override at deploy time via `--parameters ApiThrottlingRateLimit=...`.',
356
+ default: 100,
357
+ });
358
+ const apiThrottlingBurstParam = new CfnParameter(this, 'ApiThrottlingBurstLimit', {
359
+ type: 'Number',
360
+ description:
361
+ 'API Gateway stage throttling: burst capacity (concurrent requests allowed momentarily) ' +
362
+ 'paired with ApiThrottlingRateLimit. ' +
363
+ 'Override at deploy time via `--parameters ApiThrottlingBurstLimit=...`.',
364
+ default: 200,
365
+ });
212
366
  const motd = (props.motdLines ?? DEFAULT_MOTD_LINES).join('\n');
213
367
 
214
368
  // EventBridge schedule expressions are surfaced as CloudFormation
@@ -238,17 +392,66 @@ export class IrcAwsStack extends Stack {
238
392
  default: Schedule.rate(PING_CHECKER_SCHEDULE_RATE).expressionString,
239
393
  });
240
394
 
241
- // Prefix every physical table name with the environment so parallel
242
- // envs (staging + production) cannot collide in one account+region.
243
- // The first letter is capitalised to compose cleanly with the
244
- // PascalCase logical ids (`staging` `StagingConnections`).
395
+ // DynamoDB table names are the bare logical ids (`Connections`,
396
+ // `Nicks`, …). There is a single deploy target per platform (the
397
+ // staging/prod split was collapsed); staging vs production isolation
398
+ // is driven by which AWS account + region the deploy credentials
399
+ // target, not by table-name prefixing.
400
+ //
401
+ // State-bearing tables (every table EXCEPT `Connections`) are protected
402
+ // from accidental data loss by THREE independent mechanisms, each
403
+ // toggleable so a deliberate teardown can still proceed:
404
+ // • `RemovalPolicy.RETAIN` — orphans the table (keeps its data) when
405
+ // the stack is deleted, instead of CloudFormation deleting it.
406
+ // • `deletionProtection: true` — DynamoDB refuses a `DeleteTable`
407
+ // API call while this is on, blocking a stray
408
+ // `aws dynamodb delete-table` even from an admin credential.
409
+ // • `pointInTimeRecovery` — continuous, per-table restore (within
410
+ // ~35 days) against accidental writes/deletes that RETAIN +
411
+ // deletion protection cannot stop (e.g. a buggy `UpdateItem`).
412
+ //
413
+ // NOTE: SNAPSHOT semantics are deliberately NOT used. DynamoDB tables
414
+ // do NOT snapshot on stack delete (`RemovalPolicy.SNAPSHOT` is a no-op
415
+ // here and would lull an operator into a false sense of backup). The
416
+ // correct combination is RETAIN (orphan + keep) + PITR (continuous,
417
+ // in-place restore). `Connections` is the sole exception: its rows are
418
+ // ephemeral per-connection records (TTL-reaped, sweeper-cleaned), so it
419
+ // stays DESTROY with no PITR and no deletion protection at all times.
420
+ //
421
+ // Cost: RETAIN and deletionProtection are free; PITR is billed
422
+ // continuously at ~$0.20/GB-month per table (see docs/AWS-Deployment.md).
423
+ const allowTableDeletion = props.allowTableDeletion === true;
424
+ const enablePitr = props.enablePitr !== false;
245
425
  const tables = Object.fromEntries(
246
- Object.entries(TABLE_DEFS).map(([logicalId, tableProps]) => {
426
+ Object.entries(TABLE_DEFS).map(([logicalId, tableDef]) => {
427
+ const isStateTable = logicalId !== EPHEMERAL_TABLE_LOGICAL_ID;
428
+ // `exactOptionalPropertyTypes` forbids passing `undefined` for
429
+ // optional props, so the protection knobs are spread in
430
+ // conditionally: Connections omits all three (always DESTROY,
431
+ // no PITR, no deletion protection); state tables set all three
432
+ // based on the flags.
433
+ const protectionProps: Partial<
434
+ Pick<TableProps, 'deletionProtection' | 'pointInTimeRecovery'>
435
+ > = isStateTable
436
+ ? {
437
+ deletionProtection: !allowTableDeletion,
438
+ pointInTimeRecovery: enablePitr,
439
+ }
440
+ : {};
441
+ // The GSI side-channel rides alongside the construct props in
442
+ // TABLE_DEFS; pop it before constructing (current CDK declares
443
+ // indexes via addGlobalSecondaryIndex, not TableProps).
444
+ const { globalSecondaryIndexes, ...tableProps } = tableDef;
247
445
  const table = new Table(this, logicalId, {
248
446
  ...tableProps,
249
- tableName: prefixedTableName(environmentName, logicalId),
250
- removalPolicy: RemovalPolicy.DESTROY,
447
+ tableName: logicalId,
448
+ removalPolicy:
449
+ isStateTable && !allowTableDeletion ? RemovalPolicy.RETAIN : RemovalPolicy.DESTROY,
450
+ ...protectionProps,
251
451
  });
452
+ for (const gsi of globalSecondaryIndexes ?? []) {
453
+ table.addGlobalSecondaryIndex(gsi);
454
+ }
252
455
  return [logicalId, table];
253
456
  }),
254
457
  );
@@ -352,19 +555,25 @@ export class IrcAwsStack extends Stack {
352
555
  // APIGW validates this role when it is set as the account CloudWatch
353
556
  // role, and rejects one scoped to a single log-group ARN: execution
354
557
  // logs land in APIGW's OWN log groups (AWS/ApiGateway...), separate
355
- // from this stack's access-log group. Grant the logs write actions
356
- // across all log groups. Defined inline (not via the
357
- // AmazonAPIGatewayPushToCloudWatchLogs managed policy) because that
358
- // managed policy is not available in every partition.
558
+ // from this stack's access-log group. Grant ONLY the three write
559
+ // actions APIGW needs, across all log groups (the wildcard is an
560
+ // inherent APIGW constraint those group ARNs are not predictable at
561
+ // synth time). The legacy policy also carried Describe* actions; they
562
+ // were dropped so a compromised role cannot enumerate (or, given the
563
+ // read actions AWS's own managed policy for this purpose includes,
564
+ // exfiltrate) arbitrary log groups in the account.
565
+ //
566
+ // Managed-policy decision: the AWS-managed
567
+ // AmazonAPIGatewayPushToCloudWatchLogs policy was considered and
568
+ // REJECTED. Its fixed grant set is broader than needed — it grants
569
+ // DescribeLogGroups/DescribeLogStreams (deliberately dropped here)
570
+ // plus GetLogEvents/FilterLogEvents, all on '*' — and AWS can widen
571
+ // its contents without this stack being re-reviewed. It is also not
572
+ // available in every partition. The inline policy keeps the grant set
573
+ // exact and auditable at synth time.
359
574
  apiLoggingRole.addToPolicy(
360
575
  new PolicyStatement({
361
- actions: [
362
- 'logs:CreateLogGroup',
363
- 'logs:CreateLogStream',
364
- 'logs:DescribeLogGroups',
365
- 'logs:DescribeLogStreams',
366
- 'logs:PutLogEvents',
367
- ],
576
+ actions: ['logs:CreateLogGroup', 'logs:CreateLogStream', 'logs:PutLogEvents'],
368
577
  resources: ['*'],
369
578
  }),
370
579
  );
@@ -414,13 +623,23 @@ export class IrcAwsStack extends Stack {
414
623
  sourceIp: '$context.sourceIp',
415
624
  }),
416
625
  };
417
- // Execution logging: the parameter drives LoggingLevel (OFF/INFO/ERROR);
418
- // DataTraceEnabled logs full request/response bodies when a level is
419
- // active. This is the layer that shows whether APIGW received and routed
420
- // a client frame or dropped it pre-Lambda.
626
+ // Execution logging: the parameter drives LoggingLevel (OFF/INFO/ERROR).
627
+ // `DataTraceEnabled` is hard-locked to `false` here. For a WebSocket IRC
628
+ // endpoint, body tracing writes the FULL payload of every frame
629
+ // `PASS <password>`, `AUTHENTICATE <base64-SASL-PLAIN>`,
630
+ // `JOIN #chan <channel-key>`, and every `PRIVMSG`/`NOTICE` — to the
631
+ // APIGW execution-log CloudWatch group (AWS/ApiGateway…). Anyone with
632
+ // `logs:GetLogEvents` recovers IRC credentials verbatim. The logging
633
+ // LEVEL (INFO request/response metadata without bodies) is enough to
634
+ // diagnose frame-routing drops; the only way to re-enable full-body
635
+ // tracing is the explicit, double-opt-in escape hatch asserted below.
421
636
  cfnStage.defaultRouteSettings = {
422
637
  loggingLevel: apiLoggingLevelParam.valueAsString,
423
- dataTraceEnabled: true,
638
+ dataTraceEnabled,
639
+ // Stage throttling (see the parameter definitions above): global
640
+ // rate + burst backstop behind the per-IP admission gates.
641
+ throttlingRateLimit: apiThrottlingRateParam.valueAsNumber,
642
+ throttlingBurstLimit: apiThrottlingBurstParam.valueAsNumber,
424
643
  };
425
644
 
426
645
  stage.grantManagementApiAccess(handler);
@@ -428,18 +647,64 @@ export class IrcAwsStack extends Stack {
428
647
  handler.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
429
648
  pingChecker.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
430
649
 
650
+ // Optional WAFv2 rate-based rule on the WebSocket API's `$connect`
651
+ // (see the `wafConnectRateLimit` prop doc): WAF only inspects the
652
+ // initial HTTP upgrade on a WebSocket API, so the rate statement
653
+ // applies exactly where credential brute-force has to spend its
654
+ // budget — connection establishment — and never to established
655
+ // frames. Blocks happen at the edge, before a Lambda invocation is
656
+ // billed. Unset → no WAF resources at all (the default).
657
+ if (props.wafConnectRateLimit !== undefined) {
658
+ const webAcl = new CfnWebACL(this, 'ConnectRateWebAcl', {
659
+ scope: 'REGIONAL',
660
+ defaultAction: { allow: {} },
661
+ // CFN requires a VisibilityConfig on both the ACL and the rule;
662
+ // sampled requests + the CloudWatch metric keep operator
663
+ // visibility without full request logging.
664
+ visibilityConfig: {
665
+ cloudWatchMetricsEnabled: true,
666
+ metricName: 'IrcConnectWebAcl',
667
+ sampledRequestsEnabled: true,
668
+ },
669
+ rules: [
670
+ {
671
+ name: 'ConnectRateLimit',
672
+ priority: 0,
673
+ action: { block: {} },
674
+ visibilityConfig: {
675
+ cloudWatchMetricsEnabled: true,
676
+ metricName: 'IrcConnectRateLimit',
677
+ sampledRequestsEnabled: true,
678
+ },
679
+ statement: {
680
+ rateBasedStatement: {
681
+ limit: props.wafConnectRateLimit,
682
+ aggregateKeyType: 'IP',
683
+ },
684
+ },
685
+ },
686
+ ],
687
+ });
688
+ new CfnWebACLAssociation(this, 'ConnectRateWebAclAssociation', {
689
+ webAclArn: webAcl.attrArn,
690
+ // WAF associates with API Gateway stages via the v1-style stage
691
+ // ARN namespace (`/restapis/…`) — WebSocket APIs use the same
692
+ // format (there is no `WebSocketStage.stageArn` L2 helper).
693
+ resourceArn: `arn:${this.partition}:apigateway:${this.region}::/restapis/${webSocketApi.apiId}/stages/${stage.stageName}`,
694
+ });
695
+ }
696
+
431
697
  // Inject each table's physical name as an env var so every Lambda can
432
698
  // construct a `TablesConfig` at cold start via `buildDepsFromEnv`. The
433
- // env-var value is the environment-prefixed physical name (computed
434
- // through the same helper as the Table construct so they match
435
- // exactly), NOT a CloudFormation Ref — the runtime needs the literal
436
- // per-env table name to target the correct table.
699
+ // env-var value is the literal physical table name (the bare logical
700
+ // id, computed through the same mapping as the Table construct so they
701
+ // match exactly), NOT a CloudFormation Ref — the runtime needs the
702
+ // literal name to target the correct table.
437
703
  for (const logicalId of Object.keys(TABLE_DEFS)) {
438
704
  const envName = `${logicalId.toUpperCase()}_TABLE`;
439
- const physicalName = prefixedTableName(environmentName, logicalId);
440
- handler.addEnvironment(envName, physicalName);
441
- sweeper.addEnvironment(envName, physicalName);
442
- pingChecker.addEnvironment(envName, physicalName);
705
+ handler.addEnvironment(envName, logicalId);
706
+ sweeper.addEnvironment(envName, logicalId);
707
+ pingChecker.addEnvironment(envName, logicalId);
443
708
  }
444
709
 
445
710
  // Server identity is injected into the handler, ping checker, AND the
@@ -461,6 +726,16 @@ export class IrcAwsStack extends Stack {
461
726
  sweeper.addEnvironment('NETWORK_NAME', networkName);
462
727
  sweeper.addEnvironment('MOTD', motd);
463
728
 
729
+ // CSWSH defence — opt-in `WEB_ORIGINS` allowlist on the `$connect`
730
+ // handler. Only the wss handler runs `$connect`, so only it needs
731
+ // the env var (the sweeper, ping-checker, and NLB handler never see
732
+ // an `event.headers.Origin`). When the prop is unset/empty the env
733
+ // var is omitted entirely and the handler's defence is disabled —
734
+ // existing deployments without a web frontend are unchanged.
735
+ if (props.webOrigins !== undefined && props.webOrigins.length > 0) {
736
+ handler.addEnvironment('WEB_ORIGINS', props.webOrigins);
737
+ }
738
+
464
739
  // EventBridge schedule → sweeper Lambda. The schedule expression comes
465
740
  // from a CloudFormation Parameter (see above), so it can be overridden
466
741
  // at deploy time; the sweeper is self-throttling
@@ -526,12 +801,18 @@ export class IrcAwsStack extends Stack {
526
801
  // Inject the same table names, server identity, and management URL.
527
802
  for (const logicalId of Object.keys(TABLE_DEFS)) {
528
803
  const envName = `${logicalId.toUpperCase()}_TABLE`;
529
- nlbHandler.addEnvironment(envName, prefixedTableName(environmentName, logicalId));
804
+ nlbHandler.addEnvironment(envName, logicalId);
530
805
  }
531
806
  nlbHandler.addEnvironment('SERVER_NAME', serverName);
532
807
  nlbHandler.addEnvironment('NETWORK_NAME', networkName);
533
808
  nlbHandler.addEnvironment('MOTD', motd);
534
809
  nlbHandler.addEnvironment('MANAGEMENT_URL', stage.callbackUrl);
810
+ // The NLB handler fans channel messages out to OTHER connections via
811
+ // the management API — including wss clients on the API Gateway
812
+ // transport — so it needs the same `execute-api:ManageConnections`
813
+ // grant as the wss handler and ping checker. Without it every
814
+ // cross-transport `postToConnection` fails with 403.
815
+ stage.grantManagementApiAccess(nlbHandler);
535
816
 
536
817
  const nlb = new NetworkLoadBalancer(this, 'IrcNlb', {
537
818
  vpc,
@@ -571,6 +852,39 @@ export class IrcAwsStack extends Stack {
571
852
  description: 'HTTPS management endpoint for ApiGatewayManagementApi.postToConnection.',
572
853
  value: stage.callbackUrl,
573
854
  });
855
+
856
+ // -------------------------------------------------------------------------
857
+ // Web client hosting substrate (S3 + CloudFront + OAC).
858
+ //
859
+ // Serves the shared `apps/web/dist` artifact (Kiwi SPA + landing page)
860
+ // exclusively over CloudFront. The S3 origin bucket is private; CloudFront
861
+ // reads it via Origin Access Control (the modern replacement for OAI).
862
+ // Only provisioned when `webSite` is supplied — staging defaults to the
863
+ // `*.cloudfront.net` URL, prod passes the custom-domain fields. Pure
864
+ // infrastructure; the per-env config bake and the s3-sync deploy pipeline
865
+ // are separate.
866
+ // -------------------------------------------------------------------------
867
+ if (props.webSite !== undefined) {
868
+ const site = new StaticSite(this, 'StaticSite', props.webSite);
869
+ // Expose the deploy-pipeline-relevant resource IDs at the PARENT-stack
870
+ // level (stable logical IDs — no CDK hashing, which only kicks in for
871
+ // outputs nested under child constructs). `scripts/deploy-web-aws.mjs`
872
+ // reads these via `aws cloudformation describe-stacks --query` to drive
873
+ // `s3 sync apps/web/dist s3://<bucket> --delete` and
874
+ // `cloudfront create-invalidation --distribution-id <id> --paths /*`.
875
+ // `ConnectUrl` (above) supplies the wss URL that the same script bakes
876
+ // into the SPA's config.json via `build.mjs --api-url`.
877
+ new CfnOutput(this, 'WebsiteBucketName', {
878
+ description:
879
+ 'S3 origin bucket holding the web client build artifact (apps/web/dist). The deploy pipeline runs `aws s3 sync apps/web/dist s3://<this-bucket> --delete`.',
880
+ value: site.bucket.bucketName,
881
+ });
882
+ new CfnOutput(this, 'WebsiteDistributionId', {
883
+ description:
884
+ 'CloudFront distribution ID serving the web client. The deploy pipeline runs `aws cloudfront create-invalidation --distribution-id <this-id> --paths /*` after each s3 sync.',
885
+ value: site.distribution.distributionId,
886
+ });
887
+ }
574
888
  }
575
889
  }
576
890
 
@@ -582,6 +896,34 @@ function handlerEntry(): string {
582
896
  return resolve(HERE, '..', '..', '..', 'packages', 'aws-adapter', 'src', 'handlers', 'index.ts');
583
897
  }
584
898
 
899
+ /**
900
+ * Resolves the API Gateway `DataTraceEnabled` flag for the stage from the
901
+ * construct props. Safe-by-default: returns `false` unless the explicit,
902
+ * documented escape hatch is invoked. See {@link IrcStackProps.allowDataTrace}.
903
+ *
904
+ * Post-T170 (staging/prod collapse) every synth is effectively a prod synth,
905
+ * so there is no "non-prod" branch to quietly enable tracing on. The guard
906
+ * therefore refuses synthesis the moment `allowDataTrace` is requested
907
+ * without the explicit `iUnderstandThisLeaksCredentials` acknowledgement —
908
+ * forcing the operator to read the credential-leak warning.
909
+ */
910
+ function resolveDataTraceEnabled(props: IrcStackProps): boolean {
911
+ if (props.allowDataTrace === true) {
912
+ if (props.iUnderstandThisLeaksCredentials === true) {
913
+ return true;
914
+ }
915
+ throw new Error(
916
+ 'Refusing to enable API Gateway DataTraceEnabled: for a WebSocket IRC ' +
917
+ 'endpoint this logs the FULL body of every frame (PASS <password>, ' +
918
+ 'AUTHENTICATE <base64-SASL-PLAIN>, JOIN #chan <channel-key>, every ' +
919
+ 'PRIVMSG/NOTICE) to the APIGW CloudWatch log group, leaking IRC ' +
920
+ 'credentials to anyone with logs:GetLogEvents. To override in a ' +
921
+ 'throwaway sandbox ONLY, also pass iUnderstandThisLeaksCredentials=true.',
922
+ );
923
+ }
924
+ return false;
925
+ }
926
+
585
927
  /**
586
928
  * Internal Lambda-construct props: `NodejsFunctionProps` narrowed so the
587
929
  * runtime and handler are required. Every call site in the stack already
@@ -623,14 +965,3 @@ function makeLambda(scope: Construct, id: string, props: StrictLambdaProps): Lam
623
965
  }
624
966
  return new NodejsFunction(scope, id, props);
625
967
  }
626
-
627
- /**
628
- * Builds the physical DynamoDB table name for a logical id under the
629
- * given environment. Capitalises the environment's first letter so it
630
- * composes cleanly with PascalCase logical ids
631
- * (`staging` + `Connections` → `StagingConnections`).
632
- */
633
- function prefixedTableName(environmentName: string, logicalId: string): string {
634
- const cap = environmentName.charAt(0).toUpperCase() + environmentName.slice(1);
635
- return `${cap}${logicalId}`;
636
- }