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
@@ -26,6 +26,8 @@ Key in-repo paths cited below:
26
26
  | `packages/aws-adapter/src/cdk-table-defs.ts` | Five DynamoDB table shapes. |
27
27
  | `packages/aws-adapter/src/config-loader.ts` | Lambda env → `ServerConfig` mapping. |
28
28
  | `packages/aws-adapter/src/handlers/index.ts` | Lambda entry (`handler`, `sweeperHandler`, `pingCheckerHandler`). |
29
+ | `apps/aws-stack/src/static-site.ts` | CDK construct for the optional web client (S3 + CloudFront + OAC). See §16. |
30
+ | `scripts/deploy-web-aws.mjs` | Two-phase web client deploy (describe → bake → s3 sync → invalidate). See §16. |
29
31
  | `.github/workflows/deploy-aws.yml` | Staging deploy + smoke e2e CI. |
30
32
 
31
33
  **Acceptance criterion:** a new contributor can deploy their own
@@ -36,8 +38,11 @@ staging instance following only this doc.
36
38
  ## 1. What gets deployed
37
39
 
38
40
  A single CDK v2 stack (`IrcAwsStack`) provisions the entire AWS
39
- substrate. There are no static assets CloudFront and S3 are not part
40
- of this deployment. Every resource is regional.
41
+ substrate. Every resource is regional. The IRC runtime itself is
42
+ stateful compute (Lambda + DynamoDB + API Gateway WebSocket); the
43
+ optional **web client** (S3 + CloudFront + OAC) is provisioned only
44
+ when the `webSite`/`webSiteCustomDomain` CDK context is supplied. See
45
+ §16 for the web-serving path.
41
46
 
42
47
  ```
43
48
  ┌──────────────────────────────────────────────────────────────────┐
@@ -191,19 +196,30 @@ If you skip this, the first `cdk deploy` fails with a bucket-not-found
191
196
  error (see §13.1). Bootstrap is idempotent — re-running on an
192
197
  already-bootstrapped account is a no-op.
193
198
 
194
- ### 3.4 Optional: set up OIDC for CI
199
+ ### 3.4 Set up OIDC for CI (required)
195
200
 
196
- If you intend to use the GitHub Actions deploy workflow (§12) without
197
- long-lived access keys, configure GitHub OIDC:
201
+ > **Mandatory.** Long-lived access keys (`AWS_ACCESS_KEY_ID` /
202
+ > `AWS_SECRET_ACCESS_KEY`) are NOT accepted by the deploy workflow.
203
+ > The workflow assumes a role via GitHub OIDC web identity and asserts
204
+ > the assumed role matches `AWS_DEPLOY_ROLE_ARN` before any `cdk
205
+ > deploy`. The full trust-policy snippet, condition keys, and
206
+ > verification steps are in **§17**.
198
207
 
199
- 1. Create an IAM identity provider for `token.actions.githubusercontent.com`.
208
+ To deploy via the GitHub Actions workflow (§12) you MUST configure
209
+ GitHub OIDC — there is no static-key fallback. Summary of the one-time
210
+ setup (full snippet and verification in §17):
211
+
212
+ 1. Create an IAM identity provider for
213
+ `token.actions.githubusercontent.com` (one per AWS account).
200
214
  2. Create a role with a trust policy granting
201
- `sts:AssumeRoleWithWebIdentity` to your repo's `ref:refs/heads/main`.
215
+ `sts:AssumeRoleWithWebIdentity` to your repo's
216
+ `ref:refs/heads/main` claim (the workflow is
217
+ `workflow_dispatch`-only on `main`).
202
218
  3. Attach a permission policy broad enough to manage the stack (IAM,
203
- API Gateway v2, Lambda, DynamoDB, EventBridge, CloudFormation).
219
+ API Gateway v2, Lambda, DynamoDB, EventBridge, CloudFormation)
220
+ ideally the same surface `cdk deploy` needs.
204
221
  4. Note the role ARN — it becomes the `AWS_DEPLOY_ROLE_ARN` secret in
205
- CI. Static keys (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) are
206
- the fallback; the workflow supports both.
222
+ CI.
207
223
 
208
224
  ---
209
225
 
@@ -283,18 +299,18 @@ shared protocol layer.
283
299
 
284
300
  ---
285
301
 
286
- ## 5. Deploy staging
302
+ ## 5. Deploy
287
303
 
288
- Staging is the environment CI deploys on every push to `main`; you can
289
- also deploy it manually from a clean checkout.
304
+ The deploy is `workflow_dispatch`-only (a maintainer runs it by hand);
305
+ you can also deploy manually from a clean checkout.
290
306
 
291
307
  ### 5.1 Deploy from your machine
292
308
 
293
309
  ```bash
294
310
  # From the repo root:
295
- pnpm deploy:aws:staging
311
+ pnpm deploy:aws
296
312
  # Equivalent to:
297
- # pnpm --filter @serverless-ircd/aws-stack deploy:staging
313
+ # pnpm --filter @serverless-ircd/aws-stack run deploy
298
314
  # → cdk deploy --all --require-approval never
299
315
  ```
300
316
 
@@ -349,38 +365,39 @@ TCP IRC client to a WebSocket endpoint.
349
365
 
350
366
  ---
351
367
 
352
- ## 6. Deploy production
353
-
354
- The repo-level shortcut is:
355
-
356
- ```bash
357
- pnpm deploy:aws:prod
358
- # Equivalent to:
359
- # pnpm --filter @serverless-ircd/aws-stack deploy:prod
360
- # → cdk deploy --all --require-approval never
361
- ```
368
+ ## 6. Production hardening
362
369
 
363
- **There is no separate production stack.** `deploy:prod` and
364
- `deploy:staging` run the identical CDK command both deploy a stack
365
- named `IrcAwsStack` (hardcoded in `apps/aws-stack/bin/aws.ts`). The
366
- staging/production distinction is **which AWS account and region the
367
- stack is deployed into**, controlled by the credentials and
368
- `AWS_REGION` in the environment.
370
+ There is a single deploy target per platform (`pnpm deploy:aws`). The
371
+ staging/prod split was collapsed there is no `-staging` / `-prod`
372
+ stack-name suffix, no `environmentName` CDK context, and no per-env
373
+ table-name prefixing. The stack is always named `IrcAwsStack` (hardcoded
374
+ in `apps/aws-stack/bin/aws.ts`), and table names are the bare logical
375
+ ids (`Connections`, `Nicks`, …). The staging/production distinction is
376
+ **which AWS account and region the stack is deployed into**, controlled
377
+ by the credentials and `AWS_REGION` in the environment.
369
378
 
370
- For a real production deployment, before your first `pnpm deploy:aws:prod`:
379
+ For a real production deployment, before your first `pnpm deploy:aws`:
371
380
 
372
381
  1. **Use a dedicated AWS account** (or at least a dedicated region) for
373
382
  production. Do not point production credentials at the same stack
374
383
  CI is deploying to on every push.
375
- 2. **Change `RemovalPolicy.DESTROY` to `RemovalPolicy.RETAIN`** on the
376
- five DynamoDB tables (see §9.4). The committed stack deletes all
377
- data on `cdk destroy` intentional for staging, dangerous for
378
- production.
384
+ 2. **Keep the default table protection on.** State-bearing tables
385
+ (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`) default to
386
+ `RemovalPolicy.RETAIN` + `deletionProtection: true` + PITR-on, so a
387
+ mistaken `cdk destroy` or a stray `aws dynamodb delete-table` cannot
388
+ wipe them (see §9.4). Only set `-c allowTableDeletion=true` for a
389
+ deliberate teardown.
379
390
  3. **Set the server identity** (`SERVER_NAME`, `NETWORK_NAME`,
380
391
  `MOTD`) — see §7.1. `SERVER_NAME` is required at boot; the stack
381
392
  falls back to `irc.localhost` when the prop is omitted, which is
382
393
  intentionally not production-ready.
383
394
  4. **Pick a region close to your users** (see §10).
395
+ 5. **Do NOT re-enable API Gateway `DataTraceEnabled`.** It is OFF by default
396
+ (safe). Turning it on logs the full body of every IRC frame (`PASS`,
397
+ `AUTHENTICATE <SASL-PLAIN>`, channel keys, all `PRIVMSG`/`NOTICE`) to
398
+ CloudWatch, leaking credentials to anyone with `logs:GetLogEvents`. The
399
+ two-step sandbox-only escape hatch is documented in §7.6; never use it
400
+ against a production account.
384
401
 
385
402
  There is **no CI auto-deploy to production**. The deploy workflow
386
403
  (`deploy-aws.yml`) only triggers on push to `main` and targets
@@ -418,8 +435,7 @@ These three values are injected into the `IrcHandler` and
418
435
  pass the construct props when creating the stack:
419
436
 
420
437
  ```ts
421
- new IrcAwsStack(app, 'IrcAwsStack-production', {
422
- environmentName: 'production',
438
+ new IrcAwsStack(app, 'IrcAwsStack', {
423
439
  serverName: 'irc.my.net',
424
440
  networkName: 'MyNet',
425
441
  motdLines: ['Welcome to my network.', 'Be excellent to each other.'],
@@ -484,6 +500,13 @@ any of them** — to enable one, add an `addEnvironment(...)` line in
484
500
  | `MAX_LIST_ENTRIES` | int | `maxListEntries` |
485
501
  | `QUIT_MESSAGE` | string | `quitMessage` |
486
502
  | `SASL_ACCOUNTS` | string | `saslAccounts` |
503
+ | `MAX_CONNECTIONS_PER_IP` | int | `maxConnectionsPerIp` |
504
+ | `PER_IP_CONNECTION_RATE_MAX` | int | `perIpConnectionRate.max` |
505
+ | `PER_IP_CONNECTION_RATE_WINDOW_MS` | int | `perIpConnectionRate.windowMs` |
506
+
507
+ The last three are the per-IP admission knobs (§7.8): setting only one
508
+ half of the rate pair falls back to the schema default (5 per 60 000 ms)
509
+ for the other half.
487
510
 
488
511
  `SASL_ACCOUNTS` is the legacy/config fallback (newline-delimited
489
512
  `username:password` pairs); it is used only when the `Accounts` table is
@@ -493,18 +516,25 @@ Invalid values cause a readable boot-time error — the Lambda will
493
516
  throw on the first invocation (cold start). Test changes locally with
494
517
  `pnpm --filter @serverless-ircd/aws-adapter test`.
495
518
 
519
+ > **Note:** `WEB_ORIGINS` is intentionally NOT in this table. It is an
520
+ > adapter-level operational concern (the `$connect` CSWSH defence), not
521
+ > a `ServerConfig` knob — the env var is parsed directly by
522
+ > `buildDepsFromEnv` via `parseWebOrigins` rather than threaded through
523
+ > the shared Zod schema. See §8.2.
524
+
496
525
  ### 7.4 No per-environment stack parameter
497
526
 
498
- There is no `stageName` or `environmentType` parameter on the CDK
499
- stack. The stack name (`IrcAwsStack`), table names
500
- (`Connections`, `ChannelMeta`, `ChannelMembers`, `Nicks`, `Accounts`),
501
- API Gateway stage name (`prod`), and Lambda construct ids are all
502
- hardcoded. To run staging and production in **the same AWS account**,
503
- you would need to fork the stack to parameterize these names; the
504
- recommended path is to use separate accounts (or separate regions) for
505
- staging vs production. This is the same architectural choice the
506
- Cloudflare adapter makes (staging and production are separate Worker
507
- namespaces).
527
+ There is no `stageName`, `environmentType`, or `environmentName`
528
+ parameter on the CDK stack (the former staging/prod split was
529
+ collapsed into a single target). The stack name (`IrcAwsStack`), table
530
+ names (`Connections`, `ChannelMeta`, `ChannelMembers`, `Nicks`,
531
+ `Accounts`, `Services`), API Gateway stage name (`prod`), and Lambda
532
+ construct ids are all hardcoded. To run staging and production in
533
+ **the same AWS account**, you would need to fork the stack to
534
+ parameterize these names; the recommended path is to use separate
535
+ accounts (or separate regions) for staging vs production. This is the
536
+ same architectural choice the Cloudflare adapter makes (one Worker
537
+ namespace; staging vs production isolation is by account).
508
538
 
509
539
  ### 7.5 Lambda runtime and bundling
510
540
 
@@ -525,6 +555,91 @@ namespaces).
525
555
  | Auto-deploy | `true` (changes propagate without a manual deployment). |
526
556
  | Auth on `$connect`| None today (no authorizer). See §13.5. |
527
557
  | Route responses | Default (`$connect` returns 200 → upgrade; `$default` and `$disconnect` are fire-and-forget). |
558
+ | Logging level | `INFO` by default; override at deploy time via `--parameters ApiGatewayLoggingLevel=OFF\|INFO\|ERROR`. Emits request/response metadata (route, status, source IP, …) **without** frame bodies. |
559
+ | Full-frame body tracing (`DataTraceEnabled`) | **OFF by default and hard-locked.** See the warning below. |
560
+
561
+ #### ⚠️ Re-enabling `DataTraceEnabled` leaks IRC credentials
562
+
563
+ API Gateway execution-log body tracing (`DataTraceEnabled = true`) writes
564
+ the FULL payload of every WebSocket frame to the APIGW CloudWatch log group
565
+ (`AWS/ApiGateway…`, separate from the stack's access-log group). For an IRC
566
+ endpoint that includes, verbatim:
567
+
568
+ - `PASS <server password>`
569
+ - `AUTHENTICATE <base64-SASL-PLAIN>` (decodes to `account\0account\0password`)
570
+ - `JOIN #channel <channel-key>`
571
+ - every `PRIVMSG` / `NOTICE` (private messages)
572
+
573
+ Anyone with `logs:GetLogEvents` — operators, SREs, a Logpush destination, or
574
+ compromised CI credentials — recovers server passwords, SASL accounts,
575
+ channel keys, and private messages. Retention is account-default (unbounded).
576
+
577
+ For this reason `DataTraceEnabled` defaults to `false` and is the single
578
+ highest-impact logging control on the stack. INFO-level execution logging
579
+ alone is enough to diagnose whether API Gateway received and routed a frame
580
+ or dropped it pre-Lambda.
581
+
582
+ Because the staging/prod split was collapsed (every synth is effectively a
583
+ prod synth), there is no quiet "non-prod" path to re-enable it. The only way
584
+ back on is an explicit, **two-step opt-in** that exists solely for live
585
+ debugging in a throwaway sandbox:
586
+
587
+ ```sh
588
+ pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- \
589
+ -c allowDataTrace=true \
590
+ -c iUnderstandThisLeaksCredentials=true
591
+ ```
592
+
593
+ Setting only `-c allowDataTrace=true` **refuses synthesis** with a loud error
594
+ that names the credentials it would leak. The awkward second flag is
595
+ intentional — it cannot be set by accident. Tear the sandbox stack down
596
+ immediately after debugging; never deploy this combination to a shared
597
+ account.
598
+
599
+ ### 7.7 DynamoDB table-protection context flags
600
+
601
+ Two CDK context flags control the state-bearing tables' data-loss
602
+ protection (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`).
603
+ `Connections` is always `DESTROY` with no PITR and is unaffected by
604
+ either flag. See §9.4 for the full table and the rationale (and the
605
+ "no snapshot-on-delete" note).
606
+
607
+ | Context flag | Default | Effect when flipped |
608
+ |------------------------|---------|-------------------------------------------------------------------------------------|
609
+ | `allowTableDeletion` | `false` | `true` → state tables become `RemovalPolicy.DESTROY` + `deletionProtection: false`, so a deliberate `cdk destroy` teardown can delete them. Data is irreversibly lost. |
610
+ | `enablePitr` | `true` | `false` → point-in-time recovery is disabled on every state table. Independent of `allowTableDeletion` (a teardown keeps PITR on). PITR costs ~$0.20/GB-month per table (§11.1). |
611
+
612
+ Both arrive as strings on the CLI and are normalised with the shared
613
+ `parseContextBool` helper (same path as `allowDataTrace`), so
614
+ `-c allowTableDeletion=false` stays false-y. They map onto the
615
+ `IrcStackProps.allowTableDeletion` / `IrcStackProps.enablePitr` construct
616
+ props (`apps/aws-stack/bin/aws.ts`).
617
+
618
+ ### 7.8 Connect throttling (per-IP admission, stage limits, WAF)
619
+
620
+ Connection setup is where credential brute-force spends its budget
621
+ (`PASS` / `AUTHENTICATE` per connection), so the stack throttles it in
622
+ three layers, outermost first:
623
+
624
+ | Layer | Where | Knob | Default |
625
+ |-------|-------|------|---------|
626
+ | **1. WAF edge rate limit** | WAFv2 rate-based rule, associated with the stage. WAF only inspects the initial HTTP upgrade on a WebSocket API, so the rule is effectively scoped to `$connect` and never re-fires on established frames. | `-c wafConnectRateLimit=<req/5min/IP>` (CDK context; blocks at the edge before a Lambda invocation is billed) | off (no WAF resources) |
627
+ | **2. APIGW stage throttling** | `defaultRouteSettings` on the stage — global backstop across ALL routes and IPs. | `--parameters ApiThrottlingRateLimit=…` / `--parameters ApiThrottlingBurstLimit=…` (CloudFormation parameters) | 100 rps / burst 200 |
628
+ | **3. Per-IP admission gates** | Inside the `$connect` Lambda: a paginated Query against the `Connections` table's `sourceIp-connectedSince` GSI counts the caller IP's live connections (`maxConnectionsPerIp`) and in-window establishments (`perIpConnectionRate`); over-budget connects return `429` and no row is written. The same rate budget gates NLB new flows (§AWS-TCP-Deployment). | `MAX_CONNECTIONS_PER_IP`, `PER_IP_CONNECTION_RATE_MAX`, `PER_IP_CONNECTION_RATE_WINDOW_MS` env vars (§7.3) | 10 / 5 per 60 s |
629
+
630
+ Notes:
631
+
632
+ - Layer 1 is opt-in because WAFv2 web ACLs cost ~$5/mo + $1/rule + $0.60/M
633
+ requests; for a small deployment, layers 2+3 are usually enough. Enable
634
+ with `pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- -c wafConnectRateLimit=2000`
635
+ (2000 upgrade requests per 5-minute window per client IP before the IP
636
+ is blocked at the edge).
637
+ - Layer 3's per-IP counts are eventually consistent (GSI reads), so a
638
+ coordinated burst from one IP can slightly overshoot the cap — the same
639
+ best-effort TOCTOU contract as the global `maxClients` counter.
640
+ - The 429 response carries `ERROR :Closing link` semantics at the HTTP
641
+ layer where possible; clients should treat it as back-pressure (retry
642
+ after the window decays), not a permanent ban.
528
643
 
529
644
  ---
530
645
 
@@ -599,7 +714,7 @@ root):
599
714
  node --input-type=module -e '
600
715
  import { createDynamoDocumentClient, putAccountCredential } from "@serverless-ircd/aws-adapter";
601
716
  const dynamo = createDynamoDocumentClient({ region: "us-east-1" });
602
- await putAccountCredential(dynamo, "StagingAccounts", "alice", "change-me");
717
+ await putAccountCredential(dynamo, "Accounts", "alice", "change-me");
603
718
  console.log("seeded alice");
604
719
  '
605
720
  ```
@@ -610,13 +725,13 @@ calls for batch provisioning:
610
725
  ```bash
611
726
  # Seed against DynamoDB Local:
612
727
  npx tsx tools/seed-aws-accounts.ts \
613
- --table StagingAccounts \
728
+ --table Accounts \
614
729
  --endpoint http://localhost:8000 \
615
730
  --accounts alice:s3cret bob:password2
616
731
 
617
732
  # Or from a file (newline-delimited username:password):
618
733
  npx tsx tools/seed-aws-accounts.ts \
619
- --table StagingAccounts \
734
+ --table Accounts \
620
735
  --region us-east-1 \
621
736
  --file accounts.txt
622
737
  ```
@@ -627,7 +742,7 @@ row directly:
627
742
  ```bash
628
743
  # Hash with the adapter, then write the row yourself:
629
744
  aws dynamodb put-item \
630
- --table-name StagingAccounts \
745
+ --table-name Accounts \
631
746
  --item '{"account":{"S":"alice"},"algorithm":{"S":"scrypt"},"salt":{"S":"<base64-salt>"},"hash":{"S":"<base64-hash>"}}'
632
747
  ```
633
748
 
@@ -641,14 +756,80 @@ aws dynamodb put-item \
641
756
  vars are visible in the console and in CloudFormation describe output;
642
757
  they are not secret.
643
758
 
759
+ ### 8.2 `$connect` Origin allowlist (`WEB_ORIGINS`, CSWSH defence)
760
+
761
+ WebSocket upgrades do not follow the same-origin policy, so a malicious
762
+ web page can open a WebSocket to the IRC server from the victim's
763
+ browser and drive the session with the victim's credentials (Cross-Site
764
+ WebSocket Hijacking, CSWSH). API Gateway WebSocket has **no built-in
765
+ `Origin` validation** — the `$connect` Lambda receives
766
+ `event.headers.Origin` and must enforce the allowlist itself.
767
+
768
+ **Knob:** `WEB_ORIGINS` — a comma-separated list of allowed web origins
769
+ (e.g. `https://app.example.com, https://staging.app.example.com`).
770
+ Mirror of the CF Worker's `WEB_ORIGINS` (TICKET-137).
771
+
772
+ **Behaviour** (`packages/aws-adapter/src/origin-allowlist.ts` and
773
+ `packages/aws-adapter/src/handlers/connect.ts`):
774
+
775
+ | `WEB_ORIGINS` | `Origin` header on the upgrade | Outcome |
776
+ |---------------|--------------------------------|----------------------------------------|
777
+ | unset / empty | (any) | **Allow** — defence disabled (default) |
778
+ | set | absent (non-browser client) | **Allow** — curl / WeeChat / `tcp-ws-forwarder` / NLB TCP+TLS pass through |
779
+ | set | present, in allowlist | **Allow** |
780
+ | set | present, NOT in allowlist | **Deny** — `$connect` returns `403`; API Gateway closes the upgrade |
781
+
782
+ **The defence is opt-in.** Unset `WEB_ORIGINS` skips the check entirely
783
+ — existing AWS deployments without a web frontend are unchanged on
784
+ upgrade. The check only matters once you ship a web client (TICKET-164)
785
+ and want to admit the SPA's origin while rejecting hostile ones.
786
+
787
+ **Matching semantics** (mirror the CF Worker):
788
+ - entries are trimmed of surrounding whitespace and lowercased
789
+ (`https://APP.Example.COM` → `https://app.example.com`);
790
+ - the incoming `Origin` header is normalised the same way before the
791
+ membership test, so the match is case-insensitive on scheme+host
792
+ (per RFC 3986);
793
+ - origins are otherwise case-sensitive — `http://app.example.com` is
794
+ a different origin from `https://app.example.com` and is denied if
795
+ only the `https://` form is listed (defends against a stripped-TLS
796
+ attacker on the same hostname).
797
+
798
+ **Deliberate divergence from the CF policy:** the CF Worker has a
799
+ same-origin auto-derive mode (when `WEB_ORIGINS` is unset, it compares
800
+ `Origin` against the request's own `Host`). On AWS the SPA is served
801
+ from a different CloudFront origin than the API Gateway WebSocket
802
+ endpoint (`wss://{api-id}.execute-api.…`), so the request's own host is
803
+ never the SPA's origin — auto-derive does not apply. The AWS defence is
804
+ therefore explicit-allowlist only.
805
+
806
+ **Wiring.** Inject `WEB_ORIGINS` on the `$connect` Lambda via the CDK
807
+ stack prop:
808
+
809
+ ```typescript
810
+ new IrcAwsStack(app, 'IrcAwsStack', {
811
+ webOrigins: 'https://app.example.com, https://staging.app.example.com',
812
+ // ...other props
813
+ });
814
+ ```
815
+
816
+ …or at deploy time via CDK context (`-c webOrigins=…`). The stack
817
+ injects the env var only on the wss `$connect` handler (the sweeper,
818
+ ping-checker, and NLB handler never run `$connect`). The handler parses
819
+ the value once per cold start via `parseWebOrigins` and stores the
820
+ resulting `Set<string>` on `HandlerDeps.webOrigins`.
821
+
822
+ `WEB_ORIGINS` is **not** a secret — it is a public allowlist of origin
823
+ strings. Do not put it in Secrets Manager / SSM; set it as a plain
824
+ Lambda env var (the CDK stack does this directly via
825
+ `addEnvironment`).
826
+
644
827
  The CI deploy uses **GitHub Actions secrets** plus an optional repo
645
828
  variable (see §12):
646
829
 
647
830
  | Secret | Used by | Purpose |
648
831
  |-------------------------|-------------------|---------------------------------------------------------------|
649
- | `AWS_ACCESS_KEY_ID` | `deploy-aws.yml` | Static access key for CDK deploy. Required unless OIDC is used. |
650
- | `AWS_SECRET_ACCESS_KEY` | `deploy-aws.yml` | Companion secret key. Required unless OIDC is used. |
651
- | `AWS_DEPLOY_ROLE_ARN` | `deploy-aws.yml` | Optional. When set, the job assumes this role via OIDC web identity instead of using static keys. |
832
+ | `AWS_DEPLOY_ROLE_ARN` | `deploy-aws.yml` | **Required.** ARN of the IAM role the workflow assumes via GitHub OIDC web identity. No static-key fallback. |
652
833
  | `AWS_REGION` | `deploy-aws.yml` | Region to deploy into (e.g. `us-east-1`). |
653
834
 
654
835
  | Variable | Purpose |
@@ -728,28 +909,62 @@ postToConnection, per-channel send-list cache, a dedicated fanout
728
909
  Lambda) are deferred to post-v1; the load-test ticket will surface
729
910
  the ceiling.
730
911
 
731
- ### 9.4 Removal policy (IMPORTANT)
912
+ ### 9.4 Removal policy, deletion protection, and PITR (IMPORTANT)
913
+
914
+ Every table is protected from accidental data loss by default. The
915
+ state-bearing tables (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`)
916
+ get **three independent safeguards**, each toggleable so a deliberate
917
+ teardown can still proceed; the one ephemeral table (`Connections`) is
918
+ always DESTROY:
919
+
920
+ | Table | RemovalPolicy | `deletionProtection` | PITR |
921
+ |-----------------------------------------------|---------------|----------------------|------|
922
+ | `Nicks`, `Services`, `ChannelMeta`, `ChannelMembers` | `RETAIN` (default) | `true` (default) | `true` (default) |
923
+ | `Connections` | `DESTROY` | (unset → off) | (unset → off) |
924
+
925
+ - **`RemovalPolicy.RETAIN`** — when the stack is deleted, CloudFormation
926
+ *orphans* the table instead of deleting it; the data survives.
927
+ - **`deletionProtection: true`** — DynamoDB refuses a `DeleteTable` API
928
+ call while this is on, blocking a stray `aws dynamodb delete-table` even
929
+ from an admin or compromised credential.
930
+ - **`pointInTimeRecovery` (PITR)** — continuous, per-table restore (within
931
+ the last ~35 days) against accidental writes/deletes that RETAIN +
932
+ deletion protection cannot stop (e.g. a buggy `UpdateItem` that
933
+ overwrites rows).
934
+
935
+ > **No snapshot-on-delete.** DynamoDB tables do NOT snapshot when a stack
936
+ > is deleted (`RemovalPolicy.SNAPSHOT` is a no-op here). `RETAIN` (orphan +
937
+ > keep) + PITR (continuous, in-place restore) is the correct combination;
938
+ > do not rely on a snapshot that does not exist.
939
+
940
+ Two CDK context flags control these (threaded through `bin/aws.ts`, see
941
+ §7.7):
942
+
943
+ - **`allowTableDeletion`** (default `false`). Set `-c allowTableDeletion=true`
944
+ for a deliberate `cdk destroy` teardown: every state table flips to
945
+ `RemovalPolicy.DESTROY` with `deletionProtection: false` so CloudFormation
946
+ can delete them. The data is **irreversibly lost** — flip this off only
947
+ when you genuinely want the tables gone. `Connections` is always DESTROY
948
+ regardless.
949
+ - **`enablePitr`** (default `true`). Set `-c enablePitr=false` to disable
950
+ PITR on every state table. Independent of `allowTableDeletion` — a
951
+ teardown keeps PITR on (harmless, and the data is still recoverable up
952
+ until the table is actually deleted). `Connections` never carries PITR.
732
953
 
733
- The stack sets `RemovalPolicy.DESTROY` on every table:
954
+ ```bash
955
+ # Default deploy: state tables RETAIN + deletion-protected + PITR-on.
956
+ pnpm --filter @serverless-ircd/aws-stack run cdk:synth
734
957
 
735
- ```ts
736
- // apps/aws-stack/src/aws-stack.ts:48
737
- const tables = Object.entries(TABLE_DEFS).map(
738
- ([logicalId, tableProps]) =>
739
- new Table(this, logicalId, { ...tableProps, removalPolicy: RemovalPolicy.DESTROY }),
740
- );
741
- ```
958
+ # Deliberate teardown (state tables become DELETABLE):
959
+ pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- -c allowTableDeletion=true
742
960
 
743
- **This means `cdk destroy` deletes every table and all of its data.**
744
- This is intentional for staging (you want a clean teardown between
745
- experiments) and **dangerous for production**.
961
+ # Disable PITR (e.g. cost-sensitive dev deploy):
962
+ pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- -c enablePitr=false
963
+ ```
746
964
 
747
- For production, fork the stack to use `RemovalPolicy.RETAIN` (or
748
- `RETAIN_ON_UPDATE`): tables become orphans on stack deletion and keep
749
- their data. There is no built-in knob for this today — it requires
750
- editing `aws-stack.ts`. Recommendation: ship a `production: boolean`
751
- stack prop in a future change that flips the removal policy. Flagged
752
- in §15.
965
+ The protection knobs are asserted per parameter combination in
966
+ `apps/aws-stack/tests/stack.test.ts` (the "DynamoDB table protection"
967
+ suite).
753
968
 
754
969
  ### 9.5 TTL on `Connections.idleSince`
755
970
 
@@ -819,9 +1034,10 @@ see ~10 ms. **Pick the region closest to your user base.**
819
1034
 
820
1035
  ### 10.2 Recommendation: single-region for v1
821
1036
 
822
- For v1, deploy in one region. There are no static assets (CloudFront
823
- and S3 are not part of this stack), so a CDN adds no value here.
824
- Single-region keeps the data model simple: the cross-table
1037
+ For v1, deploy in one region. The IRC runtime is regional stateful
1038
+ compute (Lambda + DynamoDB + APIGW); CloudFront only enters the picture
1039
+ for the optional web client (§16) and is global-edge, not a region
1040
+ picker. Single-region keeps the data model simple: the cross-table
825
1041
  `TransactWriteItems` flows (§9.2) only work within one region.
826
1042
 
827
1043
  ### 10.3 Multi-region is out of scope for v1
@@ -877,12 +1093,25 @@ verify against the current AWS pricing page** before budgeting:
877
1093
  | Lambda | GB-second | $0.0000166667 (≈ $0.0167 / GB-hour) |
878
1094
  | DynamoDB on-demand | write request unit (WRU) | $1.25 / million |
879
1095
  | DynamoDB on-demand | read request unit (RRU) | $0.25 / million |
1096
+ | DynamoDB PITR (continuous)| GB-month of table data + indexes, per table | ~$0.20 / GB-month |
880
1097
  | EventBridge Scheduler | event published | $1.00 / million |
881
1098
  | CloudWatch Logs | GB ingested | $0.50 |
882
1099
 
883
1100
  The Lambda free tier (1M requests + 400k GB-seconds per month) covers
884
1101
  a meaningful slice of a small staging deployment.
885
1102
 
1103
+ > **Table-protection cost.** `RemovalPolicy.RETAIN` and
1104
+ > `deletionProtection` are **free** — they are CloudFormation/DynamoDB
1105
+ > flags, not billable features. Point-in-time recovery (PITR) **is**
1106
+ > billed: continuously at ~$0.20/GB-month **per table**, charged on the
1107
+ > full table size including indexes, for as long as PITR is on. With the
1108
+ > default (`enablePitr=true`) this applies to the four state tables
1109
+ > (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`); `Connections`
1110
+ > never carries PITR. For a small deployment these tables are tiny
1111
+ > (kilobytes to low megabytes), so the PITR line item is fractions of a
1112
+ > cent; it only becomes material if a table grows to many GB. Disable
1113
+ > with `-c enablePitr=false` on a cost-sensitive dev deploy (see §9.4).
1114
+
886
1115
  ### 11.2 Back-of-envelope: 100 concurrent users
887
1116
 
888
1117
  Assume a small staging deployment with 100 concurrent users, average
@@ -922,10 +1151,10 @@ single 1k-member announcement channel can multiply the fanout cost by
922
1151
 
923
1152
  ## 12. CI/CD
924
1153
 
925
- The GitHub Actions workflow at `.github/workflows/deploy-aws.yml`
926
- deploys staging on every push to `main` and replays the smoke e2e. It
927
- is the canonical deploy path manual `pnpm deploy:aws:staging` is for
928
- iteration only.
1154
+ The GitHub Actions workflow at `.github/workflows/deploy-aws.yml` is
1155
+ `workflow_dispatch`-only (a maintainer triggers it by hand); there is
1156
+ no automatic push-to-main deploy. It deploys the single AWS target and
1157
+ replays the smoke e2e.
929
1158
 
930
1159
  ### 12.1 What the workflow does
931
1160
 
@@ -936,60 +1165,91 @@ Steps performed (in order):
936
1165
  3. `pnpm install --frozen-lockfile`.
937
1166
  4. `pnpm build` — builds all workspace packages.
938
1167
  5. `pnpm typecheck` and `pnpm test` — gate the deploy.
939
- 6. Configure AWS credentials (OIDC if `AWS_DEPLOY_ROLE_ARN` is set,
940
- otherwise static keys).
941
- 7. `pnpm deploy:aws:staging` `cdk deploy --all --require-approval never`.
942
- 8. Resolve the smoke URL: repo variable `AWS_SMOKE_URL` first, else
943
- the stack's `ConnectUrl` CloudFormation output via
944
- `aws cloudformation describe-stacks --stack-name IrcAwsStack`, else
945
- skip the smoke step with a warning.
946
- 9. `pnpm smoke:aws:staging -- --url "$SMOKE_URL"` failure fails the
947
- build.
948
-
949
- Concurrency is serialized via `concurrency.group: aws-staging`,
950
- `cancel-in-progress: false`, so two pushes cannot race the same
1168
+ 6. **Assert `AWS_DEPLOY_ROLE_ARN` is non-empty** (fail-fast before any
1169
+ AWS call).
1170
+ 7. Configure AWS credentials via OIDC (`aws-actions/configure-aws-credentials@v4`
1171
+ with `role-to-assume` only no static keys).
1172
+ 8. **Assert `aws sts get-caller-identity` returns the configured role**
1173
+ (defence-in-depth against silent fallbacks).
1174
+ 9. `pnpm deploy:aws` `cdk deploy --all --require-approval never`.
1175
+ 10. Resolve the smoke URL: repo variable `AWS_SMOKE_URL` first, else
1176
+ the stack's `ConnectUrl` CloudFormation output via
1177
+ `aws cloudformation describe-stacks --stack-name IrcAwsStack`, else
1178
+ skip the smoke step with a warning.
1179
+ 11. `pnpm smoke:aws -- --url "$SMOKE_URL"` failure fails the
1180
+ build.
1181
+
1182
+ Concurrency is serialized via `concurrency.group: aws-deploy`,
1183
+ `cancel-in-progress: false`, so two runs cannot race the same
951
1184
  CloudFormation stack.
952
1185
 
953
1186
  ### 12.2 Required repo configuration
954
1187
 
955
1188
  Under **Settings → Secrets and variables → Actions**:
956
1189
 
957
- - **Secret `AWS_ACCESS_KEY_ID`** — required unless OIDC.
958
- - **Secret `AWS_SECRET_ACCESS_KEY`** required unless OIDC.
959
- - **Secret `AWS_DEPLOY_ROLE_ARN`** optional; enables OIDC.
1190
+ - **Secret `AWS_DEPLOY_ROLE_ARN`** — **required**. ARN of the IAM
1191
+ role the workflow assumes via GitHub OIDC. There is no static-key
1192
+ fallback. Set this up once per AWS account (see §17 for the trust
1193
+ policy).
960
1194
  - **Secret `AWS_REGION`** — required (e.g. `us-east-1`).
961
1195
  - **Variable `AWS_SMOKE_URL`** — optional but recommended; the
962
1196
  workflow can fall back to the CFN output, but setting this variable
963
1197
  explicitly avoids the `describe-stacks` round-trip and the
964
1198
  possibility of a stale cached value after a redeploy.
965
1199
 
966
- ### 12.3 OIDC vs static keys
1200
+ > **Removed:** `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are no
1201
+ > longer read by the workflow. Long-lived access keys powerful enough
1202
+ > to deploy CloudFormation + IAM + Lambda + DynamoDB are effectively
1203
+ > account-admin; a single exfil vector yields full AWS account
1204
+ > takeover. The H1 security-hardening ticket dropped them. If you have
1205
+ > stale `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` secrets in the
1206
+ > repo, delete them and invalidate the underlying IAM access key in
1207
+ > IAM → Users → Security credentials.
967
1208
 
968
- The workflow supports both auth modes:
1209
+ ### 12.3 OIDC is mandatory
1210
+
1211
+ The workflow accepts **only** GitHub OIDC web-identity credentials:
969
1212
 
970
1213
  ```yaml
1214
+ permissions:
1215
+ id-token: write
1216
+ contents: read
1217
+
1218
+ # …
1219
+
1220
+ - name: Assert OIDC deploy role ARN is set
1221
+ if: env.AWS_DEPLOY_ROLE_ARN == ''
1222
+ env:
1223
+ AWS_DEPLOY_ROLE_ARN: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
1224
+ run: |
1225
+ echo "::error::AWS_DEPLOY_ROLE_ARN secret is empty …"
1226
+ exit 1
1227
+
971
1228
  - name: Configure AWS credentials
972
1229
  uses: aws-actions/configure-aws-credentials@v4
973
1230
  with:
974
1231
  aws-region: ${{ secrets.AWS_REGION }}
975
1232
  role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
976
- aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
977
- aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
1233
+
1234
+ - name: Assert assumed role matches AWS_DEPLOY_ROLE_ARN
1235
+ # aws sts get-caller-identity + ARN-shape check; aborts before cdk deploy.
1236
+ run: …
978
1237
  ```
979
1238
 
980
- When `AWS_DEPLOY_ROLE_ARN` is set, the action assumes the role via
981
- OIDC web identity (requires `permissions.id-token: write`, which the
982
- workflow declares) and ignores the static keys. When unset, it falls
983
- back to the static access-key pair. OIDC is the recommended path for
984
- any deployment tied to a real AWS account no long-lived credentials
985
- to rotate.
1239
+ The `id-token: write` permission lets Actions mint the OIDC JWT that
1240
+ `sts:AssumeRoleWithWebIdentity` consumes. After the assume-role step
1241
+ the workflow calls `aws sts get-caller-identity` and asserts the
1242
+ returned ARN's role-name + account-id match `AWS_DEPLOY_ROLE_ARN`, so
1243
+ a silent fallback to ambient static credentials cannot ship a deploy.
1244
+ See §17 for the matching IAM trust policy and verification.
986
1245
 
987
- ### 12.4 No CI auto-deploy to production
1246
+ ### 12.4 Manual-only deploy trigger
988
1247
 
989
- Production deploys are intentionally manual (`pnpm deploy:aws:prod`
990
- from a clean checkout on `main`, against separate credentials). The
991
- workflow's `on:` block only triggers on `push` to `main` and
992
- `workflow_dispatch`, both of which target staging.
1248
+ The workflow's `on:` block is `workflow_dispatch`-only — there is no
1249
+ automatic push-to-main trigger, and no separate staging/prod target.
1250
+ A maintainer runs the deploy by hand against whichever AWS account
1251
+ and region the secrets point at; staging vs production isolation is
1252
+ driven by account/region, not by a workflow-per-env split.
993
1253
 
994
1254
  ---
995
1255
 
@@ -1005,7 +1265,7 @@ The CDK bootstrap stack hasn't been created in this account/region.
1005
1265
  ```bash
1006
1266
  cd apps/aws-stack
1007
1267
  pnpm cdk bootstrap
1008
- pnpm deploy:aws:staging
1268
+ pnpm deploy:aws
1009
1269
  ```
1010
1270
 
1011
1271
  Bootstrap is idempotent and per-region — re-run it if you change
@@ -1024,7 +1284,7 @@ injects these — if one is missing in the console, somebody hand-edited
1024
1284
  the Lambda config. Fix by re-deploying:
1025
1285
 
1026
1286
  ```bash
1027
- pnpm deploy:aws:staging
1287
+ pnpm deploy:aws
1028
1288
  ```
1029
1289
 
1030
1290
  If the error mentions `MANAGEMENT_URL` being absent: that env var is
@@ -1073,7 +1333,7 @@ to resolve the smoke URL. If this returns nothing, either:
1073
1333
  name, the workflow's `--stack-name IrcAwsStack` won't find it.
1074
1334
  Update the workflow or rename back.
1075
1335
  - **The deploy failed silently.** Check the prior `pnpm
1076
- deploy:aws:staging` step's exit code. `cdk deploy` with
1336
+ deploy:aws` step's exit code. `cdk deploy` with
1077
1337
  `--require-approval never` can still fail on IAM or
1078
1338
  CloudFormation errors.
1079
1339
  - **Wrong region.** The `describe-stacks` call uses `AWS_REGION` from
@@ -1234,14 +1494,18 @@ npm install -g aws-cdk-local
1234
1494
  export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
1235
1495
  LOCALSTACK=1 pnpm --filter @serverless-ircd/aws-stack test
1236
1496
 
1237
- # Staging
1238
- pnpm deploy:aws:staging
1497
+ # Deploy (single target — staging vs prod is which account/region you point at)
1498
+ pnpm deploy:aws
1239
1499
  node apps/aws-stack/scripts/smoke.mjs \
1240
1500
  --url wss://<id>.execute-api.<region>.amazonaws.com/prod
1241
1501
  aws logs tail /aws/lambda/IrcHandler --follow # live logs
1242
1502
 
1243
- # Production (manual, separate credentials)
1244
- pnpm deploy:aws:prod
1503
+ # Web client (opt-in; provision via webSite/webSiteCustomDomain context)
1504
+ pnpm deploy:aws -- -c webSiteCustomDomain=app.example.com \
1505
+ -c webSiteCertificateArn=arn:aws:acm:us-east-1:... \
1506
+ -c webSiteHostedZoneName=example.com. -c webSiteHostedZoneId=... \
1507
+ -c webOrigins=https://app.example.com # CSWSH (§8.2, §16.4)
1508
+ node scripts/deploy-web-aws.mjs # bake + s3 sync + invalidate (§16.3)
1245
1509
 
1246
1510
  # Tests / lint
1247
1511
  pnpm test # full workspace
@@ -1263,9 +1527,420 @@ Key files:
1263
1527
  | `packages/aws-adapter/src/tables.ts` | Runtime table-name + key-column constants. |
1264
1528
  | `packages/aws-adapter/src/config-loader.ts` | Lambda env → `ServerConfig` schema mapping. |
1265
1529
  | `packages/aws-adapter/src/handlers/index.ts` | Lambda entry points (`handler`, `sweeperHandler`, `pingCheckerHandler`). |
1530
+ | `apps/aws-stack/src/static-site.ts` | CDK construct for the optional web client (S3 + CloudFront + OAC). See §16. |
1531
+ | `scripts/deploy-web-aws.mjs` | Two-phase web client deploy helper. See §16. |
1266
1532
  | `.github/workflows/deploy-aws.yml` | Staging deploy + smoke e2e CI. |
1267
1533
 
1268
- ### Known gaps flagged for follow-up
1534
+ ---
1535
+
1536
+ ## 16. Web client (S3 + CloudFront + OAC)
1537
+
1538
+ The CF Worker serves the SPA from the same origin as the WebSocket
1539
+ (one Worker, one domain). AWS **cannot** do this — API Gateway
1540
+ WebSocket and a static SPA host are different AWS services on
1541
+ different endpoints — so the AWS web client splits in two: the wss
1542
+ IRC endpoint stays on API Gateway, and the SPA is served from an S3
1543
+ bucket fronted by CloudFront. This § documents the architecture, the
1544
+ build, the deploy pipeline, the CSWSH defence (mandatory once the
1545
+ SPA ships), and the optional custom-domain setup.
1546
+
1547
+ > **Cross-reference:** `docs/WebClientGuide.md` covers the
1548
+ > Cloudflare path (one origin for HTTP + WebSocket). The CF-vs-AWS
1549
+ > architectural difference is called out in §16.5 below.
1550
+
1551
+ ### 16.1 What gets provisioned
1552
+
1553
+ The `StaticSite` construct (`apps/aws-stack/src/static-site.ts`,
1554
+ provisioned only when `IrcStackProps.webSite` is supplied) creates:
1555
+
1556
+ - a **private S3 bucket** holding the `apps/web/dist` build artifact
1557
+ (`index.html` for the landing page, `webclient/index.html` for the
1558
+ Kiwi SPA, `webclient/static/*` for hashed JS/CSS) — the same layout
1559
+ the CF Worker's `[assets]` binding serves, so one artifact ships on
1560
+ both platforms unchanged;
1561
+ - a **CloudFront distribution** with an S3 origin pointing at the
1562
+ bucket via **Origin Access Control (OAC)** — the modern replacement
1563
+ for the legacy OAI. Direct S3 access (`https://<bucket>.s3.…/…`)
1564
+ is denied by the bucket policy; only CloudFront URLs resolve;
1565
+ - `defaultRootObject = index.html` so requests to `/` serve the
1566
+ project landing page (the static HTML front door with links to the
1567
+ SPA, the rendered docs, and the source repo);
1568
+ - a **custom error response** mapping S3's `403`/`404` → `200` with
1569
+ response page `/webclient/index.html`, so client-side routes under
1570
+ `/webclient/*` resolve to the SPA shell (Kiwi is hash/history-routed);
1571
+ - three CFN outputs at the parent-stack level (stable logical IDs):
1572
+ `WebsiteURL` (the `*.cloudfront.net` default OR the custom domain),
1573
+ `WebsiteBucketName`, `WebsiteDistributionId`. The deploy pipeline
1574
+ (§16.3) consumes the latter two.
1575
+
1576
+ **Why not Amplify / why not S3-only:** Amplify Hosting adds a managed
1577
+ build step the project doesn't need (the SPA build is already
1578
+ `pnpm --filter web build:prod-aws`); S3-only (no CloudFront) forces
1579
+ either a public bucket (anti-pattern) or a signed-URL-per-asset
1580
+ scheme (heavy). CloudFront + OAC is the AWS-recommended static-site
1581
+ shape and gives HTTPS on `*.cloudfront.net` for free.
1582
+
1583
+ ### 16.2 Opt-in: provision the web client
1584
+
1585
+ The web client is **opt-in**. To provision it, pass `webSite` props
1586
+ on the stack. From CDK context (the easiest path), set
1587
+ `webSiteCustomDomain`:
1588
+
1589
+ ```bash
1590
+ pnpm deploy:aws -- \
1591
+ -c webSiteCustomDomain=app.example.com \
1592
+ -c webSiteCertificateArn=arn:aws:acm:us-east-1:111122223333:certificate/abc-def-... \
1593
+ -c webSiteHostedZoneName=example.com. \
1594
+ -c webSiteHostedZoneId=Z2KVMGOMGMOU2
1595
+ ```
1596
+
1597
+ The ACM certificate **must** be in `us-east-1` (CloudFront requirement).
1598
+ Hosted-zone name + ID are both required because CDK's
1599
+ `fromHostedZoneAttributes` needs both; retrieve them once via
1600
+ `aws route53 list-hosted-zones-by-name --dns-name <zone>`.
1601
+
1602
+ > **Default-`*.cloudfront.net` only (no custom domain) gap:**
1603
+ > `bin/aws.ts` currently keys the `webSite` prop's existence on
1604
+ > `webSiteCustomDomain` being set, so provisioning the construct
1605
+ > without a custom domain requires editing `bin/aws.ts` to pass
1606
+ > `webSite: {}` directly. Tracked as a follow-up. For v1 deploys,
1607
+ > supply a custom domain (prod-style) or patch `bin/aws.ts`.
1608
+
1609
+ ### 16.3 Two-phase deploy (stack → bake → s3 sync → invalidate)
1610
+
1611
+ Unlike the CF Worker (where `wrangler deploy` ships the SPA in the
1612
+ same command as the runtime), the AWS SPA deploy is **two-phase** and
1613
+ **stack-output-driven**: the CloudFormation stack must exist before
1614
+ the SPA can be baked, because the baked `config.json` carries the
1615
+ API Gateway WebSocket URL as a literal. The deploy pipeline
1616
+ (`scripts/deploy-web-aws.mjs`) runs:
1617
+
1618
+ 1. `aws cloudformation describe-stacks --stack-name IrcAwsStack`
1619
+ reads the `ConnectUrl`, `WebsiteBucketName`, and
1620
+ `WebsiteDistributionId` outputs. If any are missing the script
1621
+ exits non-zero ("stack not deployed" or "web client construct not
1622
+ provisioned").
1623
+ 2. `pnpm --filter @serverless-ircd/web run build:prod-aws -- --api-url <ConnectUrl>`
1624
+ bakes the SPA with the real API Gateway URL. The `--api-url` flag is
1625
+ overridden **in memory** after Zod parse — the source
1626
+ `apps/web/static/config.prod-aws.json` is never mutated, so the
1627
+ templated `{{API_ID}}.execute-api.{{REGION}}.amazonaws.com` host and
1628
+ `/{{STAGE}}` path placeholders stay in the repo and no concrete URL
1629
+ is committed. The build rejects any leftover `{{...}}` placeholders.
1630
+
1631
+ The flag value (`wss://<id>.execute-api.<region>.amazonaws.com/<stage>`)
1632
+ is **split** by `parseApiUrlForKiwi` into Kiwi's two URL fields:
1633
+ `server` ← `<id>.execute-api.<region>.amazonaws.com` (hostname only,
1634
+ no scheme) and `direct_path` ← `/<stage>`. irc-framework builds the
1635
+ WS URL itself as `wss://<server>:<port><direct_path>` — it prepends
1636
+ `wss://` whenever `tls: true`, so passing the full URL through to
1637
+ `server` would double the scheme (`wss://wss//<host>...`) and Firefox
1638
+ would reject the connection. The split keeps `server` host-only and
1639
+ routes the API Gateway stage name through `direct_path`.
1640
+ 3. `aws s3 sync apps/web/dist s3://<WebsiteBucketName> --delete`
1641
+ uploads the build. The CLI skips unchanged objects via etag
1642
+ comparison, so a re-deploy with no SPA changes uploads nothing.
1643
+ (This is why the deploy uses the AWS CLI rather than the SDK —
1644
+ `s3 sync`'s etag-based unchanged-skip is non-trivial to reproduce
1645
+ on `@aws-sdk/client-s3`.)
1646
+ 4. `aws cloudfront create-invalidation --distribution-id <WebsiteDistributionId> --paths /*`
1647
+ busts the edge cache so the next visitor gets the new build.
1648
+
1649
+ **CI wiring:** the `deploy-web` job in `.github/workflows/deploy-aws.yml`
1650
+ runs `node scripts/deploy-web-aws.mjs` with `needs: deploy` (after the
1651
+ CDK stack applies). Both jobs are `workflow_dispatch`-only — a
1652
+ maintainer runs the workflow by hand (T170 collapsed the staging/prod
1653
+ split; there is no push trigger and no `staging` branch).
1654
+
1655
+ **Why a script instead of CDK `BucketDeployment`:** `BucketDeployment`
1656
+ re-synths on every deploy, is slower, and — critically — cannot bake
1657
+ a post-deploy stack output (the API Gateway URL) into the SPA config,
1658
+ because at synth time the URL does not exist yet. The two-phase
1659
+ script reads the output, then bakes.
1660
+
1661
+ ### 16.4 CSWSH defence (`WEB_ORIGINS`) — mandatory once the SPA ships
1662
+
1663
+ **Once you deploy the web client, set `WEB_ORIGINS`.** WebSocket
1664
+ upgrades do not follow the same-origin policy, so a malicious web
1665
+ page can otherwise open a wss to the IRC endpoint from a victim's
1666
+ browser and drive the session with the victim's credentials
1667
+ (Cross-Site WebSocket Hijacking, CSWSH). API Gateway WebSocket has
1668
+ no built-in Origin validation — the `$connect` Lambda enforces it.
1669
+
1670
+ Pass the SPA's origin alongside the `webSite` props:
1671
+
1672
+ ```bash
1673
+ pnpm deploy:aws -- \
1674
+ -c webSiteCustomDomain=app.example.com \
1675
+ -c webSiteCertificateArn=... \
1676
+ -c webSiteHostedZoneName=example.com. \
1677
+ -c webSiteHostedZoneId=... \
1678
+ -c webOrigins=https://app.example.com
1679
+ ```
1680
+
1681
+ The full behaviour matrix, matching semantics, and the deliberate
1682
+ divergence from the CF Worker policy (no same-origin auto-derive on
1683
+ AWS — the SPA and the wss endpoint are on different origins) are in
1684
+ **§8.2**. Unset `WEB_ORIGINS` disables the check; this is why the
1685
+ defence is documented as "mandatory-once-the-SPA-ships" rather than
1686
+ "mandatory always" — bare-IRC deployments without a web frontend
1687
+ (`curl`, WeeChat, `tcp-ws-forwarder`) never send `Origin` and are
1688
+ correctly served without the knob.
1689
+
1690
+ ### 16.5 CF-vs-AWS: one origin vs two origins
1691
+
1692
+ The architectural difference is the reason this § exists:
1693
+
1694
+ | Platform | HTTP (SPA) origin | WebSocket origin | Same-origin? |
1695
+ |----------|---------------------------------|-----------------------------------|--------------|
1696
+ | CF | the Worker (`*.workers.dev` / custom domain) | the same Worker | **Yes** — one domain serves both |
1697
+ | AWS | CloudFront (`*.cloudfront.net` / custom domain) | API Gateway (`*.execute-api.*.amazonaws.com`) | **No** — two different endpoints |
1698
+
1699
+ Two practical consequences:
1700
+
1701
+ 1. **The baked SPA config (`config.prod-aws.json`) carries a templated
1702
+ host + stage path** because the browser cannot infer either from
1703
+ `window.location`. The CF config (`config.prod.json`) uses Kiwi's
1704
+ `{{hostname}}` template resolved at load time and no `direct_path`
1705
+ (the Worker's WS upgrade is at root). The AWS config templates
1706
+ `server` = `{{API_ID}}.execute-api.{{REGION}}.amazonaws.com` (host
1707
+ only — irc-framework prepends `wss://` from `tls: true`) and
1708
+ `direct_path` = `/{{STAGE}}` (the API Gateway stage segment). The
1709
+ AWS deploy pipeline splits the stack-output URL into these two
1710
+ fields at bake time (§16.3 step 2).
1711
+ 2. **The CF Worker's CSWSH defence has a same-origin auto-derive
1712
+ mode** (when `WEB_ORIGINS` is unset, compare `Origin` against the
1713
+ request's own `Host`). AWS cannot use auto-derive — the request's
1714
+ own host is the API Gateway domain, never the SPA's — so the AWS
1715
+ defence is explicit-allowlist only (§8.2).
1716
+
1717
+ ### 16.6 Custom domain (ACM + CloudFront alias + Route53)
1718
+
1719
+ `StaticSite` accepts four optional props for the custom domain (see
1720
+ `apps/aws-stack/src/static-site.ts`):
1721
+
1722
+ | Prop | Purpose |
1723
+ |-------------------|----------------------------------------------------------------------|
1724
+ | `customDomain` | The hostname to serve the SPA on (e.g. `app.example.com`). |
1725
+ | `certificateArn` | ARN of an ACM certificate covering `customDomain`. Must be in `us-east-1`. |
1726
+ | `hostedZoneName` | Public Route53 hosted zone name (e.g. `example.com.`). |
1727
+ | `hostedZoneId` | Public Route53 hosted zone ID for the above. |
1728
+
1729
+ When all four are supplied (the CDK context wiring in `bin/aws.ts`
1730
+ handles the optional spread), the construct adds the alias to the
1731
+ CloudFront distribution and creates a Route53 `ARecord` aliasing the
1732
+ distribution. When `customDomain` is omitted, the distribution serves
1733
+ on its default `*.cloudfront.net` URL — but see the gap note in
1734
+ §16.2 (the current `bin/aws.ts` wiring only provisions the construct
1735
+ at all when `webSiteCustomDomain` is set).
1736
+
1737
+ ACM certificate validation: the certificate must be DNS-validated
1738
+ and in `ISSUED` status before `cdk deploy` runs — CloudFront will
1739
+ not deploy an alias to a pending certificate. Create it ahead of time
1740
+ in `us-east-1` via the console or:
1741
+
1742
+ ```bash
1743
+ aws acm request-certificate \
1744
+ --domain-name app.example.com \
1745
+ --validation-method DNS \
1746
+ --region us-east-1
1747
+ # add the returned CNAME to Route53, wait for ISSUED
1748
+ ```
1749
+
1750
+ ### 16.7 Verification (manual smoke check)
1751
+
1752
+ After a `deploy-web` job completes:
1753
+
1754
+ ```bash
1755
+ # Stack outputs
1756
+ aws cloudformation describe-stacks --stack-name IrcAwsStack \
1757
+ --query 'Stacks[0].Outputs[?OutputKey==`WebsiteURL`].OutputValue' --output text
1758
+
1759
+ # Landing page
1760
+ curl -sI https://<distribution-or-custom-domain>/ | head -1
1761
+ # Landing page (the project front door — links to SPA + docs + source repo)
1762
+ curl -sI https://<distribution-or-custom-domain>/ | head -1
1763
+ # SPA shell (200, served via the 404→/webclient/index.html error mapping for /webclient/* routes)
1764
+ curl -sI https://<distribution-or-custom-domain>/webclient/ | head -1
1765
+ # Baked config — server MUST be the real wss URL, not a {{...}} template
1766
+ curl -s https://<distribution-or-custom-domain>/webclient/static/config.json | jq .startupOptions.server
1767
+ ```
1768
+
1769
+ Open `https://<distribution-or-custom-domain>/webclient/` in a browser,
1770
+ register, join `#welcome`, send a `PRIVMSG`, see the echo. (Visiting the
1771
+ root `https://<distribution-or-custom-domain>/` serves the landing page,
1772
+ which links to the SPA at `/webclient/`.) If the SPA loads but never
1773
+ connects, check (1) the baked `config.json` `server` matches the stack's
1774
+ `ConnectUrl` output, (2) `WEB_ORIGINS` (§16.4) includes the CloudFront
1775
+ origin you're loading the SPA from, and (3) the `$connect` route is
1776
+ reachable.
1777
+
1778
+ ---
1779
+
1780
+ ## 17. OIDC deploy role (CI)
1781
+
1782
+ The GitHub Actions deploy workflow (§12) accepts **only** GitHub OIDC
1783
+ web-identity credentials. Long-lived access keys
1784
+ (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) are NOT supported — a
1785
+ single exfiltration vector against them (rogue PR `printenv`,
1786
+ compromised third-party Action, poisoned runner) yields full AWS
1787
+ account takeover because the deploy role can manage CloudFormation,
1788
+ IAM, Lambda, and DynamoDB. This section documents the operator-side
1789
+ setup that the `AWS_DEPLOY_ROLE_ARN` repo secret points at.
1790
+
1791
+ ### 17.1 One-time AWS account setup
1792
+
1793
+ 1. **Create the GitHub OIDC identity provider** (one per AWS account,
1794
+ idempotent):
1795
+
1796
+ ```bash
1797
+ aws iam create-open-id-connect-provider \
1798
+ --url https://token.actions.githubusercontent.com \
1799
+ --client-id-list sts.amazonaws.com \
1800
+ --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 \
1801
+ --tags Key=managed-by,Value=github-actions
1802
+ ```
1803
+
1804
+ > The `--thumbprint-list` above is the well-known root CA
1805
+ > thumbprint GitHub's OIDC tokens chain to. AWS now also accepts
1806
+ > the trust-root automatically, but specifying it keeps the
1807
+ > provider deterministic across accounts.
1808
+
1809
+ 2. **Create the deploy role** with the trust policy below (save as
1810
+ `trust-policy.json` then `create-role`). The single condition key
1811
+ binds the role to this repository's `main` branch — the only ref
1812
+ the `workflow_dispatch` deploy workflow can run from:
1813
+
1814
+ ```json
1815
+ {
1816
+ "Version": "2012-10-17",
1817
+ "Statement": [
1818
+ {
1819
+ "Effect": "Allow",
1820
+ "Principal": {
1821
+ "Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
1822
+ },
1823
+ "Action": "sts:AssumeRoleWithWebIdentity",
1824
+ "Condition": {
1825
+ "StringEquals": {
1826
+ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
1827
+ "token.actions.githubusercontent.com:sub": "repo:<owner>/<repo>:ref:refs/heads/main"
1828
+ }
1829
+ }
1830
+ }
1831
+ ]
1832
+ }
1833
+ ```
1834
+
1835
+ Replace `<ACCOUNT_ID>` with the AWS account id and `<owner>/<repo>`
1836
+ with the GitHub owner/repo (e.g. `ServerlessIRCd/ServerlessIRCd`).
1837
+ The `sub` condition is the security boundary: only workflows
1838
+ running on a ref matching `refs/heads/main` in this exact repo can
1839
+ assume the role. Do NOT widen it to `StringLike` with a wildcard —
1840
+ that lets forked PRs mint credentials.
1841
+
1842
+ ```bash
1843
+ aws iam create-role \
1844
+ --role-name IrcDeployRole \
1845
+ --assume-role-policy-document file://trust-policy.json \
1846
+ --description "GitHub Actions OIDC role for IrcAwsStack deploys"
1847
+
1848
+ # Note the returned ARN — this becomes AWS_DEPLOY_ROLE_ARN.
1849
+ aws iam get-role --role-name IrcDeployRole --query 'Role.Arn' --output text
1850
+ ```
1851
+
1852
+ 3. **Attach a permission policy.** The role needs the same surface
1853
+ `cdk deploy` uses: IAM (pass-role + role creation), API Gateway v2,
1854
+ Lambda, DynamoDB, EventBridge, CloudFormation, S3 (the CDK
1855
+ bootstrap bucket), and logs. The CDK bootstrap already creates a
1856
+ `cdk-hnb659fds-deploy-role-<account>-<region>` role with the right
1857
+ shape — for a v1 deployment, the simplest pattern is to let the
1858
+ GitHub-OIDC role `sts:AssumeRole` the CDK deploy role:
1859
+
1860
+ ```json
1861
+ {
1862
+ "Version": "2012-10-17",
1863
+ "Statement": [
1864
+ {
1865
+ "Effect": "Allow",
1866
+ "Action": "sts:AssumeRole",
1867
+ "Resource": "arn:aws:iam::<ACCOUNT_ID>:role/cdk-hnb659fds-deploy-role-<ACCOUNT_ID>-<REGION>"
1868
+ }
1869
+ ]
1870
+ }
1871
+ ```
1872
+
1873
+ Attach this as an inline policy on `IrcDeployRole`. (If you would
1874
+ rather grant the deploy permissions directly on `IrcDeployRole`,
1875
+ mirror the statement list from the CDK deploy role's
1876
+ `DefaultDeploymentRole`.)
1877
+
1878
+ 4. **Store the role ARN as the `AWS_DEPLOY_ROLE_ARN` repo secret**
1879
+ (Settings → Secrets and variables → Actions → New repository
1880
+ secret). This is the **only** AWS-shaped secret the workflow reads.
1881
+
1882
+ ### 17.2 Condition key reference
1883
+
1884
+ | JWT claim (`token.actions.githubusercontent.com:<key>`) | Value to bind | Meaning |
1885
+ |---------------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------------|
1886
+ | `aud` | `sts.amazonaws.com` | Required. AWS is the intended audience of the OIDC token. |
1887
+ | `sub` | `repo:<owner>/<repo>:ref:refs/heads/main` | Required. Pinning to `main` matches the `workflow_dispatch` trigger. |
1888
+
1889
+ The workflow's `on:` block is `workflow_dispatch` only, and a
1890
+ maintainer runs it from the `main` branch in the GitHub UI, so the
1891
+ `sub` claim always carries `:ref:refs/heads/main`. If you later add a
1892
+ push trigger on another branch, add a second condition entry rather
1893
+ than loosening this one.
1894
+
1895
+ ### 17.3 CI verification (operator-run, out-of-band)
1896
+
1897
+ Once the role and secret are configured, verify end-to-end by running
1898
+ the workflow from the Actions UI against a sandbox account. The
1899
+ workflow's `Assert assumed role matches AWS_DEPLOY_ROLE_ARN` step
1900
+ prints the assumed role-name + account on success:
1901
+
1902
+ ```
1903
+ OIDC role assertion passed: IrcDeployRole in account 111122223333
1904
+ ```
1905
+
1906
+ If that step fails, the trust policy condition keys do not match the
1907
+ workflow's `sub` claim — re-check `repo:<owner>/<repo>` and the branch
1908
+ ref. The workflow aborts BEFORE `cdk deploy`, so a misconfigured trust
1909
+ policy cannot mutate the stack.
1910
+
1911
+ Authoritative reference: GitHub's "Configuring OpenID Connect in
1912
+ Amazon Web Services" guide
1913
+ (<https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services>)
1914
+ and the AWS "Creating OpenID Connect (OIDC) identity providers" guide
1915
+ walk through the IAM-side setup in more detail.
1916
+
1917
+ ### 17.4 What the workflow enforces in-repo
1918
+
1919
+ The CI-hardening test suite (`tools/ci-hardening/tests/deploy-aws-oidc.test.ts`)
1920
+ asserts the workflow shape so the security property cannot regress
1921
+ silently:
1922
+
1923
+ - No `aws-access-key-id` / `aws-secret-access-key` inputs to
1924
+ `configure-aws-credentials@v4` anywhere in the file.
1925
+ - Top-level `permissions:` block grants exactly
1926
+ `{ id-token: write, contents: read }` (least-privilege).
1927
+ - Every `configure-aws-credentials` step has a `role-to-assume` input.
1928
+ - The `deploy` and `deploy-web` jobs both have an
1929
+ `if: env.AWS_DEPLOY_ROLE_ARN == ''` guard that runs `exit 1` before
1930
+ any AWS call.
1931
+ - The `deploy` job runs `aws sts get-caller-identity` and references
1932
+ `AWS_DEPLOY_ROLE_ARN` before any `cdk deploy` (defence-in-depth
1933
+ against silent OIDC fallback).
1934
+
1935
+ The `deploy-web` job (TICKET-167, §16) inherits the same OIDC model:
1936
+ it has its own empty-ARN guard before `configure-aws-credentials` and
1937
+ assumes the same role via web identity — no static keys, no ambient
1938
+ credential inheritance from the parent `deploy` job. The two jobs are
1939
+ independent `workflow_dispatch` runs against the same role ARN.
1940
+
1941
+ ---
1942
+
1943
+ ## 18. Open questions / future work
1269
1944
 
1270
1945
  These are facts the **code does not yet have an answer for**; the
1271
1946
  recommendations above are deployer guidance, not built-in knobs: