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
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { IrcMessage } from '../src/protocol/messages';
3
- import { IrcParseError, MAX_INPUT_BYTES, parse } from '../src/protocol/parser';
3
+ import {
4
+ IrcParseError,
5
+ MAX_INPUT_BYTES,
6
+ MAX_TAGS,
7
+ MAX_TAG_BYTES,
8
+ parse,
9
+ parseTags,
10
+ } from '../src/protocol/parser';
4
11
 
5
12
  describe('parse — golden vectors', () => {
6
13
  it('parses a simple command with a channel and trailing text', () => {
@@ -125,6 +132,79 @@ describe('parse — message-tags (IRCv3)', () => {
125
132
  });
126
133
  });
127
134
 
135
+ describe('parse — tag-map prototype hardening (magic tag keys)', () => {
136
+ // IRCv3 Message Tags: tag KEYS are attacker-controlled. On a map built
137
+ // from a plain object literal, magic keys hit `Object.prototype`
138
+ // accessors instead of creating own properties: `__proto__=x` invokes
139
+ // the prototype setter (a no-op for a string RHS — the tag is silently
140
+ // dropped), `toString` / `constructor` / `hasOwnProperty` become own
141
+ // data properties that shadow inherited methods, and even when absent,
142
+ // inherited members are reachable through tag lookups. The tag map must
143
+ // have a null prototype so every tag is an own data property and no
144
+ // inherited member leaks into the tag namespace.
145
+
146
+ it('keeps a `__proto__` tag as an own data property instead of invoking the setter', () => {
147
+ const tags = parse('@__proto__=x PRIVMSG #foo :hi').tags;
148
+ expect(Object.hasOwn(tags, '__proto__')).toBe(true);
149
+ expect(tags.__proto__).toBe('x');
150
+ });
151
+
152
+ it('keeps a valueless `__proto__` tag as an own property with empty-string value', () => {
153
+ const tags = parse('@__proto__;a=b PRIVMSG #foo :hi').tags;
154
+ expect(Object.hasOwn(tags, '__proto__')).toBe(true);
155
+ expect(tags.__proto__).toBe('');
156
+ });
157
+
158
+ it('builds the tag map with a null prototype', () => {
159
+ expect(Object.getPrototypeOf(parse('@a=b PRIVMSG #foo :hi').tags)).toBe(null);
160
+ });
161
+
162
+ it('exposes no inherited `toString` / `constructor` / `hasOwnProperty` when no such tag exists', () => {
163
+ const tags = parse('@a=b PRIVMSG #foo :hi').tags;
164
+ expect(tags.toString).toBeUndefined();
165
+ expect(tags.constructor).toBeUndefined();
166
+ expect(tags.hasOwnProperty).toBeUndefined();
167
+ });
168
+
169
+ it('does not shadow `Object.prototype.toString` on any other object after `@toString=x`', () => {
170
+ const tags = parse('@toString=x PRIVMSG #foo :hi').tags;
171
+ expect(Object.hasOwn(tags, 'toString')).toBe(true);
172
+ expect(tags.toString).toBe('x');
173
+ const unrelated = {};
174
+ expect(unrelated.toString()).toBe('[object Object]');
175
+ expect(Object.keys(unrelated)).toEqual([]);
176
+ });
177
+
178
+ it('round-trips every magic key as an own data property', () => {
179
+ const tags = parse(
180
+ '@__proto__=p;constructor=c;toString=t;hasOwnProperty=h PRIVMSG #foo :hi',
181
+ ).tags;
182
+ expect(Object.hasOwn(tags, '__proto__')).toBe(true);
183
+ expect(tags.__proto__).toBe('p');
184
+ expect(Object.hasOwn(tags, 'constructor')).toBe(true);
185
+ expect(tags.constructor).toBe('c');
186
+ expect(Object.hasOwn(tags, 'toString')).toBe(true);
187
+ expect(tags.toString).toBe('t');
188
+ expect(Object.hasOwn(tags, 'hasOwnProperty')).toBe(true);
189
+ expect(tags.hasOwnProperty).toBe('h');
190
+ });
191
+
192
+ it('round-trips every magic key as a valueless tag', () => {
193
+ const tags = parse('@__proto__;constructor;toString;hasOwnProperty PRIVMSG #foo :hi').tags;
194
+ expect(tags.__proto__).toBe('');
195
+ expect(tags.constructor).toBe('');
196
+ expect(tags.toString).toBe('');
197
+ expect(tags.hasOwnProperty).toBe('');
198
+ });
199
+
200
+ it('leaves every `Object.prototype` member untouched after parsing magic keys', () => {
201
+ parse('@__proto__=p;constructor=c;toString=t;hasOwnProperty=h PRIVMSG #foo :hi');
202
+ expect(Object.prototype.toString).toBeTypeOf('function');
203
+ expect(Object.prototype.hasOwnProperty).toBeTypeOf('function');
204
+ expect(Object.getPrototypeOf({})).toBe(Object.prototype);
205
+ });
206
+ });
207
+
128
208
  describe('parse — framing tolerance', () => {
129
209
  it('strips a trailing CR LF', () => {
130
210
  expect(parse('PING :tok\r\n').params).toEqual(['tok']);
@@ -134,14 +214,89 @@ describe('parse — framing tolerance', () => {
134
214
  expect(parse('PING :tok\n').params).toEqual(['tok']);
135
215
  });
136
216
 
137
- it('preserves CR/LF that appears inside the trailing param', () => {
138
- // The CR/LF strip regex is anchored to the END of the line only; a CR/LF
139
- // embedded in a trailing param must survive into the parsed value.
140
- expect(parse('PRIVMSG #foo :a\rb').params[1]).toBe('a\rb');
217
+ it('preserves an embedded LF in a trailing param (CR-only hardening)', () => {
218
+ // The CR/LF strip regex is anchored to the END of the line only; an LF
219
+ // embedded in a trailing param survives into the parsed value. Bare CR
220
+ // used to survive too — it is now rejected outright (see the bare-CR
221
+ // suite below); the hardening is deliberately CR-only.
141
222
  expect(parse('PRIVMSG #foo :a\nb').params[1]).toBe('a\nb');
142
223
  });
143
224
  });
144
225
 
226
+ describe('parse — bare CR rejection', () => {
227
+ // After the trailing CR/LF strip, ANY remaining CR in the body is bare by
228
+ // definition: a mid-line CR-LF would have split the frame at the transport
229
+ // layer, so a surviving CR means frame control characters are being
230
+ // smuggled inside message content. Each case asserts the error class AND
231
+ // the diagnostic message so the guard is pinned against sibling throws.
232
+
233
+ it('rejects a bare CR inside a trailing param', () => {
234
+ expect(() => parse('PRIVMSG #foo :hello\rworld')).toThrow(IrcParseError);
235
+ expect(() => parse('PRIVMSG #foo :hello\rworld')).toThrow('bare CR');
236
+ });
237
+
238
+ it('rejects a bare CR inside a middle param', () => {
239
+ expect(() => parse('PRIVMSG #fo\ro :hi')).toThrow(IrcParseError);
240
+ expect(() => parse('PRIVMSG #fo\ro :hi')).toThrow('bare CR');
241
+ });
242
+
243
+ it('rejects a bare CR that precedes a valid trailing CRLF terminator', () => {
244
+ expect(() => parse('PRIVMSG #foo :a\rb\r\n')).toThrow(IrcParseError);
245
+ expect(() => parse('PRIVMSG #foo :a\rb\r\n')).toThrow('bare CR');
246
+ });
247
+
248
+ it('rejects a bare CR in the tag section', () => {
249
+ expect(() => parse('@time=n\row PRIVMSG #foo :hi')).toThrow(IrcParseError);
250
+ expect(() => parse('@time=n\row PRIVMSG #foo :hi')).toThrow('bare CR');
251
+ });
252
+
253
+ it('rejects a bare CR in the source prefix', () => {
254
+ expect(() => parse(':ni\rck!u@h PRIVMSG #foo :hi')).toThrow(IrcParseError);
255
+ expect(() => parse(':ni\rck!u@h PRIVMSG #foo :hi')).toThrow('bare CR');
256
+ });
257
+
258
+ it('still accepts a line whose only CR is part of the trailing CRLF terminator', () => {
259
+ expect(parse('PING :tok\r\n').params).toEqual(['tok']);
260
+ });
261
+
262
+ it('treats a trailing bare CR (no LF) as a frame remnant and strips it', () => {
263
+ // The trailing strip removes the whole trailing CR/LF run, so a lone
264
+ // trailing CR is a terminator remnant (tolerated for sloppy framers),
265
+ // not body content — nothing bare survives the strip.
266
+ expect(parse('PING :tok\r').params).toEqual(['tok']);
267
+ });
268
+ });
269
+
270
+ describe('parse — tag-value control-character rejection (IRCv3 message-tags)', () => {
271
+ // The IRCv3 Message Tags spec forbids CR, LF and NUL in tag values — not
272
+ // just literally, but via their escape sequences too. The line-level bare
273
+ // CR guard cannot see these: the wire bytes are `\\r`, not CR, so the
274
+ // check must fire on the UNESCAPED value.
275
+
276
+ it('rejects a tag value whose escape sequence decodes to CR', () => {
277
+ expect(() => parse('@msg-id=x\\ry PRIVMSG #foo :hi')).toThrow(IrcParseError);
278
+ expect(() => parse('@msg-id=x\\ry PRIVMSG #foo :hi')).toThrow('tag value');
279
+ });
280
+
281
+ it('rejects a tag value whose escape sequence decodes to LF', () => {
282
+ expect(() => parse('@msg-id=x\\ny PRIVMSG #foo :hi')).toThrow(IrcParseError);
283
+ expect(() => parse('@msg-id=x\\ny PRIVMSG #foo :hi')).toThrow('tag value');
284
+ });
285
+
286
+ it('rejects a literal NUL in a tag value', () => {
287
+ expect(() => parse('@msg-id=x\0y PRIVMSG #foo :hi')).toThrow(IrcParseError);
288
+ expect(() => parse('@msg-id=x\0y PRIVMSG #foo :hi')).toThrow('tag value');
289
+ });
290
+
291
+ it('still accepts tag values that use only the legal escapes', () => {
292
+ expect(parse('@a=x\\:y\\sz\\\\w PRIVMSG #foo :hi').tags).toEqual({ a: 'x;y z\\w' });
293
+ });
294
+
295
+ it('still accepts a valueless tag (no value to validate)', () => {
296
+ expect(parse('@+away PRIVMSG #foo :hi').tags).toEqual({ '+away': '' });
297
+ });
298
+ });
299
+
145
300
  describe('parse — malformed input', () => {
146
301
  // Each case asserts both the error class AND the diagnostic message text.
147
302
  // The message assertion is what pins the failure to the *correct* guard:
@@ -219,6 +374,23 @@ describe('parse — RFC 1459 §2.3 input line-length cap', () => {
219
374
  expect(() => parse(line)).toThrow(IrcParseError);
220
375
  });
221
376
 
377
+ it('rejects a multi-byte line that fits 512 UTF-16 units but exceeds 512 bytes', () => {
378
+ // "PRIVMSG #foo :" (14 ASCII) + 400 'é' = 414 code units, 814 UTF-8 bytes.
379
+ // The byte cap must see the 814, not the 414.
380
+ const line = `PRIVMSG #foo :${'é'.repeat(400)}`;
381
+ expect(line.length).toBeLessThanOrEqual(MAX_INPUT_BYTES);
382
+ expect(() => parse(line)).toThrow(IrcParseError);
383
+ });
384
+
385
+ it('accepts a multi-byte line whose UTF-8 length is exactly 512 bytes', () => {
386
+ // "PRIVMSG #foo :" (14 ASCII) + 249 'é' (2 bytes each) = 512 bytes in
387
+ // 263 code units. Byte accounting must not over-reject.
388
+ const body = 'é'.repeat(249);
389
+ const line = `PRIVMSG #foo :${body}`;
390
+ const parsed = parse(line);
391
+ expect(parsed.params).toEqual(['#foo', body]);
392
+ });
393
+
222
394
  it('reports the actual byte cap in the error message for diagnostics', () => {
223
395
  const body = 'x'.repeat(MAX_INPUT_BYTES);
224
396
  try {
@@ -229,6 +401,16 @@ describe('parse — RFC 1459 §2.3 input line-length cap', () => {
229
401
  expect((err as Error).message).toMatch(/512/u);
230
402
  }
231
403
  });
404
+
405
+ it('reports the UTF-8 byte count (not the code-unit count) in the error message', () => {
406
+ try {
407
+ parse(`PRIVMSG #foo :${'é'.repeat(400)}`);
408
+ expect.fail('expected parse to throw');
409
+ } catch (err) {
410
+ expect(err).toBeInstanceOf(IrcParseError);
411
+ expect((err as Error).message).toMatch(/814/u);
412
+ }
413
+ });
232
414
  });
233
415
 
234
416
  describe('parse — command token grammar (anchored regex)', () => {
@@ -312,3 +494,103 @@ describe('unescapeTagValue — boundary conditions', () => {
312
494
  expect(parse('@a=x\\\\ PRIVMSG #foo :hi').tags.a).toBe('x\\');
313
495
  });
314
496
  });
497
+
498
+ describe('parse — IRCv3 tag count cap', () => {
499
+ // The IRCv3 Message Tags spec recommends at most 15 tags per message.
500
+ // Every tag is unescaped and (when message-tags is negotiated)
501
+ // re-serialized onto downstream lines, so tag count is an amplification
502
+ // lever, not just parse cost. These tests assert the error class AND the
503
+ // diagnostic message so the guard is pinned against sibling throws.
504
+
505
+ it('accepts a line with exactly 15 tags (at-limit)', () => {
506
+ const tags = Array.from({ length: MAX_TAGS }, (_, i) => `t${i}`);
507
+ const parsed = parse(`@${tags.join(';')} PRIVMSG #foo :hi`);
508
+ expect(Object.keys(parsed.tags)).toHaveLength(MAX_TAGS);
509
+ });
510
+
511
+ it('rejects a line with 16 tags', () => {
512
+ const tags = Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}`);
513
+ const line = `@${tags.join(';')} PRIVMSG #foo :hi`;
514
+ expect(() => parse(line)).toThrow(IrcParseError);
515
+ expect(() => parse(line)).toThrow('too many tags');
516
+ });
517
+
518
+ it('does not count empty tag entries toward the cap', () => {
519
+ // 15 real tags separated by doubled semicolons: the skipped empty
520
+ // entries are not tags, so the line stays at the limit.
521
+ const tags = Array.from({ length: MAX_TAGS }, (_, i) => `t${i}`);
522
+ const parsed = parse(`@${tags.join(';;')} PRIVMSG #foo :hi`);
523
+ expect(Object.keys(parsed.tags)).toHaveLength(MAX_TAGS);
524
+ });
525
+
526
+ it('reports the observed and maximum tag counts in the diagnostic', () => {
527
+ const tags = Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}`);
528
+ try {
529
+ parse(`@${tags.join(';')} PRIVMSG #foo :hi`);
530
+ expect.fail('expected parse to throw');
531
+ } catch (err) {
532
+ expect(err).toBeInstanceOf(IrcParseError);
533
+ expect((err as Error).message).toContain(`${MAX_TAGS + 1}`);
534
+ expect((err as Error).message).toContain(`${MAX_TAGS}`);
535
+ }
536
+ });
537
+ });
538
+
539
+ describe('parseTags — IRCv3 tag-section byte cap', () => {
540
+ // The IRCv3 Message Tags spec recommends a tag section of at most 8192
541
+ // bytes. Tested at the parseTags seam because the RFC 1459 512-byte line
542
+ // cap rejects any over-budget section before tags are reached — the
543
+ // guard holds the spec budget for parseTags callers independent of the
544
+ // line cap. Section here = tag data between '@' and its terminating
545
+ // space (both excluded).
546
+
547
+ it('accepts a tag section of exactly 8192 bytes (at-limit)', () => {
548
+ const section = `a=${'x'.repeat(MAX_TAG_BYTES - 2)}`;
549
+ const tags = parseTags(section, `@${section} PRIVMSG #foo :hi`);
550
+ expect(tags.a).toBe('x'.repeat(MAX_TAG_BYTES - 2));
551
+ });
552
+
553
+ it('rejects a tag section of 8193 bytes', () => {
554
+ const section = `a=${'x'.repeat(MAX_TAG_BYTES - 1)}`;
555
+ expect(() => parseTags(section, `@${section} PRIVMSG #foo :hi`)).toThrow(IrcParseError);
556
+ expect(() => parseTags(section, `@${section} PRIVMSG #foo :hi`)).toThrow('tag section exceeds');
557
+ });
558
+
559
+ it('measures UTF-8 bytes, not UTF-16 code units', () => {
560
+ // 4095 'é' after 'a=' is 4097 code units but exactly 8192 bytes →
561
+ // at-limit. One more 'é' crosses the byte budget while still being
562
+ // only ~4098 code units: byte accounting must be the binding measure.
563
+ const atLimit = `a=${'é'.repeat((MAX_TAG_BYTES - 2) / 2)}`;
564
+ expect(parseTags(atLimit, `@${atLimit} PRIVMSG #foo :hi`).a).toBe(
565
+ 'é'.repeat((MAX_TAG_BYTES - 2) / 2),
566
+ );
567
+ const overLimit = `a=${'é'.repeat(MAX_TAG_BYTES / 2)}`;
568
+ expect(() => parseTags(overLimit, `@${overLimit} PRIVMSG #foo :hi`)).toThrow(
569
+ 'tag section exceeds',
570
+ );
571
+ });
572
+
573
+ it('reports the budget and the observed byte count in the diagnostic', () => {
574
+ const section = `a=${'x'.repeat(MAX_TAG_BYTES - 1)}`;
575
+ try {
576
+ parseTags(section, `@${section} PRIVMSG #foo :hi`);
577
+ expect.fail('expected parseTags to throw');
578
+ } catch (err) {
579
+ expect(err).toBeInstanceOf(IrcParseError);
580
+ expect((err as Error).message).toContain(`${MAX_TAG_BYTES}`);
581
+ expect((err as Error).message).toContain(`${MAX_TAG_BYTES + 1}`);
582
+ }
583
+ });
584
+
585
+ it('checks the byte budget before the tag count', () => {
586
+ // A section violating BOTH caps reports the byte budget: the byte
587
+ // check is O(1) up front, the count check needs the split.
588
+ const entries = Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}=x`.padEnd(600, 'y'));
589
+ const section = entries.join(';');
590
+ expect(() => parseTags(section, `@${section} PRIVMSG #foo :hi`)).toThrow('tag section exceeds');
591
+ });
592
+
593
+ it('keeps the null-prototype tag map on the happy path', () => {
594
+ expect(Object.getPrototypeOf(parseTags('a=b', '@a=b PRIVMSG #foo :hi'))).toBe(null);
595
+ });
596
+ });
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { describe, expect, it } from 'vitest';
13
- import { hashAccountCredential } from '../src/credential-hashing';
13
+ import { certIdentityFromSubject } from '../src/certfp';
14
14
  import {
15
15
  FakeClock,
16
16
  InMemoryServicesStore,
@@ -265,25 +265,146 @@ describe('PersistentServicesStore — per-mutator ops', () => {
265
265
  expect(s.batches).toEqual([]);
266
266
  });
267
267
 
268
- it('ingestAccountCredential enqueues upsertNick when a new credential is adopted', async () => {
268
+ it('setNickPassword upsertNick with the re-hashed credential', async () => {
269
269
  const s = recordingStore();
270
- const cred = hashAccountCredential('alice', 'hunter2');
271
- s.ingestAccountCredential(cred);
270
+ s.registerNick('alice', 'hunter2', 'a@x');
271
+ s.setNickPassword('alice', 'newpassword');
272
272
  await s.flush();
273
- expect(ops(s).some((w) => w.kind === 'upsertNick')).toBe(true);
274
- const op = ops(s).find((w) => w.kind === 'upsertNick');
275
- if (op?.kind === 'upsertNick') {
276
- expect(op.row.account).toBe('alice');
277
- expect(op.row.credential.algorithm).toBe('scrypt');
273
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
274
+ // registerNick enqueues one upsert; setNickPassword enqueues a second.
275
+ expect(upserts).toHaveLength(2);
276
+ const last = upserts[upserts.length - 1];
277
+ if (last?.kind === 'upsertNick') {
278
+ // The plaintext never lands on the queued row.
279
+ expect(JSON.stringify(last.row)).not.toContain('newpassword');
280
+ expect(last.row.credential.algorithm).toBe('scrypt');
281
+ }
282
+ // The in-memory credential now verifies the new password.
283
+ expect(s.verifyNick('alice', 'newpassword').ok).toBe(true);
284
+ expect(s.verifyNick('alice', 'hunter2').ok).toBe(false);
285
+ });
286
+
287
+ it('setNickPassword on an unregistered nick enqueues nothing', async () => {
288
+ const s = recordingStore();
289
+ s.setNickPassword('ghost', 'newpassword');
290
+ await s.flush();
291
+ expect(s.batches).toEqual([]);
292
+ });
293
+
294
+ it('recordIdentifyFailure → upsertNick with the recorded timestamps', async () => {
295
+ const clock = new FakeClock(NOW);
296
+ const s = new RecordingStore({ clock });
297
+ s.registerNick('alice', 'pw', 'a@x');
298
+ clock.advance(5);
299
+ s.recordIdentifyFailure('alice');
300
+ await s.flush();
301
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
302
+ // registerNick + recordIdentifyFailure → two upserts.
303
+ expect(upserts).toHaveLength(2);
304
+ const last = upserts[upserts.length - 1];
305
+ if (last?.kind === 'upsertNick') {
306
+ expect(last.row.identifyFailures).toEqual([NOW + 5]);
307
+ expect(last.row.pendingIdentifyFailureNotice).toBe(1);
308
+ }
309
+ });
310
+
311
+ it('recordIdentifyFailure on an unregistered nick enqueues nothing', async () => {
312
+ const s = recordingStore();
313
+ s.recordIdentifyFailure('ghost');
314
+ await s.flush();
315
+ expect(s.batches).toEqual([]);
316
+ });
317
+
318
+ it('clearIdentifyFailures on an unregistered nick enqueues nothing and returns 0', async () => {
319
+ const s = recordingStore();
320
+ expect(s.clearIdentifyFailures('ghost')).toBe(0);
321
+ await s.flush();
322
+ expect(s.batches).toEqual([]);
323
+ });
324
+
325
+ it('clearIdentifyFailures → upsertNick resetting the throttle state', async () => {
326
+ const clock = new FakeClock(NOW);
327
+ const s = new RecordingStore({ clock });
328
+ s.registerNick('alice', 'pw', 'a@x');
329
+ s.recordIdentifyFailure('alice');
330
+ await s.flush();
331
+ expect(s.batches).toHaveLength(1); // register + record upserts
332
+
333
+ expect(s.clearIdentifyFailures('alice')).toBe(1);
334
+ await s.flush();
335
+ // The reset MUST produce its own write-behind batch.
336
+ expect(s.batches).toHaveLength(2);
337
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
338
+ const last = upserts[upserts.length - 1];
339
+ if (last?.kind === 'upsertNick') {
340
+ expect(last.row.identifyFailures).toEqual([]);
341
+ expect(last.row.pendingIdentifyFailureNotice).toBe(0);
342
+ }
343
+ });
344
+
345
+ it('clearIdentifyFailures with nothing queued enqueues nothing', async () => {
346
+ const s = recordingStore();
347
+ s.registerNick('alice', 'pw', 'a@x');
348
+ await s.flush();
349
+ expect(s.batches).toHaveLength(1); // the registerNick upsert only
350
+ expect(s.clearIdentifyFailures('alice')).toBe(0);
351
+ await s.flush();
352
+ expect(s.batches).toHaveLength(1);
353
+ });
354
+
355
+ it('addCertFP → upsertNick when a new subject is attached', async () => {
356
+ const s = recordingStore();
357
+ s.registerNick('alice', 'pw', 'a@x');
358
+ s.addCertFP('alice', 'CN=alice');
359
+ await s.flush();
360
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
361
+ // registerNick + addCertFP → two upserts.
362
+ expect(upserts).toHaveLength(2);
363
+ const certUpsert = upserts[1];
364
+ if (certUpsert?.kind === 'upsertNick') {
365
+ expect(certUpsert.row.certSubjects).toEqual(['CN=alice']);
366
+ }
367
+ });
368
+
369
+ it('addCertFP on an already-attached subject enqueues nothing (idempotent)', async () => {
370
+ const s = recordingStore();
371
+ s.registerNick('alice', 'pw', 'a@x');
372
+ s.addCertFP('alice', 'CN=alice');
373
+ s.addCertFP('alice', 'CN=alice');
374
+ await s.flush();
375
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
376
+ // registerNick + first addCertFP → two upserts. Second addCertFP is a no-op.
377
+ expect(upserts).toHaveLength(2);
378
+ });
379
+
380
+ it('addCertFP on an unregistered nick enqueues nothing', async () => {
381
+ const s = recordingStore();
382
+ s.addCertFP('ghost', 'CN=ghost');
383
+ await s.flush();
384
+ expect(s.batches).toEqual([]);
385
+ });
386
+
387
+ it('removeCertFP → upsertNick when a subject is detached', async () => {
388
+ const s = recordingStore();
389
+ s.registerNick('alice', 'pw', 'a@x');
390
+ s.addCertFP('alice', 'CN=alice');
391
+ s.removeCertFP('alice', 'CN=alice');
392
+ await s.flush();
393
+ const upserts = ops(s).filter((w) => w.kind === 'upsertNick');
394
+ // registerNick + addCertFP + removeCertFP → three upserts.
395
+ expect(upserts).toHaveLength(3);
396
+ const last = upserts[2];
397
+ if (last?.kind === 'upsertNick') {
398
+ expect(last.row.certSubjects).toEqual([]);
278
399
  }
279
400
  });
280
401
 
281
- it('ingestAccountCredential enqueues nothing when the nick is already registered', async () => {
402
+ it('removeCertFP on a non-attached subject enqueues nothing', async () => {
282
403
  const s = recordingStore();
283
404
  s.registerNick('alice', 'pw', 'a@x');
284
- const cred = hashAccountCredential('alice', 'otherpw');
285
- s.ingestAccountCredential(cred);
405
+ s.removeCertFP('alice', 'CN=alice');
286
406
  await s.flush();
407
+ // Only the registerNick upsert.
287
408
  expect(ops(s).filter((w) => w.kind === 'upsertNick')).toHaveLength(1);
288
409
  });
289
410
 
@@ -562,6 +683,85 @@ describe('PersistentServicesStore — per-mutator ops', () => {
562
683
  });
563
684
  });
564
685
 
686
+ // ============================================================================
687
+ // CertFP — canonical-DN / fingerprint write-behind
688
+ // ============================================================================
689
+
690
+ describe('PersistentServicesStore — CertFP ops (verifyCertFP migration / addCertFingerprint)', () => {
691
+ function lastNickRow(s: RecordingStore): ServicesNickRow | undefined {
692
+ const upserts = s.batches.flat().filter((w) => w.kind === 'upsertNick');
693
+ const last = upserts[upserts.length - 1];
694
+ return last?.kind === 'upsertNick' ? last.row : undefined;
695
+ }
696
+
697
+ it('verifyCertFP legacy-spelling migration re-persists the nick row', async () => {
698
+ const s = recordingStore();
699
+ s.registerNick('alice', 'pw', 'a@x');
700
+ s.addCertFP('alice', 'CN=alice , O=Example Inc');
701
+ await s.flush(); // drain the register/add writes
702
+ s.batches.length = 0;
703
+
704
+ const out = s.verifyCertFP(certIdentityFromSubject('O=Example Inc,CN=alice'));
705
+ expect(out.ok).toBe(true);
706
+ await s.flush();
707
+ expect(lastNickRow(s)?.certSubjects).toEqual(['cn=alice,o=Example Inc']);
708
+ });
709
+
710
+ it('verifyCertFP fingerprint pinning re-persists the nick row', async () => {
711
+ const s = recordingStore();
712
+ s.registerNick('alice', 'pw', 'a@x');
713
+ s.addCertFP('alice', 'CN=alice');
714
+ await s.flush();
715
+ s.batches.length = 0;
716
+
717
+ const out = s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
718
+ expect(out.ok).toBe(true);
719
+ await s.flush();
720
+ expect(lastNickRow(s)?.certSubjects).toEqual(['cn=alice', 'fp:aa112233']);
721
+ });
722
+
723
+ it('verifyCertFP enqueues nothing when no binding changed (fingerprint arm)', async () => {
724
+ const s = recordingStore();
725
+ s.registerNick('alice', 'pw', 'a@x');
726
+ s.addCertFingerprint('alice', 'aa112233');
727
+ await s.flush();
728
+ s.batches.length = 0;
729
+
730
+ const out = s.verifyCertFP(certIdentityFromSubject('CN=alice', 'aa112233'));
731
+ expect(out.ok).toBe(true);
732
+ await s.flush();
733
+ expect(s.batches).toEqual([]);
734
+ });
735
+
736
+ it('verifyCertFP enqueues nothing on an unchanged canonical DN arm', async () => {
737
+ const s = recordingStore();
738
+ s.registerNick('alice', 'pw', 'a@x');
739
+ s.addCertFP('alice', 'cn=alice');
740
+ await s.flush();
741
+ s.batches.length = 0;
742
+
743
+ const out = s.verifyCertFP(certIdentityFromSubject('CN=alice'));
744
+ expect(out.ok).toBe(true);
745
+ await s.flush();
746
+ expect(s.batches).toEqual([]);
747
+ });
748
+
749
+ it('addCertFingerprint re-persists the nick row once per new binding', async () => {
750
+ const s = recordingStore();
751
+ s.registerNick('alice', 'pw', 'a@x');
752
+ await s.flush();
753
+ s.batches.length = 0;
754
+
755
+ expect(s.addCertFingerprint('alice', 'AA:11')).toBe(true);
756
+ s.addCertFingerprint('alice', 'aa11'); // normalised duplicate — no write
757
+ expect(s.addCertFingerprint('ghost', 'aa11')).toBe(false); // unknown nick
758
+ await s.flush();
759
+ const upserts = s.batches.flat().filter((w) => w.kind === 'upsertNick');
760
+ expect(upserts).toHaveLength(1);
761
+ expect(lastNickRow(s)?.certSubjects).toEqual(['fp:aa11']);
762
+ });
763
+ });
764
+
565
765
  // ============================================================================
566
766
  // helpers
567
767
  // ============================================================================