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
package/README.md CHANGED
@@ -6,14 +6,16 @@ platform-agnostic core, and two thin adapters run it on **Cloudflare Workers**
6
6
 
7
7
  One TypeScript codebase. Two serverless substrates. No S2S linking in v1.
8
8
 
9
- > **Status:** **v0.1.0 (preview).** The pure protocol core, the
9
+ > **Status:** **v0.2.0 (preview).** The pure protocol core, the
10
10
  > `IrcRuntime` port + in-memory runtime, a runnable local CLI server,
11
- > and the Cloudflare Workers adapter (deployed to staging) are all
12
- > functional. SASL, flood control, the AWS adapter, server-password
13
- > enforcement, and the CI coverage gate are still ahead. See
14
- > `CHANGELOG.md` for the v0.1.0 manifest, `docs/release.md` for the
15
- > release process, and `progress.md` / `tickets.md` for per-ticket
16
- > status.
11
+ > the **Cloudflare Workers** adapter, and the **AWS** (API Gateway
12
+ > WebSocket + Lambda + DynamoDB + CDK) adapter are all functional and
13
+ > deployed to staging. SASL (`PLAIN`), token-bucket flood control,
14
+ > server-password enforcement, hostmask cloaking, connection admission,
15
+ > IRCv3 chat history, and a CI coverage + mutation-testing gate landed
16
+ > in v0.2.0. Remaining 0.x work: oper credentials /
17
+ > config loader, SASL `EXTERNAL` (mTLS), and production TLS transport.
18
+ > See `CHANGELOG.md` for the per-release manifests.
17
19
 
18
20
  ---
19
21
 
@@ -52,7 +54,7 @@ Hexagonal / ports-and-adapters. The core reasons about IRC; adapters move bytes.
52
54
  └─────────────────────────────────────────────────────────────────┘
53
55
  ▲ ▲
54
56
  ┌────────────────────────────┐ ┌──────────────────────────────┐
55
- adapters/cf │ │ adapters/aws
57
+ packages/cf-adapter │ │ packages/aws-adapter
56
58
  │ ConnectionDO │ │ $connect/$disconnect/ │
57
59
  │ ChannelDO │ │ $default Lambda handlers │
58
60
  │ RegistryDO │ │ DynamoDB tables │
@@ -60,8 +62,8 @@ Hexagonal / ports-and-adapters. The core reasons about IRC; adapters move bytes.
60
62
  └────────────────────────────┘ └──────────────────────────────┘
61
63
  ```
62
64
 
63
- A planned `packages/in-memory-runtime` provides a reference implementation of
64
- `IrcRuntime` used by integration tests and a local CLI server.
65
+ `packages/in-memory-runtime` is the reference implementation of
66
+ `IrcRuntime`, used by integration tests and the local CLI server.
65
67
 
66
68
  ### The key idea: pure reducers + location-of-authority
67
69
 
@@ -98,34 +100,36 @@ ServerlessIRCd/
98
100
  ├── packages/
99
101
  │ ├── irc-core/ pure protocol + reducers (the brain)
100
102
  │ ├── irc-server/ orchestration, IrcRuntime port, dispatch
101
- │ ├── in-memory-runtime/ reference runtime + local CLI server (planned)
102
- │ ├── cf-adapter/ Durable Objects + CfRuntime (planned)
103
- └── aws-adapter/ Lambda + DynamoDB + AwsRuntime (planned)
103
+ │ ├── in-memory-runtime/ reference runtime (used by tests + local CLI)
104
+ │ ├── irc-test-support/ parametrized IRC scenario suite + harness seam
105
+ ├── cf-adapter/ Durable Objects + CfRuntime
106
+ │ └── aws-adapter/ Lambda + DynamoDB + AwsRuntime
104
107
  ├── apps/
105
- │ ├── cf-worker/ worker entry, DO migrations, bindings (planned)
106
- │ ├── aws-stack/ CDK app entry (planned)
107
- │ └── local-cli/ runnable WS server using in-memory-runtime
108
+ │ ├── cf-worker/ worker entry, DO migrations, bindings
109
+ │ ├── aws-stack/ CDK stack (APIGW WS + Lambda + DynamoDB)
110
+ │ └── local-cli/ runnable WS + TCP server using in-memory-runtime
108
111
  ├── tools/
109
- └── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
110
- ├── docs/
111
- │ └── architecture-aws.md
112
+ ├── tcp-ws-forwarder/ local TCP↔ws/wss bridge for stock IRC clients
113
+ │ └── ci-hardening/ coverage-gate + mutation-config validators
112
114
  ├── pnpm-workspace.yaml turbo.json tsconfig.base.json
113
- └── PLAN.md progress.md tickets.md
115
+ └── README.md CHANGELOG.md
114
116
  ```
115
117
 
116
118
  ---
117
119
 
118
120
  ## Toolchain
119
121
 
120
- | Concern | Choice |
121
- |----------------|---------------------------------------------------|
122
- | Runtime | Node ≥ 20, ES2022+ |
123
- | Package mgr | pnpm workspaces |
124
- | Build cache | turbo |
125
- | Language | TypeScript (strict, `exactOptionalPropertyTypes`) |
126
- | Test runner | vitest + `@vitest/coverage-v8` |
127
- | Property tests | fast-check |
128
- | Lint / format | Biome |
122
+ | Concern | Choice |
123
+ |------------------|---------------------------------------------------|
124
+ | Runtime | Node ≥ 20, ES2022+ |
125
+ | Package mgr | pnpm workspaces |
126
+ | Build cache | turbo |
127
+ | Language | TypeScript (strict, `exactOptionalPropertyTypes`) |
128
+ | Test runner | vitest + `@vitest/coverage-v8` |
129
+ | Property tests | fast-check |
130
+ | Mutation tests | Stryker (≥80% score gate on irc-core) |
131
+ | AWS IaC | AWS CDK v2 |
132
+ | Lint / format | Biome |
129
133
 
130
134
  ---
131
135
 
@@ -140,26 +144,37 @@ pnpm build # build all packages (turbo)
140
144
  pnpm typecheck # tsc --noEmit across the workspace
141
145
  pnpm test # run all tests once
142
146
  pnpm test:watch # vitest watch mode
143
- pnpm coverage # tests + v8 coverage report
147
+ pnpm coverage # tests + v8 coverage report (enforces thresholds)
144
148
 
145
149
  pnpm lint # biome check .
146
150
  pnpm lint:fix # biome check --write .
147
151
  pnpm format # biome format --write .
148
152
 
153
+ pnpm mutation # Stryker spot-check on irc-core (protocol + commands)
154
+
149
155
  pnpm clean # remove dist/coverage/.turbo + node_modules
150
156
  ```
151
157
 
152
158
  Coverage reports are written to `packages/*/coverage/`. CI (`.github/workflows/ci.yml`)
153
- runs lint, typecheck, and coverage on every push and pull request.
159
+ runs lint, typecheck, the coverage gate, the parametrized contract suite, and a
160
+ Stryker mutation spot-check on every push and pull request. Coverage thresholds
161
+ enforce 100% on `irc-core` / `irc-server` / `in-memory-runtime` and ≥90% on every
162
+ other package.
154
163
 
155
164
  ---
156
165
 
157
166
  ## Running the local server
158
167
 
159
- `apps/local-cli` is a runnable WebSocket IRC server built on the in-memory
160
- runtime. It's the manual-test harness and the e2e fixture target — the same
161
- `irc-core` reducers and `ConnectionActor` the cloud adapters will use, just
162
- wired to a plain `ws.WebSocketServer` on localhost.
168
+ `apps/local-cli` is a runnable IRC server built on the in-memory runtime.
169
+ It's the manual-test harness and the e2e fixture target — the same
170
+ `irc-core` reducers and `ConnectionActor` the cloud adapters use, just
171
+ wired to plain localhost listeners. By default it binds **two** listeners
172
+ that share one runtime, so a TCP client and a WS client can see each other
173
+ (cross-transport channel broadcast):
174
+
175
+ - a **WebSocket** listener (the serverless transport) on `--port`, and
176
+ - an **RFC-style TCP** listener on `--tcp-port` (default `<port> + 1`) so
177
+ real IRC clients (WeeChat, HexChat, irssi, …) can dial in directly.
163
178
 
164
179
  First build the workspace (the CLI runs from compiled `dist/`):
165
180
 
@@ -178,37 +193,50 @@ pnpm --filter local-cli start --port 6667 --host 127.0.0.1
178
193
  On startup it logs a JSON line like:
179
194
 
180
195
  ```json
181
- {"ts":"...","level":"info","msg":"local-cli listening","url":"ws://127.0.0.1:6667/","port":6667,"hostname":"127.0.0.1"}
196
+ {"ts":"...","level":"info","msg":"local-cli listening","wsUrl":"ws://127.0.0.1:6667/","port":6667,"tcpPort":6668,"hostname":"127.0.0.1"}
182
197
  ```
183
198
 
184
199
  ### CLI flags
185
200
 
186
- | Flag | Default | Description |
187
- |--------------|-------------|----------------------------------------------|
188
- | `--port <n>` | `6667` | TCP port to bind. Use `0` for an ephemeral. |
189
- | `--host <h>` | `127.0.0.1` | Hostname / interface to bind. |
190
- | `-h, --help` | | Show help and exit. |
201
+ | Flag | Default | Description |
202
+ |--------------------|---------------|--------------------------------------------------------|
203
+ | `--port <n>` | `6667` | WebSocket port to bind. Use `0` for an ephemeral. |
204
+ | `--host <h>` | `127.0.0.1` | Hostname / interface to bind. |
205
+ | `--tcp-port <n>` | `<port> + 1` | TCP port for RFC-style IRC clients. |
206
+ | `--no-tcp` | | Disable the TCP listener (WebSocket only). |
207
+ | `--motd-file <p>` | built-in | Read MOTD lines from this file (one per line). |
208
+ | `-h, --help` | | Show help and exit. |
191
209
 
192
210
  Pass `--host 0.0.0.0` to expose the server on all interfaces. `SIGINT` /
193
211
  `SIGTERM` perform a graceful shutdown (closes active sockets, then exits).
194
212
 
195
213
  ### Connecting
196
214
 
197
- The server speaks WebSocket text frames (one IRC message per frame). Point any
198
- WebSocket-capable IRC client (WeeChat with the `irc` protocol's WS relay, or a
199
- small script) at `ws://127.0.0.1:6667/`. Server name and MOTD are fixed
200
- defaults (`irc.example.com`) until the config-loader ticket lands.
215
+ The WebSocket listener speaks WebSocket text frames (one IRC message per
216
+ frame) — point any WebSocket-capable IRC client or script at
217
+ `ws://127.0.0.1:6667/`. The TCP listener speaks plain RFC IRC over the
218
+ wire, so stock clients connect directly, e.g. in WeeChat:
219
+
220
+ ```
221
+ /connect 127.0.0.1/6668
222
+ ```
223
+
224
+ No `tcp-ws-forwarder` is needed for local development — both transports
225
+ are built in. The MOTD is the built-in default unless `--motd-file` is
226
+ given.
201
227
 
202
228
  ---
203
229
 
204
- ## Connecting a TCP IRC client (TCP→WS forwarder)
230
+ ## Connecting a TCP IRC client to a deployed stack (TCP→WS forwarder)
205
231
 
206
- v1 is WebSocket-only, so a stock TCP IRC client (WeeChat, HexChat, irssi, …)
207
- cannot dial a deployed worker or the local WS server directly.
208
- `tools/tcp-ws-forwarder` is a local bridge: it listens on a TCP port and, for
209
- each connection, opens one WebSocket to a `ws://` / `wss://` target and pumps
210
- IRC lines both ways reassembling the TCP byte stream into one message per WS
211
- frame outbound, and normalizing inbound frames back to canonical CRLF.
232
+ The local CLI ships a built-in TCP listener, so no bridge is needed for local
233
+ development. Deployed stacks (Cloudflare Workers and AWS API Gateway) are
234
+ WebSocket-only, so a stock TCP IRC client (WeeChat, HexChat, irssi, …) cannot
235
+ dial them directly. `tools/tcp-ws-forwarder` is a local bridge for that case:
236
+ it listens on a TCP port and, for each connection, opens one WebSocket to a
237
+ `ws://` / `wss://` target and pumps IRC lines both ways reassembling the TCP
238
+ byte stream into one message per WS frame outbound, and normalizing inbound
239
+ frames back to canonical CRLF.
212
240
 
213
241
  Build the workspace, then start the forwarder (from the repo root):
214
242
 
@@ -255,14 +283,15 @@ This project follows strict TDD (Red → Green → Refactor) — every reducer i
255
283
  landed test-first. See the project-level `CLAUDE.md` / `AGENTS.md` for the
256
284
  full rules.
257
285
 
258
- | Layer | Tooling | What it asserts |
259
- |------------------|-----------------------------------------|---------------------------------------|
260
- | Parser/serializer| vitest + fast-check | Grammar correctness, round-trip |
261
- | Reducers (core) | vitest pure unit tests | `(state,msg) → {state,effects}` exact |
262
- | Runtime contract | vitest parametrized over `IrcRuntime` | Same scenarios pass in-memory+CF+AWS |
263
- | CF adapter | `vitest-pool-workers` (real workerd) | DO behavior, alarms, stub fanout |
264
- | AWS adapter | vitest + dynamodb-local / localstack | DynamoDB schema, transactions, fanout |
265
- | E2E | real WS clients in `tools/irc-client` | RFC-shaped flows vs deployed stack |
286
+ | Layer | Tooling | What it asserts |
287
+ |--------------------|---------------------------------------------|---------------------------------------|
288
+ | Parser/serializer | vitest + fast-check | Grammar correctness, round-trip |
289
+ | Reducers (core) | vitest pure unit tests | `(state,msg) → {state,effects}` exact |
290
+ | Mutation (core) | Stryker spot-check (≥80% score gate) | Reducer test quality, surviving mutants |
291
+ | Runtime contract | vitest parametrized via `irc-test-support` | Same scenarios pass in-memory+CF+AWS |
292
+ | CF adapter | `vitest-pool-workers` (real workerd) | DO behavior, alarms, stub fanout |
293
+ | AWS adapter | vitest + testcontainers (DynamoDB Local) | DynamoDB schema, transactions, fanout |
294
+ | E2E | `scripts/smoke.mjs` against deployed stack | CONNECT→REGISTER→JOIN→PRIVMSG→QUIT |
266
295
 
267
296
  **Determinism:** reducers never touch real timers or randomness. A `Clock`
268
297
  port and an `IdFactory` port are injected so tests are fully deterministic.
@@ -274,20 +303,22 @@ port and an `IdFactory` port are injected so tests are fully deterministic.
274
303
  A v1 ships when `irc-core` is at 100% coverage, the parametrized contract
275
304
  suite passes against every runtime, both adapters are deployed to staging,
276
305
  and ≥3 reference clients (WeeChat, HexChat, IRCCloud, TheLounge) connect
277
- cleanly. Full per-ticket status lives in `progress.md`. Per-release
278
- manifests live in `CHANGELOG.md`; the release process is documented in
279
- `docs/release.md`.
306
+ cleanly. Per-release manifests live in `CHANGELOG.md`.
280
307
 
281
308
  - **Phase 0** — Foundation (monorepo, turbo, vitest, biome, CI). ✅
282
- - **Phase 1** — Pure protocol engine: reducers, IRCv3 caps, isupport.
283
- Most of this landed in **v0.1.0**; SASL and flood control are still
284
- pending.
309
+ - **Phase 1** — Pure protocol engine: reducers, IRCv3 caps, isupport,
310
+ SASL (`PLAIN`), token-bucket flood control, chat history. (most in
311
+ **v0.1.0**; SASL/flood/chathistory landed in **v0.2.0**).
285
312
  - **Phase 2** — `IrcRuntime` port, in-memory runtime, `ConnectionActor`,
286
313
  local CLI, parametrized contract suite. ✅ landed in **v0.1.0**.
287
314
  - **Phase 3** — Cloudflare adapter (ConnectionDO / ChannelDO / RegistryDO).
288
- ✅ landed in **v0.1.0** (staging only; prod deploy is manual).
315
+ ✅ landed in **v0.1.0** (staging auto-deploy via CI; prod deploy is manual).
289
316
  - **Phase 4** — AWS adapter (APIGW WS + Lambda + DynamoDB + CDK).
317
+ ✅ landed in **v0.2.0** (staging auto-deploy via CI; prod deploy is manual).
290
318
  - **Phase 5** — Observability, security hardening, config, CI gates.
319
+ ✅ mostly landed (logger port, server-password, cloaking, admission
320
+ limits, coverage + mutation gates). Remaining: oper credentials / config
321
+ loader.
291
322
  - **Phase 6** — Load testing, client compatibility sweep, ADRs.
292
323
 
293
324
  ---
@@ -296,28 +327,31 @@ manifests live in `CHANGELOG.md`; the release process is documented in
296
327
 
297
328
  **Core (RFC 1459/2812 subset):** registration (`NICK`/`USER`/`CAP`/`PASS`),
298
329
  `PING`/`PONG`, `QUIT`, `JOIN`, `PART`, `PRIVMSG`, `NOTICE`, `MODE` (user +
299
- channel), `TOPIC`, `KICK`, `INVITE`, `NAMES`, `LIST`, `WHO`, `WHOIS`, `MOTD`.
330
+ channel), `TOPIC`, `KICK`, `INVITE`, `NAMES`, `LIST`, `WHO`, `WHOIS`, `MOTD`,
331
+ `AWAY`.
300
332
 
301
333
  **Channel modes:** `o v b i k l t n m s p`.
302
334
  **User modes:** `i`, `o` (local only), `w`, `s`.
303
335
 
304
336
  **IRCv3 extensions (negotiated via `CAP`):** `message-tags`, `server-time`,
305
- `account-tag`, `echo-message`, `batch`, `sasl` (`PLAIN`, `EXTERNAL` reserved),
306
- `multi-prefix`, `away-notify`, `chghost`, `invite-notify`, `extended-join`.
337
+ `account-tag`, `echo-message`, `batch`, `sasl` (`PLAIN` implemented,
338
+ `EXTERNAL` reserved pending mTLS), `multi-prefix`, `away-notify`, `chghost`,
339
+ `invite-notify`, `extended-join`, `draft/chathistory`, `safelist`.
307
340
 
308
- **Transport:** WebSocket text frames only (one IRC message per frame, with
309
- tolerance for `\r\n`-joined frames). No plaintext TCP in v1 keeps the
310
- serverless story clean. Stock TCP IRC clients reach the server through the
311
- local [`tcp-ws-forwarder`](#connecting-a-tcp-irc-client-tcpws-forwarder).
341
+ **Transport:** deployed stacks (Cloudflare Workers, AWS API Gateway) speak
342
+ WebSocket text frames only (one IRC message per frame, with tolerance for
343
+ `\r\n`-joined frames). The local CLI additionally binds a plain RFC TCP
344
+ listener for direct IRC-client access. Stock TCP IRC clients reach a deployed
345
+ WebSocket endpoint through the local
346
+ [`tcp-ws-forwarder`](#connecting-a-tcp-irc-client-to-a-deployed-stack-tcpws-forwarder).
312
347
 
313
348
  ---
314
349
 
315
350
  ## Further reading
316
351
 
317
- - `PLAN.md` — full project plan, platform mappings, risks, decisions.
318
- - `CHANGELOG.md` per-release manifests (Keep a Changelog format).
319
- - `docs/release.md` — release process runbook (versioning, tagging,
320
- rollback).
321
- - `progress.md` / `tickets.md` what's done, what's next.
322
- - `docs/deployment-cf.md` Cloudflare deployment guide.
323
- - `docs/architecture-aws.md` — AWS adapter design notes.
352
+ - `CHANGELOG.md` — per-release manifests (Keep a Changelog format), including
353
+ the v0.2.0 work (AWS adapter, SASL, flood control, security hardening, CI
354
+ gates).
355
+ - `README.md` in each `packages/*` and `apps/*` — per-package notes (e.g.
356
+ `packages/aws-adapter/README.md` for the DynamoDB-Local test setup,
357
+ `apps/aws-stack/README.md` for CDK commands and localstack validation).
@@ -0,0 +1,73 @@
1
+ # `@serverless-ircd/aws-stack`
2
+
3
+ AWS CDK v2 stack that provisions the AWS substrate for ServerlessIRCd
4
+ (PLAN §6.2): an API Gateway v2 WebSocket API, a single Node 20 Lambda wired
5
+ to the `$connect` / `$disconnect` / `$default` routes, five DynamoDB tables
6
+ (`Connections`, `ChannelMeta`, `ChannelMembers`, `Nicks`, `Accounts`), and the
7
+ least-privilege IAM glue to let the Lambda read/write the tables and post back
8
+ to connected clients via the management API.
9
+
10
+ The Lambda handler is currently a stub (`{ statusCode: 200 }`); the real
11
+ `AwsRuntime` dispatch lands in TICKET-040.
12
+
13
+ ## Prerequisites
14
+
15
+ - Node.js 20+
16
+ - pnpm (workspace root installs this package)
17
+ - An AWS account with credentials configured
18
+ (`aws configure`, `AWS_PROFILE`, or `SSO`)
19
+
20
+ ## Commands
21
+
22
+ ```sh
23
+ pnpm cdk:synth # synthesize the CloudFormation template (cdk.out/)
24
+ pnpm deploy:staging # cdk deploy --all
25
+ pnpm deploy:prod # cdk deploy --all --require-approval never
26
+ pnpm test # synth-time assertions (no AWS, no Docker)
27
+ pnpm typecheck
28
+ ```
29
+
30
+ Repo-level shortcuts (from the monorepo root):
31
+
32
+ ```sh
33
+ pnpm deploy:aws:staging
34
+ pnpm deploy:aws:prod
35
+ pnpm smoke:aws:staging # deferred to TICKET-043
36
+ ```
37
+
38
+ ## Optional localstack validation
39
+
40
+ The default `pnpm test` runs only the in-process synth assertions
41
+ (`tests/stack.test.ts`). A deploy-time check against localstack lives in
42
+ `tests/localstack.test.ts` and is **skipped unless `LOCALSTACK=1`** is set,
43
+ so CI never depends on a Docker daemon.
44
+
45
+ To run it locally:
46
+
47
+ ```sh
48
+ # 1. Start localstack (requires Docker)
49
+ docker run --rm -d -p 4566:4566 localstack/localstack
50
+
51
+ # 2. Install the localstack-aware CDK CLI globally
52
+ npm install -g aws-cdk-local
53
+
54
+ # 3. Point the AWS SDK at localstack (cdklocal reads these)
55
+ export AWS_ACCESS_KEY_ID=test
56
+ export AWS_SECRET_ACCESS_KEY=test
57
+ export AWS_DEFAULT_REGION=us-east-1
58
+
59
+ # 4. Run the gated test
60
+ LOCALSTACK=1 pnpm test
61
+ ```
62
+
63
+ ## Stack outputs
64
+
65
+ | Output | Description |
66
+ |----------------|--------------------------------------------------------------------|
67
+ | `ConnectUrl` | `wss://` endpoint IRC clients dial. |
68
+ | `ManagementUrl`| `https://` endpoint for `ApiGatewayManagementApi.postToConnection`.|
69
+
70
+ ## Handoff
71
+
72
+ Lambda runtime logic, DynamoDB access patterns, and the `AwsRuntime`
73
+ implementation are TICKET-040. The scheduler/sweeper is TICKET-041/042.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * CDK app entry point for `apps/aws-stack`.
3
+ *
4
+ * Run via `tsx bin/aws.ts` (see `cdk.json`). Environment resolves to the
5
+ * CDK CLI's default account/region when those env vars are set; otherwise
6
+ * the stack is environment-agnostic (suitable for `cdk synth` and localstack).
7
+ *
8
+ * Per-environment isolation is driven by the `environmentName` CDK context
9
+ * variable (`-c environmentName=staging`). It defaults to `staging` so the
10
+ * existing deploy pipeline stays green without changes, and it is folded
11
+ * into the CloudFormation stack id (`IrcAwsStack-<environmentName>`) so
12
+ * parallel envs coexist in one account+region.
13
+ */
14
+
15
+ import { App } from 'aws-cdk-lib';
16
+ import type { IrcStackProps } from '../src/aws-stack.js';
17
+ import { IrcAwsStack } from '../src/aws-stack.js';
18
+
19
+ const app = new App();
20
+
21
+ const account = process.env.CDK_DEFAULT_ACCOUNT;
22
+ const region = process.env.CDK_DEFAULT_REGION;
23
+ const environmentName =
24
+ (app.node.tryGetContext('environmentName') as string | undefined) ?? 'staging';
25
+
26
+ // Server identity is read from CDK context so deploys can override it
27
+ // without editing source (`-c serverName=…`, `-c networkName=…`,
28
+ // `-c motd=…`). `motd` may be passed as a `\n`-embedded string from the
29
+ // CLI; split it back into lines so the stack re-joins with `\n` and the
30
+ // Lambda config loader round-trips it correctly. Defaults (omitted here)
31
+ // live on the stack itself.
32
+ const serverName = app.node.tryGetContext('serverName') as string | undefined;
33
+ const networkName = app.node.tryGetContext('networkName') as string | undefined;
34
+ const rawMotd = app.node.tryGetContext('motd');
35
+ const motdLines = Array.isArray(rawMotd)
36
+ ? (rawMotd as string[])
37
+ : typeof rawMotd === 'string'
38
+ ? rawMotd.split('\n')
39
+ : undefined;
40
+
41
+ const stackProps: IrcStackProps = {
42
+ environmentName,
43
+ ...(serverName !== undefined ? { serverName } : {}),
44
+ ...(networkName !== undefined ? { networkName } : {}),
45
+ ...(motdLines !== undefined ? { motdLines } : {}),
46
+ ...(account && region ? { env: { account, region } } : {}),
47
+ };
48
+
49
+ new IrcAwsStack(app, `IrcAwsStack-${environmentName}`, stackProps);
@@ -0,0 +1,10 @@
1
+ {
2
+ "app": "tsx bin/aws.ts",
3
+ "context": {
4
+ "environmentName": "staging"
5
+ },
6
+ "watch": {
7
+ "include": ["src/**", "bin/**"],
8
+ "exclude": ["tests/**", "dist/**", "node_modules/**"]
9
+ }
10
+ }
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@serverless-ircd/aws-stack",
3
+ "version": "0.2.0",
4
+ "private": true,
5
+ "description": "AWS CDK v2 stack: API Gateway v2 WebSocket API, Lambda, DynamoDB tables, least-privilege IAM",
6
+ "license": "BSD-3-Clause",
7
+ "type": "module",
8
+ "scripts": {
9
+ "build": "tsc -p tsconfig.build.json",
10
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "coverage": "vitest run --coverage",
14
+ "clean": "rimraf dist coverage .tsbuildinfo .turbo cdk.out",
15
+ "cdk": "cdk",
16
+ "cdk:synth": "cdk synth",
17
+ "deploy:staging": "cdk deploy --all --require-approval never",
18
+ "deploy:prod": "cdk deploy --all --require-approval never",
19
+ "smoke:staging": "node scripts/smoke.mjs"
20
+ },
21
+ "devDependencies": {
22
+ "aws-cdk": "^2.160.0",
23
+ "aws-cdk-lib": "^2.160.0",
24
+ "constructs": "^10.4.0",
25
+ "@types/node": "^26.1.1",
26
+ "@types/ws": "^8.5.13",
27
+ "esbuild": "^0.28.1",
28
+ "rimraf": "^6.0.0",
29
+ "tsx": "^4.19.0",
30
+ "typescript": "^5.6.0",
31
+ "vitest": "^4.1.0",
32
+ "@vitest/coverage-v8": "^4.1.0",
33
+ "ws": "^8.18.0"
34
+ },
35
+ "dependencies": {
36
+ "@serverless-ircd/aws-adapter": "workspace:*"
37
+ },
38
+ "engines": {
39
+ "node": ">=20"
40
+ }
41
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Type declarations for `scripts/smoke-helpers.mjs` (plain ESM consumed by the
3
+ * AWS staging smoke e2e). The runtime file ships as `.mjs` so CI can run it
4
+ * without a build step; this declaration lets the workspace typecheck resolve
5
+ * the imports from `tests/smoke-helpers.test.ts`.
6
+ */
7
+
8
+ export type ParseResult = { url: string; help: boolean };
9
+
10
+ export declare const DEFAULT_SMOKE_URL: string;
11
+
12
+ export declare function numericEquals(line: string, code: string): boolean;
13
+
14
+ export declare function parseArgs(argv: string[]): ParseResult;
15
+
16
+ export declare function waitForPredicate(
17
+ predicate: (line: string) => boolean,
18
+ label: string,
19
+ received: string[],
20
+ timeoutMs: number,
21
+ pollMs: number,
22
+ ): Promise<string>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Pure helpers backing the AWS staging smoke e2e (`scripts/smoke.mjs`).
3
+ *
4
+ * Kept dependency-free and side-effect-free (apart from the unavoidable
5
+ * `setTimeout` polling inside `waitForPredicate`) so they can be unit-tested
6
+ * in isolation. The smoke script itself owns the WebSocket I/O and process
7
+ * exit codes; these helpers own the parsing and timing logic.
8
+ */
9
+
10
+ /** Default WebSocket target when no `--url` is passed (matches the local dev convention). */
11
+ export const DEFAULT_SMOKE_URL = 'ws://localhost:8787';
12
+
13
+ /**
14
+ * Numeric command position in an IRC line (after an optional `:server`
15
+ * prefix). Recognises both `001 :Welcome` and `:irc.example.com 001 ...`.
16
+ */
17
+ export function numericEquals(line, code) {
18
+ const parts = line.split(' ');
19
+ const start = parts[0]?.startsWith(':') ? 1 : 0;
20
+ return parts[start] === code;
21
+ }
22
+
23
+ /**
24
+ * Parse the smoke script's CLI args. Returns `{ url, help }` — `url` always
25
+ * carries a sensible default, `help` is set when `-h`/`--help` was requested
26
+ * so the caller can print usage and exit 0. Throws on unknown flags or a
27
+ * dangling `--url` with no value.
28
+ *
29
+ * @param {string[]} argv
30
+ * @returns {{ url: string; help: boolean }}
31
+ */
32
+ export function parseArgs(argv) {
33
+ let url = DEFAULT_SMOKE_URL;
34
+ let help = false;
35
+ for (let i = 0; i < argv.length; i++) {
36
+ const a = argv[i];
37
+ const next = argv[i + 1];
38
+ if (a === '--') {
39
+ // POSIX options terminator — `pnpm <script> -- --url x` forwards it.
40
+ // Nothing positional follows for this script; just consume and continue.
41
+ continue;
42
+ }
43
+ if (a === '--url') {
44
+ if (next === undefined) {
45
+ throw new Error('--url requires a value');
46
+ }
47
+ url = next;
48
+ i++;
49
+ } else if (a === '-h' || a === '--help') {
50
+ help = true;
51
+ } else {
52
+ throw new Error(`unknown argument: ${a}`);
53
+ }
54
+ }
55
+ return { url, help };
56
+ }
57
+
58
+ /**
59
+ * Resolve with the first entry in `received` that satisfies `predicate`, or
60
+ * reject with a descriptive message once `timeoutMs` elapses. Polls every
61
+ * `pollMs` so a live socket can keep appending to `received` (by reference)
62
+ * between checks.
63
+ *
64
+ * @param {(line: string) => boolean} predicate
65
+ * @param {string} label
66
+ * @param {string[]} received
67
+ * @param {number} timeoutMs
68
+ * @param {number} pollMs
69
+ * @returns {Promise<string>}
70
+ */
71
+ export function waitForPredicate(predicate, label, received, timeoutMs, pollMs) {
72
+ return new Promise((resolve, reject) => {
73
+ const deadline = Date.now() + timeoutMs;
74
+ const check = () => {
75
+ for (const line of received) {
76
+ if (predicate(line)) {
77
+ resolve(line);
78
+ return;
79
+ }
80
+ }
81
+ if (Date.now() >= deadline) {
82
+ reject(new Error(`timed out waiting for ${label}; received=${JSON.stringify(received)}`));
83
+ return;
84
+ }
85
+ setTimeout(check, pollMs);
86
+ };
87
+ check();
88
+ });
89
+ }