serverless-ircd 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -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,25 +365,18 @@ 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
@@ -381,6 +390,12 @@ For a real production deployment, before your first `pnpm deploy:aws:prod`:
381
390
  falls back to `irc.localhost` when the prop is omitted, which is
382
391
  intentionally not production-ready.
383
392
  4. **Pick a region close to your users** (see §10).
393
+ 5. **Do NOT re-enable API Gateway `DataTraceEnabled`.** It is OFF by default
394
+ (safe). Turning it on logs the full body of every IRC frame (`PASS`,
395
+ `AUTHENTICATE <SASL-PLAIN>`, channel keys, all `PRIVMSG`/`NOTICE`) to
396
+ CloudWatch, leaking credentials to anyone with `logs:GetLogEvents`. The
397
+ two-step sandbox-only escape hatch is documented in §7.6; never use it
398
+ against a production account.
384
399
 
385
400
  There is **no CI auto-deploy to production**. The deploy workflow
386
401
  (`deploy-aws.yml`) only triggers on push to `main` and targets
@@ -418,8 +433,7 @@ These three values are injected into the `IrcHandler` and
418
433
  pass the construct props when creating the stack:
419
434
 
420
435
  ```ts
421
- new IrcAwsStack(app, 'IrcAwsStack-production', {
422
- environmentName: 'production',
436
+ new IrcAwsStack(app, 'IrcAwsStack', {
423
437
  serverName: 'irc.my.net',
424
438
  networkName: 'MyNet',
425
439
  motdLines: ['Welcome to my network.', 'Be excellent to each other.'],
@@ -493,18 +507,25 @@ Invalid values cause a readable boot-time error — the Lambda will
493
507
  throw on the first invocation (cold start). Test changes locally with
494
508
  `pnpm --filter @serverless-ircd/aws-adapter test`.
495
509
 
510
+ > **Note:** `WEB_ORIGINS` is intentionally NOT in this table. It is an
511
+ > adapter-level operational concern (the `$connect` CSWSH defence), not
512
+ > a `ServerConfig` knob — the env var is parsed directly by
513
+ > `buildDepsFromEnv` via `parseWebOrigins` rather than threaded through
514
+ > the shared Zod schema. See §8.2.
515
+
496
516
  ### 7.4 No per-environment stack parameter
497
517
 
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).
518
+ There is no `stageName`, `environmentType`, or `environmentName`
519
+ parameter on the CDK stack (the former staging/prod split was
520
+ collapsed into a single target). The stack name (`IrcAwsStack`), table
521
+ names (`Connections`, `ChannelMeta`, `ChannelMembers`, `Nicks`,
522
+ `Accounts`, `Services`), API Gateway stage name (`prod`), and Lambda
523
+ construct ids are all hardcoded. To run staging and production in
524
+ **the same AWS account**, you would need to fork the stack to
525
+ parameterize these names; the recommended path is to use separate
526
+ accounts (or separate regions) for staging vs production. This is the
527
+ same architectural choice the Cloudflare adapter makes (one Worker
528
+ namespace; staging vs production isolation is by account).
508
529
 
509
530
  ### 7.5 Lambda runtime and bundling
510
531
 
@@ -525,6 +546,46 @@ namespaces).
525
546
  | Auto-deploy | `true` (changes propagate without a manual deployment). |
526
547
  | Auth on `$connect`| None today (no authorizer). See §13.5. |
527
548
  | Route responses | Default (`$connect` returns 200 → upgrade; `$default` and `$disconnect` are fire-and-forget). |
549
+ | 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. |
550
+ | Full-frame body tracing (`DataTraceEnabled`) | **OFF by default and hard-locked.** See the warning below. |
551
+
552
+ #### ⚠️ Re-enabling `DataTraceEnabled` leaks IRC credentials
553
+
554
+ API Gateway execution-log body tracing (`DataTraceEnabled = true`) writes
555
+ the FULL payload of every WebSocket frame to the APIGW CloudWatch log group
556
+ (`AWS/ApiGateway…`, separate from the stack's access-log group). For an IRC
557
+ endpoint that includes, verbatim:
558
+
559
+ - `PASS <server password>`
560
+ - `AUTHENTICATE <base64-SASL-PLAIN>` (decodes to `account\0account\0password`)
561
+ - `JOIN #channel <channel-key>`
562
+ - every `PRIVMSG` / `NOTICE` (private messages)
563
+
564
+ Anyone with `logs:GetLogEvents` — operators, SREs, a Logpush destination, or
565
+ compromised CI credentials — recovers server passwords, SASL accounts,
566
+ channel keys, and private messages. Retention is account-default (unbounded).
567
+
568
+ For this reason `DataTraceEnabled` defaults to `false` and is the single
569
+ highest-impact logging control on the stack. INFO-level execution logging
570
+ alone is enough to diagnose whether API Gateway received and routed a frame
571
+ or dropped it pre-Lambda.
572
+
573
+ Because the staging/prod split was collapsed (every synth is effectively a
574
+ prod synth), there is no quiet "non-prod" path to re-enable it. The only way
575
+ back on is an explicit, **two-step opt-in** that exists solely for live
576
+ debugging in a throwaway sandbox:
577
+
578
+ ```sh
579
+ pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- \
580
+ -c allowDataTrace=true \
581
+ -c iUnderstandThisLeaksCredentials=true
582
+ ```
583
+
584
+ Setting only `-c allowDataTrace=true` **refuses synthesis** with a loud error
585
+ that names the credentials it would leak. The awkward second flag is
586
+ intentional — it cannot be set by accident. Tear the sandbox stack down
587
+ immediately after debugging; never deploy this combination to a shared
588
+ account.
528
589
 
529
590
  ---
530
591
 
@@ -599,7 +660,7 @@ root):
599
660
  node --input-type=module -e '
600
661
  import { createDynamoDocumentClient, putAccountCredential } from "@serverless-ircd/aws-adapter";
601
662
  const dynamo = createDynamoDocumentClient({ region: "us-east-1" });
602
- await putAccountCredential(dynamo, "StagingAccounts", "alice", "change-me");
663
+ await putAccountCredential(dynamo, "Accounts", "alice", "change-me");
603
664
  console.log("seeded alice");
604
665
  '
605
666
  ```
@@ -610,13 +671,13 @@ calls for batch provisioning:
610
671
  ```bash
611
672
  # Seed against DynamoDB Local:
612
673
  npx tsx tools/seed-aws-accounts.ts \
613
- --table StagingAccounts \
674
+ --table Accounts \
614
675
  --endpoint http://localhost:8000 \
615
676
  --accounts alice:s3cret bob:password2
616
677
 
617
678
  # Or from a file (newline-delimited username:password):
618
679
  npx tsx tools/seed-aws-accounts.ts \
619
- --table StagingAccounts \
680
+ --table Accounts \
620
681
  --region us-east-1 \
621
682
  --file accounts.txt
622
683
  ```
@@ -627,7 +688,7 @@ row directly:
627
688
  ```bash
628
689
  # Hash with the adapter, then write the row yourself:
629
690
  aws dynamodb put-item \
630
- --table-name StagingAccounts \
691
+ --table-name Accounts \
631
692
  --item '{"account":{"S":"alice"},"algorithm":{"S":"scrypt"},"salt":{"S":"<base64-salt>"},"hash":{"S":"<base64-hash>"}}'
632
693
  ```
633
694
 
@@ -641,14 +702,80 @@ aws dynamodb put-item \
641
702
  vars are visible in the console and in CloudFormation describe output;
642
703
  they are not secret.
643
704
 
705
+ ### 8.2 `$connect` Origin allowlist (`WEB_ORIGINS`, CSWSH defence)
706
+
707
+ WebSocket upgrades do not follow the same-origin policy, so a malicious
708
+ web page can open a WebSocket to the IRC server from the victim's
709
+ browser and drive the session with the victim's credentials (Cross-Site
710
+ WebSocket Hijacking, CSWSH). API Gateway WebSocket has **no built-in
711
+ `Origin` validation** — the `$connect` Lambda receives
712
+ `event.headers.Origin` and must enforce the allowlist itself.
713
+
714
+ **Knob:** `WEB_ORIGINS` — a comma-separated list of allowed web origins
715
+ (e.g. `https://app.example.com, https://staging.app.example.com`).
716
+ Mirror of the CF Worker's `WEB_ORIGINS` (TICKET-137).
717
+
718
+ **Behaviour** (`packages/aws-adapter/src/origin-allowlist.ts` and
719
+ `packages/aws-adapter/src/handlers/connect.ts`):
720
+
721
+ | `WEB_ORIGINS` | `Origin` header on the upgrade | Outcome |
722
+ |---------------|--------------------------------|----------------------------------------|
723
+ | unset / empty | (any) | **Allow** — defence disabled (default) |
724
+ | set | absent (non-browser client) | **Allow** — curl / WeeChat / `tcp-ws-forwarder` / NLB TCP+TLS pass through |
725
+ | set | present, in allowlist | **Allow** |
726
+ | set | present, NOT in allowlist | **Deny** — `$connect` returns `403`; API Gateway closes the upgrade |
727
+
728
+ **The defence is opt-in.** Unset `WEB_ORIGINS` skips the check entirely
729
+ — existing AWS deployments without a web frontend are unchanged on
730
+ upgrade. The check only matters once you ship a web client (TICKET-164)
731
+ and want to admit the SPA's origin while rejecting hostile ones.
732
+
733
+ **Matching semantics** (mirror the CF Worker):
734
+ - entries are trimmed of surrounding whitespace and lowercased
735
+ (`https://APP.Example.COM` → `https://app.example.com`);
736
+ - the incoming `Origin` header is normalised the same way before the
737
+ membership test, so the match is case-insensitive on scheme+host
738
+ (per RFC 3986);
739
+ - origins are otherwise case-sensitive — `http://app.example.com` is
740
+ a different origin from `https://app.example.com` and is denied if
741
+ only the `https://` form is listed (defends against a stripped-TLS
742
+ attacker on the same hostname).
743
+
744
+ **Deliberate divergence from the CF policy:** the CF Worker has a
745
+ same-origin auto-derive mode (when `WEB_ORIGINS` is unset, it compares
746
+ `Origin` against the request's own `Host`). On AWS the SPA is served
747
+ from a different CloudFront origin than the API Gateway WebSocket
748
+ endpoint (`wss://{api-id}.execute-api.…`), so the request's own host is
749
+ never the SPA's origin — auto-derive does not apply. The AWS defence is
750
+ therefore explicit-allowlist only.
751
+
752
+ **Wiring.** Inject `WEB_ORIGINS` on the `$connect` Lambda via the CDK
753
+ stack prop:
754
+
755
+ ```typescript
756
+ new IrcAwsStack(app, 'IrcAwsStack', {
757
+ webOrigins: 'https://app.example.com, https://staging.app.example.com',
758
+ // ...other props
759
+ });
760
+ ```
761
+
762
+ …or at deploy time via CDK context (`-c webOrigins=…`). The stack
763
+ injects the env var only on the wss `$connect` handler (the sweeper,
764
+ ping-checker, and NLB handler never run `$connect`). The handler parses
765
+ the value once per cold start via `parseWebOrigins` and stores the
766
+ resulting `Set<string>` on `HandlerDeps.webOrigins`.
767
+
768
+ `WEB_ORIGINS` is **not** a secret — it is a public allowlist of origin
769
+ strings. Do not put it in Secrets Manager / SSM; set it as a plain
770
+ Lambda env var (the CDK stack does this directly via
771
+ `addEnvironment`).
772
+
644
773
  The CI deploy uses **GitHub Actions secrets** plus an optional repo
645
774
  variable (see §12):
646
775
 
647
776
  | Secret | Used by | Purpose |
648
777
  |-------------------------|-------------------|---------------------------------------------------------------|
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. |
778
+ | `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
779
  | `AWS_REGION` | `deploy-aws.yml` | Region to deploy into (e.g. `us-east-1`). |
653
780
 
654
781
  | Variable | Purpose |
@@ -819,9 +946,10 @@ see ~10 ms. **Pick the region closest to your user base.**
819
946
 
820
947
  ### 10.2 Recommendation: single-region for v1
821
948
 
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
949
+ For v1, deploy in one region. The IRC runtime is regional stateful
950
+ compute (Lambda + DynamoDB + APIGW); CloudFront only enters the picture
951
+ for the optional web client (§16) and is global-edge, not a region
952
+ picker. Single-region keeps the data model simple: the cross-table
825
953
  `TransactWriteItems` flows (§9.2) only work within one region.
826
954
 
827
955
  ### 10.3 Multi-region is out of scope for v1
@@ -922,10 +1050,10 @@ single 1k-member announcement channel can multiply the fanout cost by
922
1050
 
923
1051
  ## 12. CI/CD
924
1052
 
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.
1053
+ The GitHub Actions workflow at `.github/workflows/deploy-aws.yml` is
1054
+ `workflow_dispatch`-only (a maintainer triggers it by hand); there is
1055
+ no automatic push-to-main deploy. It deploys the single AWS target and
1056
+ replays the smoke e2e.
929
1057
 
930
1058
  ### 12.1 What the workflow does
931
1059
 
@@ -936,60 +1064,91 @@ Steps performed (in order):
936
1064
  3. `pnpm install --frozen-lockfile`.
937
1065
  4. `pnpm build` — builds all workspace packages.
938
1066
  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
1067
+ 6. **Assert `AWS_DEPLOY_ROLE_ARN` is non-empty** (fail-fast before any
1068
+ AWS call).
1069
+ 7. Configure AWS credentials via OIDC (`aws-actions/configure-aws-credentials@v4`
1070
+ with `role-to-assume` only no static keys).
1071
+ 8. **Assert `aws sts get-caller-identity` returns the configured role**
1072
+ (defence-in-depth against silent fallbacks).
1073
+ 9. `pnpm deploy:aws` `cdk deploy --all --require-approval never`.
1074
+ 10. Resolve the smoke URL: repo variable `AWS_SMOKE_URL` first, else
1075
+ the stack's `ConnectUrl` CloudFormation output via
1076
+ `aws cloudformation describe-stacks --stack-name IrcAwsStack`, else
1077
+ skip the smoke step with a warning.
1078
+ 11. `pnpm smoke:aws -- --url "$SMOKE_URL"` failure fails the
1079
+ build.
1080
+
1081
+ Concurrency is serialized via `concurrency.group: aws-deploy`,
1082
+ `cancel-in-progress: false`, so two runs cannot race the same
951
1083
  CloudFormation stack.
952
1084
 
953
1085
  ### 12.2 Required repo configuration
954
1086
 
955
1087
  Under **Settings → Secrets and variables → Actions**:
956
1088
 
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.
1089
+ - **Secret `AWS_DEPLOY_ROLE_ARN`** — **required**. ARN of the IAM
1090
+ role the workflow assumes via GitHub OIDC. There is no static-key
1091
+ fallback. Set this up once per AWS account (see §17 for the trust
1092
+ policy).
960
1093
  - **Secret `AWS_REGION`** — required (e.g. `us-east-1`).
961
1094
  - **Variable `AWS_SMOKE_URL`** — optional but recommended; the
962
1095
  workflow can fall back to the CFN output, but setting this variable
963
1096
  explicitly avoids the `describe-stacks` round-trip and the
964
1097
  possibility of a stale cached value after a redeploy.
965
1098
 
966
- ### 12.3 OIDC vs static keys
1099
+ > **Removed:** `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are no
1100
+ > longer read by the workflow. Long-lived access keys powerful enough
1101
+ > to deploy CloudFormation + IAM + Lambda + DynamoDB are effectively
1102
+ > account-admin; a single exfil vector yields full AWS account
1103
+ > takeover. The H1 security-hardening ticket dropped them. If you have
1104
+ > stale `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` secrets in the
1105
+ > repo, delete them and invalidate the underlying IAM access key in
1106
+ > IAM → Users → Security credentials.
1107
+
1108
+ ### 12.3 OIDC is mandatory
967
1109
 
968
- The workflow supports both auth modes:
1110
+ The workflow accepts **only** GitHub OIDC web-identity credentials:
969
1111
 
970
1112
  ```yaml
1113
+ permissions:
1114
+ id-token: write
1115
+ contents: read
1116
+
1117
+ # …
1118
+
1119
+ - name: Assert OIDC deploy role ARN is set
1120
+ if: env.AWS_DEPLOY_ROLE_ARN == ''
1121
+ env:
1122
+ AWS_DEPLOY_ROLE_ARN: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
1123
+ run: |
1124
+ echo "::error::AWS_DEPLOY_ROLE_ARN secret is empty …"
1125
+ exit 1
1126
+
971
1127
  - name: Configure AWS credentials
972
1128
  uses: aws-actions/configure-aws-credentials@v4
973
1129
  with:
974
1130
  aws-region: ${{ secrets.AWS_REGION }}
975
1131
  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 }}
1132
+
1133
+ - name: Assert assumed role matches AWS_DEPLOY_ROLE_ARN
1134
+ # aws sts get-caller-identity + ARN-shape check; aborts before cdk deploy.
1135
+ run: …
978
1136
  ```
979
1137
 
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.
1138
+ The `id-token: write` permission lets Actions mint the OIDC JWT that
1139
+ `sts:AssumeRoleWithWebIdentity` consumes. After the assume-role step
1140
+ the workflow calls `aws sts get-caller-identity` and asserts the
1141
+ returned ARN's role-name + account-id match `AWS_DEPLOY_ROLE_ARN`, so
1142
+ a silent fallback to ambient static credentials cannot ship a deploy.
1143
+ See §17 for the matching IAM trust policy and verification.
986
1144
 
987
- ### 12.4 No CI auto-deploy to production
1145
+ ### 12.4 Manual-only deploy trigger
988
1146
 
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.
1147
+ The workflow's `on:` block is `workflow_dispatch`-only — there is no
1148
+ automatic push-to-main trigger, and no separate staging/prod target.
1149
+ A maintainer runs the deploy by hand against whichever AWS account
1150
+ and region the secrets point at; staging vs production isolation is
1151
+ driven by account/region, not by a workflow-per-env split.
993
1152
 
994
1153
  ---
995
1154
 
@@ -1005,7 +1164,7 @@ The CDK bootstrap stack hasn't been created in this account/region.
1005
1164
  ```bash
1006
1165
  cd apps/aws-stack
1007
1166
  pnpm cdk bootstrap
1008
- pnpm deploy:aws:staging
1167
+ pnpm deploy:aws
1009
1168
  ```
1010
1169
 
1011
1170
  Bootstrap is idempotent and per-region — re-run it if you change
@@ -1024,7 +1183,7 @@ injects these — if one is missing in the console, somebody hand-edited
1024
1183
  the Lambda config. Fix by re-deploying:
1025
1184
 
1026
1185
  ```bash
1027
- pnpm deploy:aws:staging
1186
+ pnpm deploy:aws
1028
1187
  ```
1029
1188
 
1030
1189
  If the error mentions `MANAGEMENT_URL` being absent: that env var is
@@ -1073,7 +1232,7 @@ to resolve the smoke URL. If this returns nothing, either:
1073
1232
  name, the workflow's `--stack-name IrcAwsStack` won't find it.
1074
1233
  Update the workflow or rename back.
1075
1234
  - **The deploy failed silently.** Check the prior `pnpm
1076
- deploy:aws:staging` step's exit code. `cdk deploy` with
1235
+ deploy:aws` step's exit code. `cdk deploy` with
1077
1236
  `--require-approval never` can still fail on IAM or
1078
1237
  CloudFormation errors.
1079
1238
  - **Wrong region.** The `describe-stacks` call uses `AWS_REGION` from
@@ -1234,14 +1393,18 @@ npm install -g aws-cdk-local
1234
1393
  export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
1235
1394
  LOCALSTACK=1 pnpm --filter @serverless-ircd/aws-stack test
1236
1395
 
1237
- # Staging
1238
- pnpm deploy:aws:staging
1396
+ # Deploy (single target — staging vs prod is which account/region you point at)
1397
+ pnpm deploy:aws
1239
1398
  node apps/aws-stack/scripts/smoke.mjs \
1240
1399
  --url wss://<id>.execute-api.<region>.amazonaws.com/prod
1241
1400
  aws logs tail /aws/lambda/IrcHandler --follow # live logs
1242
1401
 
1243
- # Production (manual, separate credentials)
1244
- pnpm deploy:aws:prod
1402
+ # Web client (opt-in; provision via webSite/webSiteCustomDomain context)
1403
+ pnpm deploy:aws -- -c webSiteCustomDomain=app.example.com \
1404
+ -c webSiteCertificateArn=arn:aws:acm:us-east-1:... \
1405
+ -c webSiteHostedZoneName=example.com. -c webSiteHostedZoneId=... \
1406
+ -c webOrigins=https://app.example.com # CSWSH (§8.2, §16.4)
1407
+ node scripts/deploy-web-aws.mjs # bake + s3 sync + invalidate (§16.3)
1245
1408
 
1246
1409
  # Tests / lint
1247
1410
  pnpm test # full workspace
@@ -1263,9 +1426,420 @@ Key files:
1263
1426
  | `packages/aws-adapter/src/tables.ts` | Runtime table-name + key-column constants. |
1264
1427
  | `packages/aws-adapter/src/config-loader.ts` | Lambda env → `ServerConfig` schema mapping. |
1265
1428
  | `packages/aws-adapter/src/handlers/index.ts` | Lambda entry points (`handler`, `sweeperHandler`, `pingCheckerHandler`). |
1429
+ | `apps/aws-stack/src/static-site.ts` | CDK construct for the optional web client (S3 + CloudFront + OAC). See §16. |
1430
+ | `scripts/deploy-web-aws.mjs` | Two-phase web client deploy helper. See §16. |
1266
1431
  | `.github/workflows/deploy-aws.yml` | Staging deploy + smoke e2e CI. |
1267
1432
 
1268
- ### Known gaps flagged for follow-up
1433
+ ---
1434
+
1435
+ ## 16. Web client (S3 + CloudFront + OAC)
1436
+
1437
+ The CF Worker serves the SPA from the same origin as the WebSocket
1438
+ (one Worker, one domain). AWS **cannot** do this — API Gateway
1439
+ WebSocket and a static SPA host are different AWS services on
1440
+ different endpoints — so the AWS web client splits in two: the wss
1441
+ IRC endpoint stays on API Gateway, and the SPA is served from an S3
1442
+ bucket fronted by CloudFront. This § documents the architecture, the
1443
+ build, the deploy pipeline, the CSWSH defence (mandatory once the
1444
+ SPA ships), and the optional custom-domain setup.
1445
+
1446
+ > **Cross-reference:** `docs/WebClientGuide.md` covers the
1447
+ > Cloudflare path (one origin for HTTP + WebSocket). The CF-vs-AWS
1448
+ > architectural difference is called out in §16.5 below.
1449
+
1450
+ ### 16.1 What gets provisioned
1451
+
1452
+ The `StaticSite` construct (`apps/aws-stack/src/static-site.ts`,
1453
+ provisioned only when `IrcStackProps.webSite` is supplied) creates:
1454
+
1455
+ - a **private S3 bucket** holding the `apps/web/dist` build artifact
1456
+ (`index.html` for the landing page, `webclient/index.html` for the
1457
+ Kiwi SPA, `webclient/static/*` for hashed JS/CSS) — the same layout
1458
+ the CF Worker's `[assets]` binding serves, so one artifact ships on
1459
+ both platforms unchanged;
1460
+ - a **CloudFront distribution** with an S3 origin pointing at the
1461
+ bucket via **Origin Access Control (OAC)** — the modern replacement
1462
+ for the legacy OAI. Direct S3 access (`https://<bucket>.s3.…/…`)
1463
+ is denied by the bucket policy; only CloudFront URLs resolve;
1464
+ - `defaultRootObject = index.html` so requests to `/` serve the
1465
+ project landing page (the static HTML front door with links to the
1466
+ SPA, the rendered docs, and the source repo);
1467
+ - a **custom error response** mapping S3's `403`/`404` → `200` with
1468
+ response page `/webclient/index.html`, so client-side routes under
1469
+ `/webclient/*` resolve to the SPA shell (Kiwi is hash/history-routed);
1470
+ - three CFN outputs at the parent-stack level (stable logical IDs):
1471
+ `WebsiteURL` (the `*.cloudfront.net` default OR the custom domain),
1472
+ `WebsiteBucketName`, `WebsiteDistributionId`. The deploy pipeline
1473
+ (§16.3) consumes the latter two.
1474
+
1475
+ **Why not Amplify / why not S3-only:** Amplify Hosting adds a managed
1476
+ build step the project doesn't need (the SPA build is already
1477
+ `pnpm --filter web build:prod-aws`); S3-only (no CloudFront) forces
1478
+ either a public bucket (anti-pattern) or a signed-URL-per-asset
1479
+ scheme (heavy). CloudFront + OAC is the AWS-recommended static-site
1480
+ shape and gives HTTPS on `*.cloudfront.net` for free.
1481
+
1482
+ ### 16.2 Opt-in: provision the web client
1483
+
1484
+ The web client is **opt-in**. To provision it, pass `webSite` props
1485
+ on the stack. From CDK context (the easiest path), set
1486
+ `webSiteCustomDomain`:
1487
+
1488
+ ```bash
1489
+ pnpm deploy:aws -- \
1490
+ -c webSiteCustomDomain=app.example.com \
1491
+ -c webSiteCertificateArn=arn:aws:acm:us-east-1:111122223333:certificate/abc-def-... \
1492
+ -c webSiteHostedZoneName=example.com. \
1493
+ -c webSiteHostedZoneId=Z2KVMGOMGMOU2
1494
+ ```
1495
+
1496
+ The ACM certificate **must** be in `us-east-1` (CloudFront requirement).
1497
+ Hosted-zone name + ID are both required because CDK's
1498
+ `fromHostedZoneAttributes` needs both; retrieve them once via
1499
+ `aws route53 list-hosted-zones-by-name --dns-name <zone>`.
1500
+
1501
+ > **Default-`*.cloudfront.net` only (no custom domain) gap:**
1502
+ > `bin/aws.ts` currently keys the `webSite` prop's existence on
1503
+ > `webSiteCustomDomain` being set, so provisioning the construct
1504
+ > without a custom domain requires editing `bin/aws.ts` to pass
1505
+ > `webSite: {}` directly. Tracked as a follow-up. For v1 deploys,
1506
+ > supply a custom domain (prod-style) or patch `bin/aws.ts`.
1507
+
1508
+ ### 16.3 Two-phase deploy (stack → bake → s3 sync → invalidate)
1509
+
1510
+ Unlike the CF Worker (where `wrangler deploy` ships the SPA in the
1511
+ same command as the runtime), the AWS SPA deploy is **two-phase** and
1512
+ **stack-output-driven**: the CloudFormation stack must exist before
1513
+ the SPA can be baked, because the baked `config.json` carries the
1514
+ API Gateway WebSocket URL as a literal. The deploy pipeline
1515
+ (`scripts/deploy-web-aws.mjs`) runs:
1516
+
1517
+ 1. `aws cloudformation describe-stacks --stack-name IrcAwsStack`
1518
+ reads the `ConnectUrl`, `WebsiteBucketName`, and
1519
+ `WebsiteDistributionId` outputs. If any are missing the script
1520
+ exits non-zero ("stack not deployed" or "web client construct not
1521
+ provisioned").
1522
+ 2. `pnpm --filter @serverless-ircd/web run build:prod-aws -- --api-url <ConnectUrl>`
1523
+ bakes the SPA with the real API Gateway URL. The `--api-url` flag is
1524
+ overridden **in memory** after Zod parse — the source
1525
+ `apps/web/static/config.prod-aws.json` is never mutated, so the
1526
+ templated `{{API_ID}}.execute-api.{{REGION}}.amazonaws.com` host and
1527
+ `/{{STAGE}}` path placeholders stay in the repo and no concrete URL
1528
+ is committed. The build rejects any leftover `{{...}}` placeholders.
1529
+
1530
+ The flag value (`wss://<id>.execute-api.<region>.amazonaws.com/<stage>`)
1531
+ is **split** by `parseApiUrlForKiwi` into Kiwi's two URL fields:
1532
+ `server` ← `<id>.execute-api.<region>.amazonaws.com` (hostname only,
1533
+ no scheme) and `direct_path` ← `/<stage>`. irc-framework builds the
1534
+ WS URL itself as `wss://<server>:<port><direct_path>` — it prepends
1535
+ `wss://` whenever `tls: true`, so passing the full URL through to
1536
+ `server` would double the scheme (`wss://wss//<host>...`) and Firefox
1537
+ would reject the connection. The split keeps `server` host-only and
1538
+ routes the API Gateway stage name through `direct_path`.
1539
+ 3. `aws s3 sync apps/web/dist s3://<WebsiteBucketName> --delete`
1540
+ uploads the build. The CLI skips unchanged objects via etag
1541
+ comparison, so a re-deploy with no SPA changes uploads nothing.
1542
+ (This is why the deploy uses the AWS CLI rather than the SDK —
1543
+ `s3 sync`'s etag-based unchanged-skip is non-trivial to reproduce
1544
+ on `@aws-sdk/client-s3`.)
1545
+ 4. `aws cloudfront create-invalidation --distribution-id <WebsiteDistributionId> --paths /*`
1546
+ busts the edge cache so the next visitor gets the new build.
1547
+
1548
+ **CI wiring:** the `deploy-web` job in `.github/workflows/deploy-aws.yml`
1549
+ runs `node scripts/deploy-web-aws.mjs` with `needs: deploy` (after the
1550
+ CDK stack applies). Both jobs are `workflow_dispatch`-only — a
1551
+ maintainer runs the workflow by hand (T170 collapsed the staging/prod
1552
+ split; there is no push trigger and no `staging` branch).
1553
+
1554
+ **Why a script instead of CDK `BucketDeployment`:** `BucketDeployment`
1555
+ re-synths on every deploy, is slower, and — critically — cannot bake
1556
+ a post-deploy stack output (the API Gateway URL) into the SPA config,
1557
+ because at synth time the URL does not exist yet. The two-phase
1558
+ script reads the output, then bakes.
1559
+
1560
+ ### 16.4 CSWSH defence (`WEB_ORIGINS`) — mandatory once the SPA ships
1561
+
1562
+ **Once you deploy the web client, set `WEB_ORIGINS`.** WebSocket
1563
+ upgrades do not follow the same-origin policy, so a malicious web
1564
+ page can otherwise open a wss to the IRC endpoint from a victim's
1565
+ browser and drive the session with the victim's credentials
1566
+ (Cross-Site WebSocket Hijacking, CSWSH). API Gateway WebSocket has
1567
+ no built-in Origin validation — the `$connect` Lambda enforces it.
1568
+
1569
+ Pass the SPA's origin alongside the `webSite` props:
1570
+
1571
+ ```bash
1572
+ pnpm deploy:aws -- \
1573
+ -c webSiteCustomDomain=app.example.com \
1574
+ -c webSiteCertificateArn=... \
1575
+ -c webSiteHostedZoneName=example.com. \
1576
+ -c webSiteHostedZoneId=... \
1577
+ -c webOrigins=https://app.example.com
1578
+ ```
1579
+
1580
+ The full behaviour matrix, matching semantics, and the deliberate
1581
+ divergence from the CF Worker policy (no same-origin auto-derive on
1582
+ AWS — the SPA and the wss endpoint are on different origins) are in
1583
+ **§8.2**. Unset `WEB_ORIGINS` disables the check; this is why the
1584
+ defence is documented as "mandatory-once-the-SPA-ships" rather than
1585
+ "mandatory always" — bare-IRC deployments without a web frontend
1586
+ (`curl`, WeeChat, `tcp-ws-forwarder`) never send `Origin` and are
1587
+ correctly served without the knob.
1588
+
1589
+ ### 16.5 CF-vs-AWS: one origin vs two origins
1590
+
1591
+ The architectural difference is the reason this § exists:
1592
+
1593
+ | Platform | HTTP (SPA) origin | WebSocket origin | Same-origin? |
1594
+ |----------|---------------------------------|-----------------------------------|--------------|
1595
+ | CF | the Worker (`*.workers.dev` / custom domain) | the same Worker | **Yes** — one domain serves both |
1596
+ | AWS | CloudFront (`*.cloudfront.net` / custom domain) | API Gateway (`*.execute-api.*.amazonaws.com`) | **No** — two different endpoints |
1597
+
1598
+ Two practical consequences:
1599
+
1600
+ 1. **The baked SPA config (`config.prod-aws.json`) carries a templated
1601
+ host + stage path** because the browser cannot infer either from
1602
+ `window.location`. The CF config (`config.prod.json`) uses Kiwi's
1603
+ `{{hostname}}` template resolved at load time and no `direct_path`
1604
+ (the Worker's WS upgrade is at root). The AWS config templates
1605
+ `server` = `{{API_ID}}.execute-api.{{REGION}}.amazonaws.com` (host
1606
+ only — irc-framework prepends `wss://` from `tls: true`) and
1607
+ `direct_path` = `/{{STAGE}}` (the API Gateway stage segment). The
1608
+ AWS deploy pipeline splits the stack-output URL into these two
1609
+ fields at bake time (§16.3 step 2).
1610
+ 2. **The CF Worker's CSWSH defence has a same-origin auto-derive
1611
+ mode** (when `WEB_ORIGINS` is unset, compare `Origin` against the
1612
+ request's own `Host`). AWS cannot use auto-derive — the request's
1613
+ own host is the API Gateway domain, never the SPA's — so the AWS
1614
+ defence is explicit-allowlist only (§8.2).
1615
+
1616
+ ### 16.6 Custom domain (ACM + CloudFront alias + Route53)
1617
+
1618
+ `StaticSite` accepts four optional props for the custom domain (see
1619
+ `apps/aws-stack/src/static-site.ts`):
1620
+
1621
+ | Prop | Purpose |
1622
+ |-------------------|----------------------------------------------------------------------|
1623
+ | `customDomain` | The hostname to serve the SPA on (e.g. `app.example.com`). |
1624
+ | `certificateArn` | ARN of an ACM certificate covering `customDomain`. Must be in `us-east-1`. |
1625
+ | `hostedZoneName` | Public Route53 hosted zone name (e.g. `example.com.`). |
1626
+ | `hostedZoneId` | Public Route53 hosted zone ID for the above. |
1627
+
1628
+ When all four are supplied (the CDK context wiring in `bin/aws.ts`
1629
+ handles the optional spread), the construct adds the alias to the
1630
+ CloudFront distribution and creates a Route53 `ARecord` aliasing the
1631
+ distribution. When `customDomain` is omitted, the distribution serves
1632
+ on its default `*.cloudfront.net` URL — but see the gap note in
1633
+ §16.2 (the current `bin/aws.ts` wiring only provisions the construct
1634
+ at all when `webSiteCustomDomain` is set).
1635
+
1636
+ ACM certificate validation: the certificate must be DNS-validated
1637
+ and in `ISSUED` status before `cdk deploy` runs — CloudFront will
1638
+ not deploy an alias to a pending certificate. Create it ahead of time
1639
+ in `us-east-1` via the console or:
1640
+
1641
+ ```bash
1642
+ aws acm request-certificate \
1643
+ --domain-name app.example.com \
1644
+ --validation-method DNS \
1645
+ --region us-east-1
1646
+ # add the returned CNAME to Route53, wait for ISSUED
1647
+ ```
1648
+
1649
+ ### 16.7 Verification (manual smoke check)
1650
+
1651
+ After a `deploy-web` job completes:
1652
+
1653
+ ```bash
1654
+ # Stack outputs
1655
+ aws cloudformation describe-stacks --stack-name IrcAwsStack \
1656
+ --query 'Stacks[0].Outputs[?OutputKey==`WebsiteURL`].OutputValue' --output text
1657
+
1658
+ # Landing page
1659
+ curl -sI https://<distribution-or-custom-domain>/ | head -1
1660
+ # Landing page (the project front door — links to SPA + docs + source repo)
1661
+ curl -sI https://<distribution-or-custom-domain>/ | head -1
1662
+ # SPA shell (200, served via the 404→/webclient/index.html error mapping for /webclient/* routes)
1663
+ curl -sI https://<distribution-or-custom-domain>/webclient/ | head -1
1664
+ # Baked config — server MUST be the real wss URL, not a {{...}} template
1665
+ curl -s https://<distribution-or-custom-domain>/webclient/static/config.json | jq .startupOptions.server
1666
+ ```
1667
+
1668
+ Open `https://<distribution-or-custom-domain>/webclient/` in a browser,
1669
+ register, join `#welcome`, send a `PRIVMSG`, see the echo. (Visiting the
1670
+ root `https://<distribution-or-custom-domain>/` serves the landing page,
1671
+ which links to the SPA at `/webclient/`.) If the SPA loads but never
1672
+ connects, check (1) the baked `config.json` `server` matches the stack's
1673
+ `ConnectUrl` output, (2) `WEB_ORIGINS` (§16.4) includes the CloudFront
1674
+ origin you're loading the SPA from, and (3) the `$connect` route is
1675
+ reachable.
1676
+
1677
+ ---
1678
+
1679
+ ## 17. OIDC deploy role (CI)
1680
+
1681
+ The GitHub Actions deploy workflow (§12) accepts **only** GitHub OIDC
1682
+ web-identity credentials. Long-lived access keys
1683
+ (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) are NOT supported — a
1684
+ single exfiltration vector against them (rogue PR `printenv`,
1685
+ compromised third-party Action, poisoned runner) yields full AWS
1686
+ account takeover because the deploy role can manage CloudFormation,
1687
+ IAM, Lambda, and DynamoDB. This section documents the operator-side
1688
+ setup that the `AWS_DEPLOY_ROLE_ARN` repo secret points at.
1689
+
1690
+ ### 17.1 One-time AWS account setup
1691
+
1692
+ 1. **Create the GitHub OIDC identity provider** (one per AWS account,
1693
+ idempotent):
1694
+
1695
+ ```bash
1696
+ aws iam create-open-id-connect-provider \
1697
+ --url https://token.actions.githubusercontent.com \
1698
+ --client-id-list sts.amazonaws.com \
1699
+ --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 \
1700
+ --tags Key=managed-by,Value=github-actions
1701
+ ```
1702
+
1703
+ > The `--thumbprint-list` above is the well-known root CA
1704
+ > thumbprint GitHub's OIDC tokens chain to. AWS now also accepts
1705
+ > the trust-root automatically, but specifying it keeps the
1706
+ > provider deterministic across accounts.
1707
+
1708
+ 2. **Create the deploy role** with the trust policy below (save as
1709
+ `trust-policy.json` then `create-role`). The single condition key
1710
+ binds the role to this repository's `main` branch — the only ref
1711
+ the `workflow_dispatch` deploy workflow can run from:
1712
+
1713
+ ```json
1714
+ {
1715
+ "Version": "2012-10-17",
1716
+ "Statement": [
1717
+ {
1718
+ "Effect": "Allow",
1719
+ "Principal": {
1720
+ "Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
1721
+ },
1722
+ "Action": "sts:AssumeRoleWithWebIdentity",
1723
+ "Condition": {
1724
+ "StringEquals": {
1725
+ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
1726
+ "token.actions.githubusercontent.com:sub": "repo:<owner>/<repo>:ref:refs/heads/main"
1727
+ }
1728
+ }
1729
+ }
1730
+ ]
1731
+ }
1732
+ ```
1733
+
1734
+ Replace `<ACCOUNT_ID>` with the AWS account id and `<owner>/<repo>`
1735
+ with the GitHub owner/repo (e.g. `ServerlessIRCd/ServerlessIRCd`).
1736
+ The `sub` condition is the security boundary: only workflows
1737
+ running on a ref matching `refs/heads/main` in this exact repo can
1738
+ assume the role. Do NOT widen it to `StringLike` with a wildcard —
1739
+ that lets forked PRs mint credentials.
1740
+
1741
+ ```bash
1742
+ aws iam create-role \
1743
+ --role-name IrcDeployRole \
1744
+ --assume-role-policy-document file://trust-policy.json \
1745
+ --description "GitHub Actions OIDC role for IrcAwsStack deploys"
1746
+
1747
+ # Note the returned ARN — this becomes AWS_DEPLOY_ROLE_ARN.
1748
+ aws iam get-role --role-name IrcDeployRole --query 'Role.Arn' --output text
1749
+ ```
1750
+
1751
+ 3. **Attach a permission policy.** The role needs the same surface
1752
+ `cdk deploy` uses: IAM (pass-role + role creation), API Gateway v2,
1753
+ Lambda, DynamoDB, EventBridge, CloudFormation, S3 (the CDK
1754
+ bootstrap bucket), and logs. The CDK bootstrap already creates a
1755
+ `cdk-hnb659fds-deploy-role-<account>-<region>` role with the right
1756
+ shape — for a v1 deployment, the simplest pattern is to let the
1757
+ GitHub-OIDC role `sts:AssumeRole` the CDK deploy role:
1758
+
1759
+ ```json
1760
+ {
1761
+ "Version": "2012-10-17",
1762
+ "Statement": [
1763
+ {
1764
+ "Effect": "Allow",
1765
+ "Action": "sts:AssumeRole",
1766
+ "Resource": "arn:aws:iam::<ACCOUNT_ID>:role/cdk-hnb659fds-deploy-role-<ACCOUNT_ID>-<REGION>"
1767
+ }
1768
+ ]
1769
+ }
1770
+ ```
1771
+
1772
+ Attach this as an inline policy on `IrcDeployRole`. (If you would
1773
+ rather grant the deploy permissions directly on `IrcDeployRole`,
1774
+ mirror the statement list from the CDK deploy role's
1775
+ `DefaultDeploymentRole`.)
1776
+
1777
+ 4. **Store the role ARN as the `AWS_DEPLOY_ROLE_ARN` repo secret**
1778
+ (Settings → Secrets and variables → Actions → New repository
1779
+ secret). This is the **only** AWS-shaped secret the workflow reads.
1780
+
1781
+ ### 17.2 Condition key reference
1782
+
1783
+ | JWT claim (`token.actions.githubusercontent.com:<key>`) | Value to bind | Meaning |
1784
+ |---------------------------------------------------------|------------------------------------------------------------|----------------------------------------------------------------------|
1785
+ | `aud` | `sts.amazonaws.com` | Required. AWS is the intended audience of the OIDC token. |
1786
+ | `sub` | `repo:<owner>/<repo>:ref:refs/heads/main` | Required. Pinning to `main` matches the `workflow_dispatch` trigger. |
1787
+
1788
+ The workflow's `on:` block is `workflow_dispatch` only, and a
1789
+ maintainer runs it from the `main` branch in the GitHub UI, so the
1790
+ `sub` claim always carries `:ref:refs/heads/main`. If you later add a
1791
+ push trigger on another branch, add a second condition entry rather
1792
+ than loosening this one.
1793
+
1794
+ ### 17.3 CI verification (operator-run, out-of-band)
1795
+
1796
+ Once the role and secret are configured, verify end-to-end by running
1797
+ the workflow from the Actions UI against a sandbox account. The
1798
+ workflow's `Assert assumed role matches AWS_DEPLOY_ROLE_ARN` step
1799
+ prints the assumed role-name + account on success:
1800
+
1801
+ ```
1802
+ OIDC role assertion passed: IrcDeployRole in account 111122223333
1803
+ ```
1804
+
1805
+ If that step fails, the trust policy condition keys do not match the
1806
+ workflow's `sub` claim — re-check `repo:<owner>/<repo>` and the branch
1807
+ ref. The workflow aborts BEFORE `cdk deploy`, so a misconfigured trust
1808
+ policy cannot mutate the stack.
1809
+
1810
+ Authoritative reference: GitHub's "Configuring OpenID Connect in
1811
+ Amazon Web Services" guide
1812
+ (<https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services>)
1813
+ and the AWS "Creating OpenID Connect (OIDC) identity providers" guide
1814
+ walk through the IAM-side setup in more detail.
1815
+
1816
+ ### 17.4 What the workflow enforces in-repo
1817
+
1818
+ The CI-hardening test suite (`tools/ci-hardening/tests/deploy-aws-oidc.test.ts`)
1819
+ asserts the workflow shape so the security property cannot regress
1820
+ silently:
1821
+
1822
+ - No `aws-access-key-id` / `aws-secret-access-key` inputs to
1823
+ `configure-aws-credentials@v4` anywhere in the file.
1824
+ - Top-level `permissions:` block grants exactly
1825
+ `{ id-token: write, contents: read }` (least-privilege).
1826
+ - Every `configure-aws-credentials` step has a `role-to-assume` input.
1827
+ - The `deploy` and `deploy-web` jobs both have an
1828
+ `if: env.AWS_DEPLOY_ROLE_ARN == ''` guard that runs `exit 1` before
1829
+ any AWS call.
1830
+ - The `deploy` job runs `aws sts get-caller-identity` and references
1831
+ `AWS_DEPLOY_ROLE_ARN` before any `cdk deploy` (defence-in-depth
1832
+ against silent OIDC fallback).
1833
+
1834
+ The `deploy-web` job (TICKET-167, §16) inherits the same OIDC model:
1835
+ it has its own empty-ARN guard before `configure-aws-credentials` and
1836
+ assumes the same role via web identity — no static keys, no ambient
1837
+ credential inheritance from the parent `deploy` job. The two jobs are
1838
+ independent `workflow_dispatch` runs against the same role ARN.
1839
+
1840
+ ---
1841
+
1842
+ ## 18. Open questions / future work
1269
1843
 
1270
1844
  These are facts the **code does not yet have an answer for**; the
1271
1845
  recommendations above are deployer guidance, not built-in knobs: