serverless-ircd 0.1.0 → 0.3.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 (204) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,1186 @@
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. **Override the server identity** (`SERVER_NAME`, `NETWORK_NAME`,
380
+ `MOTD`) — see §7.1. The committed defaults are placeholders.
381
+ 4. **Pick a region close to your users** (see §10).
382
+
383
+ There is **no CI auto-deploy to production**. The deploy workflow
384
+ (`deploy-aws.yml`) only triggers on push to `main` and targets
385
+ staging. Production deploys are always manual from a clean checkout.
386
+
387
+ ---
388
+
389
+ ## 7. Configuration reference
390
+
391
+ Server identity (`SERVER_NAME`, `NETWORK_NAME`, `MOTD`) and the logical
392
+ environment name are CDK construct props on `IrcAwsStack`
393
+ (`apps/aws-stack/src/aws-stack.ts`); pass them when instantiating the
394
+ stack. All other deployment knobs still live in the stack source.
395
+ Unlike the Cloudflare adapter (where config lives in a declarative
396
+ `wrangler.toml`), AWS config is code — non-identity overrides require
397
+ editing the stack and re-deploying.
398
+
399
+ ### 7.1 Server identity (construct props)
400
+
401
+ The three identity values are sourced from `IrcStackProps` and joined
402
+ to the env vars the Lambda config loader reads. `motdLines` is a
403
+ `string[]`; the stack joins it with `\n` so the loader splits it back
404
+ into one entry per IRC numeric (`375` / `372` / `376`):
405
+
406
+ ```ts
407
+ // apps/aws-stack/src/aws-stack.ts (excerpt)
408
+ const motd = (props.motdLines ?? DEFAULT_MOTD_LINES).join('\n');
409
+ handler.addEnvironment('SERVER_NAME', serverName);
410
+ handler.addEnvironment('NETWORK_NAME', networkName);
411
+ handler.addEnvironment('MOTD', motd);
412
+ ```
413
+
414
+ These three values are injected into the `IrcHandler` and
415
+ `IrcPingChecker` Lambda environments at synth time. To override them,
416
+ pass the construct props when creating the stack:
417
+
418
+ ```ts
419
+ new IrcAwsStack(app, 'IrcAwsStack-production', {
420
+ environmentName: 'production',
421
+ serverName: 'irc.my.net',
422
+ networkName: 'MyNet',
423
+ motdLines: ['Welcome to my network.', 'Be excellent to each other.'],
424
+ });
425
+ ```
426
+
427
+ | Env var | Purpose | Committed default |
428
+ |-----------------|-----------------------------------------------|------------------------------------------------|
429
+ | `SERVER_NAME` | Server name sent in `001`/`005` numerics. | `irc.example.com` (placeholder — override!) |
430
+ | `NETWORK_NAME` | Network label in `005 NETWORK=…`. | `ExampleNet` |
431
+ | `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). |
432
+
433
+ > **MOTD delimiter contract:** the config loader
434
+ > (`packages/aws-adapter/src/config-loader.ts`) splits `MOTD` on `\n`
435
+ > to form the multi-line MOTD array. The CDK stack's `motdLines`
436
+ > construct prop is a `string[]` joined with `\n`, so each array entry
437
+ > becomes its own line and round-trips back through the loader into the
438
+ > expected number of `motdLines`. Do **not** use a comma (or any other
439
+ > character) as a separator — the loader treats it as literal line
440
+ > content, collapsing the MOTD into a single line.
441
+
442
+ ### 7.2 Lambda runtime config (injected by CDK, do not edit)
443
+
444
+ These env vars are wired automatically by the stack at synth time. Do
445
+ not hand-set them in the console — the next `cdk deploy` will
446
+ overwrite them.
447
+
448
+ | Env var | Source | Consumed by |
449
+ |------------------------|-------------------------------------------|--------------------------------------|
450
+ | `MANAGEMENT_URL` | `stage.callbackUrl` (CfnOutput `ManagementUrl`) | `IrcHandler`, `IrcPingChecker` (builds `ApiGatewayManagementApi` for `postToConnection`). |
451
+ | `CONNECTIONS_TABLE` | `Connections` (physical table name) | All three Lambdas (`tablesConfigFromEnv`). |
452
+ | `CHANNELMETA_TABLE` | `ChannelMeta` | All three Lambdas. |
453
+ | `CHANNELMEMBERS_TABLE` | `ChannelMembers` | All three Lambdas. |
454
+ | `NICKS_TABLE` | `Nicks` | All three Lambdas. |
455
+ | `ACCOUNTS_TABLE` | `Accounts` | All three Lambdas. |
456
+ | `AWS_REGION` | Injected by the Lambda runtime, not CDK. | `buildDepsFromEnv` (constructs the DynamoDB client). |
457
+
458
+ The `tablesConfigFromEnv` helper
459
+ (`packages/aws-adapter/src/handlers/index.ts:226`) throws on cold
460
+ start if any of the five `<NAME>_TABLE` vars is missing. The smoke
461
+ timeout in §13.2 traces back to this.
462
+
463
+ ### 7.3 Optional config-loader env vars (read if present)
464
+
465
+ The shared config loader (`packages/aws-adapter/src/config-loader.ts`)
466
+ also reads these optional env vars and feeds them through the shared
467
+ Zod `ServerConfig` schema. **The committed CDK stack does not inject
468
+ any of them** — to enable one, add an `addEnvironment(...)` line in
469
+ `aws-stack.ts` and re-deploy.
470
+
471
+ | Env var | Type | Schema field |
472
+ |---------------------------|---------|-----------------------|
473
+ | `MAX_CLIENTS` | int | `maxClients` |
474
+ | `CHANNEL_PREFIXES` | string | `channelPrefixes` |
475
+ | `OPER_USER` | string | `operCreds[0].user` |
476
+ | `OPER_PASSWORD` | string | `operCreds[0].password` |
477
+ | `MAX_CHANNELS_PER_USER` | int | `maxChannelsPerUser` |
478
+ | `MAX_TARGETS_PER_COMMAND` | int | `maxTargetsPerCommand`|
479
+ | `NICK_LEN` | int | `nickLen` |
480
+ | `CHANNEL_LEN` | int | `channelLen` |
481
+ | `TOPIC_LEN` | int | `topicLen` |
482
+ | `MAX_LIST_ENTRIES` | int | `maxListEntries` |
483
+ | `QUIT_MESSAGE` | string | `quitMessage` |
484
+ | `SASL_ACCOUNTS` | string | `saslAccounts` |
485
+
486
+ `SASL_ACCOUNTS` is the legacy/config fallback (newline-delimited
487
+ `username:password` pairs); it is used only when the `Accounts` table is
488
+ empty. When the table has rows it is authoritative — see §8.1.
489
+
490
+ Invalid values cause a readable boot-time error — the Lambda will
491
+ throw on the first invocation (cold start). Test changes locally with
492
+ `pnpm --filter @serverless-ircd/aws-adapter test`.
493
+
494
+ ### 7.4 No per-environment stack parameter
495
+
496
+ There is no `stageName` or `environmentType` parameter on the CDK
497
+ stack. The stack name (`IrcAwsStack`), table names
498
+ (`Connections`, `ChannelMeta`, `ChannelMembers`, `Nicks`, `Accounts`),
499
+ API Gateway stage name (`prod`), and Lambda construct ids are all
500
+ hardcoded. To run staging and production in **the same AWS account**,
501
+ you would need to fork the stack to parameterize these names; the
502
+ recommended path is to use separate accounts (or separate regions) for
503
+ staging vs production. This is the same architectural choice the
504
+ Cloudflare adapter makes (staging and production are separate Worker
505
+ namespaces).
506
+
507
+ ### 7.5 Lambda runtime and bundling
508
+
509
+ | Knob | Value |
510
+ |-------------------|------------------------------------------------------------|
511
+ | Runtime | `nodejs20.x` (`Runtime.NODEJS_20_X`). |
512
+ | Entry | `packages/aws-adapter/src/handlers/index.ts` (computed by `handlerEntry()` in `aws-stack.ts`). |
513
+ | Handler exports | `IrcHandler` → `handler`; `IrcSweeper` → `sweeperHandler`; `IrcPingChecker` → `pingCheckerHandler`. |
514
+ | Bundler | esbuild (via `NodejsFunction`). |
515
+ | External modules | `@aws-sdk/*` (ships with the runtime; keeps the bundle small). |
516
+ | Memory / timeout | CDK defaults (128 MB / 3 s). Override via `memorySize`/`timeout` props if you observe cold-start or fanout timeouts. |
517
+
518
+ ### 7.6 API Gateway stage
519
+
520
+ | Knob | Value |
521
+ |-------------------|------------------------------------------------------------|
522
+ | Stage name | `prod` (hardcoded as `STAGE_NAME` in `aws-stack.ts`). |
523
+ | Auto-deploy | `true` (changes propagate without a manual deployment). |
524
+ | Auth on `$connect`| None today (no authorizer). See §13.5. |
525
+ | Route responses | Default (`$connect` returns 200 → upgrade; `$default` and `$disconnect` are fire-and-forget). |
526
+
527
+ ---
528
+
529
+ ## 8. Secrets
530
+
531
+ **As of v1 the Lambda consumes no Secrets-Manager/SSM bindings.** Server
532
+ password and oper creds are handled by later hardening work; when they
533
+ ship, the natural homes are AWS Secrets Manager or SSM Parameter Store
534
+ (the AWS adapter architecture doc calls both out as supporting
535
+ services). SASL account credentials, however, **are** persisted — in the
536
+ `Accounts` DynamoDB table (see §8.1).
537
+
538
+ When secrets land, the typical pattern will be:
539
+
540
+ 1. Create the secret in Secrets Manager (or a `SecureString`
541
+ parameter in SSM).
542
+ 2. Grant the Lambda role `secretsmanager:GetSecretValue` (or
543
+ `ssm:GetParameter`) scoped to that one secret ARN.
544
+ 3. Read it inside `buildDepsFromEnv` (cold start) — never inside the
545
+ per-frame hot path.
546
+
547
+ ```bash
548
+ aws secretsmanager create-secret \
549
+ --name serverless-ircd/staging/SERVER_PASSWORD \
550
+ --secret-string "$(openssl rand -base64 32)"
551
+ ```
552
+
553
+ ### 8.1 SASL accounts (`Accounts` table + seed tooling)
554
+
555
+ SASL PLAIN credentials live in the `Accounts` DynamoDB table as
556
+ `HashedAccountCredential` rows — `{ account, algorithm, salt, hash }`,
557
+ all strings, with scrypt hashes encoded as base64 (never plaintext).
558
+ At Lambda cold start, `buildDepsFromEnv` → `resolveAccountStore`
559
+ scans the table once and pre-loads every row into a
560
+ `DynamoAccountStore`; the synchronous `verify()` called from the SASL
561
+ reducer works against that in-memory snapshot. The snapshot is rebuilt
562
+ on every cold start, so account changes take effect within one cold
563
+ start (seconds to minutes).
564
+
565
+ **Precedence** (`resolveAccountStore`, in `account-store.ts`):
566
+
567
+ 1. The `Accounts` table is **authoritative when it has ≥1 row** — the
568
+ table wins and the config seed is ignored.
569
+ 2. When the table is empty, the adapter falls back to the
570
+ `SASL_ACCOUNTS` env var (newline-delimited `username:password` pairs,
571
+ see §7.3) so existing deployments keep working without migrating.
572
+ 3. When neither source has accounts, no `AccountStore` is bound and
573
+ `AUTHENTICATE PLAIN` returns `904 ERR_SASLFAIL`.
574
+
575
+ **Seed / CRUD tooling.** Provision accounts by writing
576
+ `HashedAccountCredential` rows produced by `hashAccountCredential`
577
+ (scrypt, random salt, base64-encoded). The adapter exports
578
+ `putAccountCredential(docClient, tableName, username, password)`
579
+ for this — a repeat call for the same `username` overwrites the row
580
+ (the PK is `account`). A minimal seeding snippet (Node 20, from the repo
581
+ root):
582
+
583
+ ```bash
584
+ node --input-type=module -e '
585
+ import { createDynamoDocumentClient, putAccountCredential } from "@serverless-ircd/aws-adapter";
586
+ const dynamo = createDynamoDocumentClient({ region: "us-east-1" });
587
+ await putAccountCredential(dynamo, "StagingAccounts", "alice", "change-me");
588
+ console.log("seeded alice");
589
+ '
590
+ ```
591
+
592
+ A repo-local seed script (`tools/seed-aws-accounts.ts`) wraps the same
593
+ calls for batch provisioning:
594
+
595
+ ```bash
596
+ # Seed against DynamoDB Local:
597
+ npx tsx tools/seed-aws-accounts.ts \
598
+ --table StagingAccounts \
599
+ --endpoint http://localhost:8000 \
600
+ --accounts alice:s3cret bob:password2
601
+
602
+ # Or from a file (newline-delimited username:password):
603
+ npx tsx tools/seed-aws-accounts.ts \
604
+ --table StagingAccounts \
605
+ --region us-east-1 \
606
+ --file accounts.txt
607
+ ```
608
+
609
+ For a one-off `aws-cli` write, hash the password out-of-band and put the
610
+ row directly:
611
+
612
+ ```bash
613
+ # Hash with the adapter, then write the row yourself:
614
+ aws dynamodb put-item \
615
+ --table-name StagingAccounts \
616
+ --item '{"account":{"S":"alice"},"algorithm":{"S":"scrypt"},"salt":{"S":"<base64-salt>"},"hash":{"S":"<base64-hash>"}}'
617
+ ```
618
+
619
+ > The `Accounts` table is granted to all three Lambdas (`handler`,
620
+ > `sweeper`, `pingChecker`) via `grantReadWriteData`; only the handler
621
+ > reads it at cold start. The sweeper/ping-checker never touch SASL
622
+ > state, but sharing the grant keeps the IAM story uniform.
623
+
624
+ **Never** put credentials, password hashes, or API tokens in
625
+ `aws-stack.ts`, in Lambda env vars, or in committed files. Lambda env
626
+ vars are visible in the console and in CloudFormation describe output;
627
+ they are not secret.
628
+
629
+ The CI deploy uses **GitHub Actions secrets** plus an optional repo
630
+ variable (see §12):
631
+
632
+ | Secret | Used by | Purpose |
633
+ |-------------------------|-------------------|---------------------------------------------------------------|
634
+ | `AWS_ACCESS_KEY_ID` | `deploy-aws.yml` | Static access key for CDK deploy. Required unless OIDC is used. |
635
+ | `AWS_SECRET_ACCESS_KEY` | `deploy-aws.yml` | Companion secret key. Required unless OIDC is used. |
636
+ | `AWS_DEPLOY_ROLE_ARN` | `deploy-aws.yml` | Optional. When set, the job assumes this role via OIDC web identity instead of using static keys. |
637
+ | `AWS_REGION` | `deploy-aws.yml` | Region to deploy into (e.g. `us-east-1`). |
638
+
639
+ | Variable | Purpose |
640
+ |-----------------|---------------------------------------------------------------------|
641
+ | `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. |
642
+
643
+ Configure all of these under repo **Settings → Secrets and variables
644
+ → Actions**.
645
+
646
+ ---
647
+
648
+ ## 9. DynamoDB capacity planning
649
+
650
+ ### 9.1 All five tables run on-demand
651
+
652
+ Every table is created with `BillingMode.PAY_PER_REQUEST` (asserted in
653
+ `apps/aws-stack/tests/stack.test.ts:45`). On-demand pricing charges
654
+ per request:
655
+
656
+ - **Write request units (WRU)** — one WRU = one write of up to 1 KB
657
+ (or 2 WRU for a strongly-consistent write of up to 1 KB). A
658
+ conditional write that fails (e.g. `attribute_not_exists` rejects a
659
+ nick collision) still costs 1 WRU.
660
+ - **Read request units (RRU)** — one RRU = one eventually-consistent
661
+ read of up to 4 KB (or 2 RRU for strongly consistent).
662
+
663
+ There is no minimum spend and no capacity to plan at deploy time.
664
+ This is intentional for v1 — it removes a class of
665
+ `ProvisionedThroughputExceededException` failure modes. See §11 for
666
+ the cost trade-off and when to consider provisioned capacity.
667
+
668
+ ### 9.2 Schema and access patterns
669
+
670
+ Five tables (defined in
671
+ `packages/aws-adapter/src/cdk-table-defs.ts`):
672
+
673
+ | Table | PK (HASH) | SK (RANGE) | TTL | Hottest access pattern |
674
+ |-----------------|-------------------|------------------|-------------|----------------------------------------------------------|
675
+ | `Connections` | `connectionId` | — | `idleSince` | `GetItem`/`UpdateItem` on every frame; `Scan` by sweeper. |
676
+ | `ChannelMeta` | `channelName` | — | — | `GetItem`/`UpdateItem` on MODE/TOPIC/KICK. Low volume. |
677
+ | `ChannelMembers`| `channelName` | `connectionId` | — | **`Query` on every channel PRIVMSG for fanout.** Hottest. |
678
+ | `Nicks` | `nickLower` | — | — | `PutItem` (conditional) on NICK; `GetItem` on PRIVMSG to a nick. |
679
+ | `Accounts` | `account` | — | — | `GetItem` on SASL AUTHENTICATE. Cold most of the time. |
680
+
681
+ Uniqueness invariants are enforced structurally (not in application
682
+ code):
683
+
684
+ - `Nicks.PK = nickLower` — conditional `PutItem` with
685
+ `attribute_not_exists(PK)` makes nick reservation atomic across
686
+ concurrent Lambdas. Two clients racing on `Alice` both get evaluated
687
+ against the same row; exactly one wins.
688
+ - `ChannelMembers` PK+SK composite — `TransactWriteItems` for JOIN/PART
689
+ updates `Connections.joined` and `ChannelMembers` in one atomic
690
+ write (up to 100 items per transaction, the AWS limit).
691
+ - `Accounts` row schema — `{ account (PK), algorithm, salt, hash }`,
692
+ all strings. `algorithm` is `'scrypt'`; `salt` and `hash` are
693
+ base64-encoded. Never plaintext. SASL PLAIN verifies against this
694
+ hash server-side. Seed rows with `putAccountCredential` (see §8.1).
695
+
696
+ ### 9.3 The hottest pattern: channel fanout
697
+
698
+ Channel PRIVMSG is the dominant cost driver. There is no long-lived
699
+ per-channel process (contrast with the Cloudflare adapter's
700
+ `ChannelDO`). Fanout is computed per message:
701
+
702
+ 1. The reducer emits `Broadcast("#chan", [line], except=sender)`.
703
+ 2. `AwsRuntime.broadcast()` issues a `Query` on `ChannelMembers` with
704
+ `PK = "#chan"` → returns one row per member.
705
+ 3. For each member, the Lambda calls
706
+ `ApiGatewayManagementApi.postToConnection(member, line)`.
707
+
708
+ That's O(members) Management API calls per message. A 100-member
709
+ channel receiving one PRIVMSG per second produces ~100 `postToConnection`
710
+ calls/second and one `Query`/second. A 1k-member announcement channel
711
+ at the same rate produces ~1k calls/second. Mitigations (batched
712
+ postToConnection, per-channel send-list cache, a dedicated fanout
713
+ Lambda) are deferred to post-v1; the load-test ticket will surface
714
+ the ceiling.
715
+
716
+ ### 9.4 Removal policy (IMPORTANT)
717
+
718
+ The stack sets `RemovalPolicy.DESTROY` on every table:
719
+
720
+ ```ts
721
+ // apps/aws-stack/src/aws-stack.ts:48
722
+ const tables = Object.entries(TABLE_DEFS).map(
723
+ ([logicalId, tableProps]) =>
724
+ new Table(this, logicalId, { ...tableProps, removalPolicy: RemovalPolicy.DESTROY }),
725
+ );
726
+ ```
727
+
728
+ **This means `cdk destroy` deletes every table and all of its data.**
729
+ This is intentional for staging (you want a clean teardown between
730
+ experiments) and **dangerous for production**.
731
+
732
+ For production, fork the stack to use `RemovalPolicy.RETAIN` (or
733
+ `RETAIN_ON_UPDATE`): tables become orphans on stack deletion and keep
734
+ their data. There is no built-in knob for this today — it requires
735
+ editing `aws-stack.ts`. Recommendation: ship a `production: boolean`
736
+ stack prop in a future change that flips the removal policy. Flagged
737
+ in §14.
738
+
739
+ ### 9.5 TTL on `Connections.idleSince`
740
+
741
+ The `Connections` table carries a DynamoDB TTL attribute (`idleSince`),
742
+ asserted at synth time (`stack.test.ts:52`). The Lambda updates
743
+ `idleSince` on every frame; DynamoDB silently deletes rows whose
744
+ `idleSince` is older than the TTL window (typically within 48 hours of
745
+ expiry, no SLA). This is a **safety net** for rows the sweeper misses.
746
+
747
+ ### 9.6 The gone-connection sweeper
748
+
749
+ A second Lambda (`IrcSweeper`, handler export `sweeperHandler`) runs on
750
+ an EventBridge schedule at `rate(5 minutes)`:
751
+
752
+ ```ts
753
+ new Rule(this, 'SweeperSchedule', {
754
+ schedule: Schedule.rate(Duration.minutes(5)),
755
+ targets: [new LambdaFunction(sweeper)],
756
+ });
757
+ ```
758
+
759
+ Each tick `Scan`s the entire `Connections` table, identifies rows past
760
+ a hard stale threshold, and runs the same cleanup path as
761
+ `$disconnect` (delete memberships, release nicks, delete the row). The
762
+ sweeper catches connections that vanished without APIGW emitting
763
+ `$disconnect` (e.g. TCP RST that APIGW didn't observe).
764
+
765
+ **Cost note:** `Scan` reads the **entire** table every 5 minutes. At
766
+ 1 KB per row, scanning 10k connections costs ~3 RRU (10k / 4 KB per
767
+ RRU) per tick, ~864 ticks/day, ~2.6k RRU/day — negligible. At 100k
768
+ connections it's ~26k RRU/day — still small but visible. A future
769
+ optimization is to make the sweeper `Query` a sparse GSI keyed by
770
+ `idleSince` instead of `Scan`.
771
+
772
+ ### 9.7 When to consider provisioned capacity
773
+
774
+ Switch a table to `PROVISIONED` billing (with or without autoscaling)
775
+ when its traffic is predictable and steady. The clearest signal is
776
+ when the on-demand bill for a single table exceeds the cost of
777
+ provisioned capacity for the same throughput — DynamoDB's pricing page
778
+ breaks this out. In practice:
779
+
780
+ - `ChannelMembers` is the first candidate (channel fanout dominates).
781
+ - `Connections` is a poor candidate (spiky, cold-start-driven).
782
+ - `Accounts`/`ChannelMeta`/`Nicks` are unlikely to need provisioned
783
+ capacity in any v1-size deployment.
784
+
785
+ Switching billing modes is a no-downtime `UpdateTable` operation; the
786
+ stack change is `billingMode: BillingMode.PROVISIONED` plus
787
+ `readCapacity`/`writeCapacity` props.
788
+
789
+ ---
790
+
791
+ ## 10. Region strategy
792
+
793
+ ### 10.1 Everything is regional
794
+
795
+ API Gateway WebSocket endpoints are regional. DynamoDB is regional.
796
+ EventBridge Scheduler is regional. Lambda is regional. The stack
797
+ deploys into exactly one region (the one your credentials resolve to
798
+ via `CDK_DEFAULT_REGION`).
799
+
800
+ WebSocket clients pay round-trip time to that region. A user in
801
+ Frankfurt connecting to a `us-east-1` deployment will see ~90 ms RTT
802
+ on every PRIVMSG roundtrip; the same user against `eu-central-1` will
803
+ see ~10 ms. **Pick the region closest to your user base.**
804
+
805
+ ### 10.2 Recommendation: single-region for v1
806
+
807
+ For v1, deploy in one region. There are no static assets (CloudFront
808
+ and S3 are not part of this stack), so a CDN adds no value here.
809
+ Single-region keeps the data model simple: the cross-table
810
+ `TransactWriteItems` flows (§9.2) only work within one region.
811
+
812
+ ### 10.3 Multi-region is out of scope for v1
813
+
814
+ If you ever need multi-region active-active, the considerations are:
815
+
816
+ - **DynamoDB global tables** — replicate all five tables. Eventual
817
+ consistency across regions complicates the conditional-write
818
+ invariants (nick uniqueness, membership transactions): a nick
819
+ reserved in region A may not be visible in region B for a second or
820
+ two, allowing duplicate reservations. The reducer logic would need
821
+ to tolerate this.
822
+ - **Per-region APIGW + Lambda** — each region runs the full stack;
823
+ clients are routed by DNS (Route 53 latency-based routing).
824
+ - **Session affinity** — a client's `connectionId` is regional. A
825
+ reconnect after the 2-hour APIGW cap must land on the same region
826
+ or treat the new connection as a fresh registration (re-NICK,
827
+ re-JOIN).
828
+
829
+ This is post-v1 work; the AWS adapter architecture doc calls it out
830
+ as an open question.
831
+
832
+ ### 10.4 Region pick shortlist
833
+
834
+ For a globally distributed user base, the typical picks:
835
+
836
+ - **US-East-1 (N. Virginia)** — newest services, cheapest, ~100 ms to
837
+ EU and ~150 ms to APAC. Default for the CI workflow's `AWS_REGION`.
838
+ - **EU-West-1 (Ireland)** / **EU-Central-1 (Frankfurt)** — best for
839
+ EU users.
840
+ - **AP-Northeast-1 (Tokyo)** / **AP-Southeast-1 (Singapore)** — best
841
+ for APAC users.
842
+
843
+ ---
844
+
845
+ ## 11. Cost notes
846
+
847
+ All figures are the public AWS list price as of late 2024 — **always
848
+ verify against the current AWS pricing page** before budgeting:
849
+
850
+ - API Gateway WebSocket: <https://aws.amazon.com/apigateway/pricing/>
851
+ - Lambda: <https://aws.amazon.com/lambda/pricing/>
852
+ - DynamoDB: <https://aws.amazon.com/dynamodb/pricing/>
853
+ - EventBridge: <https://aws.amazon.com/eventbridge/pricing/>
854
+
855
+ ### 11.1 Billable services
856
+
857
+ | Service | Unit | List price (late 2024) |
858
+ |--------------------------|------------------------------------------------|-------------------------------|
859
+ | API Gateway WebSocket | connection-minute | $0.80 / million |
860
+ | API Gateway WebSocket | message (inbound or outbound) | $1.00 / million |
861
+ | Lambda | request | $0.20 / million |
862
+ | Lambda | GB-second | $0.0000166667 (≈ $0.0167 / GB-hour) |
863
+ | DynamoDB on-demand | write request unit (WRU) | $1.25 / million |
864
+ | DynamoDB on-demand | read request unit (RRU) | $0.25 / million |
865
+ | EventBridge Scheduler | event published | $1.00 / million |
866
+ | CloudWatch Logs | GB ingested | $0.50 |
867
+
868
+ The Lambda free tier (1M requests + 400k GB-seconds per month) covers
869
+ a meaningful slice of a small staging deployment.
870
+
871
+ ### 11.2 Back-of-envelope: 100 concurrent users
872
+
873
+ Assume a small staging deployment with 100 concurrent users, average
874
+ 5 messages/sec across all channels, each connection active for 1 hour,
875
+ average Lambda invocation 50 ms at 128 MB:
876
+
877
+ | Dimension | Estimate | Monthly cost (approx) |
878
+ |------------------------------------------|---------------------------------------|-----------------------|
879
+ | APIGW connection-minutes | 100 conn × 60 min × 24 h × 30 d = 4.32M conn-min | ~$3.50 |
880
+ | APIGW messages (inbound + fanout) | 5 msg/s × 86400 × 30 = 13M inbound, ~3× fanout | ~$50 |
881
+ | Lambda invocations | ~1 per inbound + fanout ≈ 40M | ~$8.00 (free tier offsets some) |
882
+ | Lambda GB-seconds | 40M × 0.05 s × 0.125 GB = 250k GB-s | ~$4.20 |
883
+ | DynamoDB writes (Connections updates + ChannelMembers deltas) | ~5M WRU | ~$6.25 |
884
+ | DynamoDB reads (fanout Queries + Connections GetItem) | ~30M RRU | ~$7.50 |
885
+ | EventBridge events (sweeper + pingChecker) | 5-min + 1-min ticks ≈ 50k events | <$0.10 |
886
+ | CloudWatch Logs | ~5 GB | ~$2.50 |
887
+
888
+ **Rough total: ~$80/month** for a 100-concurrent-user staging
889
+ deployment. The dominant cost is **APIGW messages** (inbound + the
890
+ per-member fanout). The single biggest lever is channel size — a
891
+ single 1k-member announcement channel can multiply the fanout cost by
892
+ 10×.
893
+
894
+ ### 11.3 Cost-trim levers
895
+
896
+ - **Cap channel size** with `+l` mode (the existing MODE enforcement).
897
+ - **Consider provisioned capacity** for `ChannelMembers` once traffic
898
+ is steady (§9.7).
899
+ - **Shorten the sweeper cadence** if you can tolerate slower cleanup —
900
+ `rate(5 minutes)` is a balance; `rate(15 minutes)` cuts sweeper
901
+ cost 3× at the cost of slower gone-connection reaping.
902
+ - **Tune Lambda memory** — 128 MB is the default and cheapest per
903
+ invocation, but a higher memory allocation can reduce duration
904
+ enough to win on GB-seconds.
905
+
906
+ ---
907
+
908
+ ## 12. CI/CD
909
+
910
+ The GitHub Actions workflow at `.github/workflows/deploy-aws.yml`
911
+ deploys staging on every push to `main` and replays the smoke e2e. It
912
+ is the canonical deploy path — manual `pnpm deploy:aws:staging` is for
913
+ iteration only.
914
+
915
+ ### 12.1 What the workflow does
916
+
917
+ Steps performed (in order):
918
+
919
+ 1. Checkout.
920
+ 2. Install pnpm 9 + Node 20 (`cache: pnpm`).
921
+ 3. `pnpm install --frozen-lockfile`.
922
+ 4. `pnpm build` — builds all workspace packages.
923
+ 5. `pnpm typecheck` and `pnpm test` — gate the deploy.
924
+ 6. Configure AWS credentials (OIDC if `AWS_DEPLOY_ROLE_ARN` is set,
925
+ otherwise static keys).
926
+ 7. `pnpm deploy:aws:staging` → `cdk deploy --all --require-approval never`.
927
+ 8. Resolve the smoke URL: repo variable `AWS_SMOKE_URL` first, else
928
+ the stack's `ConnectUrl` CloudFormation output via
929
+ `aws cloudformation describe-stacks --stack-name IrcAwsStack`, else
930
+ skip the smoke step with a warning.
931
+ 9. `pnpm smoke:aws:staging -- --url "$SMOKE_URL"` — failure fails the
932
+ build.
933
+
934
+ Concurrency is serialized via `concurrency.group: aws-staging`,
935
+ `cancel-in-progress: false`, so two pushes cannot race the same
936
+ CloudFormation stack.
937
+
938
+ ### 12.2 Required repo configuration
939
+
940
+ Under **Settings → Secrets and variables → Actions**:
941
+
942
+ - **Secret `AWS_ACCESS_KEY_ID`** — required unless OIDC.
943
+ - **Secret `AWS_SECRET_ACCESS_KEY`** — required unless OIDC.
944
+ - **Secret `AWS_DEPLOY_ROLE_ARN`** — optional; enables OIDC.
945
+ - **Secret `AWS_REGION`** — required (e.g. `us-east-1`).
946
+ - **Variable `AWS_SMOKE_URL`** — optional but recommended; the
947
+ workflow can fall back to the CFN output, but setting this variable
948
+ explicitly avoids the `describe-stacks` round-trip and the
949
+ possibility of a stale cached value after a redeploy.
950
+
951
+ ### 12.3 OIDC vs static keys
952
+
953
+ The workflow supports both auth modes:
954
+
955
+ ```yaml
956
+ - name: Configure AWS credentials
957
+ uses: aws-actions/configure-aws-credentials@v4
958
+ with:
959
+ aws-region: ${{ secrets.AWS_REGION }}
960
+ role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
961
+ aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
962
+ aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
963
+ ```
964
+
965
+ When `AWS_DEPLOY_ROLE_ARN` is set, the action assumes the role via
966
+ OIDC web identity (requires `permissions.id-token: write`, which the
967
+ workflow declares) and ignores the static keys. When unset, it falls
968
+ back to the static access-key pair. OIDC is the recommended path for
969
+ any deployment tied to a real AWS account — no long-lived credentials
970
+ to rotate.
971
+
972
+ ### 12.4 No CI auto-deploy to production
973
+
974
+ Production deploys are intentionally manual (`pnpm deploy:aws:prod`
975
+ from a clean checkout on `main`, against separate credentials). The
976
+ workflow's `on:` block only triggers on `push` to `main` and
977
+ `workflow_dispatch`, both of which target staging.
978
+
979
+ ---
980
+
981
+ ## 13. Troubleshooting
982
+
983
+ ### 13.1 `cdk deploy` fails with bucket-not-found
984
+
985
+ > `BUCKET_NAME_PARAMETER_<id> does not exist` or
986
+ > `NoSuchBucket: The specified bucket does not exist`
987
+
988
+ The CDK bootstrap stack hasn't been created in this account/region.
989
+
990
+ ```bash
991
+ cd apps/aws-stack
992
+ pnpm cdk bootstrap
993
+ pnpm deploy:aws:staging
994
+ ```
995
+
996
+ Bootstrap is idempotent and per-region — re-run it if you change
997
+ regions.
998
+
999
+ ### 13.2 Lambda throws on cold start because of a missing env var
1000
+
1001
+ Symptom: smoke e2e times out waiting for `001`; CloudWatch Logs for
1002
+ `IrcHandler` show an `Error: Missing env var CONNECTIONS_TABLE (table
1003
+ name for Connections)` (or one of the other four `<NAME>_TABLE` vars).
1004
+
1005
+ The `tablesConfigFromEnv` helper throws on the first invocation if any
1006
+ of `CONNECTIONS_TABLE`, `CHANNELMETA_TABLE`, `CHANNELMEMBERS_TABLE`,
1007
+ `NICKS_TABLE`, `ACCOUNTS_TABLE` is missing. The CDK stack always
1008
+ injects these — if one is missing in the console, somebody hand-edited
1009
+ the Lambda config. Fix by re-deploying:
1010
+
1011
+ ```bash
1012
+ pnpm deploy:aws:staging
1013
+ ```
1014
+
1015
+ If the error mentions `MANAGEMENT_URL` being absent: that env var is
1016
+ optional at the loader level (`buildDepsFromEnv` tolerates its absence
1017
+ by setting `managementApi = null`), but the handler's fanout path will
1018
+ fail at the first `postToConnection`. Verify both `IrcHandler` and
1019
+ `IrcPingChecker` carry `MANAGEMENT_URL` in the console.
1020
+
1021
+ ### 13.3 Smoke e2e times out waiting for `001`
1022
+
1023
+ The connection upgrade succeeded (the WebSocket `open` fired) but
1024
+ registration didn't complete. Common causes:
1025
+
1026
+ - **The handler threw on the first frame.** Tail the logs:
1027
+ ```bash
1028
+ aws logs tail /aws/lambda/IrcHandler --follow
1029
+ ```
1030
+ and reproduce. Look for the cold-start `buildDepsFromEnv` throw
1031
+ (§13.2) or an unexpected exception in `handleDefault`.
1032
+ - **The `prod` stage didn't deploy.** The stage is `autoDeploy: true`,
1033
+ so this is rare, but verify the URL ends in `/prod` and that the
1034
+ route is wired:
1035
+ ```bash
1036
+ aws apigatewayv2 get-routes --api-id <api-id>
1037
+ ```
1038
+ You should see three routes: `$connect`, `$disconnect`, `$default`.
1039
+ - **Client connected over plain `ws://`.** APIGW WebSocket stages only
1040
+ accept `wss://`. Use the URL printed by `cdk deploy` (the
1041
+ `ConnectUrl` output).
1042
+
1043
+ ### 13.4 `aws cloudformation describe-stacks` returns nothing in CI
1044
+
1045
+ The workflow uses:
1046
+
1047
+ ```bash
1048
+ aws cloudformation describe-stacks \
1049
+ --stack-name IrcAwsStack \
1050
+ --query 'Stacks[0].Outputs[?OutputKey==`ConnectUrl`].OutputValue' \
1051
+ --output text
1052
+ ```
1053
+
1054
+ to resolve the smoke URL. If this returns nothing, either:
1055
+
1056
+ - **The stack name is wrong.** It's hardcoded as `IrcAwsStack` in
1057
+ `apps/aws-stack/bin/aws.ts`. If you forked the stack to a different
1058
+ name, the workflow's `--stack-name IrcAwsStack` won't find it.
1059
+ Update the workflow or rename back.
1060
+ - **The deploy failed silently.** Check the prior `pnpm
1061
+ deploy:aws:staging` step's exit code. `cdk deploy` with
1062
+ `--require-approval never` can still fail on IAM or
1063
+ CloudFormation errors.
1064
+ - **Wrong region.** The `describe-stacks` call uses `AWS_REGION` from
1065
+ the secrets; it must match the region the stack was deployed into.
1066
+
1067
+ The workflow prints a `::warning::` and skips the smoke step rather
1068
+ than failing the build — a silent skip is the failure mode to watch
1069
+ for.
1070
+
1071
+ ### 13.5 WebSocket client can't connect
1072
+
1073
+ The `$connect` route is wired to `IrcHandler` and currently uses **no
1074
+ auth** (no authorizer). If the client gets a 4xx/5xx on the HTTP
1075
+ upgrade:
1076
+
1077
+ - Verify the URL is the `ConnectUrl` output, not `ManagementUrl` (the
1078
+ management endpoint rejects WebSocket upgrades).
1079
+ - Verify the `IrcHandler` Lambda has `execute-api:ManageConnections`
1080
+ on this stage (the stack grants this via
1081
+ `stage.grantManagementApiAccess(handler)`).
1082
+ - If you add an authorizer or IP allowlist later, the `$connect`
1083
+ Lambda is the natural place to gate by source IP (read
1084
+ `event.requestContext.connectionId` and
1085
+ `event.requestContext.sourceIp`).
1086
+
1087
+ ### 13.6 DynamoDB table vanished after `cdk destroy`
1088
+
1089
+ By design. The stack uses `RemovalPolicy.DESTROY` on every table
1090
+ (§9.4) so staging teardowns are clean. **Do not run
1091
+ `cdk destroy` against a production stack.** For production, fork the
1092
+ stack to `RemovalPolicy.RETAIN` before the first deploy; flagged in
1093
+ §14.
1094
+
1095
+ ### 13.7 High DynamoDB cost on `Connections`
1096
+
1097
+ The gone-connection sweeper `Scan`s the entire `Connections` table
1098
+ every 5 minutes (§9.6). At very large scale this dominates the read
1099
+ bill. Short-term mitigation: increase the sweeper's `rate(...)` to
1100
+ `rate(15 minutes)` or `rate(30 minutes)` in `aws-stack.ts`. Long-term:
1101
+ add a sparse GSI keyed by `idleSince` and switch the sweeper to a
1102
+ `Query` against it (post-v1 work).
1103
+
1104
+ ### 13.8 Nick reservation always fails
1105
+
1106
+ A client's `NICK` always returns `433 ERR_NICKNAMEINUSE`, even for
1107
+ nicks that should be free. Two deployments sharing the same DynamoDB
1108
+ table name space is the usual cause — e.g. you deployed `IrcAwsStack`
1109
+ twice into the same account/region with a forked stack name. The
1110
+ `Nicks` table name is hardcoded as `Nicks` (physical name), so two
1111
+ stacks in the same region collide on the same table. Use separate
1112
+ regions or fork the table-name prefix.
1113
+
1114
+ ---
1115
+
1116
+ ## 14. Quick reference
1117
+
1118
+ ```bash
1119
+ # One-time account setup (per region)
1120
+ aws configure # or: export AWS_PROFILE=...
1121
+ cd apps/aws-stack && pnpm cdk bootstrap
1122
+
1123
+ # Local dev
1124
+ pnpm install
1125
+ pnpm build
1126
+ pnpm --filter @serverless-ircd/aws-stack test # synth-time assertions
1127
+ pnpm --filter @serverless-ircd/aws-stack cdk:synth # writes cdk.out/
1128
+
1129
+ # Optional localstack validation (Docker required)
1130
+ docker run --rm -d -p 4566:4566 localstack/localstack
1131
+ npm install -g aws-cdk-local
1132
+ export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test AWS_DEFAULT_REGION=us-east-1
1133
+ LOCALSTACK=1 pnpm --filter @serverless-ircd/aws-stack test
1134
+
1135
+ # Staging
1136
+ pnpm deploy:aws:staging
1137
+ node apps/aws-stack/scripts/smoke.mjs \
1138
+ --url wss://<id>.execute-api.<region>.amazonaws.com/prod
1139
+ aws logs tail /aws/lambda/IrcHandler --follow # live logs
1140
+
1141
+ # Production (manual, separate credentials)
1142
+ pnpm deploy:aws:prod
1143
+
1144
+ # Tests / lint
1145
+ pnpm test # full workspace
1146
+ pnpm --filter @serverless-ircd/aws-adapter test
1147
+ pnpm --filter @serverless-ircd/aws-stack test
1148
+ pnpm lint
1149
+ ```
1150
+
1151
+ Key files:
1152
+
1153
+ | Path | What |
1154
+ |---------------------------------------------------|-----------------------------------------------------|
1155
+ | `apps/aws-stack/src/aws-stack.ts` | CDK stack — all constructs and env-var wiring. |
1156
+ | `apps/aws-stack/bin/aws.ts` | App entry. Hardcodes stack name `IrcAwsStack`. |
1157
+ | `apps/aws-stack/cdk.json` | `app = tsx bin/aws.ts`. |
1158
+ | `apps/aws-stack/scripts/smoke.mjs` | CI + local smoke e2e. |
1159
+ | `apps/aws-stack/tests/stack.test.ts` | Synth-time assertions (cross-check for every fact). |
1160
+ | `packages/aws-adapter/src/cdk-table-defs.ts` | Five DynamoDB table shapes (keys, billing, TTL). |
1161
+ | `packages/aws-adapter/src/tables.ts` | Runtime table-name + key-column constants. |
1162
+ | `packages/aws-adapter/src/config-loader.ts` | Lambda env → `ServerConfig` schema mapping. |
1163
+ | `packages/aws-adapter/src/handlers/index.ts` | Lambda entry points (`handler`, `sweeperHandler`, `pingCheckerHandler`). |
1164
+ | `.github/workflows/deploy-aws.yml` | Staging deploy + smoke e2e CI. |
1165
+
1166
+ ### Known gaps flagged for follow-up
1167
+
1168
+ These are facts the **code does not yet have an answer for**; the
1169
+ recommendations above are deployer guidance, not built-in knobs:
1170
+
1171
+ 1. **`RemovalPolicy.DESTROY` is hardcoded.** There is no stack prop to
1172
+ flip it to `RETAIN` for production. Recommendation: a future change
1173
+ adds a `production: boolean` (or similar) construct prop that
1174
+ toggles the removal policy across all five tables.
1175
+ 2. ~~**`SERVER_NAME` / `NETWORK_NAME` / `MOTD` are hardcoded in
1176
+ `aws-stack.ts`.**~~ **Resolved** — these are now CDK construct props
1177
+ (`serverName`, `networkName`, `motdLines`) on `IrcStackProps` (see
1178
+ §7.1).
1179
+ 3. ~~**MOTD multiline parsing.**~~ **Resolved** — the stack's
1180
+ `motdLines` prop is a `string[]` joined with `\n`, which the config
1181
+ loader splits back into one line per entry. The delimiter is `\n`
1182
+ only; commas are treated as literal line content (see §7.1).
1183
+ 4. **No per-environment stack parameter.** Staging and production
1184
+ cannot coexist in the same account/region without forking the
1185
+ stack to parameterize names. Recommendation: separate accounts for
1186
+ staging vs production for v1.