serverless-ircd 0.9.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Guards on the production `apps/cf-worker/wrangler.toml`.
3
+ *
4
+ * The `[observability]` block controls how much of the worker's console
5
+ * output (every structured log record the ConnectionActor emits) is
6
+ * retained by Workers Analytics / `wrangler tail` / Logpush. Full-rate
7
+ * sampling is unnecessary for triage — every record is scrubbed to
8
+ * non-sensitive fields — and multiplies the volume of client-adjacent
9
+ * data retained, so the default is capped. This test pins the cap so a
10
+ * casual edit cannot silently restore 100% sampling.
11
+ */
12
+
13
+ import { describe, expect, it } from 'vitest';
14
+ import wranglerToml from '../wrangler.toml?raw';
15
+
16
+ /**
17
+ * Extracts the raw right-hand side of `key = value` from a top-level
18
+ * `[section]` table of the production wrangler config. Walks lines and
19
+ * switches tables on whole-line headers only (so a comment that merely
20
+ * mentions `[observability]` cannot confuse the parse). Returns
21
+ * `undefined` when either the table or the binding is absent so
22
+ * missing-value regressions fail loudly.
23
+ */
24
+ function tableBinding(section: string, key: string): string | undefined {
25
+ let inSection = false;
26
+ for (const line of wranglerToml.split('\n')) {
27
+ const header = line.match(/^\s*\[([^\]]+)\]\s*$/);
28
+ if (header !== null) {
29
+ inSection = header[1] === section;
30
+ continue;
31
+ }
32
+ if (!inSection) continue;
33
+ const binding = line.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*(?:#.*)?$`));
34
+ if (binding !== null) return binding[1];
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ describe('wrangler.toml observability', () => {
40
+ it('defaults head sampling to a reduced rate instead of 100%', () => {
41
+ expect(tableBinding('observability', 'head_sampling_rate')).toBe('0.1');
42
+ });
43
+
44
+ it('keeps observability enabled (records must still reach the pipeline)', () => {
45
+ expect(tableBinding('observability', 'enabled')).toBe('true');
46
+ });
47
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Worker-edge admission cap (`maxClients`) integration tests.
3
+ *
4
+ * TDD outline:
5
+ * "Red: maxClients = 1; a second upgrade is admitted today; assert it
6
+ * receives 429 (with the `ERROR :Closing link: server full` body).
7
+ * Green: CounterDO admission check at the edge."
8
+ *
9
+ * Drives the production worker entry (`src/worker.ts`) end-to-end through
10
+ * `worker.fetch` exactly like `ws-origin.test.ts`: the admission check,
11
+ * the 429 + ERROR-line rejection, and the slot lifecycle (release on
12
+ * close → re-admit) are asserted at the HTTP boundary, with the real
13
+ * CounterDO + ConnectionDO behind the bindings.
14
+ *
15
+ * `MAX_CLIENTS` is not set in `wrangler.test.toml` (schema default
16
+ * 10 000); cap-pinned tests override it per call via `{ ...env,
17
+ * MAX_CLIENTS: '1' }`, the same per-test env-override pattern the
18
+ * origin suite uses for `WEB_ORIGINS`.
19
+ */
20
+
21
+ import { env, reset } from 'cloudflare:test';
22
+ import { afterEach, describe, expect, it } from 'vitest';
23
+ import worker from '../src/worker';
24
+
25
+ declare global {
26
+ namespace Cloudflare {
27
+ interface Env {
28
+ WEB_ORIGINS: string;
29
+ /** Optional admission cap (parsed by the shared config loader). */
30
+ MAX_CLIENTS?: string;
31
+ }
32
+ }
33
+ }
34
+
35
+ const UPGRADE_URL = 'https://irc.example.com/';
36
+
37
+ function upgradeRequest(): Request {
38
+ // No Origin header — non-browser client, so the CSWSH policy passes
39
+ // regardless of the WEB_ORIGINS value in the test vars.
40
+ return new Request(UPGRADE_URL, { headers: { Upgrade: 'websocket' } });
41
+ }
42
+
43
+ /** Env with the global admission cap pinned to a single client. */
44
+ const capOne = { ...env, MAX_CLIENTS: '1' };
45
+
46
+ // DO storage persists across tests in the same file; reset between each
47
+ // so admission entries (and any half-closed connections) from one test
48
+ // never leak into the next.
49
+ afterEach(async () => {
50
+ await reset();
51
+ });
52
+
53
+ /**
54
+ * Polls upgrade attempts until one is admitted (101). The ConnectionDO
55
+ * teardown after a client-side close is event-driven and can lag in the
56
+ * test runtime, so the re-admit assertion polls instead of assuming
57
+ * synchrony. Every rejected attempt is a pure 429 (no socket, no state).
58
+ */
59
+ async function waitForAdmission(testEnv: typeof env, timeoutMs = 8000): Promise<Response> {
60
+ const deadline = Date.now() + timeoutMs;
61
+ for (;;) {
62
+ const response = await worker.fetch(upgradeRequest(), testEnv);
63
+ if (response.status === 101) return response;
64
+ await new Promise((r) => setTimeout(r, 50));
65
+ if (Date.now() > deadline) {
66
+ throw new Error(`upgrade was not admitted before timeout (${response.status})`);
67
+ }
68
+ }
69
+ }
70
+
71
+ describe('WS admission cap (maxClients) — worker edge', () => {
72
+ it('admits an upgrade while under the cap (101)', async () => {
73
+ const response = await worker.fetch(upgradeRequest(), env);
74
+ expect(response.status).toBe(101);
75
+ response.webSocket?.accept();
76
+ response.webSocket?.close();
77
+ });
78
+
79
+ it('rejects the next upgrade with 429 once maxClients is reached', async () => {
80
+ const first = await worker.fetch(upgradeRequest(), capOne);
81
+ expect(first.status).toBe(101);
82
+ first.webSocket?.accept();
83
+
84
+ const second = await worker.fetch(upgradeRequest(), capOne);
85
+ expect(second.status).toBe(429);
86
+ expect(second.webSocket).toBeNull();
87
+ const body = await second.text();
88
+ // The IRC-style ERROR line travels in the 429 body: pre-handshake
89
+ // there is no WebSocket to carry a frame, so the body is the only
90
+ // channel a non-browser client can read.
91
+ expect(body).toBe('ERROR :Closing link: server full');
92
+
93
+ first.webSocket?.close();
94
+ });
95
+
96
+ it('frees the slot on close so a later upgrade is admitted again', async () => {
97
+ const first = await worker.fetch(upgradeRequest(), capOne);
98
+ expect(first.status).toBe(101);
99
+ first.webSocket?.accept();
100
+
101
+ const second = await worker.fetch(upgradeRequest(), capOne);
102
+ expect(second.status).toBe(429);
103
+
104
+ // Close the admitted client; its ConnectionDO teardown releases the
105
+ // counter slot, so a subsequent upgrade must be admitted.
106
+ first.webSocket?.close();
107
+ const third = await waitForAdmission(capOne);
108
+ expect(third.status).toBe(101);
109
+ third.webSocket?.accept();
110
+ third.webSocket?.close();
111
+ });
112
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Worker-edge per-IP upgrade rate limit (`perIpConnectionRate`) tests.
3
+ *
4
+ * TDD outline:
5
+ * "Red: the N+1th upgrade from one `CF-Connecting-IP` within the
6
+ * window is admitted today; assert it receives 429. Green:
7
+ * RateLimitDO check at the edge, keyed by CF-Connecting-IP."
8
+ *
9
+ * Drives the production worker entry (`src/worker.ts`) end-to-end
10
+ * through `worker.fetch` exactly like `ws-admission.test.ts`. The rate
11
+ * knobs are overridden per call via `{ ...env, PER_IP_... }` env vars,
12
+ * the same per-test override pattern the admission suite uses for
13
+ * `MAX_CLIENTS`.
14
+ */
15
+
16
+ import { env, reset } from 'cloudflare:test';
17
+ import { afterEach, describe, expect, it } from 'vitest';
18
+ import worker from '../src/worker';
19
+
20
+ declare global {
21
+ namespace Cloudflare {
22
+ interface Env {
23
+ WEB_ORIGINS: string;
24
+ MAX_CLIENTS?: string;
25
+ /** Optional per-IP upgrade rate budget (parsed by the config loader). */
26
+ PER_IP_CONNECTION_RATE_MAX?: string;
27
+ /** Optional per-IP rate window in milliseconds. */
28
+ PER_IP_CONNECTION_RATE_WINDOW_MS?: string;
29
+ }
30
+ }
31
+ }
32
+
33
+ const UPGRADE_URL = 'https://irc.example.com/';
34
+
35
+ function upgradeRequest(ip?: string): Request {
36
+ // No Origin header — non-browser client, so the CSWSH policy passes
37
+ // regardless of the WEB_ORIGINS value in the test vars.
38
+ const headers: Record<string, string> = { Upgrade: 'websocket' };
39
+ if (ip !== undefined) {
40
+ headers['CF-Connecting-IP'] = ip;
41
+ }
42
+ return new Request(UPGRADE_URL, { headers });
43
+ }
44
+
45
+ /** Env with the per-IP upgrade budget pinned to 2 per 60 s. */
46
+ const rateTwo = { ...env, PER_IP_CONNECTION_RATE_MAX: '2' };
47
+
48
+ // DO storage persists across tests in the same file; reset between each so
49
+ // rate entries (and any half-closed connections) never leak across tests.
50
+ afterEach(async () => {
51
+ await reset();
52
+ });
53
+
54
+ describe('WS per-IP upgrade rate limit — worker edge', () => {
55
+ it('admits upgrades while the IP is within its budget (101)', async () => {
56
+ const first = await worker.fetch(upgradeRequest('203.0.113.10'), rateTwo);
57
+ expect(first.status).toBe(101);
58
+ first.webSocket?.accept();
59
+ first.webSocket?.close();
60
+
61
+ const second = await worker.fetch(upgradeRequest('203.0.113.10'), rateTwo);
62
+ expect(second.status).toBe(101);
63
+ second.webSocket?.accept();
64
+ second.webSocket?.close();
65
+ });
66
+
67
+ it('rejects the over-budget upgrade from the same IP with 429', async () => {
68
+ for (let i = 0; i < 2; i++) {
69
+ const r = await worker.fetch(upgradeRequest('203.0.113.11'), rateTwo);
70
+ expect(r.status).toBe(101);
71
+ r.webSocket?.accept();
72
+ }
73
+ const third = await worker.fetch(upgradeRequest('203.0.113.11'), rateTwo);
74
+ expect(third.status).toBe(429);
75
+ expect(third.webSocket).toBeNull();
76
+ const body = await third.text();
77
+ // The IRC-style ERROR line travels in the 429 body (same convention as
78
+ // the server-full rejection) so non-browser clients see a reason.
79
+ expect(body).toBe('ERROR :Closing link: connection rate exceeded');
80
+ });
81
+
82
+ it('keys the budget on CF-Connecting-IP (a saturated IP does not block another)', async () => {
83
+ const a1 = await worker.fetch(upgradeRequest('203.0.113.12'), rateTwo);
84
+ expect(a1.status).toBe(101);
85
+ a1.webSocket?.accept();
86
+ const a2 = await worker.fetch(upgradeRequest('203.0.113.12'), rateTwo);
87
+ expect(a2.status).toBe(101);
88
+ a2.webSocket?.accept();
89
+ expect(await worker.fetch(upgradeRequest('203.0.113.12'), rateTwo)).toHaveProperty(
90
+ 'status',
91
+ 429,
92
+ );
93
+
94
+ const other = await worker.fetch(upgradeRequest('198.51.100.7'), rateTwo);
95
+ expect(other.status).toBe(101);
96
+ other.webSocket?.accept();
97
+ other.webSocket?.close();
98
+ });
99
+
100
+ it('recovers the budget after the sliding window decays', async () => {
101
+ const shortWindow = {
102
+ ...env,
103
+ PER_IP_CONNECTION_RATE_MAX: '1',
104
+ PER_IP_CONNECTION_RATE_WINDOW_MS: '500',
105
+ };
106
+ const first = await worker.fetch(upgradeRequest('203.0.113.13'), shortWindow);
107
+ expect(first.status).toBe(101);
108
+ first.webSocket?.accept();
109
+ expect(await worker.fetch(upgradeRequest('203.0.113.13'), shortWindow)).toHaveProperty(
110
+ 'status',
111
+ 429,
112
+ );
113
+ // Wait out the 500 ms window; the single admission decays and the
114
+ // budget recovers.
115
+ await new Promise((r) => setTimeout(r, 700));
116
+ const after = await worker.fetch(upgradeRequest('203.0.113.13'), shortWindow);
117
+ expect(after.status).toBe(101);
118
+ after.webSocket?.accept();
119
+ after.webSocket?.close();
120
+ });
121
+
122
+ it('skips the rate check when CF-Connecting-IP is absent (non-edge request)', async () => {
123
+ // Only the Cloudflare edge sets CF-Connecting-IP; a request lacking it
124
+ // (e.g. a direct service-binding call) bypasses the per-IP budget —
125
+ // there is no key to budget on. Production traffic always enters via
126
+ // the edge, which stamps the header.
127
+ for (let i = 0; i < 3; i++) {
128
+ const r = await worker.fetch(upgradeRequest(), rateTwo);
129
+ expect(r.status).toBe(101);
130
+ r.webSocket?.accept();
131
+ }
132
+ });
133
+ });
@@ -55,6 +55,20 @@ class_name = "ChannelDO"
55
55
  name = "CHANNEL_REGISTRY_DO"
56
56
  class_name = "ChannelRegistryDO"
57
57
 
58
+ # Global live-connection admission counter (maxClients cap). Mirrors the
59
+ # production binding; ws-admission.test.ts drives the worker edge through
60
+ # admit / reject / re-admit after close.
61
+ [[durable_objects.bindings]]
62
+ name = "COUNTER_DO"
63
+ class_name = "CounterDO"
64
+
65
+ # Per-IP upgrade rate limiter (perIpConnectionRate). Mirrors the
66
+ # production binding; ws-rate-limit.test.ts drives the worker edge
67
+ # through allow / over-budget reject / window decay.
68
+ [[durable_objects.bindings]]
69
+ name = "RATE_LIMIT_DO"
70
+ class_name = "RateLimitDO"
71
+
58
72
  # D1 backing for persistent SASL accounts
59
73
  [[d1_databases]]
60
74
  binding = "ACCOUNTS_DB"
@@ -63,4 +77,4 @@ database_id = "test-d1-worker-accounts"
63
77
 
64
78
  [[migrations]]
65
79
  tag = "v1"
66
- new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
80
+ new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "CounterDO", "RateLimitDO"]
@@ -3,8 +3,7 @@
3
3
  ## Cloudflare Worker production config for ServerlessIRCd.
4
4
  ##
5
5
  ## Deploy:
6
- ## pnpm deploy:cf:staging # wrangler deploy --env staging
7
- ## pnpm deploy:cf:prod # wrangler deploy
6
+ ## pnpm deploy:cf # wrangler deploy
8
7
  ##
9
8
  ## The `apps/cf-worker` worker is the public edge for the IRC service.
10
9
  ## It owns the three Durable Object namespaces from the cf-adapter
@@ -12,11 +11,45 @@
12
11
  ## upgrades to per-connection ConnectionDO instances. See
13
12
  ## `src/worker.ts` and PLAN §6.1.
14
13
  ##
15
- ## Secrets: NEVER put secrets (API tokens, server passwords, account
16
- ## credentials) in this file. Use `wrangler secret put <NAME>` per
17
- ## environment; they live in the Workers KV secrets store, not the repo.
18
- ## As of v1 the worker does not consume any secret bindings — server
19
- ## password, SASL `AccountStore`, and oper creds are future work.
14
+ ## Secrets: NEVER put secrets in this file's [vars] table. Vars marked
15
+ ## [secret] below live in the Workers secrets store, not the repo — set
16
+ ## them with `wrangler secret put <NAME>`.
17
+ ##
18
+ ## Every env var the Worker consumes is enumerated in the block below,
19
+ ## classified `[vars]` (plaintext knob) or `[secret]` (credential
20
+ ## material). The block is drift-guarded by `tools/ci-hardening`
21
+ ## (tests/env-var-drift.test.ts): adding a var to `CfConfigEnv`
22
+ ## (`packages/cf-adapter/src/config-loader.ts`) without a matching
23
+ ## entry here fails CI.
24
+ ## BEGIN consumed-env-vars
25
+ ## [vars] SERVER_NAME — REQUIRED. Public hostname clients see in numerics (001/005). The deploy hostname guard refuses placeholders.
26
+ ## [vars] NETWORK_NAME — Network name surfaced in 002 / ISUPPORT (default "ServerlessIRCd").
27
+ ## [vars] SERVER_VERSION — Version string for 002/004/351/371 (schema default when unset).
28
+ ## [vars] CREATED_AT — Epoch-ms number or free text for 003 RPL_CREATED.
29
+ ## [vars] MOTD_LINES — Newline-delimited MOTD text.
30
+ ## [vars] MAX_CLIENTS — Global live-connection cap enforced by COUNTER_DO admission (default 10000).
31
+ ## [vars] CHANNEL_PREFIXES — Allowed channel-type prefixes.
32
+ ## [vars] MAX_CHANNELS_PER_USER — Per-user joined-channel cap.
33
+ ## [vars] MAX_TARGETS_PER_COMMAND — Cap on comma-split targets for PRIVMSG/NOTICE/PART.
34
+ ## [vars] NICK_LEN — Maximum nick byte length.
35
+ ## [vars] CHANNEL_LEN — Maximum channel-name byte length.
36
+ ## [vars] TOPIC_LEN — Maximum topic byte length.
37
+ ## [vars] MAX_LIST_ENTRIES — Cap on LIST reply entries.
38
+ ## [vars] QUIT_MESSAGE — Default QUIT part-message.
39
+ ## [vars] MAX_FRAMES_PER_WINDOW — Per-connection inbound frame ceiling enforced before the actor write.
40
+ ## [vars] FRAME_WINDOW_SECONDS — Sliding-window length (seconds) for the frame limit.
41
+ ## [vars] MAX_CONNECTIONS_PER_IP — Per-IP simultaneous-connection cap (admission gates).
42
+ ## [vars] PER_IP_CONNECTION_RATE_MAX — Per-IP new-connection budget per window (RATE_LIMIT_DO edge check).
43
+ ## [vars] PER_IP_CONNECTION_RATE_WINDOW_MS — Per-IP rate window length in ms.
44
+ ## [vars] EXTERNAL_ENABLED — Operator opt-in ('true'/'1') for certificate-backed SASL EXTERNAL; additionally requires a bound mTLS identity source and a TLS connection (default off).
45
+ ## [vars] OPER_USER — Oper username (pair with a credential secret below).
46
+ ## [secret] OPER_PASSWORD — Legacy plaintext oper password (prefer the hashed OPER_SALT + OPER_HASH form; rotate with tools/hash-oper-cred.ts).
47
+ ## [secret] OPER_SALT — Base64 scrypt salt of the hashed oper credential (generate with tools/hash-oper-cred.ts).
48
+ ## [secret] OPER_HASH — Base64 scrypt hash of the hashed oper credential (generate with tools/hash-oper-cred.ts).
49
+ ## [secret] SERVER_PASSWORD — Server-wide PASS gate (`PASS <value>` required before 001; SASL-identified connections are exempt).
50
+ ## [secret] SASL_ACCOUNTS — Newline-delimited `user:password` SASL PLAIN seed list (see tools/seed-cf-accounts.ts; supersedes the D1 store at boot when set).
51
+ ## [vars] WEB_ORIGINS — OPTIONAL comma-separated Origin allowlist (CSWSH defence). Read directly by the worker edge, not the config loader; unset = same-origin auto-derive.
52
+ ## END consumed-env-vars
20
53
 
21
54
  name = "serverless-ircd"
22
55
  main = "src/worker.ts"
@@ -35,10 +68,16 @@ compatibility_flags = ["nodejs_compat"]
35
68
  ## `dispatch.error`, `frame.parse-error`) are the canonical query keys.
36
69
  [observability]
37
70
  enabled = true
38
- ## `head_sampling_rate` keeps 100% of console output — valuable during v1
39
- ## and consistent with the per-frame traceId scheme (every request is
40
- ## uniquely identifiable). Lower this (e.g. 0.1) once traffic grows.
41
- head_sampling_rate = 1
71
+ ## `head_sampling_rate` caps the share of console output Workers
72
+ ## Analytics retains. The default is 0.1 (10%): every record the actor
73
+ ## emits is scrubbed to non-sensitive fields (e.g. `frame.parse-error`
74
+ ## carries only token/length/reason, never the raw line), so full-rate
75
+ ## sampling is unnecessary for triage and only multiplies how much
76
+ ## client-adjacent data log destinations retain. Production operators
77
+ ## can tune this per environment by overriding the `[observability]`
78
+ ## block in an `[env.<name>]` section (e.g. staging at 1.0 while
79
+ ## debugging an incident).
80
+ head_sampling_rate = 0.1
42
81
 
43
82
  [observability.logs]
44
83
  enabled = true
@@ -55,9 +94,15 @@ invocation_logs = true
55
94
  ## listed origins proceed. When unset/empty (the default), the Worker
56
95
  ## auto-derives the expected origin from the request's own `Host` header
57
96
  ## (same-origin enforcement) — no per-env config needed. Set this only for
58
- ## cross-origin deployments where the SPA is served from a different domain
59
- ## than the Worker. Non-browser clients (curl, WeeChat, tcp-ws-forwarder)
97
+ ## cross-origin deployments where the SPA is served from a different
98
+ ## domain than the Worker. Non-browser clients (curl, WeeChat, tcp-ws-forwarder)
60
99
  ## omit `Origin` and pass through unchanged regardless.
100
+ ##
101
+ ## `MAX_CLIENTS` is the OPTIONAL global live-connection cap (default
102
+ ## 10000). The Worker reserves a slot in the COUNTER_DO counter before
103
+ ## forwarding an upgrade and rejects with 429 (ERROR :Closing link:
104
+ ## server full) once the cap is reached; slots are released on
105
+ ## connection close and TTL-reaped if a DO dies without one.
61
106
  [vars]
62
107
  SERVER_NAME = "irc.your-domain.invalid"
63
108
  NETWORK_NAME = "ServerlessIRCd"
@@ -104,55 +149,20 @@ not_found_handling = "none"
104
149
  run_worker_first = true
105
150
 
106
151
  ## ---------------------------------------------------------------------------
107
- ## Staging environment.
108
- ## ---------------------------------------------------------------------------
109
- ## `wrangler deploy --env staging` ships to the staging Worker + DO
110
- ## namespace. The CI workflow (`.github/workflows/deploy-cf.yml`) deploys
111
- ## here on every push to `main` and then runs the smoke e2e.
112
- [env.staging]
113
- name = "serverless-ircd-staging"
114
-
115
- [env.staging.observability]
116
- enabled = true
117
- head_sampling_rate = 1
118
-
119
- [env.staging.vars]
120
- SERVER_NAME = "irc-staging.example.com"
121
- NETWORK_NAME = "ServerlessIRCd (staging)"
122
- MOTD_LINES = """
123
- Welcome to ServerlessIRCd staging.
124
- This is a deployed Cloudflare Worker.
125
- This Worker was deployed by CI from the main branch.
126
- """
127
-
128
- # Staging serves the same `apps/web/dist` asset layer. Wrangler requires the
129
- # per-env assets block so `--env staging` deploys both the Worker and its
130
- # assets in a single `wrangler deploy --env staging` (the same `pnpm build`
131
- # populates `dist/` ahead of the deploy step).
132
- [env.staging.assets]
133
- directory = "../../apps/web/dist"
134
- binding = "ASSETS"
135
- not_found_handling = "none"
136
- run_worker_first = true
137
-
138
- ## ---------------------------------------------------------------------------
139
- ## D1 database — SASL account persistence.
152
+ ## D1 database — unified services persistence (NickServ / ChanServ / SASL).
140
153
  ## ---------------------------------------------------------------------------
141
- ## The `accounts` table stores scrypt-hashed SASL PLAIN credentials (never
142
- ## plaintext). Loaded at ConnectionDO construction; when empty or unreachable
143
- ## the resolver falls back to the `SASL_ACCOUNTS` env-var seed. Provision with:
154
+ ## The `nickserv_accounts` table is the single credential home for SASL
155
+ ## PLAIN, SASL EXTERNAL, PASS-auth, and NickServ IDENTIFY. All scrypt-hashed
156
+ ## credentials live here; the standalone `accounts` table was dropped.
157
+ ## Provision with:
144
158
  ## wrangler d1 create serverless-ircd-accounts
145
- ## wrangler d1 execute serverless-ircd-accounts --command \
146
- ## "CREATE TABLE IF NOT EXISTS accounts (account TEXT PRIMARY KEY, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)"
147
- ## Seed accounts via `tools/seed-cf-accounts.ts`.
159
+ ## The services tables self-provision on first load (ConnectionDO runs
160
+ ## `CREATE TABLE IF NOT EXISTS` for each table). Seed accounts via
161
+ ## `tools/seed-cf-accounts.ts`.
148
162
  [[d1_databases]]
149
163
  binding = "ACCOUNTS_DB"
150
164
  database_name = "serverless-ircd-accounts"
151
- # database_id is filled in by `wrangler d1 create` and is env-specific.
152
-
153
- [[env.staging.d1_databases]]
154
- binding = "ACCOUNTS_DB"
155
- database_name = "serverless-ircd-accounts-staging"
165
+ # database_id is filled in by `wrangler d1 create`.
156
166
 
157
167
  ## ---------------------------------------------------------------------------
158
168
  ## Durable Object bindings.
@@ -179,22 +189,26 @@ class_name = "ChannelDO"
179
189
  name = "CHANNEL_REGISTRY_DO"
180
190
  class_name = "ChannelRegistryDO"
181
191
 
182
- # Staging environment bindings (wrangler requires per-env blocks).
183
- [[env.staging.durable_objects.bindings]]
184
- name = "CONNECTION_DO"
185
- class_name = "ConnectionDO"
186
-
187
- [[env.staging.durable_objects.bindings]]
188
- name = "REGISTRY_DO"
189
- class_name = "RegistryDO"
190
-
191
- [[env.staging.durable_objects.bindings]]
192
- name = "CHANNEL_DO"
193
- class_name = "ChannelDO"
194
-
195
- [[env.staging.durable_objects.bindings]]
196
- name = "CHANNEL_REGISTRY_DO"
197
- class_name = "ChannelRegistryDO"
192
+ # Global live-connection counter enforcing `MAX_CLIENTS` (the
193
+ # `maxClients` server-config cap). A single CounterDO instance (fixed
194
+ # name) tracks one admission entry per live WebSocket; the Worker's
195
+ # `fetch` reserves a slot per upgrade and rejects with 429 when full,
196
+ # ConnectionDO releases the slot on every teardown path, and a lazy TTL
197
+ # reap on each admit reconciles entries from DO instances that died
198
+ # without delivering a close event.
199
+ [[durable_objects.bindings]]
200
+ name = "COUNTER_DO"
201
+ class_name = "CounterDO"
202
+
203
+ # Per-IP upgrade rate limiter enforcing `perIpConnectionRate` (knobs:
204
+ # `PER_IP_CONNECTION_RATE_MAX` / `PER_IP_CONNECTION_RATE_WINDOW_MS`
205
+ # vars, defaults 5 per 60 s). A single RateLimitDO instance (fixed name)
206
+ # keeps one sliding-window admission-timestamp list per
207
+ # CF-Connecting-IP; the Worker's `fetch` checks the budget before
208
+ # forwarding an upgrade and rejects over-budget IPs with 429.
209
+ [[durable_objects.bindings]]
210
+ name = "RATE_LIMIT_DO"
211
+ class_name = "RateLimitDO"
198
212
 
199
213
  # ## ---------------------------------------------------------------------------
200
214
  # ## Durable Object migrations.
@@ -210,10 +224,6 @@ class_name = "ChannelRegistryDO"
210
224
  # tag = "v1"
211
225
  # new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
212
226
 
213
- # [[env.staging.migrations]]
214
- # tag = "v1"
215
- # new_sqlite_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO"]
216
-
217
227
  [exports.ConnectionDO]
218
228
  type = "durable-object"
219
229
  storage = "sqlite"
@@ -229,3 +239,11 @@ storage = "sqlite"
229
239
  [exports.ChannelRegistryDO]
230
240
  type = "durable-object"
231
241
  storage = "sqlite"
242
+
243
+ [exports.CounterDO]
244
+ type = "durable-object"
245
+ storage = "sqlite"
246
+
247
+ [exports.RateLimitDO]
248
+ type = "durable-object"
249
+ storage = "sqlite"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/local-cli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Runnable WebSocket IRC server using the in-memory runtime; manual-test harness and e2e fixture target",
6
6
  "license": "BSD-3-Clause",
@@ -9,7 +9,11 @@
9
9
  * surfaced at boot rather than mid-session.
10
10
  */
11
11
 
12
- import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
12
+ import {
13
+ type OperCred,
14
+ type ParsedServerConfig,
15
+ parseServerConfig,
16
+ } from '@serverless-ircd/irc-core';
13
17
 
14
18
  /**
15
19
  * CLI / programmatic input for `loadServerConfigFromCliArgs`. Every
@@ -34,7 +38,15 @@ export interface CliConfigInput {
34
38
  motdLines?: string[];
35
39
  channelPrefixes?: string;
36
40
  maxClients?: number;
37
- operCreds?: Array<{ user: string; password: string }>;
41
+ /**
42
+ * IRC operator credentials. Each entry is EITHER the at-rest hashed
43
+ * form `{user, salt, hash}` (recommended — generate via
44
+ * `tools/hash-oper-cred.ts`) OR the legacy plaintext form
45
+ * `{user, password}` (deprecated — retained for a single
46
+ * deprecation-cycle window). The two shapes can be mixed in the same
47
+ * array; the shared `OperCredSchema` validates either.
48
+ */
49
+ operCreds?: ReadonlyArray<OperCred>;
38
50
  /**
39
51
  * Server-password gate. Treat as a secret; the loader threads it
40
52
  * straight through (via `stripUndefined`) to `ServerConfig.serverPassword`
@@ -18,9 +18,26 @@
18
18
  * exactly when a real IRC client connects to local-cli directly.
19
19
  */
20
20
 
21
+ import { MAX_BUFFER_BYTES } from '@serverless-ircd/irc-core';
22
+
23
+ /** Options accepted by {@link LineScanner}. */
24
+ export interface LineScannerOptions {
25
+ /**
26
+ * Invoked when a pushed chunk grows the retained carry past
27
+ * {@link MAX_BUFFER_BYTES} (raw bytes, checked once per push). The
28
+ * scanner drops the oversized carry and returns no lines; the owner
29
+ * should write `ERROR :Closing link: ...` and destroy the socket — this
30
+ * seam has no write path. Mirrors the `TcpByteStreamTransport` cap so
31
+ * both sides of the WS↔TCP bridge enforce one identical budget.
32
+ */
33
+ onOverflow?: () => void;
34
+ }
35
+
21
36
  export class LineScanner {
22
37
  private carry: Buffer = Buffer.alloc(0);
23
38
 
39
+ constructor(private readonly options?: LineScannerOptions) {}
40
+
24
41
  /**
25
42
  * Feeds a chunk of TCP bytes. Returns the complete lines found in this
26
43
  * chunk (joined with any leftover carry from prior chunks), in order,
@@ -42,6 +59,15 @@ export class LineScanner {
42
59
  nl = buf.indexOf(0x0a, start);
43
60
  }
44
61
  this.carry = buf.subarray(start);
62
+ // Cap the retained (unterminated) tail — checked once per push, in raw
63
+ // bytes. Complete lines flushed by this chunk never count against the
64
+ // budget, so legitimate multi-line batches far larger than the cap pass
65
+ // through; only an unterminated tail can grow unboundedly.
66
+ if (this.carry.length > MAX_BUFFER_BYTES) {
67
+ this.carry = Buffer.alloc(0);
68
+ this.options?.onOverflow?.();
69
+ return [];
70
+ }
45
71
  return lines;
46
72
  }
47
73