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,181 @@
1
+ /**
2
+ * `CounterDO` — global live-connection admission counter.
3
+ *
4
+ * TDD outline:
5
+ * "Red on admit-under-cap / reject-at-cap; green; then TTL reap,
6
+ * heartbeat refresh, and the lazy reconcile on admit."
7
+ *
8
+ * Tests address the single global counter instance directly over RPC,
9
+ * driving every branch of admit / release / heartbeat / sweep with
10
+ * explicit timestamps so the TTL boundary is deterministic (no wall
11
+ * clock dependence). Each test gets an isolated storage namespace, so
12
+ * the shared `global` instance never bleeds state across tests.
13
+ */
14
+
15
+ import { env, reset } from 'cloudflare:test';
16
+ import { afterEach, describe, expect, it } from 'vitest';
17
+ import { COUNTER_INSTANCE_NAME, DEFAULT_CONNECTION_TTL_MS } from '../src/counter-do';
18
+ import type { CounterRpc } from '../src/env';
19
+
20
+ declare global {
21
+ namespace Cloudflare {
22
+ interface Env {
23
+ COUNTER_DO: DurableObjectNamespace;
24
+ }
25
+ }
26
+ }
27
+
28
+ // DO storage persists across tests in the same file; reset between each so
29
+ // admission entries from one test never leak into the next. Every test
30
+ // addresses the same `global` counter instance, so this is essential.
31
+ afterEach(async () => {
32
+ await reset();
33
+ });
34
+
35
+ /** Fixed epoch base for deterministic TTL arithmetic. */
36
+ const T0 = 1_700_000_000_000;
37
+ /** Alias for readability in the TTL-boundary tests. */
38
+ const TTL = DEFAULT_CONNECTION_TTL_MS;
39
+
40
+ /** RPC handle on the single global counter instance. */
41
+ function counter(): CounterRpc {
42
+ const stub = env.COUNTER_DO.get(env.COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME));
43
+ return stub as unknown as CounterRpc;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // admit
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe('CounterDO — admit', () => {
51
+ it('admits when under the cap and reports the live count', async () => {
52
+ const c = counter();
53
+ expect(await c.admit('conn-a', 2, T0)).toEqual({ admitted: true, live: 1 });
54
+ });
55
+
56
+ it('admits a second connection while still under the cap', async () => {
57
+ const c = counter();
58
+ await c.admit('conn-b1', 2, T0);
59
+ expect(await c.admit('conn-b2', 2, T0)).toEqual({ admitted: true, live: 2 });
60
+ });
61
+
62
+ it('rejects once the cap is reached', async () => {
63
+ const c = counter();
64
+ await c.admit('conn-c1', 1, T0);
65
+ expect(await c.admit('conn-c2', 1, T0)).toEqual({ admitted: false, live: 1 });
66
+ });
67
+
68
+ it('a rejected admission registers no entry', async () => {
69
+ const c = counter();
70
+ await c.admit('conn-d1', 1, T0);
71
+ await c.admit('conn-d2', 1, T0);
72
+ expect(await c.liveCount()).toBe(1);
73
+ });
74
+
75
+ it('re-admits after the capped connection is released', async () => {
76
+ const c = counter();
77
+ await c.admit('conn-e1', 1, T0);
78
+ await c.release('conn-e1');
79
+ expect(await c.admit('conn-e2', 1, T0)).toEqual({ admitted: true, live: 1 });
80
+ });
81
+
82
+ it('exactly one of two concurrent admissions wins at cap 1', async () => {
83
+ // Two worker-edge fetches race on the single counter instance. The
84
+ // DO's serialized event loop linearises the admits; exactly one
85
+ // reservation survives. Mirrors the RegistryDO concurrency suite.
86
+ const c = counter();
87
+ const [r1, r2] = await Promise.all([c.admit('race-1', 1, T0), c.admit('race-2', 1, T0)]);
88
+ const results = [r1, r2];
89
+ expect(results.filter((r) => r.admitted).length).toBe(1);
90
+ expect(results.filter((r) => !r.admitted).length).toBe(1);
91
+ expect(await c.liveCount()).toBe(1);
92
+ });
93
+ });
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // release
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe('CounterDO — release', () => {
100
+ it('removes the entry so the slot frees', async () => {
101
+ const c = counter();
102
+ await c.admit('rel-a', 5, T0);
103
+ await c.release('rel-a');
104
+ expect(await c.liveCount()).toBe(0);
105
+ });
106
+
107
+ it('is idempotent for an unknown connection id', async () => {
108
+ const c = counter();
109
+ await c.release('never-registered');
110
+ expect(await c.liveCount()).toBe(0);
111
+ });
112
+ });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // TTL reap — housekeeping for DO instances that crashed without a close
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe('CounterDO — TTL reap (housekeeping)', () => {
119
+ it('sweep reaps entries older than the TTL and keeps fresh ones', async () => {
120
+ const c = counter();
121
+ await c.admit('stale', 5, T0);
122
+ await c.admit('fresh', 5, T0 + 60_000);
123
+ const result = await c.sweep(T0 + TTL + 60_000);
124
+ expect(result.reaped).toBe(1);
125
+ expect(result.live).toBe(1);
126
+ expect(await c.liveCount()).toBe(1);
127
+ });
128
+
129
+ it('an entry aged exactly the TTL is still live (only strictly-older reaps)', async () => {
130
+ const c = counter();
131
+ await c.admit('boundary', 5, T0);
132
+ expect(await c.sweep(T0 + TTL)).toEqual({ reaped: 0, live: 1 });
133
+ });
134
+
135
+ it('admit lazily reaps stale entries first (a crashed DO frees its slot)', async () => {
136
+ // The production failure mode: a ConnectionDO isolate is dropped
137
+ // without ever delivering a close event, so `release` never fires and
138
+ // the entry goes stale. The next admission must sweep it out instead
139
+ // of rejecting the newcomer.
140
+ const c = counter();
141
+ await c.admit('crashed', 1, T0);
142
+ expect(await c.admit('newcomer', 1, T0 + TTL + 1)).toEqual({ admitted: true, live: 1 });
143
+ expect(await c.liveCount()).toBe(1);
144
+ });
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // heartbeat — keeps live connections safe from the TTL reap
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe('CounterDO — heartbeat', () => {
152
+ it('refreshes the entry so a live connection survives the TTL reap', async () => {
153
+ const c = counter();
154
+ await c.admit('beat', 5, T0);
155
+ // The ConnectionDO's PING alarm refreshes the entry well inside the
156
+ // TTL window; by T0+TTL+60s the lastSeen has moved past the original
157
+ // admission stamp, so the sweep at T0+TTL+60s+1ms must keep it.
158
+ await c.heartbeat('beat', T0 + TTL + 60_000);
159
+ expect(await c.sweep(T0 + TTL + 60_001)).toEqual({ reaped: 0, live: 1 });
160
+ });
161
+
162
+ it('does not resurrect a released entry', async () => {
163
+ // A late heartbeat (e.g. racing the close handler) must not re-create
164
+ // a slot that was already released — that would overcount.
165
+ const c = counter();
166
+ await c.admit('gone', 5, T0);
167
+ await c.release('gone');
168
+ await c.heartbeat('gone', T0 + 1000);
169
+ expect(await c.liveCount()).toBe(0);
170
+ });
171
+ });
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // liveCount
175
+ // ---------------------------------------------------------------------------
176
+
177
+ describe('CounterDO — liveCount', () => {
178
+ it('reports zero for a fresh counter', async () => {
179
+ expect(await counter().liveCount()).toBe(0);
180
+ });
181
+ });
@@ -9,12 +9,17 @@
9
9
  */
10
10
 
11
11
  import { env } from 'cloudflare:test';
12
- import { FakeClock, type ServicesSnapshot } from '@serverless-ircd/irc-core';
12
+ import {
13
+ FakeClock,
14
+ type ServicesSnapshot,
15
+ certIdentityFromSubject,
16
+ } from '@serverless-ircd/irc-core';
13
17
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14
18
  import {
15
19
  CREATE_SERVICES_TABLES_SQL,
16
20
  D1ServicesStore,
17
21
  loadD1ServicesStore,
22
+ safeSelect,
18
23
  } from '../src/d1-services-store';
19
24
 
20
25
  declare global {
@@ -162,6 +167,119 @@ describe('D1ServicesStore — schema migration (password → algorithm/salt/hash
162
167
  });
163
168
  });
164
169
 
170
+ describe('D1ServicesStore — schema migration (cert_subjects era → identify-throttle columns)', () => {
171
+ /** Pre-throttle schema: every current column except the two new ones. */
172
+ const CERT_ERA_NICKSERV_DDL =
173
+ "CREATE TABLE nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, cert_subjects TEXT NOT NULL DEFAULT '[]', algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
174
+
175
+ afterEach(async () => {
176
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
177
+ });
178
+
179
+ it('adds the identify-throttle columns on load and keeps existing rows (defaults)', async () => {
180
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
181
+ await env.ACCOUNTS_DB.exec(CERT_ERA_NICKSERV_DDL);
182
+ await env.ACCOUNTS_DB.prepare(
183
+ 'INSERT INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
184
+ )
185
+ .bind('alice', 'alice', 'alice', 'a@x', 1, 'none', '[]', 'scrypt', 'c2FsdA==', 'aGFzaA==')
186
+ .run();
187
+
188
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
189
+ expect(store).toBeDefined();
190
+
191
+ // The additive columns now exist.
192
+ const info = await env.ACCOUNTS_DB.prepare('PRAGMA table_info(nickserv_accounts)').all<{
193
+ name: string;
194
+ }>();
195
+ const cols = info.results.map((r) => r.name);
196
+ expect(cols).toContain('identify_failures');
197
+ expect(cols).toContain('pending_identify_notice');
198
+
199
+ // The pre-existing row survived and loads with empty throttle defaults.
200
+ expect(store?.isRegisteredNick('alice')).toBe(true);
201
+ expect(store?.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
202
+ expect(store?.clearIdentifyFailures('alice')).toBe(0);
203
+ });
204
+
205
+ it('is idempotent — a second load does not re-add the columns or drop rows', async () => {
206
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
207
+ await env.ACCOUNTS_DB.exec(CERT_ERA_NICKSERV_DDL);
208
+ const first = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
209
+ first?.registerNick('carol', 'pw', 'c@x');
210
+ await first?.flush();
211
+
212
+ const second = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 1));
213
+ expect(second?.isRegisteredNick('carol')).toBe(true);
214
+ });
215
+
216
+ it('adds the cert_subjects column too when migrating from the algorithm era', async () => {
217
+ // Schema after the credential unification but before cert_subjects:
218
+ // exercises the cert_subjects ALTER arm on its own.
219
+ await env.ACCOUNTS_DB.exec('DROP TABLE IF EXISTS nickserv_accounts');
220
+ await env.ACCOUNTS_DB.exec(
221
+ 'CREATE TABLE nickserv_accounts (nick_key TEXT PRIMARY KEY, nick TEXT NOT NULL, account TEXT NOT NULL, email TEXT NOT NULL, created_at INTEGER NOT NULL, enforce TEXT NOT NULL, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)',
222
+ );
223
+
224
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
225
+ const info = await env.ACCOUNTS_DB.prepare('PRAGMA table_info(nickserv_accounts)').all<{
226
+ name: string;
227
+ }>();
228
+ const cols = info.results.map((r) => r.name);
229
+ expect(cols).toContain('cert_subjects');
230
+ expect(cols).toContain('identify_failures');
231
+ expect(cols).toContain('pending_identify_notice');
232
+ expect(store).toBeDefined();
233
+ });
234
+
235
+ it('loads rows with malformed JSON columns as empty arrays (defensive)', async () => {
236
+ await resetDb();
237
+ const insert = async (
238
+ nick: string,
239
+ certSubjects: string,
240
+ identifyFailures: string,
241
+ ): Promise<void> => {
242
+ await env.ACCOUNTS_DB.prepare(
243
+ 'INSERT INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, identify_failures, pending_identify_notice, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
244
+ )
245
+ .bind(
246
+ nick,
247
+ nick,
248
+ nick,
249
+ 'x@x',
250
+ 1,
251
+ 'none',
252
+ certSubjects,
253
+ identifyFailures,
254
+ 0,
255
+ 'scrypt',
256
+ 'c2FsdA==',
257
+ 'aGFzaA==',
258
+ )
259
+ .run();
260
+ };
261
+ // Every degenerate column shape: malformed JSON, empty string,
262
+ // non-array JSON, and arrays with ill-typed entries.
263
+ await insert('a-malformed', 'not json', 'also not json');
264
+ await insert('b-empty', '', '');
265
+ await insert('c-nonarray', '{"a":1}', '{"a":1}');
266
+ await insert('d-illtyped', '["ok",7]', '[1,"x",2]');
267
+
268
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
269
+ for (const nick of ['a-malformed', 'b-empty', 'c-nonarray']) {
270
+ expect(store?.isRegisteredNick(nick)).toBe(true);
271
+ expect(store?.getNick(nick)?.certSubjects).toEqual([]);
272
+ expect(store?.isNickIdentifyFrozen(nick, { maxFailures: 1, windowMs: 300_000 })).toBe(false);
273
+ }
274
+ // Ill-typed entries are filtered; well-typed ones survive. The window
275
+ // reaches back to the epoch so the literal timestamps count.
276
+ expect(store?.getNick('d-illtyped')?.certSubjects).toEqual(['ok']);
277
+ expect(store?.isNickIdentifyFrozen('d-illtyped', { maxFailures: 2, windowMs: NOW + 1 })).toBe(
278
+ true,
279
+ );
280
+ });
281
+ });
282
+
165
283
  describe('D1ServicesStore — NickServ round-trip across reload', () => {
166
284
  beforeEach(async () => {
167
285
  await resetDb();
@@ -194,6 +312,43 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
194
312
  expect(reloaded?.getNickEnforce('alice')).toBe('kill');
195
313
  });
196
314
 
315
+ it('persists SET PASSWORD and round-trips the new credential', async () => {
316
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
317
+ store?.registerNick('alice', 'hunter2', 'a@x');
318
+ store?.setNickPassword('alice', 'newpassword');
319
+ await store?.flush();
320
+
321
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
322
+ // The new password verifies against the reloaded store; the old one no longer does.
323
+ expect(reloaded?.verifyNick('alice', 'newpassword')).toEqual({ ok: true, account: 'alice' });
324
+ expect(reloaded?.verifyNick('alice', 'hunter2').ok).toBe(false);
325
+ });
326
+
327
+ it('persists the identify-failure throttle and round-trips the freeze', async () => {
328
+ const clock = new FakeClock(NOW);
329
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, clock);
330
+ store?.registerNick('alice', 'hunter2', 'a@x');
331
+ clock.advance(10);
332
+ store?.recordIdentifyFailure('alice');
333
+ clock.advance(10);
334
+ store?.recordIdentifyFailure('alice');
335
+ await store?.flush();
336
+
337
+ // Simulate a fresh connection (fresh store) after a deploy: the reload
338
+ // still sees the recorded failures and the queued owner-notice count.
339
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 20));
340
+ expect(reloaded?.isNickIdentifyFrozen('alice', { maxFailures: 2, windowMs: 300_000 })).toBe(
341
+ true,
342
+ );
343
+ expect(reloaded?.clearIdentifyFailures('alice')).toBe(2);
344
+
345
+ // The cleared state round-trips too.
346
+ await reloaded?.flush();
347
+ const after = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW + 20));
348
+ expect(after?.isNickIdentifyFrozen('alice', { maxFailures: 1, windowMs: 300_000 })).toBe(false);
349
+ expect(after?.clearIdentifyFailures('alice')).toBe(0);
350
+ });
351
+
197
352
  it('drops a NickServ registration after flush + reload', async () => {
198
353
  const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
199
354
  store?.registerNick('alice', 'pw', 'a@x');
@@ -206,6 +361,55 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
206
361
  const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
207
362
  expect(reloaded?.isRegisteredNick('alice')).toBe(false);
208
363
  });
364
+
365
+ it('round-trips a bound cert fingerprint and verifies EXTERNAL after reload', async () => {
366
+ // A cert binding attached before a deploy must still authenticate a
367
+ // SASL EXTERNAL client after the store rehydrates from D1 — the
368
+ // production path for certificate-backed login.
369
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
370
+ store?.registerNick('alice', 'hunter2', 'a@x');
371
+ store?.addCertFingerprint('alice', 'AA112233445566778899AABBCCDDEEFF');
372
+ await store?.flush();
373
+
374
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
375
+ // The fingerprint is normalised (lowercase, colon-free) on the round-trip.
376
+ expect(reloaded?.getNick('alice')?.certSubjects).toEqual([
377
+ 'fp:aa112233445566778899aabbccddeeff',
378
+ ]);
379
+ expect(
380
+ reloaded?.verifyCertFP(
381
+ certIdentityFromSubject('CN=alice', 'aa112233445566778899aabbccddeeff'),
382
+ ),
383
+ ).toEqual({ ok: true, account: 'alice' });
384
+ });
385
+
386
+ it('round-trips a bound subject DN and verifies EXTERNAL via the canonical DN arm', async () => {
387
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
388
+ store?.registerNick('alice', 'hunter2', 'a@x');
389
+ store?.addCertFP('alice', 'CN=alice,O=Example Inc');
390
+ await store?.flush();
391
+
392
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
393
+ // Platform-surfaced DN in a different RDN order still matches (canonical compare).
394
+ expect(reloaded?.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'))).toEqual({
395
+ ok: true,
396
+ account: 'alice',
397
+ });
398
+ });
399
+
400
+ it('keeps EXTERNAL verify failing for an unbound cert after reload', async () => {
401
+ const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
402
+ store?.registerNick('alice', 'hunter2', 'a@x');
403
+ store?.addCertFingerprint('alice', 'aa112233445566778899aabbccddeeff');
404
+ await store?.flush();
405
+
406
+ const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
407
+ expect(
408
+ reloaded?.verifyCertFP(
409
+ certIdentityFromSubject('CN=alice', 'ffffffffffffffffffffffffffffffff'),
410
+ )?.ok,
411
+ ).toBe(false);
412
+ });
209
413
  });
210
414
 
211
415
  describe('D1ServicesStore — ChanServ round-trip across reload', () => {
@@ -524,14 +728,18 @@ describe('D1ServicesStore — tolerance', () => {
524
728
  expect(snap.jupes).toEqual([]);
525
729
  });
526
730
 
527
- it('skips rows with NULL fields in the all-TEXT tables (vhosts / read_markers)', async () => {
528
- // Recreate the two all-TEXT tables without NOT NULL so a NULL field can
731
+ it('skips rows with NULL fields in the all-TEXT tables (vhosts / pending_vhosts / read_markers)', async () => {
732
+ // Recreate the all-TEXT tables without NOT NULL so a NULL field can
529
733
  // survive — exercises the typeof-string defensive skip for the loaders
530
734
  // whose INTEGER-corruption path is unreachable under SQLite affinity.
531
735
  await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_vhosts');
532
736
  await env.ACCOUNTS_DB.exec(
533
737
  'CREATE TABLE hostserv_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
534
738
  );
739
+ await env.ACCOUNTS_DB.exec('DROP TABLE hostserv_pending_vhosts');
740
+ await env.ACCOUNTS_DB.exec(
741
+ 'CREATE TABLE hostserv_pending_vhosts (account_key TEXT PRIMARY KEY, account TEXT, vhost TEXT)',
742
+ );
535
743
  await env.ACCOUNTS_DB.exec('DROP TABLE read_markers');
536
744
  await env.ACCOUNTS_DB.exec(
537
745
  'CREATE TABLE read_markers (account_key TEXT NOT NULL, channel_key TEXT NOT NULL, account TEXT, channel TEXT, msgid TEXT)',
@@ -541,6 +749,11 @@ describe('D1ServicesStore — tolerance', () => {
541
749
  )
542
750
  .bind('bad#v', null, 'cloak')
543
751
  .run();
752
+ await env.ACCOUNTS_DB.prepare(
753
+ 'INSERT INTO hostserv_pending_vhosts (account_key, account, vhost) VALUES (?, ?, ?)',
754
+ )
755
+ .bind('bad#pv', 'someone', null)
756
+ .run();
544
757
  await env.ACCOUNTS_DB.prepare(
545
758
  'INSERT INTO read_markers (account_key, channel_key, account, channel, msgid) VALUES (?, ?, ?, ?, ?)',
546
759
  )
@@ -552,6 +765,7 @@ describe('D1ServicesStore — tolerance', () => {
552
765
  if (!store) throw new Error('services store failed to load');
553
766
  const snap = store.snapshot();
554
767
  expect(snap.vhosts).toEqual([]);
768
+ expect(snap.pendingVhosts).toEqual([]);
555
769
  expect(snap.readMarkers).toEqual([]);
556
770
  });
557
771
 
@@ -566,6 +780,34 @@ describe('D1ServicesStore — tolerance', () => {
566
780
  });
567
781
  });
568
782
 
783
+ // ---------------------------------------------------------------------------
784
+ // safeSelect — defensive catch block (real D1 bindings don't throw on
785
+ // existing-table queries; the catch arm is unreachable through integration).
786
+ // ---------------------------------------------------------------------------
787
+
788
+ describe('safeSelect — defensive catch block', () => {
789
+ it('returns [] and logs when the underlying D1 query rejects', async () => {
790
+ // A misbehaving binding (transient unavailability, schema drift) would
791
+ // otherwise brick NickServ for the whole connection (the snapshot is
792
+ // cached for life). The catch arm logs to wrangler tail and degrades
793
+ // to an empty result.
794
+ const stub = {
795
+ prepare(): { all(): Promise<never> } {
796
+ return {
797
+ all: async () => {
798
+ throw new Error('simulated D1 outage');
799
+ },
800
+ };
801
+ },
802
+ };
803
+ const result = await safeSelect<{ x: unknown }>(
804
+ stub as unknown as Parameters<typeof safeSelect>[0],
805
+ 'SELECT * FROM nicks',
806
+ );
807
+ expect(result).toEqual([]);
808
+ });
809
+ });
810
+
569
811
  function emptySnapshot(): ServicesSnapshot {
570
812
  return {
571
813
  nicks: [],
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `RateLimitDO` — per-IP upgrade rate limiter.
3
+ *
4
+ * TDD outline:
5
+ * "Red on allow-under-budget / reject-over-budget / window-decay;
6
+ * green; then per-IP isolation and the concurrency linearisation."
7
+ *
8
+ * Tests address the single global limiter instance directly over RPC,
9
+ * driving every branch of `check` with explicit timestamps so the
10
+ * sliding-window boundary is deterministic (no wall clock dependence).
11
+ * Mirrors the CounterDO suite; each test gets isolated storage.
12
+ */
13
+
14
+ import { env, reset } from 'cloudflare:test';
15
+ import { afterEach, describe, expect, it } from 'vitest';
16
+ import type { RateLimitRpc } from '../src/env';
17
+ import { RATE_LIMIT_INSTANCE_NAME } from '../src/rate-limit-do';
18
+
19
+ declare global {
20
+ namespace Cloudflare {
21
+ interface Env {
22
+ RATE_LIMIT_DO: DurableObjectNamespace;
23
+ }
24
+ }
25
+ }
26
+
27
+ // DO storage persists across tests in the same file; reset between each so
28
+ // rate entries from one test never leak into the next. Every test addresses
29
+ // the same `global` limiter instance, so this is essential.
30
+ afterEach(async () => {
31
+ await reset();
32
+ });
33
+
34
+ /** Fixed epoch base for deterministic window arithmetic. */
35
+ const T0 = 1_700_000_000_000;
36
+ /** Window used by every test (1 minute, the schema default order). */
37
+ const WINDOW = 60_000;
38
+
39
+ /** RPC handle on the single global limiter instance. */
40
+ function limiter(): RateLimitRpc {
41
+ const stub = env.RATE_LIMIT_DO.get(env.RATE_LIMIT_DO.idFromName(RATE_LIMIT_INSTANCE_NAME));
42
+ return stub as unknown as RateLimitRpc;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // check — allow / reject
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe('RateLimitDO — check', () => {
50
+ it('allows the first N upgrades from one IP and reports the count', async () => {
51
+ const rl = limiter();
52
+ expect(await rl.check('203.0.113.1', 2, WINDOW, T0)).toEqual({ allowed: true, count: 1 });
53
+ expect(await rl.check('203.0.113.1', 2, WINDOW, T0 + 1000)).toEqual({
54
+ allowed: true,
55
+ count: 2,
56
+ });
57
+ });
58
+
59
+ it('rejects the over-budget upgrade from the same IP', async () => {
60
+ const rl = limiter();
61
+ await rl.check('203.0.113.2', 1, WINDOW, T0);
62
+ expect(await rl.check('203.0.113.2', 1, WINDOW, T0 + 1000)).toEqual({
63
+ allowed: false,
64
+ count: 1,
65
+ });
66
+ });
67
+
68
+ it('a rejected check does not append a timestamp (count stays at the cap)', async () => {
69
+ const rl = limiter();
70
+ await rl.check('203.0.113.3', 1, WINDOW, T0);
71
+ await rl.check('203.0.113.3', 1, WINDOW, T0 + 1000);
72
+ // Still rejected at the original count: the rejection itself must not
73
+ // consume budget (otherwise a blocked IP could never recover).
74
+ expect(await rl.check('203.0.113.3', 1, WINDOW, T0 + 2000)).toEqual({
75
+ allowed: false,
76
+ count: 1,
77
+ });
78
+ });
79
+
80
+ it('keys budgets per IP (one saturated IP does not block another)', async () => {
81
+ const rl = limiter();
82
+ await rl.check('198.51.100.1', 1, WINDOW, T0);
83
+ expect(await rl.check('198.51.100.1', 1, WINDOW, T0 + 1000)).toEqual({
84
+ allowed: false,
85
+ count: 1,
86
+ });
87
+ expect(await rl.check('198.51.100.2', 1, WINDOW, T0 + 1000)).toEqual({
88
+ allowed: true,
89
+ count: 1,
90
+ });
91
+ });
92
+ });
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // sliding window — decay after the window
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe('RateLimitDO — window reset', () => {
99
+ it('an entry aged past the window stops counting (budget recovers)', async () => {
100
+ const rl = limiter();
101
+ await rl.check('203.0.113.4', 1, WINDOW, T0);
102
+ // At T0 + WINDOW + 1 the T0 admission is strictly older than the
103
+ // cutoff (T0 + 1) → decayed out of the window.
104
+ expect(await rl.check('203.0.113.4', 1, WINDOW, T0 + WINDOW + 1)).toEqual({
105
+ allowed: true,
106
+ count: 1,
107
+ });
108
+ });
109
+
110
+ it('an entry exactly windowMs old still counts (boundary is inclusive)', async () => {
111
+ // Mirrors irc-core `recentAdmissions`: entries with t >= cutoff count,
112
+ // so an admission exactly one window old is still inside the window.
113
+ const rl = limiter();
114
+ await rl.check('203.0.113.5', 1, WINDOW, T0);
115
+ expect(await rl.check('203.0.113.5', 1, WINDOW, T0 + WINDOW)).toEqual({
116
+ allowed: false,
117
+ count: 1,
118
+ });
119
+ });
120
+
121
+ it('prunes stale entries so a saturated IP recovers exactly one slot per decayed entry', async () => {
122
+ const rl = limiter();
123
+ await rl.check('203.0.113.6', 2, WINDOW, T0);
124
+ await rl.check('203.0.113.6', 2, WINDOW, T0 + 30_000);
125
+ // Cutoff at T0+45_000 is T0-15_000 → both entries still count.
126
+ expect(await rl.check('203.0.113.6', 2, WINDOW, T0 + 45_000)).toEqual({
127
+ allowed: false,
128
+ count: 2,
129
+ });
130
+ // At T0+WINDOW+1 the cutoff is T0+1: the T0 entry decayed, the
131
+ // T0+30_000 entry remains → one slot free → allowed (and this check
132
+ // itself consumes it).
133
+ expect(await rl.check('203.0.113.6', 2, WINDOW, T0 + WINDOW + 1)).toEqual({
134
+ allowed: true,
135
+ count: 2,
136
+ });
137
+ // Window members are now {T0+30_000, T0+WINDOW+1} → exhausted again.
138
+ expect(await rl.check('203.0.113.6', 2, WINDOW, T0 + WINDOW + 2)).toEqual({
139
+ allowed: false,
140
+ count: 2,
141
+ });
142
+ });
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // concurrency — the DO's serialized event loop linearises checks
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe('RateLimitDO — concurrency', () => {
150
+ it('exactly one of two concurrent checks wins at budget 1', async () => {
151
+ const rl = limiter();
152
+ const [r1, r2] = await Promise.all([
153
+ rl.check('203.0.113.7', 1, WINDOW, T0),
154
+ rl.check('203.0.113.7', 1, WINDOW, T0),
155
+ ]);
156
+ const results = [r1, r2];
157
+ expect(results.filter((r) => r.allowed).length).toBe(1);
158
+ expect(results.filter((r) => !r.allowed).length).toBe(1);
159
+ });
160
+ });
@@ -306,4 +306,29 @@ describe('deserialize — migrate integration', () => {
306
306
  expect(state.userModes.wallops).toBe(true);
307
307
  expect(state.caps).toEqual(new Set(['server-time']));
308
308
  });
309
+
310
+ it('coalesces an explicit `secure: null` to false via the ?? fallback', () => {
311
+ // `migrate` only backfills `secure` when the key is absent (=== undefined);
312
+ // an explicit null survives and the `?? false` arm in `deserialize` must
313
+ // catch it. Mirrors a hand-edited storage row where someone wrote null.
314
+ const rec = {
315
+ version: 1,
316
+ id: 'c',
317
+ registration: 'registered' as const,
318
+ capNegotiating: false,
319
+ secure: null,
320
+ caps: [],
321
+ joinedChannels: [],
322
+ userModes: {
323
+ invisible: false,
324
+ oper: false,
325
+ wallops: false,
326
+ serverNotices: false,
327
+ tls: false,
328
+ },
329
+ lastSeen: 0,
330
+ connectedSince: 0,
331
+ };
332
+ expect(deserialize(rec).secure).toBe(false);
333
+ });
309
334
  });