serverless-ircd 0.5.0 → 0.6.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 (167) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +260 -0
  6. package/README.md +132 -22
  7. package/apps/aws-stack/README.md +33 -0
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +74 -9
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +2 -2
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +9 -7
  27. package/apps/local-cli/tests/e2e.test.ts +112 -28
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/handlers/default.ts +3 -0
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  56. package/packages/aws-adapter/src/serialize.ts +29 -1
  57. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  58. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  59. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  60. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  61. package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
  62. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  63. package/packages/aws-adapter/tests/connect.test.ts +9 -9
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  65. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  66. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  67. package/packages/aws-adapter/tests/handlers.test.ts +90 -4
  68. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  69. package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
  70. package/packages/cf-adapter/package.json +2 -1
  71. package/packages/cf-adapter/src/connection-do.ts +3 -0
  72. package/packages/cf-adapter/src/serialize.ts +25 -4
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
  74. package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
  75. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  78. package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
  79. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  80. package/packages/cf-adapter/vitest.config.ts +1 -1
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +1 -1
  83. package/packages/irc-core/src/caps/capabilities.ts +37 -3
  84. package/packages/irc-core/src/caps/index.ts +1 -0
  85. package/packages/irc-core/src/caps/sts.ts +84 -0
  86. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  87. package/packages/irc-core/src/commands/away.ts +9 -3
  88. package/packages/irc-core/src/commands/cap.ts +23 -5
  89. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  90. package/packages/irc-core/src/commands/index.ts +34 -0
  91. package/packages/irc-core/src/commands/isupport.ts +53 -0
  92. package/packages/irc-core/src/commands/mode.ts +8 -1
  93. package/packages/irc-core/src/commands/monitor.ts +327 -0
  94. package/packages/irc-core/src/commands/multiline.ts +256 -0
  95. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  96. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  97. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  98. package/packages/irc-core/src/commands/registration.ts +8 -0
  99. package/packages/irc-core/src/commands/sasl.ts +24 -1
  100. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  101. package/packages/irc-core/src/commands/whois.ts +5 -0
  102. package/packages/irc-core/src/config.ts +65 -10
  103. package/packages/irc-core/src/effects.ts +14 -0
  104. package/packages/irc-core/src/ports.ts +389 -0
  105. package/packages/irc-core/src/protocol/index.ts +14 -0
  106. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  107. package/packages/irc-core/src/protocol/numerics.ts +15 -0
  108. package/packages/irc-core/src/protocol/outbound.ts +16 -1
  109. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  110. package/packages/irc-core/src/state/connection.ts +32 -1
  111. package/packages/irc-core/src/types.ts +112 -0
  112. package/packages/irc-core/stryker.commands.conf.json +1 -2
  113. package/packages/irc-core/tests/away-store.test.ts +73 -0
  114. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  115. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  116. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  117. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  118. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  119. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  120. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  121. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  122. package/packages/irc-core/tests/commands/mode.test.ts +72 -0
  123. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  124. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  125. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  126. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  127. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  128. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  129. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  130. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  131. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  132. package/packages/irc-core/tests/config.test.ts +103 -13
  133. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  134. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  135. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  136. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  137. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  138. package/packages/irc-core/tests/types.test.ts +27 -0
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +450 -3
  141. package/packages/irc-server/src/dispatch.ts +108 -16
  142. package/packages/irc-server/tests/actor.test.ts +1432 -39
  143. package/packages/irc-server/tests/dispatch.test.ts +263 -2
  144. package/packages/irc-test-support/package.json +1 -1
  145. package/packages/irc-test-support/src/index.ts +6 -0
  146. package/packages/irc-test-support/src/test-config.ts +54 -0
  147. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  148. package/tools/ci-hardening/package.json +1 -1
  149. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  150. package/tools/load-test/package.json +33 -0
  151. package/tools/load-test/src/client.ts +351 -0
  152. package/tools/load-test/src/config.ts +313 -0
  153. package/tools/load-test/src/harness.ts +116 -0
  154. package/tools/load-test/src/main.ts +120 -0
  155. package/tools/load-test/src/metrics.ts +168 -0
  156. package/tools/load-test/src/report.ts +106 -0
  157. package/tools/load-test/tests/client.test.ts +212 -0
  158. package/tools/load-test/tests/config.test.ts +152 -0
  159. package/tools/load-test/tests/framing.test.ts +37 -0
  160. package/tools/load-test/tests/harness.test.ts +165 -0
  161. package/tools/load-test/tests/metrics.test.ts +174 -0
  162. package/tools/load-test/tests/report.test.ts +161 -0
  163. package/tools/load-test/tests/smoke.test.ts +67 -0
  164. package/tools/load-test/tsconfig.build.json +12 -0
  165. package/tools/load-test/tsconfig.test.json +10 -0
  166. package/tools/load-test/vitest.config.ts +29 -0
  167. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -0,0 +1,1275 @@
1
+ # AWS Deployment Guide
2
+
3
+ End-to-end guide for deploying ServerlessIRCd to AWS (Phase 4 of the
4
+ project plan). Covers prerequisites, first-time AWS setup, local
5
+ development, staging and production deploys, configuration reference,
6
+ DynamoDB capacity planning, region strategy, cost notes, CI/CD, and
7
+ troubleshooting.
8
+
9
+ Cross-reference: the AWS Adapter Architecture doc
10
+ (`../../ServerlessIRCd/docs/AWS-Adapter-Architecture.md`) for the
11
+ diagrams referenced throughout, and the Cloudflare Deployment Guide
12
+ (`../../ServerlessIRCd/docs/Cloudflare-Deployment-Guide.md`) for the
13
+ equivalent flow on the other supported platform. The shared
14
+ `irc-core`/`irc-server` packages and the contract test suite are
15
+ identical between platforms; only the I/O shell (this stack) differs.
16
+
17
+ Key in-repo paths cited below:
18
+
19
+ | Path | What |
20
+ |---------------------------------------------------|-----------------------------------------------|
21
+ | `apps/aws-stack/src/aws-stack.ts` | CDK stack — tables, Lambdas, APIGW, schedules.|
22
+ | `apps/aws-stack/bin/aws.ts` | CDK app entry. Stack id is `IrcAwsStack`. |
23
+ | `apps/aws-stack/cdk.json` | `app = tsx bin/aws.ts`. |
24
+ | `apps/aws-stack/scripts/smoke.mjs` | CI + local smoke e2e. |
25
+ | `apps/aws-stack/tests/stack.test.ts` | Synth-time assertions (cross-check for facts).|
26
+ | `packages/aws-adapter/src/cdk-table-defs.ts` | Five DynamoDB table shapes. |
27
+ | `packages/aws-adapter/src/config-loader.ts` | Lambda env → `ServerConfig` mapping. |
28
+ | `packages/aws-adapter/src/handlers/index.ts` | Lambda entry (`handler`, `sweeperHandler`, `pingCheckerHandler`). |
29
+ | `.github/workflows/deploy-aws.yml` | Staging deploy + smoke e2e CI. |
30
+
31
+ **Acceptance criterion:** a new contributor can deploy their own
32
+ staging instance following only this doc.
33
+
34
+ ---
35
+
36
+ ## 1. What gets deployed
37
+
38
+ 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
+
42
+ ```
43
+ ┌──────────────────────────────────────────────────────────────────┐
44
+ │ CDK stack: IrcAwsStack (apps/aws-stack/src/aws-stack.ts) │
45
+ │ │
46
+ │ ┌─ WebSocketApi (AWS::ApiGatewayV2::Api) ───────────────────┐ │
47
+ │ │ routes: $connect $disconnect $default │ │
48
+ │ │ → all three wired to IrcHandler │ │
49
+ │ │ stage: prod (auto-deploy) │ │
50
+ │ └────────────────────────────────────────────────────────────┘ │
51
+ │ │ │
52
+ │ ▼ WebSocket event per frame │
53
+ │ ┌─ Lambda: IrcHandler ───────────────────────────────────────┐ │
54
+ │ │ handler export `handler` (Node 20, esbuild bundle) │ │
55
+ │ │ entry: packages/aws-adapter/src/handlers/index.ts │ │
56
+ │ │ dispatches on event.requestContext.routeKey │ │
57
+ │ └────────────────────────────────────────────────────────────┘ │
58
+ │ │
59
+ │ ┌─ Lambda: IrcSweeper ───────────────────────────────────────┐ │
60
+ │ │ handler export `sweeperHandler` │ │
61
+ │ │ EventBridge schedule: rate(5 minutes) │ │
62
+ │ │ Scans Connections for rows past hard TTL; cleans up. │ │
63
+ │ └────────────────────────────────────────────────────────────┘ │
64
+ │ │
65
+ │ ┌─ Lambda: IrcPingChecker ───────────────────────────────────┐ │
66
+ │ │ handler export `pingCheckerHandler` │ │
67
+ │ │ EventBridge schedule: rate(1 minute) │ │
68
+ │ │ Sends PING to idle connections; disconnects no-PONG. │ │
69
+ │ └────────────────────────────────────────────────────────────┘ │
70
+ │ │
71
+ │ ┌─ DynamoDB (on-demand) ─────────────────────────────────────┐ │
72
+ │ │ Connections (PK connectionId, TTL idleSince) │ │
73
+ │ │ ChannelMeta (PK channelName) │ │
74
+ │ │ ChannelMembers (PK channelName, SK connectionId) │ │
75
+ │ │ Nicks (PK nickLower) │ │
76
+ │ │ Accounts (PK account) │ │
77
+ │ └────────────────────────────────────────────────────────────┘ │
78
+ │ │
79
+ │ CfnOutput: ConnectUrl = stage.url (wss://…) │
80
+ │ CfnOutput: ManagementUrl = stage.callbackUrl (https://…) │
81
+ └──────────────────────────────────────────────────────────────────┘
82
+ ```
83
+
84
+ All IRC protocol logic lives in the shared `irc-core` reducers and the
85
+ `irc-server` `ConnectionActor`. The Lambdas are a thin I/O shell: they
86
+ forward WebSocket events into the actor and interpret the resulting
87
+ `Effect[]` against DynamoDB and the API Gateway Management API. No IRC
88
+ state is held in the Lambda process between invocations.
89
+
90
+ The three Lambda functions share a single bundle (esbuild tree-shakes
91
+ per handler export at synth time). They run on `nodejs20.x` and exclude
92
+ `@aws-sdk/*` from the bundle because the AWS SDK v3 ships with the
93
+ Lambda Node 20 runtime.
94
+
95
+ Stack outputs (`CfnOutput`):
96
+
97
+ | Output | Value | Purpose |
98
+ |-----------------|-----------------------------|----------------------------------------------------|
99
+ | `ConnectUrl` | `wss://<id>.execute-api.<region>.amazonaws.com/prod` | WebSocket endpoint IRC clients dial. |
100
+ | `ManagementUrl` | `https://<id>.execute-api.<region>.amazonaws.com/prod` | HTTPS endpoint for `ApiGatewayManagementApi.postToConnection`. |
101
+
102
+ The same CloudFormation template serves both staging and production.
103
+ The staging/production distinction today is which AWS account and
104
+ region the stack is deployed into — there is no per-environment stack
105
+ parameter (see §6 and §7.4 for the implications).
106
+
107
+ ---
108
+
109
+ ## 2. Prerequisites
110
+
111
+ | Requirement | Version / detail |
112
+ |---------------------|---------------------------------------------------------------|
113
+ | Node.js | ≥ 20 (matches CI; `engines.node` in root `package.json` and in `apps/aws-stack/package.json`). |
114
+ | pnpm | 9.x (`packageManager: pnpm@9.15.9` in root `package.json`). |
115
+ | AWS account | IAM-permissioned to create CloudFormation stacks, IAM roles, API Gateway v2, Lambda, DynamoDB tables, and EventBridge rules. |
116
+ | AWS credentials | `aws configure`, `AWS_PROFILE`, or SSO. CDK reads the standard SDK chain. |
117
+ | CDK v2 | Comes from `apps/aws-stack/devDependencies` (`aws-cdk ^2.160.0`) — no global install needed. Use `pnpm cdk` (or `pnpm cdk:synth`) so the local binary wins over any globally-installed CDK. |
118
+ | CDK bootstrap | `cdk bootstrap` must have been run **once** in the target account/region (see §3.3). |
119
+ | Git checkout | Clean working tree on `main` for production deploys. |
120
+
121
+ Confirm the local environment:
122
+
123
+ ```bash
124
+ node --version # v20.x or newer
125
+ pnpm --version # 9.x
126
+ aws --version # aws-cli/2.x (any 2.x is fine)
127
+ pnpm install # from repo root, installs the whole workspace
128
+ pnpm build # builds irc-core, irc-server, aws-adapter, aws-stack
129
+ ```
130
+
131
+ `pnpm build` must succeed before `cdk deploy`: the `NodejsFunction`
132
+ construct transpiles the handler entry with esbuild at synth time, and
133
+ esbuild resolves `@serverless-ircd/*` workspace packages via their
134
+ compiled `dist/` outputs.
135
+
136
+ ---
137
+
138
+ ## 3. First-time AWS setup
139
+
140
+ These steps are done once per AWS account.
141
+
142
+ ### 3.1 Pick a region
143
+
144
+ API Gateway WebSocket endpoints, DynamoDB, EventBridge Scheduler, and
145
+ Lambda are all regional. WebSocket clients pay round-trip time to the
146
+ region, so pick the one closest to your user base (see §10 for the
147
+ full region discussion). Set it as your default:
148
+
149
+ ```bash
150
+ export AWS_REGION=us-east-1 # or your nearest region
151
+ ```
152
+
153
+ CDK picks the region up via `CDK_DEFAULT_REGION` (set automatically by
154
+ the CDK CLI from the SDK chain).
155
+
156
+ ### 3.2 Configure credentials
157
+
158
+ Three equivalent options:
159
+
160
+ ```bash
161
+ # Static access key (simplest):
162
+ aws configure
163
+ # → prompts for Access Key ID, Secret Key, region, output format.
164
+
165
+ # Named profile:
166
+ export AWS_PROFILE=my-profile
167
+
168
+ # SSO:
169
+ aws sso login --profile my-sso-profile
170
+ export AWS_PROFILE=my-sso-profile
171
+ ```
172
+
173
+ Verify:
174
+
175
+ ```bash
176
+ aws sts get-caller-identity
177
+ ```
178
+
179
+ ### 3.3 Bootstrap CDK
180
+
181
+ CDK needs an S3 bucket + a few IAM roles per account/region to hold
182
+ synthesized CloudFormation assets. Bootstrap once:
183
+
184
+ ```bash
185
+ cd apps/aws-stack
186
+ pnpm cdk bootstrap
187
+ # → creates a CFN stack named CDKToolkit with the bootstrap bucket.
188
+ ```
189
+
190
+ If you skip this, the first `cdk deploy` fails with a bucket-not-found
191
+ error (see §13.1). Bootstrap is idempotent — re-running on an
192
+ already-bootstrapped account is a no-op.
193
+
194
+ ### 3.4 Optional: set up OIDC for CI
195
+
196
+ If you intend to use the GitHub Actions deploy workflow (§12) without
197
+ long-lived access keys, configure GitHub OIDC:
198
+
199
+ 1. Create an IAM identity provider for `token.actions.githubusercontent.com`.
200
+ 2. Create a role with a trust policy granting
201
+ `sts:AssumeRoleWithWebIdentity` to your repo's `ref:refs/heads/main`.
202
+ 3. Attach a permission policy broad enough to manage the stack (IAM,
203
+ API Gateway v2, Lambda, DynamoDB, EventBridge, CloudFormation).
204
+ 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.
207
+
208
+ ---
209
+
210
+ ## 4. Local development
211
+
212
+ Unlike the Cloudflare adapter (which has `wrangler dev` against the
213
+ real `workerd` runtime), the AWS adapter does not yet ship a
214
+ hot-reload local Lambda/API Gateway emulator. The local dev loop is:
215
+
216
+ 1. **Synth-time assertions** (default `pnpm test`) — fast, no Docker.
217
+ 2. **Unit tests** for the handler, runtime, and access patterns under
218
+ `packages/aws-adapter/tests/`.
219
+ 3. **Optional localstack validation** — gated, requires Docker.
220
+
221
+ ### 4.1 Synth-time tests
222
+
223
+ From the repo root:
224
+
225
+ ```bash
226
+ pnpm --filter @serverless-ircd/aws-stack test
227
+ # or, equivalently:
228
+ pnpm test # runs the whole workspace including aws-stack
229
+ ```
230
+
231
+ These execute `Template.fromStack(...)` in-process — no AWS
232
+ credentials, no Docker. Every assertion in
233
+ `apps/aws-stack/tests/stack.test.ts` corresponds to a fact this guide
234
+ states: five on-demand tables, three Node 20 Lambdas, the
235
+ `$connect`/`$disconnect`/`$default` routes, the `prod` stage, the two
236
+ `rate(...)` schedules, the two CfnOutputs, and least-privilege IAM
237
+ (no `Action: "*"` or `Resource: "*"`).
238
+
239
+ ### 4.2 Synthesize the CloudFormation template
240
+
241
+ ```bash
242
+ pnpm --filter @serverless-ircd/aws-stack cdk:synth
243
+ # → writes cdk.out/<stack>.template.json
244
+ ```
245
+
246
+ Inspecting `cdk.out/` is the fastest way to see exactly what will be
247
+ deployed without touching AWS.
248
+
249
+ ### 4.3 Optional: validate against localstack
250
+
251
+ A gated test (`apps/aws-stack/tests/localstack.test.ts`) runs
252
+ `cdklocal synth` against a real localstack container and re-asserts
253
+ the five-table shape. It is **skipped unless `LOCALSTACK=1` is set** so
254
+ CI never depends on Docker. To run it locally:
255
+
256
+ ```bash
257
+ # 1. Start localstack (requires Docker):
258
+ docker run --rm -d -p 4566:4566 localstack/localstack
259
+
260
+ # 2. Install the localstack-aware CDK CLI globally:
261
+ npm install -g aws-cdk-local
262
+
263
+ # 3. Point the AWS SDK at localstack (cdklocal reads these):
264
+ export AWS_ACCESS_KEY_ID=test
265
+ export AWS_SECRET_ACCESS_KEY=test
266
+ export AWS_DEFAULT_REGION=us-east-1
267
+
268
+ # 4. Run the gated test:
269
+ LOCALSTACK=1 pnpm --filter @serverless-ircd/aws-stack test
270
+ ```
271
+
272
+ ### 4.4 Running the smoke script locally
273
+
274
+ The smoke e2e (`apps/aws-stack/scripts/smoke.mjs`) defaults to
275
+ `ws://localhost:8787`, inherited from the Cloudflare adapter's dev
276
+ port. To smoke-test the AWS adapter locally you would need to stand up
277
+ an API Gateway WebSocket emulator in front of the Lambda handler
278
+ (sam-cli + localstack can do this); that wiring is not preconfigured.
279
+ In practice, local iteration uses the unit + synth tests, and the
280
+ smoke script is run against a deployed staging URL (§5.2) or against
281
+ the Cloudflare adapter's local dev server when you're iterating on the
282
+ shared protocol layer.
283
+
284
+ ---
285
+
286
+ ## 5. Deploy staging
287
+
288
+ Staging is the environment CI deploys on every push to `main`; you can
289
+ also deploy it manually from a clean checkout.
290
+
291
+ ### 5.1 Deploy from your machine
292
+
293
+ ```bash
294
+ # From the repo root:
295
+ pnpm deploy:aws:staging
296
+ # Equivalent to:
297
+ # pnpm --filter @serverless-ircd/aws-stack deploy:staging
298
+ # → cdk deploy --all --require-approval never
299
+ ```
300
+
301
+ The first deploy of a given account/region will:
302
+
303
+ 1. Upload the esbuild-bundled handler asset to the CDK bootstrap S3
304
+ bucket.
305
+ 2. Create the `IrcAwsStack` CloudFormation stack.
306
+ 3. Provision five DynamoDB tables, three Lambda functions, one API
307
+ Gateway WebSocket API, one `prod` stage, two EventBridge rules,
308
+ least-privilege IAM roles, and the two stack outputs.
309
+ 4. Print the deployed URL on success — look for the `ConnectUrl`
310
+ output, e.g.
311
+ `wss://abc123.execute-api.us-east-1.amazonaws.com/prod`.
312
+
313
+ A typical first deploy takes 2–4 minutes (DynamoDB table creation and
314
+ API Gateway stage auto-deploy dominate).
315
+
316
+ ### 5.2 Verify with the smoke script
317
+
318
+ ```bash
319
+ node apps/aws-stack/scripts/smoke.mjs \
320
+ --url wss://abc123.execute-api.us-east-1.amazonaws.com/prod
321
+ ```
322
+
323
+ The script replays CONNECT → NICK/USER → JOIN #smoke → PRIVMSG → QUIT
324
+ and asserts the server emits `001`, `376`, `353`, `366`, and closes
325
+ the socket. Expected output on success:
326
+
327
+ ```json
328
+ {"ts":"...","level":"info","msg":"aws-stack smoke e2e passed","url":"wss://..."}
329
+ ```
330
+
331
+ On failure the script writes a `level: "error"` JSON line and exits
332
+ non-zero, which is what the CI workflow gates on.
333
+
334
+ ### 5.3 Connect a real IRC client
335
+
336
+ Point any RFC-compliant WebSocket-aware IRC client at the deployed
337
+ `wss://` URL. For WeeChat:
338
+
339
+ ```
340
+ /server add ircd abc123.execute-api.us-east-1.amazonaws.com/443
341
+ /set irc.server.ircd.ssl on
342
+ /connect ircd
343
+ /join #test
344
+ ```
345
+
346
+ For a quick text-only check, the `apps/local-cli` and
347
+ `tools/tcp-ws-forwarder` packages in the parent repo bridge a plain
348
+ TCP IRC client to a WebSocket endpoint.
349
+
350
+ ---
351
+
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
+ ```
362
+
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.
369
+
370
+ For a real production deployment, before your first `pnpm deploy:aws:prod`:
371
+
372
+ 1. **Use a dedicated AWS account** (or at least a dedicated region) for
373
+ production. Do not point production credentials at the same stack
374
+ CI is deploying to on every push.
375
+ 2. **Change `RemovalPolicy.DESTROY` to `RemovalPolicy.RETAIN`** on the
376
+ five DynamoDB tables (see §9.4). The committed stack deletes all
377
+ data on `cdk destroy` — intentional for staging, dangerous for
378
+ production.
379
+ 3. **Set the server identity** (`SERVER_NAME`, `NETWORK_NAME`,
380
+ `MOTD`) — see §7.1. `SERVER_NAME` is required at boot; the stack
381
+ falls back to `irc.localhost` when the prop is omitted, which is
382
+ intentionally not production-ready.
383
+ 4. **Pick a region close to your users** (see §10).
384
+
385
+ There is **no CI auto-deploy to production**. The deploy workflow
386
+ (`deploy-aws.yml`) only triggers on push to `main` and targets
387
+ staging. Production deploys are always manual from a clean checkout.
388
+
389
+ ---
390
+
391
+ ## 7. Configuration reference
392
+
393
+ Server identity (`SERVER_NAME`, `NETWORK_NAME`, `MOTD`) and the logical
394
+ environment name are CDK construct props on `IrcAwsStack`
395
+ (`apps/aws-stack/src/aws-stack.ts`); pass them when instantiating the
396
+ stack. All other deployment knobs still live in the stack source.
397
+ Unlike the Cloudflare adapter (where config lives in a declarative
398
+ `wrangler.toml`), AWS config is code — non-identity overrides require
399
+ editing the stack and re-deploying.
400
+
401
+ ### 7.1 Server identity (construct props)
402
+
403
+ The three identity values are sourced from `IrcStackProps` and joined
404
+ to the env vars the Lambda config loader reads. `motdLines` is a
405
+ `string[]`; the stack joins it with `\n` so the loader splits it back
406
+ into one entry per IRC numeric (`375` / `372` / `376`):
407
+
408
+ ```ts
409
+ // apps/aws-stack/src/aws-stack.ts (excerpt)
410
+ const motd = (props.motdLines ?? DEFAULT_MOTD_LINES).join('\n');
411
+ handler.addEnvironment('SERVER_NAME', serverName);
412
+ handler.addEnvironment('NETWORK_NAME', networkName);
413
+ handler.addEnvironment('MOTD', motd);
414
+ ```
415
+
416
+ These three values are injected into the `IrcHandler` and
417
+ `IrcPingChecker` Lambda environments at synth time. To override them,
418
+ pass the construct props when creating the stack:
419
+
420
+ ```ts
421
+ new IrcAwsStack(app, 'IrcAwsStack-production', {
422
+ environmentName: 'production',
423
+ serverName: 'irc.my.net',
424
+ networkName: 'MyNet',
425
+ motdLines: ['Welcome to my network.', 'Be excellent to each other.'],
426
+ });
427
+ ```
428
+
429
+ | Env var | Purpose | Committed default |
430
+ |-----------------|-----------------------------------------------|------------------------------------------------|
431
+ | `SERVER_NAME` | Server name sent in `001`/`005` numerics. **Required** — the Lambda config loader fails fast with a readable error when unset. | `irc.localhost` (clearly-local fallback; pass an explicit `serverName` in production) |
432
+ | `NETWORK_NAME` | Network label in `005 NETWORK=…`. | `ExampleNet` |
433
+ | `MOTD` | Message-of-the-day. **Delimiter: `\n`** — the loader splits the value on every newline into one MOTD line. | Two-line banner (see note below). |
434
+
435
+ > **MOTD delimiter contract:** the config loader
436
+ > (`packages/aws-adapter/src/config-loader.ts`) splits `MOTD` on `\n`
437
+ > to form the multi-line MOTD array. The CDK stack's `motdLines`
438
+ > construct prop is a `string[]` joined with `\n`, so each array entry
439
+ > becomes its own line and round-trips back through the loader into the
440
+ > expected number of `motdLines`. Do **not** use a comma (or any other
441
+ > character) as a separator — the loader treats it as literal line
442
+ > content, collapsing the MOTD into a single line.
443
+
444
+ ### 7.2 Lambda runtime config (injected by CDK, do not edit)
445
+
446
+ These env vars are wired automatically by the stack at synth time. Do
447
+ not hand-set them in the console — the next `cdk deploy` will
448
+ overwrite them.
449
+
450
+ | Env var | Source | Consumed by |
451
+ |------------------------|-------------------------------------------|--------------------------------------|
452
+ | `MANAGEMENT_URL` | `stage.callbackUrl` (CfnOutput `ManagementUrl`) | `IrcHandler`, `IrcPingChecker` (builds `ApiGatewayManagementApi` for `postToConnection`). |
453
+ | `CONNECTIONS_TABLE` | `Connections` (physical table name) | All three Lambdas (`tablesConfigFromEnv`). |
454
+ | `CHANNELMETA_TABLE` | `ChannelMeta` | All three Lambdas. |
455
+ | `CHANNELMEMBERS_TABLE` | `ChannelMembers` | All three Lambdas. |
456
+ | `NICKS_TABLE` | `Nicks` | All three Lambdas. |
457
+ | `ACCOUNTS_TABLE` | `Accounts` | All three Lambdas. |
458
+ | `AWS_REGION` | Injected by the Lambda runtime, not CDK. | `buildDepsFromEnv` (constructs the DynamoDB client). |
459
+
460
+ The `tablesConfigFromEnv` helper
461
+ (`packages/aws-adapter/src/handlers/index.ts:226`) throws on cold
462
+ start if any of the five `<NAME>_TABLE` vars is missing. The smoke
463
+ timeout in §13.2 traces back to this.
464
+
465
+ ### 7.3 Optional config-loader env vars (read if present)
466
+
467
+ The shared config loader (`packages/aws-adapter/src/config-loader.ts`)
468
+ also reads these optional env vars and feeds them through the shared
469
+ Zod `ServerConfig` schema. **The committed CDK stack does not inject
470
+ any of them** — to enable one, add an `addEnvironment(...)` line in
471
+ `aws-stack.ts` and re-deploy.
472
+
473
+ | Env var | Type | Schema field |
474
+ |---------------------------|---------|-----------------------|
475
+ | `MAX_CLIENTS` | int | `maxClients` |
476
+ | `CHANNEL_PREFIXES` | string | `channelPrefixes` |
477
+ | `OPER_USER` | string | `operCreds[0].user` |
478
+ | `OPER_PASSWORD` | string | `operCreds[0].password` |
479
+ | `MAX_CHANNELS_PER_USER` | int | `maxChannelsPerUser` |
480
+ | `MAX_TARGETS_PER_COMMAND` | int | `maxTargetsPerCommand`|
481
+ | `NICK_LEN` | int | `nickLen` |
482
+ | `CHANNEL_LEN` | int | `channelLen` |
483
+ | `TOPIC_LEN` | int | `topicLen` |
484
+ | `MAX_LIST_ENTRIES` | int | `maxListEntries` |
485
+ | `QUIT_MESSAGE` | string | `quitMessage` |
486
+ | `SASL_ACCOUNTS` | string | `saslAccounts` |
487
+
488
+ `SASL_ACCOUNTS` is the legacy/config fallback (newline-delimited
489
+ `username:password` pairs); it is used only when the `Accounts` table is
490
+ empty. When the table has rows it is authoritative — see §8.1.
491
+
492
+ Invalid values cause a readable boot-time error — the Lambda will
493
+ throw on the first invocation (cold start). Test changes locally with
494
+ `pnpm --filter @serverless-ircd/aws-adapter test`.
495
+
496
+ ### 7.4 No per-environment stack parameter
497
+
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).
508
+
509
+ ### 7.5 Lambda runtime and bundling
510
+
511
+ | Knob | Value |
512
+ |-------------------|------------------------------------------------------------|
513
+ | Runtime | `nodejs20.x` (`Runtime.NODEJS_20_X`). |
514
+ | Entry | `packages/aws-adapter/src/handlers/index.ts` (computed by `handlerEntry()` in `aws-stack.ts`). |
515
+ | Handler exports | `IrcHandler` → `handler`; `IrcSweeper` → `sweeperHandler`; `IrcPingChecker` → `pingCheckerHandler`. |
516
+ | Bundler | esbuild (via `NodejsFunction`). |
517
+ | External modules | `@aws-sdk/*` (ships with the runtime; keeps the bundle small). |
518
+ | Memory / timeout | CDK defaults (128 MB / 3 s). Override via `memorySize`/`timeout` props if you observe cold-start or fanout timeouts. |
519
+
520
+ ### 7.6 API Gateway stage
521
+
522
+ | Knob | Value |
523
+ |-------------------|------------------------------------------------------------|
524
+ | Stage name | `prod` (hardcoded as `STAGE_NAME` in `aws-stack.ts`). |
525
+ | Auto-deploy | `true` (changes propagate without a manual deployment). |
526
+ | Auth on `$connect`| None today (no authorizer). See §13.5. |
527
+ | Route responses | Default (`$connect` returns 200 → upgrade; `$default` and `$disconnect` are fire-and-forget). |
528
+
529
+ ---
530
+
531
+ ## 8. Secrets
532
+
533
+ **As of v1 the Lambda consumes no Secrets-Manager/SSM bindings.** Server
534
+ password and oper creds are handled by later hardening work; when they
535
+ ship, the natural homes are AWS Secrets Manager or SSM Parameter Store
536
+ (the AWS adapter architecture doc calls both out as supporting
537
+ services). SASL account credentials, however, **are** persisted — in the
538
+ `Accounts` DynamoDB table (see §8.1).
539
+
540
+ When secrets land, the typical pattern will be:
541
+
542
+ 1. Create the secret in Secrets Manager (or a `SecureString`
543
+ parameter in SSM).
544
+ 2. Grant the Lambda role `secretsmanager:GetSecretValue` (or
545
+ `ssm:GetParameter`) scoped to that one secret ARN.
546
+ 3. Read it inside `buildDepsFromEnv` (cold start) — never inside the
547
+ per-frame hot path.
548
+
549
+ ```bash
550
+ aws secretsmanager create-secret \
551
+ --name serverless-ircd/staging/SERVER_PASSWORD \
552
+ --secret-string "$(openssl rand -base64 32)"
553
+ ```
554
+
555
+ ### 8.1 SASL accounts (`Accounts` table + seed tooling)
556
+
557
+ SASL PLAIN credentials live in the `Accounts` DynamoDB table as
558
+ `HashedAccountCredential` rows — `{ account, algorithm, salt, hash }`,
559
+ all strings, with scrypt hashes encoded as base64 (never plaintext).
560
+ At Lambda cold start, `buildDepsFromEnv` → `resolveAccountStore`
561
+ scans the table once and pre-loads every row into a
562
+ `DynamoAccountStore`; the synchronous `verify()` called from the SASL
563
+ reducer works against that in-memory snapshot. The snapshot is rebuilt
564
+ on every cold start, so account changes take effect within one cold
565
+ start (seconds to minutes).
566
+
567
+ **Precedence** (`resolveAccountStore`, in `account-store.ts`):
568
+
569
+ 1. The `Accounts` table is **authoritative when it has ≥1 row** — the
570
+ table wins and the config seed is ignored.
571
+ 2. When the table is empty, the adapter falls back to the
572
+ `SASL_ACCOUNTS` env var (newline-delimited `username:password` pairs,
573
+ see §7.3) so existing deployments keep working without migrating.
574
+ 3. When neither source has accounts, no `AccountStore` is bound and
575
+ `AUTHENTICATE PLAIN` returns `904 ERR_SASLFAIL`.
576
+
577
+ **Seed / CRUD tooling.** Provision accounts by writing
578
+ `HashedAccountCredential` rows produced by `hashAccountCredential`
579
+ (scrypt, random salt, base64-encoded). The adapter exports
580
+ `putAccountCredential(docClient, tableName, username, password)`
581
+ for this — a repeat call for the same `username` overwrites the row
582
+ (the PK is `account`). A minimal seeding snippet (Node 20, from the repo
583
+ root):
584
+
585
+ ```bash
586
+ node --input-type=module -e '
587
+ import { createDynamoDocumentClient, putAccountCredential } from "@serverless-ircd/aws-adapter";
588
+ const dynamo = createDynamoDocumentClient({ region: "us-east-1" });
589
+ await putAccountCredential(dynamo, "StagingAccounts", "alice", "change-me");
590
+ console.log("seeded alice");
591
+ '
592
+ ```
593
+
594
+ A repo-local seed script (`tools/seed-aws-accounts.ts`) wraps the same
595
+ calls for batch provisioning:
596
+
597
+ ```bash
598
+ # Seed against DynamoDB Local:
599
+ npx tsx tools/seed-aws-accounts.ts \
600
+ --table StagingAccounts \
601
+ --endpoint http://localhost:8000 \
602
+ --accounts alice:s3cret bob:password2
603
+
604
+ # Or from a file (newline-delimited username:password):
605
+ npx tsx tools/seed-aws-accounts.ts \
606
+ --table StagingAccounts \
607
+ --region us-east-1 \
608
+ --file accounts.txt
609
+ ```
610
+
611
+ For a one-off `aws-cli` write, hash the password out-of-band and put the
612
+ row directly:
613
+
614
+ ```bash
615
+ # Hash with the adapter, then write the row yourself:
616
+ aws dynamodb put-item \
617
+ --table-name StagingAccounts \
618
+ --item '{"account":{"S":"alice"},"algorithm":{"S":"scrypt"},"salt":{"S":"<base64-salt>"},"hash":{"S":"<base64-hash>"}}'
619
+ ```
620
+
621
+ > The `Accounts` table is granted to all three Lambdas (`handler`,
622
+ > `sweeper`, `pingChecker`) via `grantReadWriteData`; only the handler
623
+ > reads it at cold start. The sweeper/ping-checker never touch SASL
624
+ > state, but sharing the grant keeps the IAM story uniform.
625
+
626
+ **Never** put credentials, password hashes, or API tokens in
627
+ `aws-stack.ts`, in Lambda env vars, or in committed files. Lambda env
628
+ vars are visible in the console and in CloudFormation describe output;
629
+ they are not secret.
630
+
631
+ The CI deploy uses **GitHub Actions secrets** plus an optional repo
632
+ variable (see §12):
633
+
634
+ | Secret | Used by | Purpose |
635
+ |-------------------------|-------------------|---------------------------------------------------------------|
636
+ | `AWS_ACCESS_KEY_ID` | `deploy-aws.yml` | Static access key for CDK deploy. Required unless OIDC is used. |
637
+ | `AWS_SECRET_ACCESS_KEY` | `deploy-aws.yml` | Companion secret key. Required unless OIDC is used. |
638
+ | `AWS_DEPLOY_ROLE_ARN` | `deploy-aws.yml` | Optional. When set, the job assumes this role via OIDC web identity instead of using static keys. |
639
+ | `AWS_REGION` | `deploy-aws.yml` | Region to deploy into (e.g. `us-east-1`). |
640
+
641
+ | Variable | Purpose |
642
+ |-----------------|---------------------------------------------------------------------|
643
+ | `AWS_SMOKE_URL` | Optional. `wss://` URL for the smoke e2e step. If unset, the workflow falls back to reading the stack's `ConnectUrl` CloudFormation output via the AWS CLI; if that also fails the smoke step is skipped with a warning. |
644
+
645
+ Configure all of these under repo **Settings → Secrets and variables
646
+ → Actions**.
647
+
648
+ ---
649
+
650
+ ## 9. DynamoDB capacity planning
651
+
652
+ ### 9.1 All five tables run on-demand
653
+
654
+ Every table is created with `BillingMode.PAY_PER_REQUEST` (asserted in
655
+ `apps/aws-stack/tests/stack.test.ts:45`). On-demand pricing charges
656
+ per request:
657
+
658
+ - **Write request units (WRU)** — one WRU = one write of up to 1 KB
659
+ (or 2 WRU for a strongly-consistent write of up to 1 KB). A
660
+ conditional write that fails (e.g. `attribute_not_exists` rejects a
661
+ nick collision) still costs 1 WRU.
662
+ - **Read request units (RRU)** — one RRU = one eventually-consistent
663
+ read of up to 4 KB (or 2 RRU for strongly consistent).
664
+
665
+ There is no minimum spend and no capacity to plan at deploy time.
666
+ This is intentional for v1 — it removes a class of
667
+ `ProvisionedThroughputExceededException` failure modes. See §11 for
668
+ the cost trade-off and when to consider provisioned capacity.
669
+
670
+ ### 9.2 Schema and access patterns
671
+
672
+ Five tables (defined in
673
+ `packages/aws-adapter/src/cdk-table-defs.ts`):
674
+
675
+ | Table | PK (HASH) | SK (RANGE) | TTL | Hottest access pattern |
676
+ |-----------------|-------------------|------------------|-------------|----------------------------------------------------------|
677
+ | `Connections` | `connectionId` | — | `idleSince` | `GetItem`/`UpdateItem` on every frame; `Scan` by sweeper. |
678
+ | `ChannelMeta` | `channelName` | — | — | `GetItem`/`UpdateItem` on MODE/TOPIC/KICK. Low volume. |
679
+ | `ChannelMembers`| `channelName` | `connectionId` | — | **`Query` on every channel PRIVMSG for fanout.** Hottest. |
680
+ | `Nicks` | `nickLower` | — | — | `PutItem` (conditional) on NICK; `GetItem` on PRIVMSG to a nick. |
681
+ | `Accounts` | `account` | — | — | `GetItem` on SASL AUTHENTICATE. Cold most of the time. |
682
+
683
+ Uniqueness invariants are enforced structurally (not in application
684
+ code):
685
+
686
+ - `Nicks.PK = nickLower` — conditional `PutItem` with
687
+ `attribute_not_exists(PK)` makes nick reservation atomic across
688
+ concurrent Lambdas. Two clients racing on `Alice` both get evaluated
689
+ against the same row; exactly one wins.
690
+ - `ChannelMembers` PK+SK composite — `TransactWriteItems` for JOIN/PART
691
+ updates `Connections.joined` and `ChannelMembers` in one atomic
692
+ write (up to 100 items per transaction, the AWS limit).
693
+ - `Accounts` row schema — `{ account (PK), algorithm, salt, hash }`,
694
+ all strings. `algorithm` is `'scrypt'`; `salt` and `hash` are
695
+ base64-encoded. Never plaintext. SASL PLAIN verifies against this
696
+ hash server-side. Seed rows with `putAccountCredential` (see §8.1).
697
+
698
+ ### 9.3 The hottest pattern: channel fanout
699
+
700
+ Channel PRIVMSG is the dominant cost driver. There is no long-lived
701
+ per-channel process (contrast with the Cloudflare adapter's
702
+ `ChannelDO`). Fanout is computed per message:
703
+
704
+ 1. The reducer emits `Broadcast("#chan", [line], except=sender)`.
705
+ 2. `AwsRuntime.broadcast()` issues a `Query` on `ChannelMembers` with
706
+ `PK = "#chan"` → returns one row per member.
707
+ 3. For each member, the Lambda calls
708
+ `ApiGatewayManagementApi.postToConnection(member, line)`.
709
+
710
+ That's O(members) Management API calls per message. A 100-member
711
+ channel receiving one PRIVMSG per second produces ~100 `postToConnection`
712
+ calls/second and one `Query`/second. A 1k-member announcement channel
713
+ at the same rate produces ~1k calls/second. Mitigations (batched
714
+ postToConnection, per-channel send-list cache, a dedicated fanout
715
+ Lambda) are deferred to post-v1; the load-test ticket will surface
716
+ the ceiling.
717
+
718
+ ### 9.4 Removal policy (IMPORTANT)
719
+
720
+ The stack sets `RemovalPolicy.DESTROY` on every table:
721
+
722
+ ```ts
723
+ // apps/aws-stack/src/aws-stack.ts:48
724
+ const tables = Object.entries(TABLE_DEFS).map(
725
+ ([logicalId, tableProps]) =>
726
+ new Table(this, logicalId, { ...tableProps, removalPolicy: RemovalPolicy.DESTROY }),
727
+ );
728
+ ```
729
+
730
+ **This means `cdk destroy` deletes every table and all of its data.**
731
+ This is intentional for staging (you want a clean teardown between
732
+ experiments) and **dangerous for production**.
733
+
734
+ For production, fork the stack to use `RemovalPolicy.RETAIN` (or
735
+ `RETAIN_ON_UPDATE`): tables become orphans on stack deletion and keep
736
+ their data. There is no built-in knob for this today — it requires
737
+ editing `aws-stack.ts`. Recommendation: ship a `production: boolean`
738
+ stack prop in a future change that flips the removal policy. Flagged
739
+ in §15.
740
+
741
+ ### 9.5 TTL on `Connections.idleSince`
742
+
743
+ The `Connections` table carries a DynamoDB TTL attribute (`idleSince`),
744
+ asserted at synth time (`stack.test.ts:52`). The Lambda updates
745
+ `idleSince` on every frame; DynamoDB silently deletes rows whose
746
+ `idleSince` is older than the TTL window (typically within 48 hours of
747
+ expiry, no SLA). This is a **safety net** for rows the sweeper misses.
748
+
749
+ ### 9.6 The gone-connection sweeper
750
+
751
+ A second Lambda (`IrcSweeper`, handler export `sweeperHandler`) runs on
752
+ an EventBridge schedule at `rate(5 minutes)`:
753
+
754
+ ```ts
755
+ new Rule(this, 'SweeperSchedule', {
756
+ schedule: Schedule.rate(Duration.minutes(5)),
757
+ targets: [new LambdaFunction(sweeper)],
758
+ });
759
+ ```
760
+
761
+ Each tick `Scan`s the entire `Connections` table, identifies rows past
762
+ a hard stale threshold, and runs the same cleanup path as
763
+ `$disconnect` (delete memberships, release nicks, delete the row). The
764
+ sweeper catches connections that vanished without APIGW emitting
765
+ `$disconnect` (e.g. TCP RST that APIGW didn't observe).
766
+
767
+ **Cost note:** `Scan` reads the **entire** table every 5 minutes. At
768
+ 1 KB per row, scanning 10k connections costs ~3 RRU (10k / 4 KB per
769
+ RRU) per tick, ~864 ticks/day, ~2.6k RRU/day — negligible. At 100k
770
+ connections it's ~26k RRU/day — still small but visible. A future
771
+ optimization is to make the sweeper `Query` a sparse GSI keyed by
772
+ `idleSince` instead of `Scan`.
773
+
774
+ ### 9.7 When to consider provisioned capacity
775
+
776
+ Switch a table to `PROVISIONED` billing (with or without autoscaling)
777
+ when its traffic is predictable and steady. The clearest signal is
778
+ when the on-demand bill for a single table exceeds the cost of
779
+ provisioned capacity for the same throughput — DynamoDB's pricing page
780
+ breaks this out. In practice:
781
+
782
+ - `ChannelMembers` is the first candidate (channel fanout dominates).
783
+ - `Connections` is a poor candidate (spiky, cold-start-driven).
784
+ - `Accounts`/`ChannelMeta`/`Nicks` are unlikely to need provisioned
785
+ capacity in any v1-size deployment.
786
+
787
+ Switching billing modes is a no-downtime `UpdateTable` operation; the
788
+ stack change is `billingMode: BillingMode.PROVISIONED` plus
789
+ `readCapacity`/`writeCapacity` props.
790
+
791
+ ---
792
+
793
+ ## 10. Region strategy
794
+
795
+ ### 10.1 Everything is regional
796
+
797
+ API Gateway WebSocket endpoints are regional. DynamoDB is regional.
798
+ EventBridge Scheduler is regional. Lambda is regional. The stack
799
+ deploys into exactly one region (the one your credentials resolve to
800
+ via `CDK_DEFAULT_REGION`).
801
+
802
+ WebSocket clients pay round-trip time to that region. A user in
803
+ Frankfurt connecting to a `us-east-1` deployment will see ~90 ms RTT
804
+ on every PRIVMSG roundtrip; the same user against `eu-central-1` will
805
+ see ~10 ms. **Pick the region closest to your user base.**
806
+
807
+ ### 10.2 Recommendation: single-region for v1
808
+
809
+ For v1, deploy in one region. There are no static assets (CloudFront
810
+ and S3 are not part of this stack), so a CDN adds no value here.
811
+ Single-region keeps the data model simple: the cross-table
812
+ `TransactWriteItems` flows (§9.2) only work within one region.
813
+
814
+ ### 10.3 Multi-region is out of scope for v1
815
+
816
+ If you ever need multi-region active-active, the considerations are:
817
+
818
+ - **DynamoDB global tables** — replicate all five tables. Eventual
819
+ consistency across regions complicates the conditional-write
820
+ invariants (nick uniqueness, membership transactions): a nick
821
+ reserved in region A may not be visible in region B for a second or
822
+ two, allowing duplicate reservations. The reducer logic would need
823
+ to tolerate this.
824
+ - **Per-region APIGW + Lambda** — each region runs the full stack;
825
+ clients are routed by DNS (Route 53 latency-based routing).
826
+ - **Session affinity** — a client's `connectionId` is regional. A
827
+ reconnect after the 2-hour APIGW cap must land on the same region
828
+ or treat the new connection as a fresh registration (re-NICK,
829
+ re-JOIN).
830
+
831
+ This is post-v1 work; the AWS adapter architecture doc calls it out
832
+ as an open question.
833
+
834
+ ### 10.4 Region pick shortlist
835
+
836
+ For a globally distributed user base, the typical picks:
837
+
838
+ - **US-East-1 (N. Virginia)** — newest services, cheapest, ~100 ms to
839
+ EU and ~150 ms to APAC. Default for the CI workflow's `AWS_REGION`.
840
+ - **EU-West-1 (Ireland)** / **EU-Central-1 (Frankfurt)** — best for
841
+ EU users.
842
+ - **AP-Northeast-1 (Tokyo)** / **AP-Southeast-1 (Singapore)** — best
843
+ for APAC users.
844
+
845
+ ---
846
+
847
+ ## 11. Cost notes
848
+
849
+ All figures are the public AWS list price as of late 2024 — **always
850
+ verify against the current AWS pricing page** before budgeting:
851
+
852
+ - API Gateway WebSocket: <https://aws.amazon.com/apigateway/pricing/>
853
+ - Lambda: <https://aws.amazon.com/lambda/pricing/>
854
+ - DynamoDB: <https://aws.amazon.com/dynamodb/pricing/>
855
+ - EventBridge: <https://aws.amazon.com/eventbridge/pricing/>
856
+
857
+ ### 11.1 Billable services
858
+
859
+ | Service | Unit | List price (late 2024) |
860
+ |--------------------------|------------------------------------------------|-------------------------------|
861
+ | API Gateway WebSocket | connection-minute | $0.80 / million |
862
+ | API Gateway WebSocket | message (inbound or outbound) | $1.00 / million |
863
+ | Lambda | request | $0.20 / million |
864
+ | Lambda | GB-second | $0.0000166667 (≈ $0.0167 / GB-hour) |
865
+ | DynamoDB on-demand | write request unit (WRU) | $1.25 / million |
866
+ | DynamoDB on-demand | read request unit (RRU) | $0.25 / million |
867
+ | EventBridge Scheduler | event published | $1.00 / million |
868
+ | CloudWatch Logs | GB ingested | $0.50 |
869
+
870
+ The Lambda free tier (1M requests + 400k GB-seconds per month) covers
871
+ a meaningful slice of a small staging deployment.
872
+
873
+ ### 11.2 Back-of-envelope: 100 concurrent users
874
+
875
+ Assume a small staging deployment with 100 concurrent users, average
876
+ 5 messages/sec across all channels, each connection active for 1 hour,
877
+ average Lambda invocation 50 ms at 128 MB:
878
+
879
+ | Dimension | Estimate | Monthly cost (approx) |
880
+ |------------------------------------------|---------------------------------------|-----------------------|
881
+ | APIGW connection-minutes | 100 conn × 60 min × 24 h × 30 d = 4.32M conn-min | ~$3.50 |
882
+ | APIGW messages (inbound + fanout) | 5 msg/s × 86400 × 30 = 13M inbound, ~3× fanout | ~$50 |
883
+ | Lambda invocations | ~1 per inbound + fanout ≈ 40M | ~$8.00 (free tier offsets some) |
884
+ | Lambda GB-seconds | 40M × 0.05 s × 0.125 GB = 250k GB-s | ~$4.20 |
885
+ | DynamoDB writes (Connections updates + ChannelMembers deltas) | ~5M WRU | ~$6.25 |
886
+ | DynamoDB reads (fanout Queries + Connections GetItem) | ~30M RRU | ~$7.50 |
887
+ | EventBridge events (sweeper + pingChecker) | 5-min + 1-min ticks ≈ 50k events | <$0.10 |
888
+ | CloudWatch Logs | ~5 GB | ~$2.50 |
889
+
890
+ **Rough total: ~$80/month** for a 100-concurrent-user staging
891
+ deployment. The dominant cost is **APIGW messages** (inbound + the
892
+ per-member fanout). The single biggest lever is channel size — a
893
+ single 1k-member announcement channel can multiply the fanout cost by
894
+ 10×.
895
+
896
+ ### 11.3 Cost-trim levers
897
+
898
+ - **Cap channel size** with `+l` mode (the existing MODE enforcement).
899
+ - **Consider provisioned capacity** for `ChannelMembers` once traffic
900
+ is steady (§9.7).
901
+ - **Shorten the sweeper cadence** if you can tolerate slower cleanup —
902
+ `rate(5 minutes)` is a balance; `rate(15 minutes)` cuts sweeper
903
+ cost 3× at the cost of slower gone-connection reaping.
904
+ - **Tune Lambda memory** — 128 MB is the default and cheapest per
905
+ invocation, but a higher memory allocation can reduce duration
906
+ enough to win on GB-seconds.
907
+
908
+ ---
909
+
910
+ ## 12. CI/CD
911
+
912
+ The GitHub Actions workflow at `.github/workflows/deploy-aws.yml`
913
+ deploys staging on every push to `main` and replays the smoke e2e. It
914
+ is the canonical deploy path — manual `pnpm deploy:aws:staging` is for
915
+ iteration only.
916
+
917
+ ### 12.1 What the workflow does
918
+
919
+ Steps performed (in order):
920
+
921
+ 1. Checkout.
922
+ 2. Install pnpm 9 + Node 20 (`cache: pnpm`).
923
+ 3. `pnpm install --frozen-lockfile`.
924
+ 4. `pnpm build` — builds all workspace packages.
925
+ 5. `pnpm typecheck` and `pnpm test` — gate the deploy.
926
+ 6. Configure AWS credentials (OIDC if `AWS_DEPLOY_ROLE_ARN` is set,
927
+ otherwise static keys).
928
+ 7. `pnpm deploy:aws:staging` → `cdk deploy --all --require-approval never`.
929
+ 8. Resolve the smoke URL: repo variable `AWS_SMOKE_URL` first, else
930
+ the stack's `ConnectUrl` CloudFormation output via
931
+ `aws cloudformation describe-stacks --stack-name IrcAwsStack`, else
932
+ skip the smoke step with a warning.
933
+ 9. `pnpm smoke:aws:staging -- --url "$SMOKE_URL"` — failure fails the
934
+ build.
935
+
936
+ Concurrency is serialized via `concurrency.group: aws-staging`,
937
+ `cancel-in-progress: false`, so two pushes cannot race the same
938
+ CloudFormation stack.
939
+
940
+ ### 12.2 Required repo configuration
941
+
942
+ Under **Settings → Secrets and variables → Actions**:
943
+
944
+ - **Secret `AWS_ACCESS_KEY_ID`** — required unless OIDC.
945
+ - **Secret `AWS_SECRET_ACCESS_KEY`** — required unless OIDC.
946
+ - **Secret `AWS_DEPLOY_ROLE_ARN`** — optional; enables OIDC.
947
+ - **Secret `AWS_REGION`** — required (e.g. `us-east-1`).
948
+ - **Variable `AWS_SMOKE_URL`** — optional but recommended; the
949
+ workflow can fall back to the CFN output, but setting this variable
950
+ explicitly avoids the `describe-stacks` round-trip and the
951
+ possibility of a stale cached value after a redeploy.
952
+
953
+ ### 12.3 OIDC vs static keys
954
+
955
+ The workflow supports both auth modes:
956
+
957
+ ```yaml
958
+ - name: Configure AWS credentials
959
+ uses: aws-actions/configure-aws-credentials@v4
960
+ with:
961
+ aws-region: ${{ secrets.AWS_REGION }}
962
+ role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
963
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
964
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
965
+ ```
966
+
967
+ When `AWS_DEPLOY_ROLE_ARN` is set, the action assumes the role via
968
+ OIDC web identity (requires `permissions.id-token: write`, which the
969
+ workflow declares) and ignores the static keys. When unset, it falls
970
+ back to the static access-key pair. OIDC is the recommended path for
971
+ any deployment tied to a real AWS account — no long-lived credentials
972
+ to rotate.
973
+
974
+ ### 12.4 No CI auto-deploy to production
975
+
976
+ Production deploys are intentionally manual (`pnpm deploy:aws:prod`
977
+ from a clean checkout on `main`, against separate credentials). The
978
+ workflow's `on:` block only triggers on `push` to `main` and
979
+ `workflow_dispatch`, both of which target staging.
980
+
981
+ ---
982
+
983
+ ## 13. Troubleshooting
984
+
985
+ ### 13.1 `cdk deploy` fails with bucket-not-found
986
+
987
+ > `BUCKET_NAME_PARAMETER_<id> does not exist` or
988
+ > `NoSuchBucket: The specified bucket does not exist`
989
+
990
+ The CDK bootstrap stack hasn't been created in this account/region.
991
+
992
+ ```bash
993
+ cd apps/aws-stack
994
+ pnpm cdk bootstrap
995
+ pnpm deploy:aws:staging
996
+ ```
997
+
998
+ Bootstrap is idempotent and per-region — re-run it if you change
999
+ regions.
1000
+
1001
+ ### 13.2 Lambda throws on cold start because of a missing env var
1002
+
1003
+ Symptom: smoke e2e times out waiting for `001`; CloudWatch Logs for
1004
+ `IrcHandler` show an `Error: Missing env var CONNECTIONS_TABLE (table
1005
+ name for Connections)` (or one of the other four `<NAME>_TABLE` vars).
1006
+
1007
+ The `tablesConfigFromEnv` helper throws on the first invocation if any
1008
+ of `CONNECTIONS_TABLE`, `CHANNELMETA_TABLE`, `CHANNELMEMBERS_TABLE`,
1009
+ `NICKS_TABLE`, `ACCOUNTS_TABLE` is missing. The CDK stack always
1010
+ injects these — if one is missing in the console, somebody hand-edited
1011
+ the Lambda config. Fix by re-deploying:
1012
+
1013
+ ```bash
1014
+ pnpm deploy:aws:staging
1015
+ ```
1016
+
1017
+ If the error mentions `MANAGEMENT_URL` being absent: that env var is
1018
+ optional at the loader level (`buildDepsFromEnv` tolerates its absence
1019
+ by setting `managementApi = null`), but the handler's fanout path will
1020
+ fail at the first `postToConnection`. Verify both `IrcHandler` and
1021
+ `IrcPingChecker` carry `MANAGEMENT_URL` in the console.
1022
+
1023
+ ### 13.3 Smoke e2e times out waiting for `001`
1024
+
1025
+ The connection upgrade succeeded (the WebSocket `open` fired) but
1026
+ registration didn't complete. Common causes:
1027
+
1028
+ - **The handler threw on the first frame.** Tail the logs:
1029
+ ```bash
1030
+ aws logs tail /aws/lambda/IrcHandler --follow
1031
+ ```
1032
+ and reproduce. Look for the cold-start `buildDepsFromEnv` throw
1033
+ (§13.2) or an unexpected exception in `handleDefault`.
1034
+ - **The `prod` stage didn't deploy.** The stage is `autoDeploy: true`,
1035
+ so this is rare, but verify the URL ends in `/prod` and that the
1036
+ route is wired:
1037
+ ```bash
1038
+ aws apigatewayv2 get-routes --api-id <api-id>
1039
+ ```
1040
+ You should see three routes: `$connect`, `$disconnect`, `$default`.
1041
+ - **Client connected over plain `ws://`.** APIGW WebSocket stages only
1042
+ accept `wss://`. Use the URL printed by `cdk deploy` (the
1043
+ `ConnectUrl` output).
1044
+
1045
+ ### 13.4 `aws cloudformation describe-stacks` returns nothing in CI
1046
+
1047
+ The workflow uses:
1048
+
1049
+ ```bash
1050
+ aws cloudformation describe-stacks \
1051
+ --stack-name IrcAwsStack \
1052
+ --query 'Stacks[0].Outputs[?OutputKey==`ConnectUrl`].OutputValue' \
1053
+ --output text
1054
+ ```
1055
+
1056
+ to resolve the smoke URL. If this returns nothing, either:
1057
+
1058
+ - **The stack name is wrong.** It's hardcoded as `IrcAwsStack` in
1059
+ `apps/aws-stack/bin/aws.ts`. If you forked the stack to a different
1060
+ name, the workflow's `--stack-name IrcAwsStack` won't find it.
1061
+ Update the workflow or rename back.
1062
+ - **The deploy failed silently.** Check the prior `pnpm
1063
+ deploy:aws:staging` step's exit code. `cdk deploy` with
1064
+ `--require-approval never` can still fail on IAM or
1065
+ CloudFormation errors.
1066
+ - **Wrong region.** The `describe-stacks` call uses `AWS_REGION` from
1067
+ the secrets; it must match the region the stack was deployed into.
1068
+
1069
+ The workflow prints a `::warning::` and skips the smoke step rather
1070
+ than failing the build — a silent skip is the failure mode to watch
1071
+ for.
1072
+
1073
+ ### 13.5 WebSocket client can't connect
1074
+
1075
+ The `$connect` route is wired to `IrcHandler` and currently uses **no
1076
+ auth** (no authorizer). If the client gets a 4xx/5xx on the HTTP
1077
+ upgrade:
1078
+
1079
+ - Verify the URL is the `ConnectUrl` output, not `ManagementUrl` (the
1080
+ management endpoint rejects WebSocket upgrades).
1081
+ - Verify the `IrcHandler` Lambda has `execute-api:ManageConnections`
1082
+ on this stage (the stack grants this via
1083
+ `stage.grantManagementApiAccess(handler)`).
1084
+ - If you add an authorizer or IP allowlist later, the `$connect`
1085
+ Lambda is the natural place to gate by source IP (read
1086
+ `event.requestContext.connectionId` and
1087
+ `event.requestContext.sourceIp`).
1088
+
1089
+ ### 13.6 DynamoDB table vanished after `cdk destroy`
1090
+
1091
+ By design. The stack uses `RemovalPolicy.DESTROY` on every table
1092
+ (§9.4) so staging teardowns are clean. **Do not run
1093
+ `cdk destroy` against a production stack.** For production, fork the
1094
+ stack to `RemovalPolicy.RETAIN` before the first deploy; flagged in
1095
+ §15.
1096
+
1097
+ ### 13.7 High DynamoDB cost on `Connections`
1098
+
1099
+ The gone-connection sweeper `Scan`s the entire `Connections` table
1100
+ every 5 minutes (§9.6). At very large scale this dominates the read
1101
+ bill. Short-term mitigation: increase the sweeper's `rate(...)` to
1102
+ `rate(15 minutes)` or `rate(30 minutes)` in `aws-stack.ts`. Long-term:
1103
+ add a sparse GSI keyed by `idleSince` and switch the sweeper to a
1104
+ `Query` against it (post-v1 work).
1105
+
1106
+ ### 13.8 Nick reservation always fails
1107
+
1108
+ A client's `NICK` always returns `433 ERR_NICKNAMEINUSE`, even for
1109
+ nicks that should be free. Two deployments sharing the same DynamoDB
1110
+ table name space is the usual cause — e.g. you deployed `IrcAwsStack`
1111
+ twice into the same account/region with a forked stack name. The
1112
+ `Nicks` table name is hardcoded as `Nicks` (physical name), so two
1113
+ stacks in the same region collide on the same table. Use separate
1114
+ regions or fork the table-name prefix.
1115
+
1116
+ ---
1117
+
1118
+ ## 14. Platform limitations
1119
+
1120
+ Architectural constraints of the API Gateway WebSocket + Lambda substrate
1121
+ that operators should be aware of. These are not bugs — they are
1122
+ consequences of the serverless transport choice (see ADR-005 and
1123
+ ADR-009 for the wss-only v1 decision and the dual-transport v1.x path).
1124
+
1125
+ ### 14.1 A Lambda `$default` invocation cannot close its own socket
1126
+
1127
+ This is the most important platform limit to understand.
1128
+
1129
+ When a client sends an IRC command that should terminate its own
1130
+ connection — `QUIT`, or a flood-control `Disconnect("Excess Flood")`,
1131
+ or a fatal protocol error — the `ConnectionActor` emits a `Disconnect`
1132
+ effect. On the Cloudflare adapter the bound `ConnectionDO` closes its
1133
+ WebSocket immediately. On AWS, **nothing closes the socket from within
1134
+ the `$default` Lambda invocation**. The `AwsRuntimeHandlers.disconnect`
1135
+ implementation in `packages/aws-adapter/src/handlers/default.ts` is
1136
+ deliberately a no-op:
1137
+
1138
+ ```ts
1139
+ disconnect: () => {
1140
+ // No-op: a Lambda `$default` invocation cannot close its own APIGW
1141
+ // socket. … See docs/AWS-Deployment.md §14.1.
1142
+ },
1143
+ ```
1144
+
1145
+ **Why.** API Gateway WebSocket connections are managed by the service,
1146
+ not by the Lambda compute. A Lambda handling a `$default` frame has no
1147
+ API to force-close the calling WebSocket synchronously — the
1148
+ `ApiGatewayManagementApi` client can *post* to a connection, but there
1149
+ is no `DeleteConnection` call available to the handling Lambda for its
1150
+ own socket. The connection stays open until APIGW observes a TCP/FIN
1151
+ (the client disconnects) or the 2-hour idle cap elapses.
1152
+
1153
+ **What actually tears the connection down.** The canonical teardown
1154
+ path is the **`$disconnect` route** (`packages/aws-adapter/src/handlers/disconnect.ts`),
1155
+ which APIGW invokes asynchronously after the socket closes. That route
1156
+ runs `cleanupConnection` — deleting the `Connections` row, every
1157
+ `ChannelMembers` row owned by it, the nick reservation, and broadcasting
1158
+ the `QUIT` line to every peer sharing a channel (see §9.6 for the
1159
+ sweeper that catches connections which vanish without a `$disconnect`).
1160
+
1161
+ So the effect-driven `Disconnect` **is not lost**: the actor's state
1162
+ mutations (nick release, roster deltas) are persisted by `handleDefault`
1163
+ before the invocation returns, and the eventual `$disconnect` route
1164
+ completes the fanout. The only observable gap is that the *socket*
1165
+ lingers between the `QUIT` command and the client (or APIGW) actually
1166
+ closing it. Well-behaved IRC clients close the socket themselves
1167
+ immediately after sending `QUIT`, so the window is sub-second.
1168
+
1169
+ **Cross-connection disconnect is also a no-op.** `AwsRuntime.disconnect(remoteConn, reason)`
1170
+ (`packages/aws-adapter/src/aws-runtime.ts:183`) cannot directly close
1171
+ another connection's WebSocket either. Commands that remove a user from
1172
+ a channel (e.g. `KICK`) delete membership rows and fan out the line, but
1173
+ do not force-close the target's socket — the kicked client will not be
1174
+ disconnected until it observes its own removal (via the `KICK` message)
1175
+ and closes, or the ping-checker/sweeper reaps it after the idle window.
1176
+
1177
+ ### 14.2 Mitigations already in place
1178
+
1179
+ The adapter ships three mechanisms that bound the gap left by the
1180
+ no-op `disconnect`:
1181
+
1182
+ | Mechanism | Where | What it catches |
1183
+ |----------------------------------------------|----------------------------------------------------|--------------------------------------------------------------|
1184
+ | `$disconnect` route | `handlers/disconnect.ts` | The normal case: client closes, APIGW emits `$disconnect`. |
1185
+ | Gone-connection sweeper | `handlers/sweeper.ts`, `rate(5 minutes)` (§9.6) | Connections that vanish without APIGW emitting `$disconnect`. |
1186
+ | Idle PING / disconnect | `handlers/ping-checker.ts`, `rate(1 minute)` (§9.6)| Connections that go idle; reaps after the no-PONG window. |
1187
+
1188
+ Together these ensure ghost rosters and stale nick reservations are
1189
+ reaped within minutes even when the transport cannot force-close.
1190
+
1191
+ ### 14.3 Future enhancement (not implemented)
1192
+
1193
+ A possible mitigation for true synchronous force-close from within
1194
+ `$default` would be to emit an EventBridge event (or an SQS message)
1195
+ from the handler that a sidecar Lambda consumes to call
1196
+ `ApiGatewayManagementApi`'s connection-deletion path against the target
1197
+ `connectionId`. This would let `KICK`/flood-disconnect close the target
1198
+ socket without waiting for the idle window. This is **out of scope for
1199
+ v1** and is tracked as a future enhancement; file an ADR before
1200
+ implementing it, because it adds a second async hop and a new failure
1201
+ mode (the sidecar falling behind).
1202
+
1203
+ ---
1204
+
1205
+ ## 15. Quick reference
1206
+
1207
+ ```bash
1208
+ # One-time account setup (per region)
1209
+ aws configure # or: export AWS_PROFILE=...
1210
+ cd apps/aws-stack && pnpm cdk bootstrap
1211
+
1212
+ # Local dev
1213
+ pnpm install
1214
+ pnpm build
1215
+ pnpm --filter @serverless-ircd/aws-stack test # synth-time assertions
1216
+ pnpm --filter @serverless-ircd/aws-stack cdk:synth # writes cdk.out/
1217
+
1218
+ # Optional localstack validation (Docker required)
1219
+ docker run --rm -d -p 4566:4566 localstack/localstack
1220
+ npm install -g aws-cdk-local
1221
+ export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
1222
+ LOCALSTACK=1 pnpm --filter @serverless-ircd/aws-stack test
1223
+
1224
+ # Staging
1225
+ pnpm deploy:aws:staging
1226
+ node apps/aws-stack/scripts/smoke.mjs \
1227
+ --url wss://<id>.execute-api.<region>.amazonaws.com/prod
1228
+ aws logs tail /aws/lambda/IrcHandler --follow # live logs
1229
+
1230
+ # Production (manual, separate credentials)
1231
+ pnpm deploy:aws:prod
1232
+
1233
+ # Tests / lint
1234
+ pnpm test # full workspace
1235
+ pnpm --filter @serverless-ircd/aws-adapter test
1236
+ pnpm --filter @serverless-ircd/aws-stack test
1237
+ pnpm lint
1238
+ ```
1239
+
1240
+ Key files:
1241
+
1242
+ | Path | What |
1243
+ |---------------------------------------------------|-----------------------------------------------------|
1244
+ | `apps/aws-stack/src/aws-stack.ts` | CDK stack — all constructs and env-var wiring. |
1245
+ | `apps/aws-stack/bin/aws.ts` | App entry. Hardcodes stack name `IrcAwsStack`. |
1246
+ | `apps/aws-stack/cdk.json` | `app = tsx bin/aws.ts`. |
1247
+ | `apps/aws-stack/scripts/smoke.mjs` | CI + local smoke e2e. |
1248
+ | `apps/aws-stack/tests/stack.test.ts` | Synth-time assertions (cross-check for every fact). |
1249
+ | `packages/aws-adapter/src/cdk-table-defs.ts` | Five DynamoDB table shapes (keys, billing, TTL). |
1250
+ | `packages/aws-adapter/src/tables.ts` | Runtime table-name + key-column constants. |
1251
+ | `packages/aws-adapter/src/config-loader.ts` | Lambda env → `ServerConfig` schema mapping. |
1252
+ | `packages/aws-adapter/src/handlers/index.ts` | Lambda entry points (`handler`, `sweeperHandler`, `pingCheckerHandler`). |
1253
+ | `.github/workflows/deploy-aws.yml` | Staging deploy + smoke e2e CI. |
1254
+
1255
+ ### Known gaps flagged for follow-up
1256
+
1257
+ These are facts the **code does not yet have an answer for**; the
1258
+ recommendations above are deployer guidance, not built-in knobs:
1259
+
1260
+ 1. **`RemovalPolicy.DESTROY` is hardcoded.** There is no stack prop to
1261
+ flip it to `RETAIN` for production. Recommendation: a future change
1262
+ adds a `production: boolean` (or similar) construct prop that
1263
+ toggles the removal policy across all five tables.
1264
+ 2. ~~**`SERVER_NAME` / `NETWORK_NAME` / `MOTD` are hardcoded in
1265
+ `aws-stack.ts`.**~~ **Resolved** — these are now CDK construct props
1266
+ (`serverName`, `networkName`, `motdLines`) on `IrcStackProps` (see
1267
+ §7.1).
1268
+ 3. ~~**MOTD multiline parsing.**~~ **Resolved** — the stack's
1269
+ `motdLines` prop is a `string[]` joined with `\n`, which the config
1270
+ loader splits back into one line per entry. The delimiter is `\n`
1271
+ only; commas are treated as literal line content (see §7.1).
1272
+ 4. **No per-environment stack parameter.** Staging and production
1273
+ cannot coexist in the same account/region without forking the
1274
+ stack to parameterize names. Recommendation: separate accounts for
1275
+ staging vs production for v1.