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
package/README.md CHANGED
@@ -32,20 +32,60 @@ One TypeScript codebase. Two serverless substrates.
32
32
  >
33
33
  > **Web client.** A vendored **Kiwi IRC** SPA is served at
34
34
  > `/webclient/` and a static landing page at `/` directly by the
35
- > Cloudflare Worker, with **Cross-Site WebSocket Hijacking (CSWSH)
36
- > defense** wired into the WS upgrade path (same-origin auto-derive by
37
- > default, optional `WEB_ORIGINS` allowlist for cross-origin
38
- > deploys). The `docs/` wiki is rendered to standalone HTML at
39
- > `/docs/`.
35
+ > Cloudflare Worker (via its `[assets]` binding) **or** by AWS via an
36
+ > **S3 + CloudFront + OAC** `StaticSite` construct (opt-in via CDK
37
+ > context). One `apps/web/dist/` artifact ships on both platforms
38
+ > unchanged. The `docs/` wiki is rendered to standalone HTML at
39
+ > `/docs/`. Both platforms wire **Cross-Site WebSocket Hijacking
40
+ > (CSWSH) defense** into the WS upgrade path: the CF Worker uses
41
+ > same-origin auto-derive by default with an optional `WEB_ORIGINS`
42
+ > allowlist for cross-origin deploys; the AWS `$connect` Lambda uses
43
+ > an explicit `WEB_ORIGINS` allowlist only (the SPA and the API
44
+ > Gateway wss endpoint are on different origins, so auto-derive does
45
+ > not apply). AWS deploys are **stack-output-driven** (a two-phase
46
+ > `scripts/deploy-web-aws.mjs` bakes the real `ConnectUrl` into the
47
+ > SPA config, then `s3 sync` + CloudFront invalidation).
48
+ >
49
+ > **Deploy security.** The AWS CI deploy is **OIDC-only** — the
50
+ > GitHub Actions workflow assumes an IAM role via GitHub OIDC web
51
+ > identity (no long-lived access keys, with an in-workflow assertion
52
+ > that the assumed role matches `AWS_DEPLOY_ROLE_ARN` before any
53
+ > `cdk deploy`). API Gateway full-frame body logging
54
+ > (`DataTraceEnabled`) is **off by default and hard-locked**, so IRC
55
+ > frames (`PASS`, `AUTHENTICATE <SASL-PLAIN>`, channel keys,
56
+ > `PRIVMSG`/`NOTICE`) are never written to CloudWatch; the only way
57
+ > back on is an explicit two-flag sandbox escape hatch. The CF deploy
58
+ > workflow refuses placeholder hostnames (`irc.example.com` /
59
+ > `irc.your-domain.invalid`), and a CI **env-var drift guard** keeps
60
+ > the `consumed-env-vars` block of `apps/cf-worker/wrangler.toml`
61
+ > exactly in sync with the config loader.
62
+ >
63
+ > **Abuse controls & hardening.** Layered admission control on both
64
+ > platforms: a global `MAX_CLIENTS` cap (Cloudflare reserves a
65
+ > `CounterDO` slot per upgrade; AWS keeps an atomic connection counter
66
+ > — over-cap connects get `429`), per-IP simultaneous-connection caps
67
+ > and sliding-window connect-rate limits (a `RateLimitDO` at the CF
68
+ > edge; a source-IP GSI count + APIGW stage throttling + an opt-in
69
+ > WAFv2 per-IP rate rule on AWS), and a per-connection inbound
70
+ > frame-rate window at every adapter boundary. Credential handling is
71
+ > hardened end-to-end: scrypt-hashed oper credentials, constant-time
72
+ > server-password comparison, timing-equalized nick verification (no
73
+ > account enumeration), SASL/OPER/IDENTIFY brute-force lockouts, and
74
+ > credential redaction from parse-error logs. SASL `EXTERNAL` is
75
+ > **operator opt-in** (`EXTERNAL_ENABLED`, default off), gated on a
76
+ > bound mTLS identity source **and** a TLS transport, and binds
77
+ > accounts to the client cert's **DER SHA-256 fingerprint**
78
+ > (`fp:<hex>`, with a canonical-DN fallback).
40
79
  >
41
80
  > **Integrated IRC services.** NickServ, ChanServ, HostServ,
42
81
  > OperServ, and MemoServ run inside the daemon (no separate services
43
82
  > process, no S2S link) backed by a `ServicesStore` port with
44
83
  > **persistent D1 (Cloudflare) and DynamoDB (AWS) backends**
45
- > (write-behind, surviving redeploys). The SASL `AccountStore` and
46
- > NickServ accounts are **unified** under one scrypt-hashed credential
47
- > store, `PASS <nick>:<password>` identifies at or after registration,
48
- > and `draft/read-marker` / `draft/pre-away` persist through the
84
+ > (write-behind, surviving redeploys). The `ServicesStore` is the
85
+ > **single credential home**: SASL PLAIN, SASL EXTERNAL (CertFP),
86
+ > NickServ `IDENTIFY`, and `PASS <nick>:<password>` all verify through
87
+ > the same scrypt-hashed `verifyNick` / `verifyCertFP` surface, and
88
+ > `draft/read-marker` / `draft/pre-away` persist through the
49
89
  > `ServicesStore`.
50
90
  >
51
91
  > See `CHANGELOG.md` for the per-release manifests.
@@ -102,12 +142,18 @@ Hexagonal / ports-and-adapters. The core implements the IRC protocol; adapters h
102
142
  **Two transports, one core.** Both adapters accept WebSocket text frames
103
143
  (the serverless default) **and** a raw `irc+tls :6697` (TLS-over-TCP)
104
144
  surface for stock IRC clients. The TCP+TLS edge is platform-specific —
105
- Cloudflare **Spectrum** terminates TLS and forwards plaintext TCP to a
106
- stateful **Container** origin (`apps/cf-tcp-container`); AWS terminates
107
- TLS at a **Network Load Balancer** and invokes a **Lambda streaming**
108
- function. Both feed the same `ConnectionActor` through a `Transport`
109
- seam (`WsTextFrameTransport` vs. `TcpByteStreamTransport`); the
110
- parser/reducer/dispatch pipeline is identical.
145
+ Cloudflare **Spectrum** fronts a stateful **Container** origin
146
+ (`apps/cf-tcp-container`) with `proxy_protocol = "v1"` and
147
+ `tls_mode = "full"`: the container itself terminates TLS (pinned to
148
+ 1.2/1.3), requires and parses a PROXY v1 header on every flow to recover
149
+ the real client IP, and runs as a non-root user. AWS terminates TLS at a
150
+ **Network Load Balancer** — the streaming handler asserts the flow is
151
+ TLS-secured (surfacing user mode `S`), rejects missing or malformed flow
152
+ headers, and enforces the same per-IP connect-rate budget — and invokes a
153
+ **Lambda streaming** function. Both feed the same `ConnectionActor`
154
+ through a `Transport` seam (`WsTextFrameTransport` vs.
155
+ `TcpByteStreamTransport`); the parser/reducer/dispatch pipeline is
156
+ identical.
111
157
 
112
158
  ### Design: pure reducers + location-of-authority
113
159
 
@@ -158,9 +204,13 @@ ServerlessIRCd/
158
204
  ├── tools/
159
205
  │ ├── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
160
206
  │ ├── load-test/ synthetic WebSocket IRC client pool (10k conns, p50/p95/p99, drop rate)
161
- │ ├── ci-hardening/ coverage-gate + mutation-config validators
207
+ │ ├── ci-hardening/ coverage-gate + mutation-config + env-var-drift validators
208
+ │ ├── hash-oper-cred.ts scrypt oper-credential generator (OPER_SALT + OPER_HASH)
162
209
  │ ├── seed-aws-accounts.ts scrypt-hash SASL PLAIN accounts into DynamoDB
163
- └── seed-cf-accounts.ts scrypt-hash SASL PLAIN accounts into Cloudflare D1
210
+ ├── seed-cf-accounts.ts scrypt-hash SASL PLAIN accounts into Cloudflare D1
211
+ │ └── migrate-accounts-to-services.ts one-shot AccountStore→ServicesStore credential backfill
212
+ ├── scripts/
213
+ │ └── deploy-web-aws.mjs stack-output-driven AWS web client deploy (describe → bake → s3 sync → invalidate)
164
214
  ├── pnpm-workspace.yaml turbo.json tsconfig.base.json
165
215
  └── README.md CHANGELOG.md
166
216
  ```
@@ -220,8 +270,8 @@ pnpm --filter web build # builds the Kiwi SPA into apps/web/dist/webclient/ (/
220
270
  Coverage reports are written to `packages/*/coverage/`. CI (`.github/workflows/ci.yml`)
221
271
  runs lint, typecheck, the coverage gate, the parametrized contract suite, and a
222
272
  Stryker mutation spot-check on every push and pull request. Coverage thresholds
223
- enforce 100% on `irc-core` / `irc-server` / `in-memory-runtime` and ≥90% on every
224
- other package.
273
+ enforce 100% on `irc-core` / `irc-server` / `in-memory-runtime` / `ci-hardening`
274
+ and ≥90% on every other package.
225
275
 
226
276
  ---
227
277
 
@@ -296,14 +346,15 @@ treats `state.account !== undefined` as already authorised, so a
296
346
  deployment with both `SERVER_PASSWORD` and configured SASL accounts
297
347
  does not need to hand the shared password to identified users. The
298
348
  server-wide gate is a shared deployment secret (never log it); per-user
299
- credentials still go through the hashed SASL account store
300
- (`HashedAccountStore` / D1 / DynamoDB), which is unaffected.
349
+ credentials still go through the unified services credential store
350
+ (`ServicesStore.verifyNick` D1 `nickserv_accounts` / DynamoDB
351
+ `Services`), which is unaffected.
301
352
 
302
353
  **PASS-based account login:** in addition to SASL, a client may identify
303
354
  to its NickServ account by sending `PASS <nick>:<password>` (the same
304
355
  `<nick>:<password>` literal the `SASL_ACCOUNTS` seed tooling uses).
305
- When an `AccountStore` is configured, `NICK alice` + `PASS alice:hunter2`
306
- + `USER …` verifies the credentials against the account store and, on
356
+ When a `ServicesStore` is configured, `NICK alice` + `PASS alice:hunter2`
357
+ + `USER …` verifies the credentials via `services.verifyNick` and, on
307
358
  success, logs the connection in as `alice` — emitting `900 RPL_LOGGEDIN`
308
359
  + `903 RPL_SASLSUCCESS` (the same "account is set" numerics SASL uses)
309
360
  before `001 RPL_WELCOME`, stamping user mode `+r`, and running the same
@@ -442,12 +493,22 @@ report).
442
493
  ## Web client (`apps/web`)
443
494
 
444
495
  A vendored **Kiwi IRC** SPA served at `/webclient/` and a static project
445
- landing page served at `/`, both baked out of `apps/web/dist/` and
446
- served directly by the Cloudflare Worker via its `[assets]` binding.
447
- The browser opens a native `wss://` directly to the Worker's
448
- IRC-over-WebSocket endpoint no proxy, gateway, or transport
449
- adaptation in the request path. See `docs/WebClientGuide.md` for the
450
- end-to-end contributor/operator guide.
496
+ landing page served at `/`, both baked out of `apps/web/dist/`. The same
497
+ artifact is served by **both** platforms:
498
+
499
+ | Platform | HTTP/SPA origin | WebSocket origin | Same-origin? |
500
+ |-------------|----------------------------------------------------|---------------------------------------------------|--------------|
501
+ | Cloudflare | the Worker (`*.workers.dev` / custom domain) | the same Worker | **Yes** — one domain serves both |
502
+ | AWS | CloudFront (`*.cloudfront.net` / custom domain) | API Gateway (`*.execute-api.*.amazonaws.com`) | **No** — two different endpoints |
503
+
504
+ On Cloudflare the Worker's `[assets]` binding serves the SPA and the WS
505
+ upgrade on one origin — the browser opens a native `wss://` directly to
506
+ the Worker, no proxy or gateway. On AWS the SPA is fronted by an
507
+ **S3 + CloudFront + OAC** `StaticSite` construct
508
+ (`apps/aws-stack/src/static-site.ts`, opt-in via CDK context) and a
509
+ two-phase deploy bakes the real `ConnectUrl` into the SPA config (see
510
+ "Deploying on AWS" below). See `docs/WebClientGuide.md` for the CF
511
+ end-to-end guide and `docs/AWS-Deployment.md` §16 for the AWS path.
451
512
 
452
513
  Build the SPA + landing page (needs the submodule + `yarn`, provided by
453
514
  `corepack enable`):
@@ -455,7 +516,7 @@ Build the SPA + landing page (needs the submodule + `yarn`, provided by
455
516
  ```bash
456
517
  git submodule update --init apps/web/upstream # one-time per clone
457
518
  git submodule update --init docs # one-time per clone (docs site)
458
- pnpm --filter web build:staging # or :prod / default
519
+ pnpm --filter web build # or build:prod (CF) / build:prod-aws (AWS)
459
520
  # → apps/web/dist/index.html (landing page, served at /)
460
521
  # → apps/web/dist/webclient/index.html (Kiwi SPA, served at /webclient/)
461
522
  # → apps/web/dist/webclient/static/config.json (baked, env-specific)
@@ -470,8 +531,10 @@ existing `[assets]` binding serves them at `/docs/<slug>.html` (and
470
531
  command if it is missing or empty (mirroring the Kiwi `upstream/` guard).
471
532
  `Home.md` is the docs landing page; `ADR-Index.md` lists every ADR.
472
533
 
473
- Then run everything locally (Worker serves SPA + landing page + WS on
474
- one origin):
534
+ ### Deploying on Cloudflare
535
+
536
+ Run everything locally (Worker serves SPA + landing page + WS on one
537
+ origin):
475
538
 
476
539
  ```bash
477
540
  pnpm build # workspace packages
@@ -482,18 +545,73 @@ pnpm --filter @serverless-ircd/cf-worker dev # http://localhost:8787
482
545
  # /health → plaintext liveness
483
546
  ```
484
547
 
485
- Deploy staging (Worker + assets in one command):
548
+ Deploy the Worker (Worker + assets in one command):
549
+
550
+ ```bash
551
+ pnpm deploy:cf # wrangler deploy
552
+ ```
553
+
554
+ #### Configuration vars & secrets (Cloudflare)
555
+
556
+ Every env var the Worker consumes is enumerated — and
557
+ **drift-guarded in CI** — in the `consumed-env-vars` block of
558
+ `apps/cf-worker/wrangler.toml`, classified as a plaintext `[vars]`
559
+ knob or a `[secret]`. Credential material must be set as Workers
560
+ secrets, never `[vars]`:
561
+
562
+ | Secret | Purpose |
563
+ |--------|---------|
564
+ | `SERVER_PASSWORD` | server-wide PASS gate (see [above](#server-password-server_password---server-password)) |
565
+ | `OPER_PASSWORD` | legacy plaintext oper credential |
566
+ | `OPER_SALT` + `OPER_HASH` | hashed oper credential — generate with `node --import tsx tools/hash-oper-cred.ts --user admin --stdin` |
567
+ | `SASL_ACCOUNTS` | newline-delimited `user:password` SASL seed list (see `tools/seed-cf-accounts.ts`) |
568
+
569
+ Set each with `wrangler secret put <NAME>`. Everything else
570
+ (`SERVER_NAME`, `MAX_CLIENTS`, `MAX_CONNECTIONS_PER_IP`,
571
+ `PER_IP_CONNECTION_RATE_*`, `MAX_FRAMES_PER_WINDOW`,
572
+ `FRAME_WINDOW_SECONDS`, `EXTERNAL_ENABLED`, …) is a non-sensitive
573
+ `[vars]` knob.
574
+
575
+ ### Deploying on AWS
576
+
577
+ The web client is **opt-in**: provision the `StaticSite` construct by
578
+ passing `webSite*` CDK context on the stack deploy, and (once the SPA
579
+ ships) set `webOrigins` for the CSWSH defence:
486
580
 
487
581
  ```bash
488
- pnpm deploy:cf:staging # wrangler deploy --env staging
582
+ pnpm deploy:aws -- \
583
+ -c webSiteCustomDomain=app.example.com \
584
+ -c webSiteCertificateArn=arn:aws:acm:us-east-1:... \
585
+ -c webSiteHostedZoneName=example.com. -c webSiteHostedZoneId=... \
586
+ -c webOrigins=https://app.example.com
489
587
  ```
490
588
 
589
+ Then bake + ship the SPA (stack-output-driven — reads `ConnectUrl`,
590
+ `WebsiteBucketName`, `WebsiteDistributionId` from the stack outputs,
591
+ bakes the real wss URL into `config.json`, `s3 sync`s the build, and
592
+ invalidates the CloudFront edge cache):
593
+
594
+ ```bash
595
+ node scripts/deploy-web-aws.mjs
596
+ ```
597
+
598
+ The ACM certificate **must** be in `us-east-1` (CloudFront requirement).
599
+ See `docs/AWS-Deployment.md` §16 for the full two-phase flow, the
600
+ `server` / `direct_path` config split (irc-framework prepends `wss://`
601
+ itself, so the host is baked scheme-less), and the custom-domain setup.
602
+
491
603
  ### WebSocket Origin policy (CSWSH defense)
492
604
 
493
- WebSocket upgrades do **not** follow the same-origin policy, so the
494
- Worker rejects browser upgrades whose `Origin` is not allowed see
495
- `apps/cf-worker/src/origin-allowlist.ts` and the SPA guide §5. Two
496
- modes, evaluated in order:
605
+ WebSocket upgrades do **not** follow the same-origin policy, so a
606
+ malicious page can open a WebSocket to the IRC server from a victim's
607
+ browser and drive the session with their credentials (Cross-Site
608
+ WebSocket Hijacking, CSWSH). Both adapters enforce an `Origin` policy
609
+ on the upgrade — but the **modes differ**, because the CF Worker serves
610
+ the SPA and the wss endpoint on the same origin while AWS serves them
611
+ on different origins:
612
+
613
+ **Cloudflare** — see `apps/cf-worker/src/origin-allowlist.ts` and the
614
+ SPA guide §5. Two modes, evaluated in order:
497
615
 
498
616
  1. **Explicit allowlist** — set `WEB_ORIGINS` (comma-separated) for
499
617
  cross-origin deploys (SPA on a different domain than the Worker,
@@ -502,11 +620,27 @@ modes, evaluated in order:
502
620
  unset/empty, the Worker compares the browser's `Origin` against the
503
621
  request's own origin. Match → proceed; mismatch → `403 Forbidden`.
504
622
 
505
- `WEB_ORIGINS` is **optional** — same-origin auto-derive needs zero
506
- per-env config and works for `*.workers.dev`, custom domains, and
507
- preview URLs alike. Non-browser clients (curl, WeeChat, the
508
- `tcp-ws-forwarder`, scripted harnesses) never send `Origin` and pass
509
- through unchanged in both modes.
623
+ `WEB_ORIGINS` is **optional** on CF — same-origin auto-derive needs
624
+ zero per-env config and works for `*.workers.dev`, custom domains, and
625
+ preview URLs alike.
626
+
627
+ **AWS** see `packages/aws-adapter/src/origin-allowlist.ts` and
628
+ `docs/AWS-Deployment.md` §8.2. **Explicit allowlist only** (no
629
+ auto-derive): the SPA is on a CloudFront origin and the wss endpoint is
630
+ on an API Gateway origin, so the request's own host is never the SPA's
631
+ origin. The defence is **opt-in** — unset `WEB_ORIGINS` skips the check
632
+ entirely (existing bare-IRC deployments without a web frontend are
633
+ unchanged on upgrade); set it to the SPA's origin(s) once the web client
634
+ ships:
635
+
636
+ ```bash
637
+ pnpm deploy:aws -- -c webOrigins=https://app.example.com
638
+ # or as a stack prop: webOrigins: 'https://app.example.com,https://staging.app.example.com'
639
+ ```
640
+
641
+ Non-browser clients (curl, WeeChat, the `tcp-ws-forwarder`, scripted
642
+ harnesses) never send `Origin` and pass through unchanged on both
643
+ platforms.
510
644
 
511
645
  ---
512
646
 
@@ -546,6 +680,73 @@ deployed stack exercises the spec path end-to-end.
546
680
 
547
681
  ---
548
682
 
683
+ ## Abuse controls & credential hardening
684
+
685
+ Every transport edge enforces layered admission and rate limits, and
686
+ every credential path verifies through hardened, timing-equalized
687
+ comparisons. The knobs below are `[vars]` on the Cloudflare Worker and
688
+ mirrored as Lambda env vars on AWS.
689
+
690
+ ### Connection admission & rate limiting
691
+
692
+ | Layer | Knobs | Enforcement |
693
+ |------------------------|---------------------------------------------------|-------------|
694
+ | Global cap | `MAX_CLIENTS` | CF reserves a `CounterDO` slot before each upgrade and answers `429` (`ERROR :Closing link: server full`) at the cap (slots released on close, TTL-reaped if a DO dies); AWS keeps an atomic connection counter in DynamoDB. |
695
+ | Per-IP simultaneous | `MAX_CONNECTIONS_PER_IP` | Admission gates on both platforms reject over-budget source IPs with `429`. |
696
+ | Per-IP connect rate | `PER_IP_CONNECTION_RATE_MAX` / `PER_IP_CONNECTION_RATE_WINDOW_MS` | CF: a `RateLimitDO` sliding window keyed on `CF-Connecting-IP`, checked at the worker edge (rejections never consume budget, so a blocked IP recovers after window decay). AWS: `$connect` counts in-window establishments via a `sourceIp+connectedSince` GSI, the NLB path applies the same budget to new flows, APIGW stage throttling backstops globally, and an opt-in WAFv2 per-IP rate rule (`-c wafConnectRateLimit=<n>`) sits at the edge. |
697
+ | Per-connection frames | `MAX_FRAMES_PER_WINDOW` / `FRAME_WINDOW_SECONDS` | Inbound frame budget enforced at the adapter boundary before the actor / storage write. |
698
+ | Line-buffer memory | (fixed) | TCP input buffers capped at 8 KiB at every transport edge (local CLI, container origin, NLB handler, forwarder). |
699
+
700
+ ### Protocol budgets
701
+
702
+ All length limits are enforced in **UTF-8 bytes**, not UTF-16 code
703
+ units: the 510-byte WS frame / 512-byte TCP line budgets, `TOPICLEN`
704
+ enforced on character boundaries, `draft/multiline` batch byte budgets
705
+ enforced incrementally as lines arrive, at most 15 tags and an
706
+ 8192-byte tag section per message, long NAMES rosters split across
707
+ multiple `353` replies within the 510-byte budget, `CHATHISTORY`
708
+ limits capped at a configurable ceiling (default 100), and
709
+ `MAX_TARGETS_PER_COMMAND` capping comma-split targets. The parser
710
+ rejects bare-`CR` smuggling and NUL/control characters in channel
711
+ names, builds tag maps with a null prototype (blocking prototype
712
+ pollution), and `OPER` requires a registered connection (`451
713
+ ERR_NOTREGISTERED`).
714
+
715
+ ### Credentials & anti-abuse
716
+
717
+ Oper credentials verify against scrypt hashes (`OPER_USER` +
718
+ `OPER_SALT`/`OPER_HASH`, generated with `tools/hash-oper-cred.ts`);
719
+ the server password compares in constant time; `verifyNick` runs a
720
+ dummy scrypt verify on unknown nicks so response timings cannot
721
+ enumerate accounts. Brute force is throttled at every auth surface: a
722
+ per-connection SASL failure lockout, a per-IP `OPER` failure lockout,
723
+ and a per-account NickServ `IDENTIFY` freeze. HostServ auto-approve
724
+ honours a vhost denylist/allowlist, and ChanServ `DEOP`/`KICK` protect
725
+ founders and enforce caller rank. Parse-error logs carry only
726
+ token/length/reason — `PASS` / `AUTHENTICATE` payloads are redacted —
727
+ and the Worker's log sampling defaults to 10%.
728
+
729
+ ### SASL EXTERNAL (mTLS)
730
+
731
+ `EXTERNAL` is advertised and accepted only when **all** of the
732
+ following hold: an mTLS identity source is bound for the connection
733
+ (CF API Shield / AWS API Gateway client certs), the operator opt-in
734
+ `EXTERNAL_ENABLED` is set (default **off**), and the transport is
735
+ TLS-secured. Otherwise the `sasl` cap and `908 ERR_SASLMECHS` list
736
+ `PLAIN` only — refusals do not count toward the SASL lockout.
737
+ Accounts bind to the client cert's **DER SHA-256 fingerprint**
738
+ (`fp:<hex>` entries — what Cloudflare surfaces from
739
+ `request.cf.tlsClientAuth`; the preferred binding) or a **canonical
740
+ subject DN** (the only identifier API Gateway exposes; DNs are
741
+ canonicalised — types lower-cased, whitespace collapsed, RDN/AVA order
742
+ sorted — so re-issued or differently-ordered certificates still match).
743
+ Transports without any client-cert surface — the Spectrum container
744
+ origin, and the NLB stream handler unless an mTLS provider is injected
745
+ — pin the mechanism off and reject `AUTHENTICATE EXTERNAL` with a
746
+ transport-specific `904`.
747
+
748
+ ---
749
+
549
750
  ## Testing strategy
550
751
 
551
752
  This project follows strict TDD (Red → Green → Refactor) — every reducer is
@@ -582,12 +783,17 @@ Active follow-ups:
582
783
  surfaces.
583
784
  - **Web client e2e** — Playwright headless-browser e2e exercising the
584
785
  vendored Kiwi IRC SPA against a deployed stack.
585
- - **Coverage hardening** — `aws-adapter` and `aws-stack` clear the 90%
586
- gate; the CF packages run under istanbul. The remaining packages
587
- (`cf-adapter`, `local-cli`, `load-test`, `cf-tcp-container`,
588
- `tcp-ws-forwarder`, `irc-test-support`, `web`) sit above the gate
589
- but below 100%; follow-ups drive each to full coverage. `irc-core`,
590
- `irc-server`, and `in-memory-runtime` are at 100%.
786
+ - **Coverage hardening** — `irc-core`, `irc-server`,
787
+ `in-memory-runtime`, `local-cli`, `cf-worker`, `aws-stack`,
788
+ `load-test`, `cf-tcp-container`, `tcp-ws-forwarder`,
789
+ `irc-test-support`, and `ci-hardening` sit at 100% line coverage;
790
+ `cf-adapter` (~99.7%) and `aws-adapter` (~98%) clear the 90% gate
791
+ with follow-ups driving each to full coverage.
792
+ - **Protocol follow-ups** — `WHOX` (`WHO <mask> %<fields>` /
793
+ `354 RPL_WHOSPCRPL`), `LIST` search masks + `ELIST=MNTU` filters,
794
+ `cap-notify` capability-change push, services data lifecycle
795
+ (last-used tracking + expiry sweep), and a persisted oper audit
796
+ trail.
591
797
  - **Persistent ChanServ ban list** — ban masks currently live on
592
798
  `ChannelState.banMasks` and do not survive an empty-recreate of a
593
799
  channel. Extending `ServicesStore` with a persistent ban list is the
@@ -637,8 +843,9 @@ for clients with a dedicated slash-command UX. Reached via `PRIVMSG
637
843
 
638
844
  - **NickServ** — `REGISTER` / `IDENTIFY` (alias: `ID`) / `DROP` / `INFO`
639
845
  (owner + oper only for the `Email:` line) / `SET ENFORCE` + nick
640
- enforcement on the `NICK` path. `INFO <nick>` with no target resolves
641
- to the caller's own account.
846
+ enforcement on the `NICK` path / `SET PASSWORD` (self-service password
847
+ change; requires the current password re-supplied). `INFO <nick>` with
848
+ no target resolves to the caller's own account.
642
849
  - **ChanServ** — `REGISTER` / `DROP` / `SET` (`FOUNDER` / `MLOCK` /
643
850
  `RESTRICTED` / `KEEPTOPIC`) / `INFO` / `ACCESS` (`SOP` / `AOP` / `HOP`
644
851
  / `VOP`, each `ADD` / `DEL` / `LIST`) / `LEVELS`, plus the prefix /
@@ -658,19 +865,32 @@ for clients with a dedicated slash-command UX. Reached via `PRIVMSG
658
865
  - **MemoServ** — `SEND` / `LIST` / `READ` / `DEL` with queue delivery
659
866
  at identify.
660
867
 
661
- The SASL `AccountStore` and NickServ accounts share one scrypt-hashed
662
- credential store, so a registered nick is also a SASL login and vice
663
- versa. Backends: D1 on Cloudflare, DynamoDB on AWS, in-memory for the
868
+ The `ServicesStore` is the **single credential home**: SASL PLAIN, SASL
869
+ EXTERNAL (CertFP), NickServ `IDENTIFY`, and `PASS <nick>:<password>` all
870
+ verify through the same scrypt-hashed `verifyNick` / `verifyCertFP`
871
+ surface, so a registered nick is also a SASL login and vice versa.
872
+ The credential env vars that seed and unlock these paths
873
+ (`SASL_ACCOUNTS`, `OPER_*`, `SERVER_PASSWORD`) are Workers secrets on
874
+ Cloudflare — the full consumed-var list lives in the drift-guarded
875
+ `consumed-env-vars` block of `apps/cf-worker/wrangler.toml` (see
876
+ [Configuration vars & secrets](#configuration-vars-secrets-cloudflare)).
877
+ Backends: D1 on Cloudflare, DynamoDB on AWS, in-memory for the
664
878
  local CLI / tests (all write-behind; registrations survive redeploys).
665
879
  When no store is bound, services commands reply `501` and the rest of
666
880
  the daemon is unaffected. See `docs/Services.md` for the full
667
881
  reference.
668
-
669
882
  **IRCv3 extensions (negotiated via `CAP`):** `message-tags` (incl. the
670
883
  `TAGMSG` command), `server-time`, `account-tag`, `account-notify`
671
884
  (pushes `ACCOUNT` on SASL login/logout), `echo-message`, `batch`,
672
- `sasl` (`PLAIN` always; `EXTERNAL` via mTLS when a client-cert trust store
673
- is bound), `multi-prefix`, `away-notify`, `chghost`, `invite-notify`,
885
+ `sasl` (`PLAIN` always; `EXTERNAL` only under the triple gate of
886
+ operator opt-in (`EXTERNAL_ENABLED`, default off) + a bound edge-mTLS
887
+ identity source + a TLS connection — accounts bind to the client
888
+ cert's DER SHA-256 fingerprint or a canonical subject DN, refusals
889
+ answer `908 ERR_SASLMECHS` listing `PLAIN` only, and the
890
+ `cf-tcp-container` Spectrum origin rejects `AUTHENTICATE EXTERNAL`
891
+ with a transport-specific `904`; see
892
+ [Abuse controls & credential hardening](#abuse-controls--credential-hardening)),
893
+ `multi-prefix`, `away-notify`, `chghost`, `invite-notify`,
674
894
  `extended-join`, `msgid` (`@+msgid=<id>` on every PRIVMSG/NOTICE/TAGMSG,
675
895
  shared between live and `draft/chathistory` replay), `standard-replies`
676
896
  (`FAIL`/`WARN`/`NOTE` replacements for a curated numeric subset),
@@ -712,9 +932,15 @@ bridges a stock TCP client to a deployed WebSocket endpoint.
712
932
  account store, service shortcut verbs), adapter backends, and an
713
933
  end-to-end registration walkthrough.
714
934
  - `docs/WebClientGuide.md` — end-to-end contributor/operator doc for
715
- the web client: build pipeline, per-env config matrix, CSWSH
716
- rationale and the optional `WEB_ORIGINS` var, local dev, optional
717
- Cloudflare Pages alternative, and troubleshooting.
935
+ the **Cloudflare** web client: build pipeline, per-env config matrix,
936
+ CSWSH rationale and the optional `WEB_ORIGINS` var, local dev,
937
+ optional Cloudflare Pages alternative, and troubleshooting.
938
+ - `docs/AWS-Deployment.md` — end-to-end AWS deploy guide: first-time
939
+ setup, CDK reference, DynamoDB capacity planning, region strategy,
940
+ cost notes, **OIDC-only CI** (§17), **APIGW `DataTraceEnabled` hard-
941
+ lock** (§7.6), and the **S3 + CloudFront + OAC web client** (§16,
942
+ including the two-phase stack-output-driven deploy and the
943
+ `webOrigins` CSWSH knob).
718
944
  - `docs/Cloudflare-TCP-Deployment.md` and `docs/AWS-TCP-Deployment.md`
719
945
  — end-to-end guides for the `:6697` TCP+TLS variants
720
946
  (Spectrum/Container on CF, NLB + Lambda streaming on AWS), including
@@ -21,8 +21,7 @@ The Lambda handler is currently a stub (`{ statusCode: 200 }`); the real
21
21
 
22
22
  ```sh
23
23
  pnpm cdk:synth # synthesize the CloudFormation template (cdk.out/)
24
- pnpm deploy:staging # cdk deploy --all
25
- pnpm deploy:prod # cdk deploy --all --require-approval never
24
+ pnpm deploy # cdk deploy --all --require-approval never
26
25
  pnpm test # synth-time assertions (no AWS, no Docker)
27
26
  pnpm typecheck
28
27
  ```
@@ -30,9 +29,8 @@ pnpm typecheck
30
29
  Repo-level shortcuts (from the monorepo root):
31
30
 
32
31
  ```sh
33
- pnpm deploy:aws:staging
34
- pnpm deploy:aws:prod
35
- pnpm smoke:aws:staging # deferred (smoke harness not yet wired)
32
+ pnpm deploy:aws
33
+ pnpm smoke:aws # deferred (smoke harness not yet wired)
36
34
  ```
37
35
 
38
36
  ## Optional localstack validation
@@ -5,11 +5,13 @@
5
5
  * CDK CLI's default account/region when those env vars are set; otherwise
6
6
  * the stack is environment-agnostic (suitable for `cdk synth` and localstack).
7
7
  *
8
- * Per-environment isolation is driven by the `environmentName` CDK context
9
- * variable (`-c environmentName=staging`). It defaults to `staging` so the
10
- * existing deploy pipeline stays green without changes, and it is folded
11
- * into the CloudFormation stack id (`IrcAwsStack-<environmentName>`) so
12
- * parallel envs coexist in one account+region.
8
+ * There is a single deploy target per platform (the staging/prod split was
9
+ * collapsed). This app synthesizes exactly one stack named `IrcAwsStack`;
10
+ * staging vs production isolation is driven by which AWS account and region
11
+ * the deploy credentials target, not by stack-name templating. Server
12
+ * identity (`serverName`, `networkName`, `motd`) and the optional TCP+TLS
13
+ * domain are read from CDK context so deploys can override them without
14
+ * editing source.
13
15
  */
14
16
 
15
17
  import { App } from 'aws-cdk-lib';
@@ -20,8 +22,6 @@ const app = new App();
20
22
 
21
23
  const account = process.env.CDK_DEFAULT_ACCOUNT;
22
24
  const region = process.env.CDK_DEFAULT_REGION;
23
- const environmentName =
24
- (app.node.tryGetContext('environmentName') as string | undefined) ?? 'staging';
25
25
 
26
26
  // Server identity is read from CDK context so deploys can override it
27
27
  // without editing source (`-c serverName=…`, `-c networkName=…`,
@@ -44,13 +44,122 @@ const motdLines = Array.isArray(rawMotd)
44
44
  // docs/AWS-TCP-Deployment.md.
45
45
  const tcpTlsDomainName = app.node.tryGetContext('tcpTlsDomainName') as string | undefined;
46
46
 
47
+ // `$connect` Origin allowlist (CSWSH defence). Opt-in: only provisioned
48
+ // when this context var is supplied as a non-empty string. Comma-separated
49
+ // origin list (e.g. `'https://app.example.com, https://staging.app.example.com'`).
50
+ // When omitted, the `$connect` handler skips the Origin check entirely
51
+ // (existing AWS deployments without a web frontend are unchanged).
52
+ const webOrigins = app.node.tryGetContext('webOrigins') as string | undefined;
53
+
54
+ // API Gateway full-frame body tracing (`DataTraceEnabled`). Safe-by-default:
55
+ // omitted → `false` (no IRC frame bodies logged). Re-enabling requires BOTH
56
+ // `-c allowDataTrace=true` AND `-c iUnderstandThisLeaksCredentials=true`; the
57
+ // stack REFUSES synth with a credential-leak error if only the first is set.
58
+ // Since the staging/prod split was collapsed every synth is effectively a
59
+ // prod synth, so there is no quiet "non-prod" path — use ONLY in a throwaway
60
+ // sandbox for live debugging. See docs/AWS-Deployment.md (§7.6).
61
+ const allowDataTrace = app.node.tryGetContext('allowDataTrace') as boolean | string | undefined;
62
+ const iUnderstandThisLeaksCredentials = app.node.tryGetContext('iUnderstandThisLeaksCredentials') as
63
+ | boolean
64
+ | string
65
+ | undefined;
66
+
67
+ // CDK context vars arrive as strings; normalise the booleans so the stack's
68
+ // guard sees real booleans (and `--c allowDataTrace=false` stays false-y).
69
+ const allowDataTraceBool = parseContextBool(allowDataTrace);
70
+ const acknowledgeLeakBool = parseContextBool(iUnderstandThisLeaksCredentials);
71
+
72
+ // DynamoDB table-deletion escape hatch (state-table protection). Safe-by-
73
+ // default: omitted → the stack keeps state tables RETAIN + deletion-protected
74
+ // + PITR-on. `-c allowTableDeletion=true` flips state tables to DESTROY +
75
+ // deletion-protection OFF so a deliberate `cdk destroy` teardown can delete
76
+ // them (the data is irreversibly lost — DynamoDB tables do not snapshot on
77
+ // delete). `Connections` is always DESTROY regardless. See §9.4 / §11.
78
+ const allowTableDeletion = app.node.tryGetContext('allowTableDeletion') as
79
+ | boolean
80
+ | string
81
+ | undefined;
82
+ const allowTableDeletionBool = parseContextBool(allowTableDeletion);
83
+
84
+ // DynamoDB point-in-time recovery toggle. Defaults to ON (the stack's own
85
+ // default when omitted); `-c enablePitr=false` disables PITR on every state
86
+ // table (PITR is billed continuously at ~$0.20/GB-month per table; RETAIN
87
+ // and deletionProtection are free). `Connections` never carries PITR. The
88
+ // flag is independent of `allowTableDeletion` — a teardown keeps PITR on.
89
+ const enablePitr = app.node.tryGetContext('enablePitr') as boolean | string | undefined;
90
+ const enablePitrBool = parseContextBool(enablePitr);
91
+
92
+ // Web client hosting (S3 + CloudFront + OAC) is opt-in. Supplying
93
+ // `webSiteCustomDomain` provisions the static-site construct; prod passes
94
+ // the custom domain + cert + hosted zone, staging leaves them unset to use
95
+ // the default `*.cloudfront.net` URL. See docs/AWS-Deployment.md (web section).
96
+ const webSiteCustomDomain = app.node.tryGetContext('webSiteCustomDomain') as string | undefined;
97
+ const webSiteCertificateArn = app.node.tryGetContext('webSiteCertificateArn') as string | undefined;
98
+ const webSiteHostedZoneName = app.node.tryGetContext('webSiteHostedZoneName') as string | undefined;
99
+ const webSiteHostedZoneId = app.node.tryGetContext('webSiteHostedZoneId') as string | undefined;
100
+ const webSite =
101
+ webSiteCustomDomain !== undefined
102
+ ? {
103
+ customDomain: webSiteCustomDomain,
104
+ ...(webSiteCertificateArn !== undefined ? { certificateArn: webSiteCertificateArn } : {}),
105
+ ...(webSiteHostedZoneName !== undefined ? { hostedZoneName: webSiteHostedZoneName } : {}),
106
+ ...(webSiteHostedZoneId !== undefined ? { hostedZoneId: webSiteHostedZoneId } : {}),
107
+ }
108
+ : undefined;
109
+
110
+ // Optional WAFv2 per-IP edge rate limit on the WebSocket API's `$connect`
111
+ // (requests per 5-minute window per client IP). Omitted → no WAF
112
+ // resources; `-c wafConnectRateLimit=2000` provisions the WebACL with a
113
+ // rate-based rule + the stage association. CDK context values arrive as
114
+ // strings, so parse to an integer and drop non-numeric values.
115
+ const rawWafConnectRateLimit = app.node.tryGetContext('wafConnectRateLimit');
116
+ const wafConnectRateLimit =
117
+ typeof rawWafConnectRateLimit === 'number'
118
+ ? rawWafConnectRateLimit
119
+ : typeof rawWafConnectRateLimit === 'string' && /^\d+$/u.test(rawWafConnectRateLimit.trim())
120
+ ? Number.parseInt(rawWafConnectRateLimit.trim(), 10)
121
+ : undefined;
122
+
47
123
  const stackProps: IrcStackProps = {
48
- environmentName,
49
124
  ...(serverName !== undefined ? { serverName } : {}),
50
125
  ...(networkName !== undefined ? { networkName } : {}),
51
126
  ...(motdLines !== undefined ? { motdLines } : {}),
52
127
  ...(tcpTlsDomainName !== undefined ? { tcpTlsDomainName } : {}),
128
+ ...(webOrigins !== undefined && webOrigins.length > 0 ? { webOrigins } : {}),
129
+ ...(webSite !== undefined ? { webSite } : {}),
130
+ ...(allowDataTraceBool !== undefined ? { allowDataTrace: allowDataTraceBool } : {}),
131
+ ...(acknowledgeLeakBool !== undefined
132
+ ? { iUnderstandThisLeaksCredentials: acknowledgeLeakBool }
133
+ : {}),
134
+ ...(allowTableDeletionBool !== undefined ? { allowTableDeletion: allowTableDeletionBool } : {}),
135
+ ...(enablePitrBool !== undefined ? { enablePitr: enablePitrBool } : {}),
136
+ ...(wafConnectRateLimit !== undefined ? { wafConnectRateLimit } : {}),
53
137
  ...(account && region ? { env: { account, region } } : {}),
54
138
  };
55
139
 
56
- new IrcAwsStack(app, `IrcAwsStack-${environmentName}`, stackProps);
140
+ new IrcAwsStack(app, 'IrcAwsStack', stackProps);
141
+
142
+ /**
143
+ * Normalises a CDK context value (`-c key=value`) into a boolean.
144
+ * CDK passes context values as strings, so `'true'` / `'false'` are
145
+ * parsed; anything else (including `undefined`) returns `undefined` so
146
+ * the downstream optional prop is simply omitted. The data-trace guard
147
+ * in `IrcAwsStack` is strict-equality-checked against `true`, so a stray
148
+ * string here would otherwise defeat the opt-in.
149
+ */
150
+ function parseContextBool(value: boolean | string | undefined): boolean | undefined {
151
+ if (value === undefined) {
152
+ return undefined;
153
+ }
154
+ if (typeof value === 'boolean') {
155
+ return value;
156
+ }
157
+ const normalised = value.trim().toLowerCase();
158
+ if (normalised === 'true') {
159
+ return true;
160
+ }
161
+ if (normalised === 'false') {
162
+ return false;
163
+ }
164
+ return undefined;
165
+ }