serverless-ircd 0.1.0 → 0.2.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 (198) 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/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,494 @@
1
+ # AWS Adapter Architecture
2
+
3
+ Architecture diagrams for the AWS implementation (Phase 4 of `PLAN.md`).
4
+ All diagrams are ASCII art for terminal-friendly viewing. Cross-reference:
5
+ `PLAN.md` §2 (hexagonal architecture), §4 (state model), §6.2 (AWS mapping).
6
+
7
+ Legend for arrows:
8
+
9
+ ──▶ synchronous call / request
10
+ ══▶ WebSocket frame (client ↔ APIGW)
11
+ ─┄┄ scheduled / asynchronous trigger
12
+ ─│▶ read/write against durable state
13
+
14
+ ---
15
+
16
+ ## 1. System overview
17
+
18
+ The AWS adapter implements the `IrcRuntime` port (defined in
19
+ `packages/irc-server`) on top of API Gateway WebSockets, Lambda, and
20
+ DynamoDB. The pure IRC core in `packages/irc-core` is cloud-agnostic and
21
+ shared with the Cloudflare adapter.
22
+
23
+ ```
24
+ ┌─────────────────────────────────────┐
25
+ │ IRC clients │
26
+ │ WeeChat · HexChat · IRCCloud · │
27
+ │ TheLounge · matrix-IRC bridge │
28
+ └──────────────────┬──────────────────┘
29
+ │ wss (one IRC msg per text frame)
30
+
31
+
32
+ ┌──────────────────────────────────────────────────────────────────────────┐
33
+ │ AWS region (single, v1) │
34
+ │ │
35
+ │ ┌──────────────────────────────────────────────────────────────────┐ │
36
+ │ │ API Gateway v2 WebSocket API │ │
37
+ │ │ routes: $connect $disconnect $default │ │
38
+ │ │ identity: connectionId (stable per WS session, ≤ 2 h) │ │
39
+ │ └────────┬──────────────────┬─────────────────────┬────────────────┘ │
40
+ │ │ │ │ │
41
+ │ ▼ ▼ ▼ │
42
+ │ ┌──────────────────────────────────────────────────────────────┐ │
43
+ │ │ Lambda (Node 20, esbuild bundle) │ │
44
+ │ │ │ │
45
+ │ │ handlers: onConnect onDisconnect onMessage │ │
46
+ │ │ pipeline: frame → ConnectionActor → reducer → dispatch │ │
47
+ │ │ runtime: AwsRuntime implements IrcRuntime │ │
48
+ │ └──────┬────────────────────────────────────────────┬──────────┘ │
49
+ │ │ │ │
50
+ │ │ ┌─────────────────────────────┘ │
51
+ │ │ │ │
52
+ │ ▼ ▼ │
53
+ │ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │
54
+ │ │ DynamoDB (on-demand) │ │ ApiGatewayManagementApi │ │
55
+ │ │ │ │ postToConnection(connectionId) │
56
+ │ │ • Connections │ │ fanout back to clients │ │
57
+ │ │ • ChannelMeta │ └──────────────┬───────────────┘ │
58
+ │ │ • ChannelMembers │ │ │
59
+ │ │ • Nicks │ │ │
60
+ │ │ • Accounts │ │ │
61
+ │ └──────────────────────────────┘ │ │
62
+ │ ▲ │ │
63
+ │ │ │ │
64
+ │ ┌──────┴───────────────┐ │ │
65
+ │ │ EventBridge Scheduler│ idle/PING sweeps │ │
66
+ │ │ per-connection jobs │──┄┄ Lambda ──────────────┘ │
67
+ │ └──────────────────────┘ │
68
+ │ │
69
+ │ ┌──────────────────────────────────────────────────────────────────┐ │
70
+ │ │ Supporting services │ │
71
+ │ │ • Secrets Manager / SSM Parameter Store (config, SASL hashes) │ │
72
+ │ │ • CloudWatch Logs + Metrics (observability) │ │
73
+ │ │ • IAM roles, least-privilege per Lambda (security) │ │
74
+ │ └──────────────────────────────────────────────────────────────────┘ │
75
+ └──────────────────────────────────────────────────────────────────────────┘
76
+
77
+
78
+ ┌─────────────────────────────────────┐
79
+ │ packages/irc-core (shared brain) │
80
+ │ pure reducers: (state,msg) → │
81
+ │ { state, effects } │
82
+ └─────────────────────────────────────┘
83
+ ```
84
+
85
+ The Lambda bundle includes `irc-core`, `irc-server`, and `aws-adapter`.
86
+ Only the I/O shell is AWS-specific.
87
+
88
+ ---
89
+
90
+ ## 2. Connection lifecycle
91
+
92
+ API Gateway emits three synthetic route events. Each invokes a Lambda;
93
+ the persistent `connectionId` is the source of truth for identity.
94
+
95
+ ```
96
+ client API Gateway Lambda handler
97
+ │ │ │
98
+ │ HTTP Upgrade (wss) │ │
99
+ │═══════════════════════════════▶│ │
100
+ │ │ $connect event │
101
+ │ │──▶ onConnect() │
102
+ │ │ │ │
103
+ │ │ │ PutItem Connections │
104
+ │ │ │ (status=connecting) │
105
+ │ │ │ │
106
+ │ │ │ optional: PASS check │
107
+ │ │ ▼ │
108
+ │ 101 Switching Protocols │ │
109
+ │◀═══════════════════════════════│ │
110
+ │ │ │
111
+ │ CAP LS / NICK / USER ... │ │
112
+ │═══════════════════════════════▶│ $default event │
113
+ │ │──▶ onMessage(body) │
114
+ │ │ │ │
115
+ │ │ │ ConnectionActor │
116
+ │ │ │ .ingest(frame) │
117
+ │ │ │ parse → route → │
118
+ │ │ │ reducer → dispatch │
119
+ │ │ │ │
120
+ │ │ │ Welcome 001..005 │
121
+ │ │ │ via postToConnection │
122
+ │ :server 001 nick :Welcome │ │ │
123
+ │◀═══════════════════════════════│◀──────┘ │
124
+ │ │ │
125
+ │ ... PRIVMSG / JOIN / MODE ... │ │
126
+ │═══════════════════════════════▶│ $default (repeat per frame) │
127
+ │ │ │
128
+ │ close / TCP drop / 2h cap │ │
129
+ │──── (or idle PING timeout) ───▶│ $disconnect event │
130
+ │ │──▶ onDisconnect() │
131
+ │ │ │ │
132
+ │ │ │ QUIT reducer: │
133
+ │ │ │ Broadcast QUIT │
134
+ │ │ │ ReleaseNick │
135
+ │ │ │ DeleteItem Connections│
136
+ │ │ │ Del ChannelMembers │
137
+ │ │ ▼ │
138
+ ```
139
+
140
+ Two failure modes worth noting:
141
+
142
+ - **Gone connection (lazy cleanup):** if `postToConnection` throws
143
+ `GoneException`, the Lambda deletes the `Connections` row and emits a
144
+ best-effort QUIT broadcast. A separate sweeper (§5) catches rows that
145
+ never receive another message.
146
+ - **2-hour APIGW cap:** clients MUST tolerate a server-driven reconnect.
147
+ The server signals via `PONG`-driven reset; the reducer stays pure and
148
+ only emits the disconnect effect.
149
+
150
+ ---
151
+
152
+ ## 3. Request pipeline inside Lambda (`onMessage`)
153
+
154
+ One Lambda invocation per WebSocket frame. The same `ConnectionActor`
155
+ used by the in-memory and Cloudflare runtimes drives the work; only
156
+ `AwsRuntime` (the I/O interpreter) differs.
157
+
158
+ ```
159
+ API Gateway $default event
160
+ { connectionId, body }
161
+
162
+
163
+ ┌────────────────────────────────────────────────────────────┐
164
+ │ onMessage(event) │
165
+ │ │
166
+ │ 1. split body on "\r\n" (tolerant: also accept single │
167
+ │ message per frame; see PLAN §9) │
168
+ │ │
169
+ │ 2. for each line: │
170
+ │ parser.parse(line) ◀── irc-core/protocol │
171
+ │ │ │
172
+ │ ▼ │
173
+ │ IrcMessage { command, params, tags } │
174
+ │ │ │
175
+ │ ▼ │
176
+ │ ConnectionActor.ingest(msg) │
177
+ │ │ │
178
+ │ │ ┌── flood-control wrapper (token bucket) │
179
+ │ │ │ (TICKET-028, pure) │
180
+ │ │ ▼ │
181
+ │ │ location-table lookup ──▶ reducer │
182
+ │ │ (irc-core/commands) │
183
+ │ │ (state, msg, ctx) │
184
+ │ │ │ │
185
+ │ │ ▼ │
186
+ │ │ { state', effects[] } │
187
+ │ │ │ │
188
+ │ ▼ ▼ │
189
+ │ dispatch(effects, AwsRuntime) ◀── irc-server │
190
+ └────────────────────────────┬───────────────────────────────┘
191
+
192
+ ┌───────────────┼────────────────┐
193
+ ▼ ▼ ▼
194
+ DynamoDB postToConnection EventBridge
195
+ (state writes) (client fanout) (schedule PING)
196
+ ```
197
+
198
+ Key invariant: the **reducer never touches AWS**. It returns `Effect[]`
199
+ values; `dispatch` is the only function that awaits I/O. That is what
200
+ makes the Phase 2 contract suite (TICKET-032) reusable across runtimes.
201
+
202
+ ---
203
+
204
+ ## 4. DynamoDB schema (PLAN §4)
205
+
206
+ Five tables. Single-table design was rejected: the access patterns
207
+ (per-connection, per-channel, per-nick) are disjoint enough that
208
+ splitting yields clearer conditional writes and transactions.
209
+
210
+ ```
211
+ Connections ChannelMeta
212
+ ┌──────────────┬─────────────┐ ┌──────────────┬────────────┐
213
+ │ PK │ connectionId│ │ PK │ channelName│
214
+ ├──────────────┼─────────────┤ │ (lowercased) │ │
215
+ │ nick │ string │ ├──────────────┼────────────┤
216
+ │ user │ string │ │ topic │ string │
217
+ │ hostmask │ string │ │ modes │ set │
218
+ │ caps │ set │ │ key │ string? │
219
+ │ account │ string? │ │ limit │ number? │
220
+ │ joined[] │ list │ │ banMasks │ list │
221
+ │ idleSince │ number │ └──────────────┴────────────┘
222
+ │ status │ enum │
223
+ │ callbackUrl │ string │ ChannelMembers
224
+ └──────────────┴─────────────┘ ┌──────────────┬────────────┐
225
+ │ PK │ channelName│
226
+ Nicks │ SK │connectionId│
227
+ ┌──────────────┬────────────┐ ├──────────────┼────────────┤
228
+ │ PK │ nickLower │ │ prefixes │ set(@,+) │
229
+ │ (lowercased) │ │ │ joinedAt │ number │
230
+ ├──────────────┼────────────┤ └──────────────┴────────────┘
231
+ │ connectionId │ string │ query pattern: PK = chan
232
+ │ (unique via │ → returns all members for fanout
233
+ │ conditional PutItem) │
234
+ └──────────────┴────────────┘ Accounts
235
+ ┌──────────────┬────────────┐
236
+ │ PK │ account │
237
+ ├──────────────┼────────────┤
238
+ │ passHash │ bytes │
239
+ │ salt │ bytes │
240
+ └──────────────┴────────────┘
241
+ ```
242
+
243
+ **Uniqueness invariants** are enforced structurally, not by application
244
+ logic:
245
+
246
+ - `Nicks.PK` — conditional `PutItem` (`attribute_not_exists(PK)`) makes
247
+ nick reservation atomic across concurrent Lambdas.
248
+ - `ChannelMembers` PK+SK composite — `TransactWriteItems` for JOIN/PART
249
+ updates `Connections.joined` and `ChannelMembers` in one atomic write.
250
+ - `Accounts.passHash` — never plaintext; PLAIN SASL verifies against
251
+ this hash server-side.
252
+
253
+ ---
254
+
255
+ ## 5. Channel fanout (PRIVMSG to #chan)
256
+
257
+ There is no long-lived per-channel process (contrast with CF's
258
+ `ChannelDO`). Instead, fanout is computed per message by querying
259
+ `ChannelMembers`.
260
+
261
+ ```
262
+ sender's Lambda (onMessage PRIVMSG #foo :hello)
263
+
264
+ │ 1. reducer emits:
265
+ │ Broadcast("#foo", [":nick PRIVMSG #foo :hello"],
266
+ │ except=sender)
267
+
268
+ │ 2. AwsRuntime.broadcast():
269
+
270
+ │ ┌────────────────────────────────┐
271
+ │ │ Query ChannelMembers │
272
+ │ │ PK = "#foo" │──┐
273
+ │ └────────────────────────────────┘ │
274
+ │ │
275
+ │ ◀─── [connA, connB, connC, ...] ────┘
276
+
277
+ │ 3. for each member (parallel, batched):
278
+
279
+ │ ┌──────────────────────────────┐
280
+ │ │ postToConnection(member, │──▶ client A
281
+ │ │ line) │──▶ client B
282
+ │ └──────────────────────────────┘──▶ client C
283
+ │ (skip sender)
284
+
285
+ │ 4. on GoneException:
286
+ │ mark conn gone ──▶ sweeper (§6)
287
+
288
+
289
+ ```
290
+
291
+ Trade-off: this is O(members) API calls per message. For hot channels
292
+ this is the main scaling ceiling on AWS (PLAN §10). Mitigations deferred
293
+ to post-v1: batched `postToConnection` calls, per-channel send-list
294
+ cache, or a fanout Lambda invoked once per message.
295
+
296
+ ---
297
+
298
+ ## 6. Idle / PING sweep (EventBridge Scheduler)
299
+
300
+ API Gateway WebSockets have no server-side idle timer. We schedule our
301
+ own per-connection PINGs.
302
+
303
+ ```
304
+ onMessage success path
305
+
306
+ │ schedule one-time EventBridge job:
307
+ │ target = ping-lambda
308
+ │ input = { connectionId }
309
+ │ at = now + PING_INTERVAL
310
+
311
+
312
+ ┌────────────────────────────┐
313
+ │ EventBridge Scheduler │
314
+ │ (one schedule per conn) │
315
+ └─────────┬──────────────────┘
316
+ │ at scheduled time
317
+
318
+ ┌─────────────────────────────────────────────┐
319
+ │ ping Lambda │
320
+ │ │
321
+ │ 1. read Connections[conn].idleSince │
322
+ │ │
323
+ │ 2. if idleSince older than threshold: │
324
+ │ postToConnection(PING <token>) │
325
+ │ schedule a 2nd job at + PONG_WINDOW │
326
+ │ │
327
+ │ 3. 2nd-job fires: │
328
+ │ if still no PONG seen │
329
+ │ → onDisconnect() cleanup │
330
+ │ else │
331
+ │ → no-op │
332
+ └─────────────────────────────────────────────┘
333
+ ```
334
+
335
+ The sweeper Lambda (TICKET-041) is a separate schedule that scans
336
+ `Connections` for rows past a hard TTL and performs the same cleanup as
337
+ `onDisconnect`. It exists to catch connections where the client
338
+ vanished without APIGW ever emitting `$disconnect`.
339
+
340
+ ---
341
+
342
+ ## 7. Membership transaction (JOIN / PART / KICK)
343
+
344
+ Cross-table consistency uses `TransactWriteItems`. Up to 100 items per
345
+ transaction (AWS limit); channels above that size use a sharded SK
346
+ scheme (post-v1).
347
+
348
+ ```
349
+ JOIN #foo
350
+
351
+
352
+ AwsRuntime.applyChannelDelta("#foo", { add: sender })
353
+
354
+ │ TransactWriteItems:
355
+
356
+ │ ┌──────────────────────────────────────────────┐
357
+ │ │ ConditionCheck ChannelMeta │
358
+ │ │ PK = "#foo" exists │
359
+ │ │ (not invite-only OR sender has invite) │
360
+ │ │ (not full: count < limit) │
361
+ │ ├──────────────────────────────────────────────┤
362
+ │ │ Put ChannelMembers │
363
+ │ │ PK="#foo", SK=sender │
364
+ │ │ condition: attribute_not_exists(SK) │
365
+ │ ├──────────────────────────────────────────────┤
366
+ │ │ Update Connections │
367
+ │ │ PK = sender │
368
+ │ │ ADD joined "#foo" │
369
+ │ │ condition: set size < MAX_CHANNELS_PER_USER│
370
+ │ └──────────────────────────────────────────────┘
371
+
372
+ │ all-or-nothing → races structurally impossible
373
+
374
+
375
+ dispatch Broadcast(JOIN), 353 NAMES, 366 END-NAMES
376
+ ```
377
+
378
+ A `CancellationException` from `TransactWriteItems` maps to the
379
+ appropriate numeric (`471` channel full, `473` invite-only,
380
+ `405` too many channels). Mapping lives in `AwsRuntime`, not the
381
+ reducer — the reducer only knows it asked for a delta and whether it
382
+ succeeded.
383
+
384
+ ---
385
+
386
+ ## 8. Where each command runs (location-of-authority, AWS)
387
+
388
+ Mirrors PLAN §2.1. On AWS the "authority" is whichever table owns the
389
+ state; the same reducer code runs everywhere.
390
+
391
+ ```
392
+ ┌──────────────────────────┬────────────────────────────────────┐
393
+ │ Command family │ Authority (DynamoDB table) │
394
+ ├──────────────────────────┼────────────────────────────────────┤
395
+ │ NICK / USER / PASS │ Connections (row per conn) │
396
+ │ CAP negotiation │ Connections.caps │
397
+ │ PING / PONG / QUIT │ Connections │
398
+ │ AWAY / SETNAME / user MODE│ Connections │
399
+ ├──────────────────────────┼────────────────────────────────────┤
400
+ │ JOIN / PART │ ChannelMembers (+ Connections.txn) │
401
+ │ channel MODE / TOPIC │ ChannelMeta │
402
+ │ KICK / INVITE │ ChannelMeta + ChannelMembers │
403
+ │ channel PRIVMSG/NOTICE │ Query ChannelMembers → fanout │
404
+ ├──────────────────────────┼────────────────────────────────────┤
405
+ │ Nick collision check │ Nicks (conditional PutItem) │
406
+ ├──────────────────────────┼────────────────────────────────────┤
407
+ │ PRIVMSG/NOTICE to user │ Nicks → Connections → postToConn │
408
+ ├──────────────────────────┼────────────────────────────────────┤
409
+ │ NAMES / WHO / WHOIS │ read-only across tables │
410
+ │ LIST │ scan ChannelMeta (throttled) │
411
+ ├──────────────────────────┼────────────────────────────────────┤
412
+ │ SASL AUTHENTICATE │ Accounts (verify hash) │
413
+ └──────────────────────────┴────────────────────────────────────┘
414
+ ```
415
+
416
+ ---
417
+
418
+ ## 9. Deploy topology (CDK, TICKET-039)
419
+
420
+ ```
421
+ ┌───────────────────────────────────────────────────────────┐
422
+ │ apps/aws-stack (CDK v2 app, TypeScript) │
423
+ │ │
424
+ │ ┌─ WebSocketApi (AWS::ApiGatewayV2::Api) │
425
+ │ │ stage: $default, auto-deploy, throttling │
426
+ │ │ │
427
+ │ ├─ Lambda: onConnect ─┐ │
428
+ │ ├─ Lambda: onDisconnect ├─ shared role │
429
+ │ ├─ Lambda: onMessage │ (or one Lambda + alias) │
430
+ │ ├─ Lambda: ping │ │
431
+ │ ├─ Lambda: sweeper ─┘ │
432
+ │ │ │
433
+ │ ├─ Integration: aws-proxy (each route → its Lambda) │
434
+ │ │ │
435
+ │ ├─ Table: Connections (PAY_PER_REQUEST, TTL=idleSince) │
436
+ │ ├─ Table: ChannelMeta (PAY_PER_REQUEST) │
437
+ │ ├─ Table: ChannelMembers (PAY_PER_REQUEST) │
438
+ │ ├─ Table: Nicks (PAY_PER_REQUEST) │
439
+ │ ├─ Table: Accounts (PAY_PER_REQUEST) │
440
+ │ │ │
441
+ │ ├─ Secret: server-config (MOTD, server name, passwords) │
442
+ │ ├─ Scheduler: ping-jobs (schedule group per env) │
443
+ │ ├─ Scheduler: sweeper (fixed-rate rate(5 minutes)) │
444
+ │ │ │
445
+ │ └─ CloudWatch dashboard, log groups, alarms │
446
+ │ │
447
+ └───────────────────────────────────────────────────────────┘
448
+
449
+ outputs:
450
+ ConnectUrl ── wss://<id>.execute-api.<region>.amazonaws.com/<stage>
451
+ ManagementUrl ── https://<id>.execute-api.<region>.amazonaws.com/<stage>
452
+ (used by Lambda for postToConnection)
453
+ ```
454
+
455
+ Local testing uses `localstack` (APIGW + Lambda + EventBridge) and
456
+ `dynamodb-local` (real DynamoDB semantics). The Phase 2 contract suite
457
+ (`tests/integration`) runs the same scenarios against `AwsRuntime`
458
+ pointed at these local emulators.
459
+
460
+ ---
461
+
462
+ ## 10. AWS vs Cloudflare (side-by-side, from PLAN §6.3)
463
+
464
+ For context; the Cloudflare diagrams live in `docs/architecture-cf.md`
465
+ (to be written in Phase 3).
466
+
467
+ ```
468
+ ┌─────────────────────┬──────────────────────┬──────────────────────┐
469
+ │ Capability │ AWS adapter │ CF adapter │
470
+ ├─────────────────────┼──────────────────────┼──────────────────────┤
471
+ │ Connection identity │ APIGW connectionId │ DO idFromName(connId)│
472
+ │ Long-lived socket │ APIGW WS (≤ 2 h) │ Hibernatable WS in DO│
473
+ │ Send to client │ postToConnection │ ws.send in target DO │
474
+ │ Channel authority │ DDB ChannelMembers │ ChannelDO single-thr │
475
+ │ Nick uniqueness │ conditional PutItem │ RegistryDO serial │
476
+ │ Cross-instance │ DDB query + fanout │ stub() DO→DO │
477
+ │ Timers │ EventBridge Scheduler│ DO alarms │
478
+ │ Durable storage │ DynamoDB │ DO storage / D1 │
479
+ │ Secret config │ Secrets Manager /SSM │ Worker secrets / KV │
480
+ └─────────────────────┴──────────────────────┴──────────────────────┘
481
+ ```
482
+
483
+ ---
484
+
485
+ ## Open questions (PLAN §9, AWS-specific)
486
+
487
+ - **Region strategy**: single region for v1. Multi-region would need
488
+ DynamoDB global tables + per-region APIGW + a routing layer.
489
+ - **Hot-channel fanout ceiling**: O(members) `postToConnection` calls
490
+ per message. Mitigation deferred; revisit at load-test time (TICKET-049).
491
+ - **2-hour APIGW WS cap**: requires a documented reconnect contract.
492
+ Clients MUST tolerate a server-initiated close + re-`$connect`.
493
+ - **DynamoDB cost runaway**: on-demand billing for v1 simplicity;
494
+ provisioned capacity is a post-launch optimization.