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,142 @@
1
+ /**
2
+ * `CounterDO` — global live-connection admission counter.
3
+ *
4
+ * A single Durable Object instance (fixed name {@link COUNTER_INSTANCE_NAME})
5
+ * maintains one admission entry per live WebSocket connection, keyed by the
6
+ * owning ConnectionDO's hex id. The worker edge reserves a slot via
7
+ * `admit` before forwarding an upgrade (enforcing `maxClients`); the
8
+ * ConnectionDO releases the slot via `release` on every teardown path
9
+ * (clean close, transport error, socketless teardown) and refreshes it
10
+ * via `heartbeat` from its PING alarm.
11
+ *
12
+ * Storage layout:
13
+ * - One key per connection: `conn:<connId>` → lastSeen epoch ms
14
+ *
15
+ * Housekeeping (DO-instance eviction): teardown relies on the close /
16
+ * error events reaching the ConnectionDO. When an isolate is dropped
17
+ * without delivering either (platform failure mode), `release` never
18
+ * fires and the entry would go stale, permanently consuming a slot.
19
+ * Instead of a periodic alarm sweep, every `admit` call first reaps
20
+ * entries whose `lastSeen` is older than the TTL — a lazy reconcile
21
+ * that keeps admission O(live connections) with no extra infrastructure.
22
+ * Live connections are safe from the reap because the ConnectionDO's
23
+ * PING alarm (60s cadence) heartbeats their entry ~5x within the
24
+ * 5-minute TTL window; only DOs that stopped heartbeating (crashed or
25
+ * evicted) let their entries age out.
26
+ *
27
+ * Atomicity: the DO's serialized event loop linearises concurrent
28
+ * `admit` calls, so the check-then-register sequence is race-free —
29
+ * exactly one of N concurrent upgrades can take the last slot. This
30
+ * mirrors the AWS adapter's atomic `__meta:connectionCount__` counter
31
+ * semantics without needing a conditional-write primitive.
32
+ */
33
+
34
+ import { DurableObject } from 'cloudflare:workers';
35
+ import type { CounterAdmissionResult, CounterRpc } from './env.js';
36
+
37
+ /** Storage key prefix for per-connection admission entries. */
38
+ const CONN_KEY_PREFIX = 'conn:';
39
+
40
+ /**
41
+ * Fixed instance name for the single global counter. The worker edge and
42
+ * every ConnectionDO address the same instance via
43
+ * `COUNTER_DO.idFromName(COUNTER_INSTANCE_NAME)`.
44
+ */
45
+ export const COUNTER_INSTANCE_NAME = 'global';
46
+
47
+ /**
48
+ * Default TTL (ms) after which an admission entry is considered stale and
49
+ * reaped. Comfortably above the ConnectionDO PING alarm cadence (60s) so
50
+ * a live connection refreshes its entry several times per window; short
51
+ * enough that a crashed DO's slot self-heals within minutes.
52
+ */
53
+ export const DEFAULT_CONNECTION_TTL_MS = 300_000;
54
+
55
+ /** Returns the storage key for `connId`. */
56
+ function connKey(connId: string): string {
57
+ return `${CONN_KEY_PREFIX}${connId}`;
58
+ }
59
+
60
+ /**
61
+ * Global live-connection counter authority.
62
+ *
63
+ * The class is exported as a binding target in `wrangler.toml`; the
64
+ * Workers runtime instantiates it with `(ctx, env)`. Implements the
65
+ * {@link CounterRpc} contract the worker edge and ConnectionDO call.
66
+ */
67
+ export class CounterDO extends DurableObject implements CounterRpc {
68
+ /**
69
+ * Admission check + slot reservation in one serialized step.
70
+ *
71
+ * Stale entries (aged past the TTL — their ConnectionDO stopped
72
+ * heartbeating) are reaped first, so a crashed DO frees capacity for
73
+ * the newcomer instead of wedgeing the cap shut. When the surviving
74
+ * live count is at or above `maxClients` the caller is rejected and
75
+ * nothing is registered; otherwise the connection's entry is written
76
+ * with `now` as its initial `lastSeen`.
77
+ */
78
+ async admit(
79
+ connId: string,
80
+ maxClients: number,
81
+ now: number = Date.now(),
82
+ ttlMs: number = DEFAULT_CONNECTION_TTL_MS,
83
+ ): Promise<CounterAdmissionResult> {
84
+ const { live } = await this.sweep(now, ttlMs);
85
+ if (live >= maxClients) {
86
+ return { admitted: false, live };
87
+ }
88
+ await this.ctx.storage.put(connKey(connId), now);
89
+ return { admitted: true, live: live + 1 };
90
+ }
91
+
92
+ /**
93
+ * Releases a connection's slot. Idempotent: deleting an absent key is
94
+ * a no-op, so teardown of a connection that never registered (direct
95
+ * DO access outside the worker edge) is harmless.
96
+ */
97
+ async release(connId: string): Promise<void> {
98
+ await this.ctx.storage.delete(connKey(connId));
99
+ }
100
+
101
+ /**
102
+ * Refreshes a live entry's `lastSeen` so the TTL reap spares it.
103
+ * No-op for unknown ids — a late heartbeat racing a release must not
104
+ * resurrect the slot (that would overcount).
105
+ */
106
+ async heartbeat(connId: string, now: number = Date.now()): Promise<void> {
107
+ const key = connKey(connId);
108
+ const existing = await this.ctx.storage.get<number>(key);
109
+ if (existing === undefined) return;
110
+ await this.ctx.storage.put(key, now);
111
+ }
112
+
113
+ /**
114
+ * Reconcile sweep: deletes every entry strictly older than the TTL and
115
+ * reports how many were reaped vs. how many remain live. Invoked
116
+ * lazily by {@link admit}; exposed as an RPC so operators (and tests)
117
+ * can reconcile explicitly.
118
+ */
119
+ async sweep(
120
+ now: number = Date.now(),
121
+ ttlMs: number = DEFAULT_CONNECTION_TTL_MS,
122
+ ): Promise<{ reaped: number; live: number }> {
123
+ const entries = await this.ctx.storage.list({ prefix: CONN_KEY_PREFIX });
124
+ let reaped = 0;
125
+ let live = 0;
126
+ for (const [key, seenAt] of entries) {
127
+ if (now - (seenAt as number) > ttlMs) {
128
+ await this.ctx.storage.delete(key);
129
+ reaped++;
130
+ } else {
131
+ live++;
132
+ }
133
+ }
134
+ return { reaped, live };
135
+ }
136
+
137
+ /** Returns the raw entry count (no reap). Observability / tests. */
138
+ async liveCount(): Promise<number> {
139
+ const entries = await this.ctx.storage.list({ prefix: CONN_KEY_PREFIX });
140
+ return entries.size;
141
+ }
142
+ }
@@ -78,7 +78,7 @@ interface D1QueryClient {
78
78
  * the list on a warm deploy is a no-op.
79
79
  */
80
80
  export const CREATE_NICKSERV_ACCOUNTS_SQL =
81
- 'CREATE TABLE IF NOT EXISTS 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)';
81
+ "CREATE TABLE IF NOT EXISTS 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 '[]', identify_failures TEXT NOT NULL DEFAULT '[]', pending_identify_notice INTEGER NOT NULL DEFAULT 0, algorithm TEXT NOT NULL, salt TEXT NOT NULL, hash TEXT NOT NULL)";
82
82
 
83
83
  export const CREATE_SERVICES_TABLES_SQL: readonly string[] = [
84
84
  CREATE_NICKSERV_ACCOUNTS_SQL,
@@ -136,7 +136,7 @@ export class D1ServicesStore extends PersistentServicesStore {
136
136
  return [
137
137
  this.db
138
138
  .prepare(
139
- 'INSERT OR REPLACE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
139
+ 'INSERT OR REPLACE INTO nickserv_accounts (nick_key, nick, account, email, created_at, enforce, cert_subjects, identify_failures, pending_identify_notice, algorithm, salt, hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
140
140
  )
141
141
  .bind(
142
142
  fold(r.nick),
@@ -145,6 +145,9 @@ export class D1ServicesStore extends PersistentServicesStore {
145
145
  r.email,
146
146
  r.createdAt,
147
147
  r.enforce,
148
+ JSON.stringify(r.certSubjects ?? []),
149
+ JSON.stringify(r.identifyFailures ?? []),
150
+ r.pendingIdentifyFailureNotice ?? 0,
148
151
  r.credential.algorithm,
149
152
  r.credential.salt,
150
153
  r.credential.hash,
@@ -361,12 +364,15 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
361
364
  email: unknown;
362
365
  created_at: unknown;
363
366
  enforce: unknown;
367
+ cert_subjects: unknown;
368
+ identify_failures: unknown;
369
+ pending_identify_notice: unknown;
364
370
  algorithm: unknown;
365
371
  salt: unknown;
366
372
  hash: unknown;
367
373
  }>(
368
374
  db,
369
- 'SELECT nick, account, email, created_at, enforce, algorithm, salt, hash FROM nickserv_accounts',
375
+ 'SELECT nick, account, email, created_at, enforce, cert_subjects, identify_failures, pending_identify_notice, algorithm, salt, hash FROM nickserv_accounts',
370
376
  );
371
377
  const out: ServicesNickRow[] = [];
372
378
  for (const r of rows) {
@@ -388,6 +394,10 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
388
394
  email: r.email,
389
395
  createdAt: r.created_at,
390
396
  enforce: r.enforce as NickEnforcePolicy,
397
+ certSubjects: parseCertSubjects(r.cert_subjects),
398
+ identifyFailures: parseIdentifyFailures(r.identify_failures),
399
+ pendingIdentifyFailureNotice:
400
+ typeof r.pending_identify_notice === 'number' ? r.pending_identify_notice : 0,
391
401
  credential: {
392
402
  account: r.account,
393
403
  algorithm: 'scrypt',
@@ -399,6 +409,41 @@ async function loadNicks(db: D1QueryClient): Promise<ServicesNickRow[]> {
399
409
  return out;
400
410
  }
401
411
 
412
+ /**
413
+ * Parses the `cert_subjects` TEXT column (JSON-encoded array) into a
414
+ * `string[]`. Tolerates `undefined` (a row written before the column
415
+ * existed), `null`, or malformed JSON — all collapse to `[]` so a stale or
416
+ * partially-migrated row loads cleanly.
417
+ */
418
+ function parseCertSubjects(raw: unknown): string[] {
419
+ if (typeof raw !== 'string' || raw.length === 0) return [];
420
+ try {
421
+ const parsed = JSON.parse(raw);
422
+ if (!Array.isArray(parsed)) return [];
423
+ return parsed.filter((s): s is string => typeof s === 'string');
424
+ } catch {
425
+ return [];
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Parses the `identify_failures` TEXT column (JSON-encoded array of
431
+ * epoch-ms timestamps) into a `number[]`. Tolerates `undefined` (a row
432
+ * written before the column existed), `null`, non-numeric entries, or
433
+ * malformed JSON — all collapse to `[]` so a stale or partially-migrated
434
+ * row loads cleanly.
435
+ */
436
+ function parseIdentifyFailures(raw: unknown): number[] {
437
+ if (typeof raw !== 'string' || raw.length === 0) return [];
438
+ try {
439
+ const parsed = JSON.parse(raw);
440
+ if (!Array.isArray(parsed)) return [];
441
+ return parsed.filter((t): t is number => typeof t === 'number');
442
+ } catch {
443
+ return [];
444
+ }
445
+ }
446
+
402
447
  async function loadChannels(db: D1QueryClient): Promise<RegisteredChannel[]> {
403
448
  const rows = await safeSelect<{
404
449
  channel: unknown;
@@ -603,8 +648,13 @@ async function loadJupes(db: D1QueryClient): Promise<JupeEntry[]> {
603
648
  * table does not exist yet (fresh deploy) or the binding is unreachable.
604
649
  * Silent-skip mirrors the DynamoDB loader: a missing table is treated as
605
650
  * an empty section rather than a fatal boot error.
651
+ *
652
+ * Exported so the catch-block fallback is unit-testable directly with a
653
+ * throwing D1 stub (the real binding does not fail on queries that target
654
+ * existing tables, so the catch arm is unreachable through integration
655
+ * tests).
606
656
  */
607
- async function safeSelect<R>(db: D1QueryClient, sql: string): Promise<R[]> {
657
+ export async function safeSelect<R>(db: D1QueryClient, sql: string): Promise<R[]> {
608
658
  try {
609
659
  const result = await db.prepare(sql).all<R>();
610
660
  return result.results;
@@ -648,35 +698,64 @@ async function ensureServicesTables(db: D1QueryClient): Promise<void> {
648
698
  }
649
699
 
650
700
  /**
651
- * One-time schema migration for the `algorithm/salt/hash` unification
652
- * (commit cfa5a3c). `CREATE TABLE IF NOT EXISTS` does NOT add columns to
653
- * an existing table, so a D1 first provisioned by an older deploy keeps
654
- * the original `nickserv_accounts (... enforce, password)` schema. Every
655
- * current SELECT/INSERT references `algorithm`, so reads throw
656
- * "no such column: algorithm" (masked by `safeSelect` as an empty
657
- * snapshot) and writes throw inside `flush()` (after the success NOTICE).
701
+ * One-time schema migrations for `nickserv_accounts`.
658
702
  *
659
- * Detection: `PRAGMA table_info`. If `nickserv_accounts` lacks
660
- * `algorithm`, it is the pre-unification schema. Those rows stored a
661
- * non-scrypt `password` the current `verifyNick` cannot validate, so the
662
- * table is dropped and recreated with the current schema (data loss is
663
- * intentional and limited to already-unusable credentials). Idempotent:
664
- * once the new schema is in place, `algorithm` is present and this is a
665
- * no-op.
703
+ * `CREATE TABLE IF NOT EXISTS` does NOT add columns to an existing table,
704
+ * so a D1 first provisioned by an older deploy keeps the original schema.
705
+ * Three additive migrations run idempotently, each gated on `PRAGMA
706
+ * table_info`:
707
+ *
708
+ * 1. **algorithm/salt/hash unification** (commit cfa5a3c). If the table
709
+ * lacks `algorithm`, it has the pre-unification `password` column
710
+ * whose credentials the current `verifyNick` cannot validate. The
711
+ * table is dropped and recreated with the current schema (data loss
712
+ * is intentional and limited to already-unusable credentials).
713
+ * 2. **cert_subjects**. If the table has `algorithm` but lacks
714
+ * `cert_subjects`, the column is added with `NOT NULL DEFAULT '[]'`
715
+ * so existing rows load with an empty cert-subject list. No data
716
+ * loss — the column is additive.
717
+ * 3. **identify throttle**. If the table lacks `identify_failures`, the
718
+ * `identify_failures` (JSON timestamp array) and
719
+ * `pending_identify_notice` (queued owner-notice count) columns are
720
+ * added with neutral defaults so pre-existing rows load unfrozen.
721
+ * No data loss — the columns are additive.
666
722
  */
667
723
  async function migrateServicesSchema(db: D1QueryClient): Promise<void> {
668
724
  const nickservInfo = await db
669
725
  .prepare('PRAGMA table_info(nickserv_accounts)')
670
726
  .all<{ name: string }>();
671
727
  const nickservCols = new Set(nickservInfo.results.map((r) => r.name));
672
- if (nickservCols.has('algorithm')) return;
673
- // Old schema detected (has `password`, lacks `algorithm`). The table is
674
- // guaranteed to exist (ensureServicesTables just ran CREATE IF NOT
675
- // EXISTS); recreate it with the current schema.
676
- await db.batch([
677
- db.prepare('DROP TABLE nickserv_accounts'),
678
- db.prepare(CREATE_NICKSERV_ACCOUNTS_SQL),
679
- ]);
728
+ if (!nickservCols.has('algorithm')) {
729
+ // Old schema detected (has `password`, lacks `algorithm`). The table is
730
+ // guaranteed to exist (ensureServicesTables just ran CREATE IF NOT
731
+ // EXISTS); recreate it with the current schema (which includes
732
+ // cert_subjects + the identify-throttle columns).
733
+ await db.batch([
734
+ db.prepare('DROP TABLE nickserv_accounts'),
735
+ db.prepare(CREATE_NICKSERV_ACCOUNTS_SQL),
736
+ ]);
737
+ return;
738
+ }
739
+ // algorithm present — check for the additive cert_subjects column.
740
+ if (!nickservCols.has('cert_subjects')) {
741
+ await db.batch([
742
+ db.prepare(
743
+ "ALTER TABLE nickserv_accounts ADD COLUMN cert_subjects TEXT NOT NULL DEFAULT '[]'",
744
+ ),
745
+ ]);
746
+ }
747
+ // cert_subjects present (or just added) — check for the additive
748
+ // identify-throttle columns.
749
+ if (!nickservCols.has('identify_failures')) {
750
+ await db.batch([
751
+ db.prepare(
752
+ "ALTER TABLE nickserv_accounts ADD COLUMN identify_failures TEXT NOT NULL DEFAULT '[]'",
753
+ ),
754
+ db.prepare(
755
+ 'ALTER TABLE nickserv_accounts ADD COLUMN pending_identify_notice INTEGER NOT NULL DEFAULT 0',
756
+ ),
757
+ ]);
758
+ }
680
759
  }
681
760
 
682
761
  export async function loadD1ServicesStore(
@@ -47,13 +47,32 @@ export interface Env {
47
47
  */
48
48
  CHANNEL_REGISTRY_DO: DurableObjectNamespace;
49
49
  /**
50
- * D1 database backing persistent SASL account credentials. The `accounts`
51
- * table holds scrypt-hashed rows (`{ account PK, algorithm, salt, hash }`),
52
- * loaded at {@link ConnectionDO} construction by `loadD1AccountStore`.
50
+ * Global live-connection counter authority. A single `CounterDO`
51
+ * instance (fixed name `global`) maintains one admission entry per
52
+ * live connection, keyed by the ConnectionDO hex id. The worker edge
53
+ * calls `admit` before forwarding an upgrade (enforcing
54
+ * `maxClients`); ConnectionDO calls `release` on every teardown path
55
+ * and `heartbeat` from its PING alarm so the counter's lazy TTL reap
56
+ * only evicts entries from DOs that stopped heartbeating.
57
+ */
58
+ COUNTER_DO: DurableObjectNamespace;
59
+ /**
60
+ * Per-IP upgrade rate limiter authority. A single `RateLimitDO`
61
+ * instance (fixed name `global`) maintains one sliding-window
62
+ * admission-timestamp list per source IP (the `CF-Connecting-IP` the
63
+ * edge stamps). The worker edge calls `check` before forwarding an
64
+ * upgrade (enforcing `perIpConnectionRate`) and rejects over-budget
65
+ * IPs with 429.
66
+ */
67
+ RATE_LIMIT_DO: DurableObjectNamespace;
68
+ /**
69
+ * D1 database backing persistent services (NickServ / ChanServ / HostServ /
70
+ * MemoServ / OperServ + the unified SASL credential store). The
71
+ * `nickserv_accounts` table holds scrypt-hashed credential rows, loaded at
72
+ * {@link ConnectionDO} construction by `loadD1ServicesStore`.
53
73
  *
54
- * Optional so deployments that have not migrated to D1 keep working: when
55
- * unset, the precedence resolver falls straight through to the
56
- * `SASL_ACCOUNTS` env-var seed (the legacy `InMemoryAccountStore`).
74
+ * Optional: when unset, services are unbound (no NickServ routing, no
75
+ * SASL/PASS credential verify).
57
76
  */
58
77
  ACCOUNTS_DB?: D1Database;
59
78
  /** Server-level config knobs (name, MOTD lines, limits). */
@@ -71,10 +90,11 @@ export interface Env {
71
90
  CREATED_AT?: string;
72
91
  MOTD_LINES: string;
73
92
  /**
74
- * SASL PLAIN accounts seeded into the in-worker `AccountStore`.
75
- * Newline-delimited `username:password` pairs; the connection-do parses
76
- * these into an `InMemoryAccountStore` so `AUTHENTICATE PLAIN` works
77
- * end-to-end. Empty/undefined disables SASL account verification.
93
+ * SASL PLAIN seed accounts. Newline-delimited `username:password` pairs;
94
+ * the connection-do ingests these into the `ServicesStore` at boot via
95
+ * `registerNick` (idempotent existing registrations win) so
96
+ * `AUTHENTICATE PLAIN`, `PASS <nick>:<password>`, and NickServ `IDENTIFY`
97
+ * all verify for the listed accounts.
78
98
  */
79
99
  SASL_ACCOUNTS?: string;
80
100
  /**
@@ -97,6 +117,20 @@ export interface Env {
97
117
  * `001 RPL_WELCOME`. Undefined / empty disables the gate.
98
118
  */
99
119
  SERVER_PASSWORD?: string;
120
+ /**
121
+ * Per-connection inbound frame window ceiling enforced by
122
+ * `ConnectionDO.webSocketMessage` BEFORE the actor / storage write.
123
+ * Parsed into `ServerConfig.adapter.maxFramesPerWindow`; unset falls
124
+ * back to the schema default (50).
125
+ */
126
+ MAX_FRAMES_PER_WINDOW?: string;
127
+ /**
128
+ * Sliding-window length (seconds) for the inbound frame limit; frames
129
+ * older than the window stop counting. Parsed into
130
+ * `ServerConfig.adapter.frameWindowSeconds`; unset falls back to the
131
+ * schema default (5).
132
+ */
133
+ FRAME_WINDOW_SECONDS?: string;
100
134
  }
101
135
 
102
136
  /**
@@ -142,3 +176,58 @@ export interface ChannelRegistryRpc {
142
176
  unregister(name: string): Promise<void>;
143
177
  list(): Promise<string[]>;
144
178
  }
179
+
180
+ /** Result of a {@link CounterRpc.admit} admission check. */
181
+ export interface CounterAdmissionResult {
182
+ /** Whether the connection may proceed to the ConnectionDO upgrade. */
183
+ readonly admitted: boolean;
184
+ /** Live connection count after any lazy reap (excludes a rejection). */
185
+ readonly live: number;
186
+ }
187
+
188
+ /** Result of a {@link RateLimitRpc.check} budget check. */
189
+ export interface RateLimitResult {
190
+ /** Whether the caller is within the per-IP budget for this window. */
191
+ readonly allowed: boolean;
192
+ /** Admissions from this IP inside the sliding window (post-prune). */
193
+ readonly count: number;
194
+ }
195
+
196
+ /**
197
+ * RPC contract the worker edge expects from the per-IP rate limiter.
198
+ * The real {@link RateLimitDO} implements this.
199
+ */
200
+ export interface RateLimitRpc {
201
+ /**
202
+ * Prunes admissions aged out of the window, then allows (stamping
203
+ * `now`) only while the surviving count is under `max`. `now` is
204
+ * overridable for deterministic window tests.
205
+ */
206
+ check(ip: string, max: number, windowMs: number, now?: number): Promise<RateLimitResult>;
207
+ }
208
+
209
+ /**
210
+ * RPC contract ConnectionDO / the worker edge expect from the global
211
+ * connection counter. The real {@link CounterDO} implements this.
212
+ */
213
+ export interface CounterRpc {
214
+ /**
215
+ * Reaps stale entries, then registers `connId` when the surviving live
216
+ * count is under `maxClients`. `now` / `ttlMs` are overridable for
217
+ * deterministic TTL tests.
218
+ */
219
+ admit(
220
+ connId: string,
221
+ maxClients: number,
222
+ now?: number,
223
+ ttlMs?: number,
224
+ ): Promise<CounterAdmissionResult>;
225
+ /** Frees the connection's slot (idempotent). */
226
+ release(connId: string): Promise<void>;
227
+ /** Refreshes the entry's lastSeen (no-op for unknown ids). */
228
+ heartbeat(connId: string, now?: number): Promise<void>;
229
+ /** Deletes entries older than the TTL; reports reaped vs. live. */
230
+ sweep(now?: number, ttlMs?: number): Promise<{ reaped: number; live: number }>;
231
+ /** Raw entry count (no reap). */
232
+ liveCount(): Promise<number>;
233
+ }
@@ -15,6 +15,12 @@ export {
15
15
  export { RegistryDO } from './registry-do.js';
16
16
  export { ChannelDO } from './channel-do.js';
17
17
  export { ChannelRegistryDO } from './channel-registry-do.js';
18
+ export {
19
+ COUNTER_INSTANCE_NAME,
20
+ CounterDO,
21
+ DEFAULT_CONNECTION_TTL_MS,
22
+ } from './counter-do.js';
23
+ export { RATE_LIMIT_INSTANCE_NAME, RateLimitDO } from './rate-limit-do.js';
18
24
  export type { DeliverResult, ConnectionDeliveryRpc } from './channel-do.js';
19
25
  export {
20
26
  DEFAULT_REGISTRY_SHARDS,
@@ -25,13 +31,17 @@ export { makeCfRuntime } from './cf-runtime.js';
25
31
  export type { CfConnectionHandlers } from './cf-runtime.js';
26
32
  export { CfStats } from './stats.js';
27
33
  export type { CfStatsRuntime } from './stats.js';
28
- export type { ChannelRpc, ChannelRegistryRpc, Env, RegistryRpc } from './env.js';
29
- export {
30
- CREATE_ACCOUNTS_TABLE_SQL,
31
- loadD1AccountStore,
32
- putAccountCredential,
33
- resolveAccountStore,
34
- } from './d1-account-store.js';
34
+ export type {
35
+ ChannelRpc,
36
+ ChannelRegistryRpc,
37
+ CounterAdmissionResult,
38
+ CounterRpc,
39
+ Env,
40
+ RateLimitResult,
41
+ RateLimitRpc,
42
+ RegistryRpc,
43
+ } from './env.js';
44
+ export { loadServerConfigFromCfEnv } from './config-loader.js';
35
45
  export {
36
46
  CREATE_SERVICES_TABLES_SQL,
37
47
  D1ServicesStore,
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `RateLimitDO` — per-IP upgrade rate limiter.
3
+ *
4
+ * A single Durable Object instance (fixed name {@link
5
+ * RATE_LIMIT_INSTANCE_NAME}) maintains one sliding-window admission
6
+ * timestamp list per source IP, keyed by the `CF-Connecting-IP` the
7
+ * Cloudflare edge stamps on every request. The worker edge calls
8
+ * {@link RateLimitDO.check} before forwarding a WebSocket upgrade and
9
+ * rejects over-budget IPs with `429` — capping how fast a single host
10
+ * can drive connection setup (brute-forcing `PASS` / SASL credentials
11
+ * requires exactly that setup rate).
12
+ *
13
+ * Storage layout:
14
+ * - One key per source IP: `rate:<ip>` → array of admission epoch ms
15
+ *
16
+ * Sliding window: an admission stops counting once it is strictly older
17
+ * than `now - windowMs` (an entry exactly `windowMs` old still counts).
18
+ * This mirrors irc-core's in-memory `AdmissionStats.recentAdmissions`
19
+ * boundary (`t >= cutoff`), so the DO and the single-process runtimes
20
+ * reject the same sequence of connects identically.
21
+ *
22
+ * Window semantics on rejected checks: a rejected check prunes stale
23
+ * timestamps but MUST NOT append one of its own — otherwise a blocked
24
+ * IP consumes budget from rejection attempts and can never recover.
25
+ *
26
+ * Atomicity: the DO's serialized event loop linearises concurrent
27
+ * `check` calls for the same IP, so N racing upgrades can never all
28
+ * squeeze under a budget of N-1 — mirroring the CounterDO admission
29
+ * semantics without a conditional-write primitive.
30
+ */
31
+
32
+ import { DurableObject } from 'cloudflare:workers';
33
+ import type { RateLimitResult, RateLimitRpc } from './env.js';
34
+
35
+ /** Storage key prefix for per-IP admission-timestamp lists. */
36
+ const RATE_KEY_PREFIX = 'rate:';
37
+
38
+ /**
39
+ * Fixed instance name for the single global limiter. The worker edge
40
+ * addresses the same instance via
41
+ * `RATE_LIMIT_DO.idFromName(RATE_LIMIT_INSTANCE_NAME)`.
42
+ */
43
+ export const RATE_LIMIT_INSTANCE_NAME = 'global';
44
+
45
+ /** Returns the storage key for `ip`. */
46
+ function rateKey(ip: string): string {
47
+ return `${RATE_KEY_PREFIX}${ip}`;
48
+ }
49
+
50
+ /**
51
+ * Per-IP upgrade rate authority.
52
+ *
53
+ * The class is exported as a binding target in `wrangler.toml`; the
54
+ * Workers runtime instantiates it with `(ctx, env)`. Implements the
55
+ * {@link RateLimitRpc} contract the worker edge calls.
56
+ */
57
+ export class RateLimitDO extends DurableObject implements RateLimitRpc {
58
+ /**
59
+ * Sliding-window budget check + admission stamp in one serialized step.
60
+ *
61
+ * Prunes timestamps that aged out of the window, then allows the check
62
+ * (appending `now`) only while the surviving count is under `max`. A
63
+ * rejected check leaves the timestamp list at the pruned count —
64
+ * rejections never consume budget. `now` is overridable for
65
+ * deterministic window tests.
66
+ */
67
+ async check(
68
+ ip: string,
69
+ max: number,
70
+ windowMs: number,
71
+ now: number = Date.now(),
72
+ ): Promise<RateLimitResult> {
73
+ const key = rateKey(ip);
74
+ const stored = await this.ctx.storage.get<number[]>(key);
75
+ const cutoff = now - windowMs;
76
+ const recent = (stored ?? []).filter((t) => t >= cutoff);
77
+ if (recent.length >= max) {
78
+ // Persist the pruned list even on rejection so an IP that stops
79
+ // connecting does not keep stale timestamps alive indefinitely.
80
+ await this.ctx.storage.put(key, recent);
81
+ return { allowed: false, count: recent.length };
82
+ }
83
+ recent.push(now);
84
+ await this.ctx.storage.put(key, recent);
85
+ return { allowed: true, count: recent.length };
86
+ }
87
+ }