serverless-ircd 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -275,6 +275,35 @@ describe('local-cli TCP transport — IRC flows', () => {
275
275
  await client.close();
276
276
  });
277
277
 
278
+ it('closes the TCP connection with ERROR when the input buffer cap is exceeded', async () => {
279
+ // A peer streaming > 8 KiB with no line terminator would grow the
280
+ // LineScanner carry unboundedly; the server must answer with the
281
+ // RFC-style ERROR notice, destroy the socket, and unregister the
282
+ // connection from the runtime.
283
+ const client = new TcpTestClient('127.0.0.1', server.tcpPort as number);
284
+ await client.opened();
285
+ await client.send('NICK overflower');
286
+ await client.send('USER overflower 0 * :Over');
287
+ await client.waitFor((l) => l.startsWith(':irc.local.example.com 001 '));
288
+
289
+ const connId = await server.runtime.lookupNick('overflower');
290
+ expect(connId).not.toBeNull();
291
+ expect(server.runtime.hasConnection(connId as string)).toBe(true);
292
+
293
+ const closed = new Promise<void>((resolve) => client.socket.once('close', () => resolve()));
294
+ await client.writeRaw('x'.repeat(9 * 1024)); // no terminator anywhere
295
+ const errorLine = await client.waitFor(
296
+ (l) => l === 'ERROR :Closing link: input buffer overflow',
297
+ 2_000,
298
+ );
299
+ expect(errorLine).toBe('ERROR :Closing link: input buffer overflow');
300
+ await closed;
301
+ expect(client.socket.destroyed).toBe(true);
302
+ // Cleanup ran: the runtime no longer tracks the connection.
303
+ await new Promise((r) => setTimeout(r, 100));
304
+ expect(server.runtime.hasConnection(connId as string)).toBe(false);
305
+ });
306
+
278
307
  it('logs an actor failure on the TCP path without crashing the server', async () => {
279
308
  const original = server.runtime.send.bind(server.runtime);
280
309
  server.runtime.send = async () => {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/web",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Web client workspace: Kiwi IRC SPA served at /webclient/ and project landing page at /",
6
6
  "license": "BSD-3-Clause",
@@ -381,10 +381,12 @@ For a real production deployment, before your first `pnpm deploy:aws`:
381
381
  1. **Use a dedicated AWS account** (or at least a dedicated region) for
382
382
  production. Do not point production credentials at the same stack
383
383
  CI is deploying to on every push.
384
- 2. **Change `RemovalPolicy.DESTROY` to `RemovalPolicy.RETAIN`** on the
385
- five DynamoDB tables (see §9.4). The committed stack deletes all
386
- data on `cdk destroy` intentional for staging, dangerous for
387
- production.
384
+ 2. **Keep the default table protection on.** State-bearing tables
385
+ (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`) default to
386
+ `RemovalPolicy.RETAIN` + `deletionProtection: true` + PITR-on, so a
387
+ mistaken `cdk destroy` or a stray `aws dynamodb delete-table` cannot
388
+ wipe them (see §9.4). Only set `-c allowTableDeletion=true` for a
389
+ deliberate teardown.
388
390
  3. **Set the server identity** (`SERVER_NAME`, `NETWORK_NAME`,
389
391
  `MOTD`) — see §7.1. `SERVER_NAME` is required at boot; the stack
390
392
  falls back to `irc.localhost` when the prop is omitted, which is
@@ -498,6 +500,13 @@ any of them** — to enable one, add an `addEnvironment(...)` line in
498
500
  | `MAX_LIST_ENTRIES` | int | `maxListEntries` |
499
501
  | `QUIT_MESSAGE` | string | `quitMessage` |
500
502
  | `SASL_ACCOUNTS` | string | `saslAccounts` |
503
+ | `MAX_CONNECTIONS_PER_IP` | int | `maxConnectionsPerIp` |
504
+ | `PER_IP_CONNECTION_RATE_MAX` | int | `perIpConnectionRate.max` |
505
+ | `PER_IP_CONNECTION_RATE_WINDOW_MS` | int | `perIpConnectionRate.windowMs` |
506
+
507
+ The last three are the per-IP admission knobs (§7.8): setting only one
508
+ half of the rate pair falls back to the schema default (5 per 60 000 ms)
509
+ for the other half.
501
510
 
502
511
  `SASL_ACCOUNTS` is the legacy/config fallback (newline-delimited
503
512
  `username:password` pairs); it is used only when the `Accounts` table is
@@ -587,6 +596,51 @@ intentional — it cannot be set by accident. Tear the sandbox stack down
587
596
  immediately after debugging; never deploy this combination to a shared
588
597
  account.
589
598
 
599
+ ### 7.7 DynamoDB table-protection context flags
600
+
601
+ Two CDK context flags control the state-bearing tables' data-loss
602
+ protection (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`).
603
+ `Connections` is always `DESTROY` with no PITR and is unaffected by
604
+ either flag. See §9.4 for the full table and the rationale (and the
605
+ "no snapshot-on-delete" note).
606
+
607
+ | Context flag | Default | Effect when flipped |
608
+ |------------------------|---------|-------------------------------------------------------------------------------------|
609
+ | `allowTableDeletion` | `false` | `true` → state tables become `RemovalPolicy.DESTROY` + `deletionProtection: false`, so a deliberate `cdk destroy` teardown can delete them. Data is irreversibly lost. |
610
+ | `enablePitr` | `true` | `false` → point-in-time recovery is disabled on every state table. Independent of `allowTableDeletion` (a teardown keeps PITR on). PITR costs ~$0.20/GB-month per table (§11.1). |
611
+
612
+ Both arrive as strings on the CLI and are normalised with the shared
613
+ `parseContextBool` helper (same path as `allowDataTrace`), so
614
+ `-c allowTableDeletion=false` stays false-y. They map onto the
615
+ `IrcStackProps.allowTableDeletion` / `IrcStackProps.enablePitr` construct
616
+ props (`apps/aws-stack/bin/aws.ts`).
617
+
618
+ ### 7.8 Connect throttling (per-IP admission, stage limits, WAF)
619
+
620
+ Connection setup is where credential brute-force spends its budget
621
+ (`PASS` / `AUTHENTICATE` per connection), so the stack throttles it in
622
+ three layers, outermost first:
623
+
624
+ | Layer | Where | Knob | Default |
625
+ |-------|-------|------|---------|
626
+ | **1. WAF edge rate limit** | WAFv2 rate-based rule, associated with the stage. WAF only inspects the initial HTTP upgrade on a WebSocket API, so the rule is effectively scoped to `$connect` and never re-fires on established frames. | `-c wafConnectRateLimit=<req/5min/IP>` (CDK context; blocks at the edge before a Lambda invocation is billed) | off (no WAF resources) |
627
+ | **2. APIGW stage throttling** | `defaultRouteSettings` on the stage — global backstop across ALL routes and IPs. | `--parameters ApiThrottlingRateLimit=…` / `--parameters ApiThrottlingBurstLimit=…` (CloudFormation parameters) | 100 rps / burst 200 |
628
+ | **3. Per-IP admission gates** | Inside the `$connect` Lambda: a paginated Query against the `Connections` table's `sourceIp-connectedSince` GSI counts the caller IP's live connections (`maxConnectionsPerIp`) and in-window establishments (`perIpConnectionRate`); over-budget connects return `429` and no row is written. The same rate budget gates NLB new flows (§AWS-TCP-Deployment). | `MAX_CONNECTIONS_PER_IP`, `PER_IP_CONNECTION_RATE_MAX`, `PER_IP_CONNECTION_RATE_WINDOW_MS` env vars (§7.3) | 10 / 5 per 60 s |
629
+
630
+ Notes:
631
+
632
+ - Layer 1 is opt-in because WAFv2 web ACLs cost ~$5/mo + $1/rule + $0.60/M
633
+ requests; for a small deployment, layers 2+3 are usually enough. Enable
634
+ with `pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- -c wafConnectRateLimit=2000`
635
+ (2000 upgrade requests per 5-minute window per client IP before the IP
636
+ is blocked at the edge).
637
+ - Layer 3's per-IP counts are eventually consistent (GSI reads), so a
638
+ coordinated burst from one IP can slightly overshoot the cap — the same
639
+ best-effort TOCTOU contract as the global `maxClients` counter.
640
+ - The 429 response carries `ERROR :Closing link` semantics at the HTTP
641
+ layer where possible; clients should treat it as back-pressure (retry
642
+ after the window decays), not a permanent ban.
643
+
590
644
  ---
591
645
 
592
646
  ## 8. Secrets
@@ -855,28 +909,62 @@ postToConnection, per-channel send-list cache, a dedicated fanout
855
909
  Lambda) are deferred to post-v1; the load-test ticket will surface
856
910
  the ceiling.
857
911
 
858
- ### 9.4 Removal policy (IMPORTANT)
912
+ ### 9.4 Removal policy, deletion protection, and PITR (IMPORTANT)
913
+
914
+ Every table is protected from accidental data loss by default. The
915
+ state-bearing tables (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`)
916
+ get **three independent safeguards**, each toggleable so a deliberate
917
+ teardown can still proceed; the one ephemeral table (`Connections`) is
918
+ always DESTROY:
919
+
920
+ | Table | RemovalPolicy | `deletionProtection` | PITR |
921
+ |-----------------------------------------------|---------------|----------------------|------|
922
+ | `Nicks`, `Services`, `ChannelMeta`, `ChannelMembers` | `RETAIN` (default) | `true` (default) | `true` (default) |
923
+ | `Connections` | `DESTROY` | (unset → off) | (unset → off) |
924
+
925
+ - **`RemovalPolicy.RETAIN`** — when the stack is deleted, CloudFormation
926
+ *orphans* the table instead of deleting it; the data survives.
927
+ - **`deletionProtection: true`** — DynamoDB refuses a `DeleteTable` API
928
+ call while this is on, blocking a stray `aws dynamodb delete-table` even
929
+ from an admin or compromised credential.
930
+ - **`pointInTimeRecovery` (PITR)** — continuous, per-table restore (within
931
+ the last ~35 days) against accidental writes/deletes that RETAIN +
932
+ deletion protection cannot stop (e.g. a buggy `UpdateItem` that
933
+ overwrites rows).
934
+
935
+ > **No snapshot-on-delete.** DynamoDB tables do NOT snapshot when a stack
936
+ > is deleted (`RemovalPolicy.SNAPSHOT` is a no-op here). `RETAIN` (orphan +
937
+ > keep) + PITR (continuous, in-place restore) is the correct combination;
938
+ > do not rely on a snapshot that does not exist.
939
+
940
+ Two CDK context flags control these (threaded through `bin/aws.ts`, see
941
+ §7.7):
942
+
943
+ - **`allowTableDeletion`** (default `false`). Set `-c allowTableDeletion=true`
944
+ for a deliberate `cdk destroy` teardown: every state table flips to
945
+ `RemovalPolicy.DESTROY` with `deletionProtection: false` so CloudFormation
946
+ can delete them. The data is **irreversibly lost** — flip this off only
947
+ when you genuinely want the tables gone. `Connections` is always DESTROY
948
+ regardless.
949
+ - **`enablePitr`** (default `true`). Set `-c enablePitr=false` to disable
950
+ PITR on every state table. Independent of `allowTableDeletion` — a
951
+ teardown keeps PITR on (harmless, and the data is still recoverable up
952
+ until the table is actually deleted). `Connections` never carries PITR.
859
953
 
860
- The stack sets `RemovalPolicy.DESTROY` on every table:
954
+ ```bash
955
+ # Default deploy: state tables RETAIN + deletion-protected + PITR-on.
956
+ pnpm --filter @serverless-ircd/aws-stack run cdk:synth
861
957
 
862
- ```ts
863
- // apps/aws-stack/src/aws-stack.ts:48
864
- const tables = Object.entries(TABLE_DEFS).map(
865
- ([logicalId, tableProps]) =>
866
- new Table(this, logicalId, { ...tableProps, removalPolicy: RemovalPolicy.DESTROY }),
867
- );
868
- ```
958
+ # Deliberate teardown (state tables become DELETABLE):
959
+ pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- -c allowTableDeletion=true
869
960
 
870
- **This means `cdk destroy` deletes every table and all of its data.**
871
- This is intentional for staging (you want a clean teardown between
872
- experiments) and **dangerous for production**.
961
+ # Disable PITR (e.g. cost-sensitive dev deploy):
962
+ pnpm --filter @serverless-ircd/aws-stack run cdk:synth -- -c enablePitr=false
963
+ ```
873
964
 
874
- For production, fork the stack to use `RemovalPolicy.RETAIN` (or
875
- `RETAIN_ON_UPDATE`): tables become orphans on stack deletion and keep
876
- their data. There is no built-in knob for this today — it requires
877
- editing `aws-stack.ts`. Recommendation: ship a `production: boolean`
878
- stack prop in a future change that flips the removal policy. Flagged
879
- in §15.
965
+ The protection knobs are asserted per parameter combination in
966
+ `apps/aws-stack/tests/stack.test.ts` (the "DynamoDB table protection"
967
+ suite).
880
968
 
881
969
  ### 9.5 TTL on `Connections.idleSince`
882
970
 
@@ -1005,12 +1093,25 @@ verify against the current AWS pricing page** before budgeting:
1005
1093
  | Lambda | GB-second | $0.0000166667 (≈ $0.0167 / GB-hour) |
1006
1094
  | DynamoDB on-demand | write request unit (WRU) | $1.25 / million |
1007
1095
  | DynamoDB on-demand | read request unit (RRU) | $0.25 / million |
1096
+ | DynamoDB PITR (continuous)| GB-month of table data + indexes, per table | ~$0.20 / GB-month |
1008
1097
  | EventBridge Scheduler | event published | $1.00 / million |
1009
1098
  | CloudWatch Logs | GB ingested | $0.50 |
1010
1099
 
1011
1100
  The Lambda free tier (1M requests + 400k GB-seconds per month) covers
1012
1101
  a meaningful slice of a small staging deployment.
1013
1102
 
1103
+ > **Table-protection cost.** `RemovalPolicy.RETAIN` and
1104
+ > `deletionProtection` are **free** — they are CloudFormation/DynamoDB
1105
+ > flags, not billable features. Point-in-time recovery (PITR) **is**
1106
+ > billed: continuously at ~$0.20/GB-month **per table**, charged on the
1107
+ > full table size including indexes, for as long as PITR is on. With the
1108
+ > default (`enablePitr=true`) this applies to the four state tables
1109
+ > (`Nicks`, `Services`, `ChannelMeta`, `ChannelMembers`); `Connections`
1110
+ > never carries PITR. For a small deployment these tables are tiny
1111
+ > (kilobytes to low megabytes), so the PITR line item is fractions of a
1112
+ > cent; it only becomes material if a table grows to many GB. Disable
1113
+ > with `-c enablePitr=false` on a cost-sensitive dev deploy (see §9.4).
1114
+
1014
1115
  ### 11.2 Back-of-envelope: 100 concurrent users
1015
1116
 
1016
1117
  Assume a small staging deployment with 100 concurrent users, average
@@ -212,6 +212,18 @@ State lifecycle is identical to the wss path: the **sweeper** Lambda
212
212
  no-PONG. Both cover TCP connections because the `connId` lives in the
213
213
  same `Connections` table.
214
214
 
215
+ **Per-IP new-flow rate limit.** The NLB path has no `$connect` event —
216
+ flow establishment IS the first chunk — so the `perIpConnectionRate`
217
+ budget is enforced inside `handleNlbStream`: the source IP (from
218
+ `x-forwarded-for`) is counted against the same `sourceIp-connectedSince`
219
+ GSI the wss `$connect` gate consults, and over-budget flows receive
220
+ `429` with no row written. Only establishment is budgeted: every
221
+ subsequent chunk of an established flow is also an invocation, and
222
+ rate-limiting those would starve an active IRC session. The knobs are
223
+ the shared `PER_IP_CONNECTION_RATE_MAX` / `PER_IP_CONNECTION_RATE_WINDOW_MS`
224
+ env vars (default 5 per 60 s; see `AWS-Deployment.md` §7.8 for the
225
+ three-layer throttling model).
226
+
215
227
  ---
216
228
 
217
229
  ## 7. Configuration reference
@@ -230,7 +242,11 @@ wss handler (`serverName`, `networkName`, `motd`), injected in
230
242
  - `SERVER_NAME`, `NETWORK_NAME`, `MOTD` (read by the shared
231
243
  `config-loader.ts`).
232
244
  - `MANAGEMENT_URL` — the wss stage's callback URL, so cross-connection
233
- fanout reaches recipients on either transport.
245
+ fanout reaches recipients on either transport. The NLB handler's
246
+ execution role also carries `execute-api:ManageConnections` on the
247
+ wss stage (`stage.grantManagementApiAccess(nlbHandler)` in
248
+ `aws-stack.ts`, asserted by a synth-time test) — without it every
249
+ cross-transport `postToConnection` fails with 403.
234
250
  - `<TABLE>_TABLE` for each of the five tables (bare logical ids).
235
251
 
236
252
  The Lambda handler entry is the same file
@@ -240,7 +256,10 @@ excluded from the bundle (ships with the runtime).
240
256
 
241
257
  There are **no TCP-specific DynamoDB tables** — the existing `Connections`
242
258
  table gains a `transportBuffer` string attribute (SET when non-empty,
243
- REMOVE when empty) on rows whose `connectionId` starts with `nlb-`.
259
+ REMOVE when empty) on rows whose `connectionId` starts with `nlb-`, plus
260
+ the `sourceIp` attribute + `sourceIp-connectedSince` GSI shared with the
261
+ wss path's per-IP admission gates (both transports stamp the flow/client
262
+ IP at establishment).
244
263
 
245
264
  ---
246
265
 
@@ -306,6 +325,22 @@ then fanout uses `ApiGatewayManagementApi.postToConnection`, which works
306
325
  for both transports. If `MANAGEMENT_URL` is unset, `managementApi` is
307
326
  `null` and cross-connection sends are silent no-ops.
308
327
 
328
+ The mirror-image symptom — a TCP client's messages never reaching wss
329
+ recipients — is an IAM problem, not a push problem: the NLB handler's
330
+ role must carry `execute-api:ManageConnections` on the wss stage
331
+ (`stage.grantManagementApiAccess(nlbHandler)`; synth-time asserted in
332
+ `apps/aws-stack/tests/stack.test.ts`). Without the grant the NLB
333
+ handler's `postToConnection` calls fail with 403 and the delivery is
334
+ silently dropped. Verify manually after a deploy:
335
+
336
+ ```bash
337
+ # 1. Connect over wss (e.g. the web client) and JOIN #fanout-check.
338
+ # 2. Connect over TLS :6697 (openssl one-liner in §11) and JOIN #fanout-check.
339
+ # 3. PRIVMSG from the TCP side — the wss client must receive it, and vice
340
+ # versa. A silent drop + 403 in the IrcNlbHandler CloudWatch logs
341
+ # means the ManageConnections grant is missing.
342
+ ```
343
+
309
344
  ### 9.4 Lurkers get disconnected after a few minutes
310
345
 
311
346
  The NLB idle timeout. A receiving-only client sends no chunks, so the
@@ -0,0 +1,55 @@
1
+ # Chat History (`draft/chathistory`)
2
+
3
+ The server implements the IRCv3 [`draft/chathistory`](https://ircv3.net/specs/extensions/chathistory)
4
+ extension: a client that negotiates the `draft/chathistory` cap may query
5
+ stored backlog (`LATEST` / `BEFORE` / `AFTER` / `AROUND` / `BETWEEN` /
6
+ `TARGETS`) and receives the matching messages wrapped in a
7
+ `BATCH chathistory <target>` frame, each replay line carrying its original
8
+ `@time=…` and `msgid=…` tags.
9
+
10
+ ## Query limit ceiling
11
+
12
+ Every client-supplied `limit` is **silently capped** at a deployment
13
+ ceiling before it reaches the `MessageStore`:
14
+
15
+ - `CHATHISTORY LATEST #chan * 999999999` returns at most
16
+ `chathistory.maxLimit` messages (default **100**) — the oversized value
17
+ never reaches the store, so a future higher-capacity backend cannot be
18
+ tricked into materializing a huge result set.
19
+ - The cap is silent: the client receives up to the ceiling worth of
20
+ replay lines and no error. Requests below the ceiling pass through
21
+ unchanged; a request for exactly the ceiling is honored in full.
22
+ - When the client omits the limit, the server default (50) applies —
23
+ also bounded by the ceiling, so a deployment that lowers
24
+ `chathistory.maxLimit` below 50 caps the default too.
25
+ - A limit that is not a non-negative integer (e.g. `abc`, `-5`) is
26
+ rejected with `461 ERR_NEEDMOREPARAMS` (Invalid parameters), unchanged
27
+ by the ceiling.
28
+
29
+ ### Configuration
30
+
31
+ | Knob | Default | Notes |
32
+ |-----------------------------|---------|------------------------------------------------|
33
+ | `chathistory.maxLimit` | `100` | Positive integer; per-`CHATHISTORY`-query cap. |
34
+
35
+ The knob lives in the server config (parsed by `ServerConfigSchema`), so
36
+ any adapter's config source can set it:
37
+
38
+ ```json
39
+ {
40
+ "serverName": "irc.example.com",
41
+ "networkName": "ExampleNet",
42
+ "chathistory": { "maxLimit": 250 }
43
+ }
44
+ ```
45
+
46
+ Omitting the `chathistory` section entirely inherits the default of 100.
47
+
48
+ ## Related
49
+
50
+ - JOIN auto-playback (`chatHistoryPlaybackLimit`, default 50) is a
51
+ separate knob covering the backlog replayed on JOIN; see
52
+ `packages/irc-core/src/config.ts`.
53
+ - `draft/read-marker` integration: `CHATHISTORY BEFORE` without an
54
+ explicit pivot falls back to the connection's last-read marker; see
55
+ `docs/PlanExtensions.md`.
@@ -287,6 +287,10 @@ is the single source of truth for the Worker.
287
287
  | `SERVER_NAME` | Server name sent in `001`/`005` numerics. **Required** — the CF config loader fails fast at boot when unset. | _No default; must be set in `wrangler.toml`._ |
288
288
  | `NETWORK_NAME` | Network label in `005 NETWORK=…`. | `ServerlessIRCd` |
289
289
  | `MOTD_LINES` | Message-of-the-day, `\n`-delimited. | Welcome banner string. |
290
+ | `MAX_CLIENTS` | Global live-connection cap (enforced by `CounterDO` at the edge). | schema default |
291
+ | `MAX_CONNECTIONS_PER_IP` | Per-IP simultaneous-connection cap (in-memory admission in `ConnectionDO`). | schema default (10) |
292
+ | `PER_IP_CONNECTION_RATE_MAX` | Per-IP upgrade rate budget — how many WebSocket upgrades one `CF-Connecting-IP` may open per sliding window. Enforced at the edge by `RateLimitDO` (429 when over budget). | schema default (5) |
293
+ | `PER_IP_CONNECTION_RATE_WINDOW_MS` | Sliding-window length in ms for the upgrade rate budget. Setting only one half of the pair falls back to the schema default (60 000 ms) for the other. | schema default (60 000) |
290
294
 
291
295
  These are read by `ConnectionDO` via `Env` (see
292
296
  `packages/cf-adapter/src/env.ts:20` and the `serverConfig()` method in
@@ -295,7 +299,7 @@ bundle and are visible in the dashboard.
295
299
 
296
300
  ### 7.2 Durable Object bindings
297
301
 
298
- Three DO classes are exported from `src/worker.ts` (re-exported from
302
+ The DO classes are exported from `src/worker.ts` (re-exported from
299
303
  `@serverless-ircd/cf-adapter`) and bound in `wrangler.toml`:
300
304
 
301
305
  | Binding name | Class | Owned state |
@@ -303,6 +307,9 @@ Three DO classes are exported from `src/worker.ts` (re-exported from
303
307
  | `CONNECTION_DO` | `ConnectionDO` | Socket + ConnectionState + PING alarms. |
304
308
  | `REGISTRY_DO` | `RegistryDO` | Nick uniqueness, nick→connId map (sharded).|
305
309
  | `CHANNEL_DO` | `ChannelDO` | Per-channel roster, modes, topic, fanout. |
310
+ | `CHANNEL_REGISTRY_DO` | `ChannelRegistryDO` | The channel-name registry (shard index). |
311
+ | `COUNTER_DO` | `CounterDO` | Global live-connection counter (`maxClients` cap, TICKET-194). |
312
+ | `RATE_LIMIT_DO` | `RateLimitDO` | Per-IP upgrade rate limiter — one sliding-window admission list per `CF-Connecting-IP`; the Worker edge checks the budget before forwarding an upgrade and rejects over-budget IPs with `429` (the per-IP layer of the connect throttling; runs before the `maxClients` counter so a flooding IP cannot consume global slots). |
306
313
 
307
314
  The class names in `wrangler.toml`'s `class_name` field MUST match the
308
315
  re-exports at the top of `apps/cf-worker/src/worker.ts:29`.
@@ -312,7 +319,7 @@ re-exports at the top of `apps/cf-worker/src/worker.ts:29`.
312
319
  ```toml
313
320
  [[migrations]]
314
321
  tag = "v1"
315
- new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
322
+ new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "CounterDO", "RateLimitDO"]
316
323
  ```
317
324
 
318
325
  `tag` is the migration id (string, monotonically tracked by wrangler).