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
@@ -17,6 +17,18 @@ interface CfEnvLike {
17
17
  CHANNEL_PREFIXES?: string;
18
18
  OPER_USER?: string;
19
19
  OPER_PASSWORD?: string;
20
+ /**
21
+ * Scrypt salt (base64) for the hashed oper credential form. When set
22
+ * alongside `OPER_USER` + `OPER_HASH`, the loader builds a hashed
23
+ * `{user, salt, hash}` cred instead of the legacy plaintext
24
+ * `{user, password}` shape.
25
+ */
26
+ OPER_SALT?: string;
27
+ /**
28
+ * Scrypt hash (base64) for the hashed oper credential form. See
29
+ * `OPER_SALT` for the full set of vars.
30
+ */
31
+ OPER_HASH?: string;
20
32
  /**
21
33
  * Server-password gate. Treat as a wrangler secret in production
22
34
  * (never a plaintext `[vars]` entry); the loader threads it through
@@ -30,6 +42,8 @@ interface CfEnvLike {
30
42
  TOPIC_LEN?: string;
31
43
  MAX_LIST_ENTRIES?: string;
32
44
  QUIT_MESSAGE?: string;
45
+ /** SASL EXTERNAL operator opt-in ('true'/'1' enable; default off). */
46
+ EXTERNAL_ENABLED?: string;
33
47
  }
34
48
 
35
49
  function baseEnv(): CfEnvLike {
@@ -82,6 +96,49 @@ describe('loadServerConfigFromCfEnv — valid env', () => {
82
96
  expect(cfg.operCreds).toEqual([{ user: 'admin', password: 'hunter2' }]);
83
97
  });
84
98
 
99
+ it('builds a hashed operCred from OPER_USER + OPER_SALT + OPER_HASH', () => {
100
+ const cfg = loadServerConfigFromCfEnv({
101
+ ...baseEnv(),
102
+ OPER_USER: 'admin',
103
+ OPER_SALT: 'c2FsdA==',
104
+ OPER_HASH: 'aGFzaA==',
105
+ });
106
+ expect(cfg.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
107
+ });
108
+
109
+ it('prefers the hashed form when both OPER_PASSWORD and OPER_HASH/OPER_SALT are set', () => {
110
+ // Hashed form takes precedence so operators can rotate to it without
111
+ // removing the legacy OPER_PASSWORD var in the same deploy.
112
+ const cfg = loadServerConfigFromCfEnv({
113
+ ...baseEnv(),
114
+ OPER_USER: 'admin',
115
+ OPER_PASSWORD: 'legacy',
116
+ OPER_SALT: 'c2FsdA==',
117
+ OPER_HASH: 'aGFzaA==',
118
+ });
119
+ expect(cfg.operCreds).toEqual([{ user: 'admin', salt: 'c2FsdA==', hash: 'aGFzaA==' }]);
120
+ });
121
+
122
+ it('throws when OPER_USER + OPER_SALT are set but OPER_HASH is missing', () => {
123
+ expect(() =>
124
+ loadServerConfigFromCfEnv({
125
+ ...baseEnv(),
126
+ OPER_USER: 'admin',
127
+ OPER_SALT: 'c2FsdA==',
128
+ }),
129
+ ).toThrowError(/hash/u);
130
+ });
131
+
132
+ it('throws when OPER_USER + OPER_HASH are set but OPER_SALT is missing', () => {
133
+ expect(() =>
134
+ loadServerConfigFromCfEnv({
135
+ ...baseEnv(),
136
+ OPER_USER: 'admin',
137
+ OPER_HASH: 'aGFzaA==',
138
+ }),
139
+ ).toThrowError(/salt/u);
140
+ });
141
+
85
142
  it('threads SERVER_PASSWORD through to serverPassword', () => {
86
143
  const cfg = loadServerConfigFromCfEnv({
87
144
  ...baseEnv(),
@@ -237,3 +294,105 @@ describe('loadServerConfigFromCfEnv — SASL_ACCOUNTS parsing', () => {
237
294
  expect(cfg.createdAt).toBe(huge);
238
295
  });
239
296
  });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Adapter frame-rate limit (per-connection inbound frame window).
300
+ // ---------------------------------------------------------------------------
301
+
302
+ describe('loadServerConfigFromCfEnv — adapter frame-rate limit', () => {
303
+ it('defaults the adapter frame window when the vars are unset', () => {
304
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
305
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 50, frameWindowSeconds: 5 });
306
+ });
307
+
308
+ it('maps MAX_FRAMES_PER_WINDOW + FRAME_WINDOW_SECONDS to the adapter section', () => {
309
+ const cfg = loadServerConfigFromCfEnv({
310
+ ...baseEnv(),
311
+ MAX_FRAMES_PER_WINDOW: '10',
312
+ FRAME_WINDOW_SECONDS: '2',
313
+ });
314
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 10, frameWindowSeconds: 2 });
315
+ });
316
+
317
+ it('fills the unset knob with its schema default when only one var is set', () => {
318
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_FRAMES_PER_WINDOW: '25' });
319
+ expect(cfg.adapter).toEqual({ maxFramesPerWindow: 25, frameWindowSeconds: 5 });
320
+ });
321
+
322
+ it('throws when MAX_FRAMES_PER_WINDOW is non-numeric', () => {
323
+ expect(() =>
324
+ loadServerConfigFromCfEnv({ ...baseEnv(), MAX_FRAMES_PER_WINDOW: 'lots' }),
325
+ ).toThrowError(/maxFramesPerWindow/u);
326
+ });
327
+
328
+ it('throws when FRAME_WINDOW_SECONDS is non-positive', () => {
329
+ expect(() =>
330
+ loadServerConfigFromCfEnv({ ...baseEnv(), FRAME_WINDOW_SECONDS: '0' }),
331
+ ).toThrowError(/frameWindowSeconds/u);
332
+ });
333
+ });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // EXTERNAL_ENABLED — SASL EXTERNAL operator opt-in (default off).
337
+ // ---------------------------------------------------------------------------
338
+
339
+ describe('loadServerConfigFromCfEnv — EXTERNAL_ENABLED (SASL EXTERNAL opt-in)', () => {
340
+ it('leaves SASL EXTERNAL off when EXTERNAL_ENABLED is unset', () => {
341
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
342
+ expect(cfg.sasl?.externalEnabled ?? false).toBe(false);
343
+ });
344
+
345
+ it('maps EXTERNAL_ENABLED=true to sasl.externalEnabled', () => {
346
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'true' });
347
+ expect(cfg.sasl?.externalEnabled).toBe(true);
348
+ });
349
+
350
+ it('maps EXTERNAL_ENABLED=1 to sasl.externalEnabled', () => {
351
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: '1' });
352
+ expect(cfg.sasl?.externalEnabled).toBe(true);
353
+ });
354
+
355
+ it('maps EXTERNAL_ENABLED=false (and any other value) to false', () => {
356
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'false' });
357
+ expect(cfg.sasl?.externalEnabled).toBe(false);
358
+ const junk = loadServerConfigFromCfEnv({ ...baseEnv(), EXTERNAL_ENABLED: 'yes please' });
359
+ expect(junk.sasl?.externalEnabled ?? false).toBe(false);
360
+ });
361
+ });
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Per-IP admission knobs — edge throttling is operator-tunable via env vars.
365
+ // ---------------------------------------------------------------------------
366
+
367
+ describe('loadServerConfigFromCfEnv — per-IP admission knobs', () => {
368
+ it('defaults maxConnectionsPerIp / perIpConnectionRate from the schema', () => {
369
+ const cfg = loadServerConfigFromCfEnv(baseEnv());
370
+ expect(cfg.maxConnectionsPerIp).toBe(10);
371
+ expect(cfg.perIpConnectionRate).toEqual({ max: 5, windowMs: 60_000 });
372
+ });
373
+
374
+ it('parses MAX_CONNECTIONS_PER_IP as an integer', () => {
375
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), MAX_CONNECTIONS_PER_IP: '3' });
376
+ expect(cfg.maxConnectionsPerIp).toBe(3);
377
+ });
378
+
379
+ it('parses PER_IP_CONNECTION_RATE_MAX / _WINDOW_MS into the rate knob', () => {
380
+ const cfg = loadServerConfigFromCfEnv({
381
+ ...baseEnv(),
382
+ PER_IP_CONNECTION_RATE_MAX: '10',
383
+ PER_IP_CONNECTION_RATE_WINDOW_MS: '30000',
384
+ });
385
+ expect(cfg.perIpConnectionRate).toEqual({ max: 10, windowMs: 30000 });
386
+ });
387
+
388
+ it('fills the unset half of the rate knob with the schema default', () => {
389
+ const cfg = loadServerConfigFromCfEnv({ ...baseEnv(), PER_IP_CONNECTION_RATE_MAX: '9' });
390
+ expect(cfg.perIpConnectionRate).toEqual({ max: 9, windowMs: 60_000 });
391
+ });
392
+
393
+ it('rejects a non-positive rate budget', () => {
394
+ expect(() =>
395
+ loadServerConfigFromCfEnv({ ...baseEnv(), PER_IP_CONNECTION_RATE_MAX: '0' }),
396
+ ).toThrowError(/perIpConnectionRate/u);
397
+ });
398
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * `ConnectionDO` × `CounterDO` — admission-slot teardown wiring.
3
+ *
4
+ * TDD outline:
5
+ * "Red: the counter is not decremented on close; assert it is. Green:
6
+ * decrement on close + error. Red: a crashed DO leaves the counter
7
+ * sticky; assert the reconcile sweep reduces it (covered in
8
+ * counter-do.test.ts — here we assert the live-side of that
9
+ * contract: the PING alarm heartbeats the entry so the sweep spares
10
+ * healthy connections)."
11
+ *
12
+ * The worker edge registers each upgrade in the global counter keyed by
13
+ * the ConnectionDO's hex id. These tests pre-register that key directly
14
+ * (simulating the worker), drive every ConnectionDO teardown path, and
15
+ * assert the slot is released.
16
+ */
17
+
18
+ import { env, reset, runDurableObjectAlarm, runInDurableObject } from 'cloudflare:test';
19
+ import { afterEach, describe, expect, it } from 'vitest';
20
+ import { COUNTER_INSTANCE_NAME, DEFAULT_CONNECTION_TTL_MS } from '../src/counter-do';
21
+ import type { CounterRpc } from '../src/env';
22
+
23
+ declare global {
24
+ namespace Cloudflare {
25
+ interface Env {
26
+ CONNECTION_DO: DurableObjectNamespace;
27
+ COUNTER_DO: DurableObjectNamespace;
28
+ }
29
+ }
30
+ }
31
+
32
+ afterEach(async () => {
33
+ await reset();
34
+ });
35
+
36
+ /** RPC handle on the single global counter instance. */
37
+ function counter(): CounterRpc {
38
+ const stub = env.COUNTER_DO.get(env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
39
+ return stub as unknown as CounterRpc;
40
+ }
41
+
42
+ /** Stub for the ConnectionDO instance named `name`. */
43
+ function connStub(name: string): DurableObjectStub {
44
+ return env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(name));
45
+ }
46
+
47
+ /**
48
+ * The canonical conn id used by the admission counter: the ConnectionDO's
49
+ * hex id. This is exactly what the worker registers
50
+ * (`idFromName(uuid).toString()`) and what the DO computes from `ctx.id`,
51
+ * so both sides agree on the key without passing it explicitly.
52
+ */
53
+ function hexId(name: string): string {
54
+ return env.CONNECTION_DO.idFromName(name).toString();
55
+ }
56
+
57
+ /** Opens a real WebSocket to the ConnectionDO named `name`. */
58
+ async function openSocket(name: string): Promise<WebSocket> {
59
+ const response = await connStub(name).fetch('https://do/upgrade', {
60
+ headers: { Upgrade: 'websocket' },
61
+ });
62
+ const ws = response.webSocket;
63
+ if (ws === undefined || ws === null) {
64
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
65
+ }
66
+ ws.accept();
67
+ await new Promise<void>((resolve) => setTimeout(resolve, 0));
68
+ return ws as WebSocket;
69
+ }
70
+
71
+ /** Simulates the worker edge: registers the connection's slot. */
72
+ async function registerSlot(name: string, now?: number): Promise<void> {
73
+ const result = await counter().admit(hexId(name), 10_000, now);
74
+ if (!result.admitted) throw new Error('test setup admit unexpectedly rejected');
75
+ }
76
+
77
+ describe('ConnectionDO — admission-slot release on teardown', () => {
78
+ it('a clean close releases the slot (webSocketClose → tearDown)', async () => {
79
+ await registerSlot('slot-close-1');
80
+ const ws = await openSocket('slot-close-1');
81
+ expect(await counter().liveCount()).toBe(1);
82
+
83
+ // Deterministic close-teardown (the platform's close event lags in
84
+ // the test runtime; __triggerClose runs the same code path).
85
+ await runInDurableObject(connStub('slot-close-1'), async (instance: unknown) => {
86
+ const doi = instance as { __triggerClose?: () => Promise<void> };
87
+ await doi.__triggerClose?.();
88
+ });
89
+
90
+ expect(await counter().liveCount()).toBe(0);
91
+ ws.close();
92
+ });
93
+
94
+ it('a transport error releases the slot (webSocketError → tearDown)', async () => {
95
+ await registerSlot('slot-error-1');
96
+ const ws = await openSocket('slot-error-1');
97
+ expect(await counter().liveCount()).toBe(1);
98
+
99
+ await runInDurableObject(connStub('slot-error-1'), async (instance: unknown) => {
100
+ const doi = instance as { __triggerWsError?: (error: unknown) => Promise<void> };
101
+ await doi.__triggerWsError?.(new Error('boom'));
102
+ });
103
+
104
+ expect(await counter().liveCount()).toBe(0);
105
+ ws.close();
106
+ });
107
+
108
+ it('a socketless teardown releases the slot (disconnect RPC → tearDownNoSocket)', async () => {
109
+ await registerSlot('slot-nosock-1');
110
+ expect(await counter().liveCount()).toBe(1);
111
+
112
+ await (
113
+ connStub('slot-nosock-1') as unknown as { disconnect: () => Promise<void> }
114
+ ).disconnect();
115
+
116
+ expect(await counter().liveCount()).toBe(0);
117
+ });
118
+
119
+ it('the PING alarm heartbeats the slot so the TTL reconcile spares live connections', async () => {
120
+ // Register with a timestamp that is ALREADY stale — only the alarm's
121
+ // heartbeat can rescue the entry from the next sweep. This is the
122
+ // live-side complement of the crashed-DO reap test: a ConnectionDO
123
+ // that keeps running must keep its slot alive.
124
+ await registerSlot('slot-beat-1', Date.now() - DEFAULT_CONNECTION_TTL_MS - 60_000);
125
+ const ws = await openSocket('slot-beat-1');
126
+
127
+ expect(await runDurableObjectAlarm(connStub('slot-beat-1'))).toBe(true);
128
+
129
+ const swept = await counter().sweep();
130
+ expect(swept.reaped).toBe(0);
131
+ expect(swept.live).toBe(1);
132
+ ws.close();
133
+ });
134
+
135
+ it('an idle-timeout alarm teardown releases the slot (alarm → tearDown)', async () => {
136
+ await registerSlot('slot-idle-1');
137
+ const ws = await openSocket('slot-idle-1');
138
+ expect(await counter().liveCount()).toBe(1);
139
+
140
+ // Backdate lastSeen past the PONG timeout so the alarm takes the
141
+ // idle-teardown branch.
142
+ await runInDurableObject(connStub('slot-idle-1'), async (instance: unknown) => {
143
+ const doi = instance as { __backdateLastSeen?: (ms: number) => Promise<void> };
144
+ await doi.__backdateLastSeen?.(48 * 60 * 60 * 1000);
145
+ });
146
+ expect(await runDurableObjectAlarm(connStub('slot-idle-1'))).toBe(true);
147
+
148
+ expect(await counter().liveCount()).toBe(0);
149
+ ws.close();
150
+ });
151
+
152
+ it('teardown of a never-registered connection is a harmless no-op', async () => {
153
+ // Direct-to-DO paths (integration harness, RPC callers) never
154
+ // registered a slot; teardown must not throw and the counter must
155
+ // stay untouched.
156
+ const ws = await openSocket('slot-unreg-1');
157
+ await runInDurableObject(connStub('slot-unreg-1'), async (instance: unknown) => {
158
+ const doi = instance as { __triggerClose?: () => Promise<void> };
159
+ await doi.__triggerClose?.();
160
+ });
161
+
162
+ expect(await counter().liveCount()).toBe(0);
163
+ ws.close();
164
+ });
165
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * CF adapter — per-connection inbound frame-rate window at the adapter
3
+ * boundary (`ConnectionDO.webSocketMessage`).
4
+ *
5
+ * The gate fires BEFORE the actor / storage write: a connection that
6
+ * delivers more than `adapter.maxFramesPerWindow` frames inside the
7
+ * `adapter.frameWindowSeconds` sliding window is closed with RFC 6455
8
+ * close code 1008 (Policy Violation) after receiving the canonical
9
+ * `ERROR :Closing link (flooding)` line.
10
+ *
11
+ * The default window (50 frames / 5 s) exercises the under/at/over
12
+ * thresholds; the sliding-window decay uses the `__setFrameWindowForTest`
13
+ * seam to install a tight window so the test does not sleep for real
14
+ * seconds.
15
+ */
16
+
17
+ import { env, runInDurableObject } from 'cloudflare:test';
18
+ import { describe, expect, it } from 'vitest';
19
+
20
+ declare global {
21
+ namespace Cloudflare {
22
+ interface Env {
23
+ CONNECTION_DO: DurableObjectNamespace;
24
+ REGISTRY_DO: DurableObjectNamespace;
25
+ CHANNEL_DO: DurableObjectNamespace;
26
+ }
27
+ }
28
+ }
29
+
30
+ /** Poll interval (ms) for the waitFor* helpers. */
31
+ const POLL_MS = 10;
32
+ /** Default timeout (ms) for the waitFor* helpers. */
33
+ const DEFAULT_TIMEOUT_MS = 4000;
34
+
35
+ class FrameWsClient {
36
+ readonly lines: string[] = [];
37
+ readonly ws: WebSocket;
38
+ closedCode: number | undefined;
39
+
40
+ constructor(ws: WebSocket) {
41
+ this.ws = ws;
42
+ ws.addEventListener('message', (ev: MessageEvent) => {
43
+ const data = typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data);
44
+ for (const line of data.split('\r\n')) {
45
+ if (line.length > 0) this.lines.push(line);
46
+ }
47
+ });
48
+ ws.addEventListener('close', (ev: CloseEvent) => {
49
+ this.closedCode = ev.code;
50
+ });
51
+ }
52
+
53
+ send(line: string): void {
54
+ this.ws.send(`${line}\r\n`);
55
+ }
56
+
57
+ countPongs(): number {
58
+ return this.lines.filter((l) => l.startsWith('PONG')).length;
59
+ }
60
+
61
+ async waitForPongs(n: number, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> {
62
+ const deadline = Date.now() + timeoutMs;
63
+ while (Date.now() < deadline) {
64
+ if (this.countPongs() >= n) return;
65
+ await new Promise((r) => setTimeout(r, POLL_MS));
66
+ }
67
+ throw new Error(`waitForPongs(${n}) timed out; lines: ${JSON.stringify(this.lines)}`);
68
+ }
69
+
70
+ async waitForClose(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<number> {
71
+ const deadline = Date.now() + timeoutMs;
72
+ while (Date.now() < deadline) {
73
+ if (this.closedCode !== undefined) return this.closedCode;
74
+ await new Promise((r) => setTimeout(r, POLL_MS));
75
+ }
76
+ throw new Error(`waitForClose timed out; lines: ${JSON.stringify(this.lines)}`);
77
+ }
78
+ }
79
+
80
+ async function openConnection(
81
+ connId: string,
82
+ ): Promise<{ client: FrameWsClient; stub: DurableObjectStub }> {
83
+ const stub = env.CONNECTION_DO.get(env.CONNECTION_DO.idFromName(connId));
84
+ const response = await stub.fetch('https://do/upgrade', { headers: { Upgrade: 'websocket' } });
85
+ const ws = response.webSocket;
86
+ if (ws === undefined || ws === null) {
87
+ throw new Error('ConnectionDO.fetch did not return a WebSocket');
88
+ }
89
+ ws.accept();
90
+ const client = await new Promise<FrameWsClient>((resolve) => {
91
+ setTimeout(() => resolve(new FrameWsClient(ws as WebSocket)), 0);
92
+ });
93
+ return { client, stub };
94
+ }
95
+
96
+ /** RPC shape of the `ConnectionDO` test seam used for the decay test. */
97
+ interface FrameWindowSeam {
98
+ __setFrameWindowForTest(cfg: { maxFrames: number; windowMs: number } | undefined): void;
99
+ }
100
+
101
+ describe('ConnectionDO — inbound frame-rate window (default 50 / 5 s)', () => {
102
+ it('admits frames while the window is under the ceiling and at the ceiling (no close)', async () => {
103
+ const { client } = await openConnection('conn-frame-under');
104
+ // 50 frames = exactly the default ceiling; every one is admitted and
105
+ // answered (each PING is one WebSocket frame through the actor).
106
+ for (let i = 1; i <= 50; i++) {
107
+ client.send(`PING :u${i}`);
108
+ }
109
+ await client.waitForPongs(50);
110
+ // Grace window: a close would have landed by now if the 50th frame
111
+ // had tripped the gate.
112
+ await new Promise((r) => setTimeout(r, 150));
113
+ expect(client.closedCode).toBeUndefined();
114
+ client.ws.close();
115
+ });
116
+
117
+ it('closes with RFC 6455 code 1008 after ERROR :Closing link (flooding) on frame 51', async () => {
118
+ const { client } = await openConnection('conn-frame-over');
119
+ for (let i = 1; i <= 50; i++) {
120
+ client.send(`PING :o${i}`);
121
+ }
122
+ await client.waitForPongs(50);
123
+
124
+ // Frame 51 exceeds the window: no actor run (no PONG), the ERROR
125
+ // line is delivered, then the socket is closed with 1008.
126
+ client.send('PING :o51');
127
+ const code = await client.waitForClose();
128
+ expect(code).toBe(1008);
129
+ expect(client.lines).toContain('ERROR :Closing link (flooding)');
130
+ expect(client.lines.some((l) => l.startsWith('PONG :o51'))).toBe(false);
131
+ });
132
+
133
+ it('admits a fresh burst after the window decays (sliding window)', async () => {
134
+ const { client, stub } = await openConnection('conn-frame-decay');
135
+ // Tight window (5 frames / 400 ms) via the test seam so the decay
136
+ // wait is fast instead of a real 5-second sleep.
137
+ await runInDurableObject(stub, (instance: unknown) => {
138
+ (instance as FrameWindowSeam).__setFrameWindowForTest({
139
+ maxFrames: 5,
140
+ windowMs: 400,
141
+ });
142
+ });
143
+
144
+ for (let i = 1; i <= 5; i++) {
145
+ client.send(`PING :d1-${i}`);
146
+ }
147
+ await client.waitForPongs(5);
148
+
149
+ // Wait past the window: the first burst decays out entirely.
150
+ await new Promise((r) => setTimeout(r, 500));
151
+ for (let i = 1; i <= 5; i++) {
152
+ client.send(`PING :d2-${i}`);
153
+ }
154
+ await client.waitForPongs(10);
155
+ await new Promise((r) => setTimeout(r, 150));
156
+ expect(client.closedCode).toBeUndefined();
157
+ expect(client.countPongs()).toBe(10);
158
+ client.ws.close();
159
+ });
160
+
161
+ it('still floods out mid-burst once the tight window is full (seam sanity)', async () => {
162
+ const { client, stub } = await openConnection('conn-frame-tight');
163
+ await runInDurableObject(stub, (instance: unknown) => {
164
+ (instance as FrameWindowSeam).__setFrameWindowForTest({
165
+ maxFrames: 2,
166
+ windowMs: 60_000,
167
+ });
168
+ });
169
+ client.send('PING :t1');
170
+ client.send('PING :t2');
171
+ client.send('PING :t3');
172
+ const code = await client.waitForClose();
173
+ expect(code).toBe(1008);
174
+ expect(client.lines).toContain('ERROR :Closing link (flooding)');
175
+ expect(client.countPongs()).toBe(2);
176
+ });
177
+ });
@@ -233,16 +233,78 @@ describe('NO_OP_SEND / NO_OP_DISCONNECT', () => {
233
233
  });
234
234
 
235
235
  // ============================================================================
236
- // extractMtlsSubject — request.cf.tlsClientAuthCertSubject capture
236
+ // extractMtlsSubject — request.cf mTLS identity capture
237
+ // (cf.tlsClientAuthCertSubject + cf.tlsClientAuth.certFingerprintSHA256)
237
238
  // ============================================================================
238
239
 
239
240
  describe('extractMtlsSubject', () => {
240
- it('returns the subject when cf.tlsClientAuthCertSubject is a string', () => {
241
+ it('returns the subject identity when cf.tlsClientAuthCertSubject is a string', () => {
241
242
  // The CF edge injects request.cf on every proxied request. We can't
242
243
  // mutate Request.cf in workerd tests (it's read-only), so the
243
244
  // upgrade-time capture is exercised via this pure seam.
244
245
  const fake = { cf: { tlsClientAuthCertSubject: 'CN=alice' } } as unknown as Request;
245
- expect(extractMtlsSubject(fake)).toBe('CN=alice');
246
+ expect(extractMtlsSubject(fake)).toEqual({
247
+ subject: 'CN=alice',
248
+ canonicalDn: 'cn=alice',
249
+ });
250
+ });
251
+
252
+ it('includes the fingerprint from cf.tlsClientAuth.certFingerprintSHA256 when present', () => {
253
+ const fake = {
254
+ cf: {
255
+ tlsClientAuthCertSubject: 'CN=alice',
256
+ tlsClientAuth: { certFingerprintSHA256: 'af363dc85bc942a8' },
257
+ },
258
+ } as unknown as Request;
259
+ expect(extractMtlsSubject(fake)).toEqual({
260
+ subject: 'CN=alice',
261
+ canonicalDn: 'cn=alice',
262
+ fingerprint: 'af363dc85bc942a8',
263
+ });
264
+ });
265
+
266
+ it('omits the fingerprint when certFingerprintSHA256 is the documented empty default', () => {
267
+ // Cloudflare documents certFingerprintSHA256 as `""` when the
268
+ // connection is not mTLS — treat that as "no fingerprint".
269
+ const fake = {
270
+ cf: {
271
+ tlsClientAuthCertSubject: 'CN=alice',
272
+ tlsClientAuth: { certFingerprintSHA256: '' },
273
+ },
274
+ } as unknown as Request;
275
+ expect(extractMtlsSubject(fake)?.fingerprint).toBeUndefined();
276
+ });
277
+
278
+ it('ignores a non-string certFingerprintSHA256', () => {
279
+ const fake = {
280
+ cf: {
281
+ tlsClientAuthCertSubject: 'CN=alice',
282
+ tlsClientAuth: { certFingerprintSHA256: 42 },
283
+ },
284
+ } as unknown as Request;
285
+ expect(extractMtlsSubject(fake)?.fingerprint).toBeUndefined();
286
+ });
287
+
288
+ it('falls back to cf.tlsClientAuth.certSubjectDN when the legacy subject field is absent', () => {
289
+ const fake = {
290
+ cf: {
291
+ tlsClientAuth: { certSubjectDN: 'CN=alice,O=Example', certFingerprintSHA256: 'aa11' },
292
+ },
293
+ } as unknown as Request;
294
+ const identity = extractMtlsSubject(fake);
295
+ expect(identity?.subject).toBe('CN=alice,O=Example');
296
+ expect(identity?.canonicalDn).toBe('cn=alice,o=Example');
297
+ expect(identity?.fingerprint).toBe('aa11');
298
+ });
299
+
300
+ it('prefers the legacy subject field over tlsClientAuth.certSubjectDN', () => {
301
+ const fake = {
302
+ cf: {
303
+ tlsClientAuthCertSubject: 'CN=legacy',
304
+ tlsClientAuth: { certSubjectDN: 'CN=newer' },
305
+ },
306
+ } as unknown as Request;
307
+ expect(extractMtlsSubject(fake)?.subject).toBe('CN=legacy');
246
308
  });
247
309
 
248
310
  it('returns undefined when cf is absent', () => {
@@ -250,8 +312,10 @@ describe('extractMtlsSubject', () => {
250
312
  expect(extractMtlsSubject(fake)).toBeUndefined();
251
313
  });
252
314
 
253
- it('returns undefined when cf is present but lacks tlsClientAuthCertSubject', () => {
254
- const fake = { cf: { country: 'US' } } as unknown as Request;
315
+ it('returns undefined when cf is present but carries no cert subject', () => {
316
+ const fake = {
317
+ cf: { country: 'US', tlsClientAuth: { certVerified: 'NONE' } },
318
+ } as unknown as Request;
255
319
  expect(extractMtlsSubject(fake)).toBeUndefined();
256
320
  });
257
321
 
@@ -259,4 +323,9 @@ describe('extractMtlsSubject', () => {
259
323
  const fake = { cf: { tlsClientAuthCertSubject: 42 } } as unknown as Request;
260
324
  expect(extractMtlsSubject(fake)).toBeUndefined();
261
325
  });
326
+
327
+ it('returns undefined when tlsClientAuth is not an object', () => {
328
+ const fake = { cf: { tlsClientAuth: 'nope' } } as unknown as Request;
329
+ expect(extractMtlsSubject(fake)).toBeUndefined();
330
+ });
262
331
  });
@@ -223,12 +223,15 @@ describe('ConnectionDO — IRCv3 WebSocket spec contract (end-to-end)', () => {
223
223
  client.messages.length = 0;
224
224
 
225
225
  // One WS message that LOOKS like two PING lines. In spec mode the frame
226
- // is a single IRC message, so exactly ONE PONG is emitted.
226
+ // is a single IRC message, so it is never split into two commands: the
227
+ // embedded CR makes the line unparseable (bare-CR rejection) and the
228
+ // actor answers with exactly ONE 421 — never a PONG, let alone two.
227
229
  client.sendRaw('PING one\r\nPING two');
228
- await client.waitForMessages((m) => countCommand([m], 'PONG') === 1, 1);
229
- // Give a grace window for a (incorrect) second PONG to arrive.
230
+ await client.waitForLine((l) => l.includes(' 421 '));
231
+ // Give a grace window for a (incorrect) PONG or second reply to arrive.
230
232
  await new Promise((r) => setTimeout(r, 100));
231
- expect(countCommand(client.messages, 'PONG')).toBe(1);
233
+ expect(countCommand(client.messages, 'PONG')).toBe(0);
234
+ expect(client.messages.filter((m) => m.includes(' 421 '))).toHaveLength(1);
232
235
  client.ws.close();
233
236
  });
234
237