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
@@ -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,7 +9,11 @@
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,
@@ -163,6 +167,119 @@ describe('D1ServicesStore — schema migration (password → algorithm/salt/hash
163
167
  });
164
168
  });
165
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
+
166
283
  describe('D1ServicesStore — NickServ round-trip across reload', () => {
167
284
  beforeEach(async () => {
168
285
  await resetDb();
@@ -207,6 +324,31 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
207
324
  expect(reloaded?.verifyNick('alice', 'hunter2').ok).toBe(false);
208
325
  });
209
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
+
210
352
  it('drops a NickServ registration after flush + reload', async () => {
211
353
  const store = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
212
354
  store?.registerNick('alice', 'pw', 'a@x');
@@ -219,6 +361,55 @@ describe('D1ServicesStore — NickServ round-trip across reload', () => {
219
361
  const reloaded = await loadD1ServicesStore(env.ACCOUNTS_DB, new FakeClock(NOW));
220
362
  expect(reloaded?.isRegisteredNick('alice')).toBe(false);
221
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
+ });
222
413
  });
223
414
 
224
415
  describe('D1ServicesStore — ChanServ round-trip across reload', () => {
@@ -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
+ });
@@ -21,6 +21,8 @@
21
21
  import { ChannelDO } from '../../src/channel-do';
22
22
  import { ChannelRegistryDO } from '../../src/channel-registry-do';
23
23
  import { ConnectionDO } from '../../src/connection-do';
24
+ import { CounterDO } from '../../src/counter-do';
25
+ import { RateLimitDO } from '../../src/rate-limit-do';
24
26
  import { RegistryDO } from '../../src/registry-do';
25
27
  import { RecordingChannelDO } from './stubs/channel-stub';
26
28
  import { RecordingRegistryDO } from './stubs/registry-stub';
@@ -29,6 +31,8 @@ export {
29
31
  ChannelDO,
30
32
  ChannelRegistryDO,
31
33
  ConnectionDO,
34
+ CounterDO,
35
+ RateLimitDO,
32
36
  RegistryDO,
33
37
  RecordingRegistryDO,
34
38
  RecordingChannelDO,
@@ -49,6 +49,23 @@ class_name = "ChannelDO"
49
49
  name = "CHANNEL_REGISTRY_DO"
50
50
  class_name = "ChannelRegistryDO"
51
51
 
52
+ # Global live-connection admission counter — single DO instance keyed by
53
+ # the fixed name `global`. The worker edge reserves a slot per upgrade
54
+ # (maxClients cap); ConnectionDO releases it on every teardown path and
55
+ # heartbeats it from the PING alarm so the lazy TTL reap only evicts
56
+ # entries from DOs that stopped heartbeating.
57
+ [[durable_objects.bindings]]
58
+ name = "COUNTER_DO"
59
+ class_name = "CounterDO"
60
+
61
+ # Per-IP upgrade rate limiter — single DO instance keyed by the fixed
62
+ # name `global`, maintaining one sliding-window admission-timestamp list
63
+ # per CF-Connecting-IP. The worker edge checks the budget before
64
+ # forwarding an upgrade and rejects over-budget IPs with 429.
65
+ [[durable_objects.bindings]]
66
+ name = "RATE_LIMIT_DO"
67
+ class_name = "RateLimitDO"
68
+
52
69
  # Recording stubs (kept under separate names for any test that wants to
53
70
  # assert "this exact RPC was emitted" rather than the end-to-end effect).
54
71
  [[durable_objects.bindings]]
@@ -71,4 +88,4 @@ class_name = "ChannelDO"
71
88
 
72
89
  [[migrations]]
73
90
  tag = "v1"
74
- new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "RecordingRegistryDO", "RecordingChannelDO"]
91
+ new_classes = ["ConnectionDO", "RegistryDO", "ChannelDO", "ChannelRegistryDO", "CounterDO", "RateLimitDO", "RecordingRegistryDO", "RecordingChannelDO"]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "description": "Single-process reference implementation of the IrcRuntime port, backed by Maps",
6
6
  "license": "BSD-3-Clause",
@@ -25,6 +25,7 @@ import {
25
25
  type ConnSnapshot,
26
26
  type ConnectionState,
27
27
  type Nick,
28
+ OperFailureStats,
28
29
  type RawLine,
29
30
  type ServerConfig,
30
31
  applyChannelDelta as applyDelta,
@@ -84,10 +85,20 @@ export class InMemoryRuntime implements IrcRuntime {
84
85
  private readonly admissionStats: AdmissionStats | undefined;
85
86
  private readonly admissionConfig: AdmissionConfig | undefined;
86
87
  private readonly configLoader: (() => Promise<ServerConfig>) | undefined;
88
+ /**
89
+ * Per-IP failed-`OPER` counter shared across every connection's actor
90
+ * (see `ctx.operFailures`). Bound to the runtime's clock so the OPER
91
+ * reducer's lockout window advances with the same time source the
92
+ * actors observe. Adapters hand `runtime.operFailures` to each
93
+ * `ConnectionActor`'s options; the counter is then per-IP across the
94
+ * whole process, not per-connection.
95
+ */
96
+ readonly operFailures: OperFailureStats;
87
97
 
88
98
  constructor(opts: InMemoryRuntimeOptions) {
89
99
  this.clock = opts.clock;
90
100
  this.configLoader = opts.configLoader;
101
+ this.operFailures = new OperFailureStats(opts.clock);
91
102
  if (opts.admission !== undefined) {
92
103
  this.admissionConfig = opts.admission;
93
104
  this.admissionStats = new AdmissionStats(this.clock);
@@ -270,6 +281,20 @@ export class InMemoryRuntime implements IrcRuntime {
270
281
  }
271
282
  }
272
283
 
284
+ /**
285
+ * Oper-only notice fanout — the recipient gate for the OPER reducer's
286
+ * per-IP lockout notice (`:<server> NOTICE * :OPER lockout triggered
287
+ * for <host>`). Identical enumeration to {@link broadcastWallops} but
288
+ * filtered on user mode `+o` instead of `+w`.
289
+ */
290
+ async broadcastOperNotice(lines: RawLine[], except?: ConnId): Promise<void> {
291
+ for (const [connId, tracked] of this.connections) {
292
+ if (connId === except) continue;
293
+ if (!tracked.state.userModes.oper) continue;
294
+ tracked.handlers.send(lines);
295
+ }
296
+ }
297
+
273
298
  // ------------------------------------------------------------------
274
299
  // IrcRuntime — nick registry
275
300
  // ------------------------------------------------------------------