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
@@ -29,9 +29,10 @@ function makeCtx(
29
29
  conn: ConnectionState,
30
30
  clock = new FakeClock(1_000),
31
31
  services?: ServicesStore,
32
+ config: ServerConfig = serverConfig,
32
33
  ): Ctx {
33
34
  return buildCtx({
34
- serverConfig,
35
+ serverConfig: config,
35
36
  clock,
36
37
  ids: new SequentialIdFactory(),
37
38
  motd: EmptyMotdProvider,
@@ -182,6 +183,21 @@ describe('topicReducer — writes', () => {
182
183
  expect(out.state.topic?.text).toBe('a'.repeat(serverConfig.topicLen));
183
184
  });
184
185
 
186
+ it('keeps an ASCII topic of exactly topicLen bytes unchanged (no truncation)', () => {
187
+ const chan = makeChan('#foo');
188
+ addMember(chan, 'c1', 'alice');
189
+ const conn = makeConn();
190
+ const ctx = makeCtx(conn, new FakeClock(1_000), undefined, {
191
+ ...serverConfig,
192
+ topicLen: 64,
193
+ });
194
+
195
+ const topic = 'a'.repeat(64);
196
+ const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo', topic], tags: {} }, ctx);
197
+
198
+ expect(out.state.topic?.text).toBe(topic);
199
+ });
200
+
185
201
  it('emits 482 ERR_CHANOPRIVSNEEDED when +t is set and the writer is a non-op member', () => {
186
202
  const chan = makeChan('#foo');
187
203
  chan.modes.topicLock = true;
@@ -238,9 +254,91 @@ describe('topicReducer — writes', () => {
238
254
  });
239
255
 
240
256
  // ============================================================================
241
- // topicReducer — rejections
257
+ // topicReducer — byte-budget truncation (TOPICLEN as a UTF-8 byte ceiling)
242
258
  // ============================================================================
243
259
 
260
+ describe('topicReducer — byte-budget truncation', () => {
261
+ const byteCtx = (conn: ConnectionState): Ctx =>
262
+ makeCtx(conn, new FakeClock(1_000), undefined, { ...serverConfig, topicLen: 64 });
263
+
264
+ it('truncates a multi-byte Latin-1 topic to the byte budget, not the char budget', () => {
265
+ const chan = makeChan('#foo');
266
+ addMember(chan, 'c1', 'alice');
267
+ const conn = makeConn();
268
+ const ctx = byteCtx(conn);
269
+
270
+ // 50 `é` chars = 50 UTF-16 units (under the 64 char budget) but 100
271
+ // UTF-8 bytes (over the 64 byte budget). Char-based slicing keeps all
272
+ // 50; byte-based truncation must keep exactly 32 (64 bytes).
273
+ const out = topicReducer(
274
+ chan,
275
+ { command: 'TOPIC', params: ['#foo', 'é'.repeat(50)], tags: {} },
276
+ ctx,
277
+ );
278
+
279
+ expect(out.state.topic?.text).toBe('é'.repeat(32));
280
+ });
281
+
282
+ it('backs off to a complete-char boundary when the budget lands mid-CJK-char', () => {
283
+ const chan = makeChan('#foo');
284
+ addMember(chan, 'c1', 'alice');
285
+ const conn = makeConn();
286
+ const ctx = byteCtx(conn);
287
+
288
+ // `好` is 3 UTF-8 bytes. 64 bytes fits 21 whole chars (63 bytes); the
289
+ // 22nd would need 66 bytes, so it is dropped whole and the result is a
290
+ // valid-UTF-8 string 1 byte under budget — never split mid-char.
291
+ const out = topicReducer(
292
+ chan,
293
+ { command: 'TOPIC', params: ['#foo', '好'.repeat(30)], tags: {} },
294
+ ctx,
295
+ );
296
+
297
+ expect(out.state.topic?.text).toBe('好'.repeat(21));
298
+ });
299
+
300
+ it('truncates an emoji topic without splitting surrogate pairs', () => {
301
+ const chan = makeChan('#foo');
302
+ addMember(chan, 'c1', 'alice');
303
+ const conn = makeConn();
304
+ const ctx = byteCtx(conn);
305
+
306
+ // `😀` is astral: 2 UTF-16 code units and 4 UTF-8 bytes each. 20 emoji
307
+ // = 40 UTF-16 units (under 64 chars) but 80 bytes (over budget). The
308
+ // byte budget fits exactly 16 emoji (64 bytes); a naive UTF-16 slice
309
+ // at 64 units would keep all 20, and a naive byte slice at 64 would
310
+ // split a surrogate pair.
311
+ const out = topicReducer(
312
+ chan,
313
+ { command: 'TOPIC', params: ['#foo', '😀'.repeat(20)], tags: {} },
314
+ ctx,
315
+ );
316
+
317
+ expect(out.state.topic?.text).toBe('😀'.repeat(16));
318
+ });
319
+
320
+ it('broadcasts and persists the byte-truncated text', () => {
321
+ const chan = makeChan('#foo');
322
+ addMember(chan, 'c1', 'alice');
323
+ const conn = makeConn();
324
+ const ctx = byteCtx(conn);
325
+
326
+ const out = topicReducer(
327
+ chan,
328
+ { command: 'TOPIC', params: ['#foo', 'é'.repeat(50)], tags: {} },
329
+ ctx,
330
+ );
331
+
332
+ const truncated = 'é'.repeat(32);
333
+ expect(out.effects).toEqual<EffectType[]>([
334
+ Effect.applyChannelDelta('#foo', {
335
+ topic: { text: truncated, setter: 'alice!alice@example.com', setAt: 1_000 },
336
+ }),
337
+ Effect.broadcast('#foo', [L(`:alice!alice@example.com TOPIC #foo :${truncated}`)]),
338
+ ]);
339
+ });
340
+ });
341
+
244
342
  describe('topicReducer — rejections', () => {
245
343
  it('emits 461 ERR_NEEDMOREPARAMS when no channel is supplied', () => {
246
344
  const chan = makeChan('#foo');
@@ -279,6 +377,43 @@ describe('topicReducer — rejections', () => {
279
377
  ]);
280
378
  });
281
379
 
380
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing NUL', () => {
381
+ const chan = makeChan('#foo');
382
+ const conn = makeConn();
383
+ const ctx = makeCtx(conn);
384
+
385
+ const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo\0bar'], tags: {} }, ctx);
386
+
387
+ expect(out.effects).toEqual<EffectType[]>([
388
+ Effect.send('c1', [L(':irc.example.com 403 alice #foo\0bar :No such channel')]),
389
+ ]);
390
+ });
391
+
392
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name containing a control character', () => {
393
+ const chan = makeChan('#foo');
394
+ const conn = makeConn();
395
+ const ctx = makeCtx(conn);
396
+
397
+ const out = topicReducer(chan, { command: 'TOPIC', params: ['#foo\x1fbar'], tags: {} }, ctx);
398
+
399
+ expect(out.effects).toEqual<EffectType[]>([
400
+ Effect.send('c1', [L(':irc.example.com 403 alice #foo\x1fbar :No such channel')]),
401
+ ]);
402
+ });
403
+
404
+ it('emits 403 ERR_NOSUCHCHANNEL for a channel name whose UTF-8 byte size exceeds the cap', () => {
405
+ const long = `#${'é'.repeat(25)}`; // 51 UTF-8 bytes, only 26 UTF-16 units
406
+ const chan = makeChan(long);
407
+ const conn = makeConn();
408
+ const ctx = makeCtx(conn);
409
+
410
+ const out = topicReducer(chan, { command: 'TOPIC', params: [long], tags: {} }, ctx);
411
+
412
+ expect(out.effects).toEqual<EffectType[]>([
413
+ Effect.send('c1', [L(`:irc.example.com 403 alice ${long} :No such channel`)]),
414
+ ]);
415
+ });
416
+
282
417
  it('emits 403 ERR_NOSUCHCHANNEL for an empty channel name', () => {
283
418
  const chan = makeChan('#foo');
284
419
  const conn = makeConn();
@@ -15,12 +15,15 @@
15
15
  */
16
16
 
17
17
  import { describe, expect, it } from 'vitest';
18
- import { applyAccountSuccess } from '../../src/commands/account-auth';
18
+ import {
19
+ applyAccountSuccess,
20
+ attemptPassAccountAuth,
21
+ passBasedAccountAuth,
22
+ } from '../../src/commands/account-auth';
19
23
  import { NICKSERV_NICK, nickservReducer } from '../../src/commands/nickserv';
24
+ import { passReducer } from '../../src/commands/registration';
20
25
  import { authenticateReducer } from '../../src/commands/sasl';
21
- import { type HashedAccountCredential, hashAccountCredential } from '../../src/credential-hashing';
22
26
  import type { Effect as EffectType } from '../../src/effects';
23
- import type { AccountStore } from '../../src/ports';
24
27
  import {
25
28
  EmptyMotdProvider,
26
29
  FakeClock,
@@ -71,18 +74,13 @@ function saslReadyState(nick = 'alice'): ConnectionState {
71
74
  return s;
72
75
  }
73
76
 
74
- function makeCtx(
75
- state: ConnectionState,
76
- accounts?: AccountStore,
77
- services?: InMemoryServicesStore,
78
- ): Ctx {
77
+ function makeCtx(state: ConnectionState, services?: InMemoryServicesStore): Ctx {
79
78
  return buildCtx({
80
79
  serverConfig,
81
80
  clock: new FakeClock(NOW),
82
81
  ids: new SequentialIdFactory(),
83
82
  motd: EmptyMotdProvider,
84
83
  connection: state,
85
- ...(accounts !== undefined ? { accounts } : {}),
86
84
  ...(services !== undefined ? { services } : {}),
87
85
  });
88
86
  }
@@ -119,6 +117,80 @@ function emptySnapshot(): ServicesSnapshot {
119
117
  };
120
118
  }
121
119
 
120
+ // ============================================================================
121
+ // AC: NickServ REGISTER → PASS <nick>:<password> succeeds post-registration
122
+ // (T178: PASS-auth consults ServicesStore.verifyNick — the bug fix)
123
+ // ============================================================================
124
+
125
+ describe('T178 unified account: NickServ REGISTER → PASS-auth (post-registration)', () => {
126
+ it('a freshly NickServ-registered nick authenticates via PASS <nick>:<password>', () => {
127
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
128
+ const registrar = makeConn('c-reg', 'alice');
129
+ nickservReducer(
130
+ registrar,
131
+ privmsg('REGISTER hunter2 alice@example.com'),
132
+ makeCtx(registrar, services),
133
+ );
134
+
135
+ // A second connection sending PASS after registration (no AccountStore).
136
+ const conn = makeConn('c1', 'alice');
137
+ const ctx = makeCtx(conn, services);
138
+ const out = passReducer(conn, { command: 'PASS', params: ['alice:hunter2'], tags: {} }, ctx);
139
+
140
+ expect(conn.account).toBe('alice');
141
+ expect(conn.userModes.registered).toBe(true);
142
+ const texts = sentTexts(out.effects);
143
+ expect(texts).toContain(
144
+ `:${SV} 900 alice alice!alice@example.com alice :You are now logged in as alice`,
145
+ );
146
+ expect(texts).toContain(`:${SV} 903 alice :SASL authentication successful`);
147
+ });
148
+
149
+ it('PASS-auth with the wrong password after NickServ REGISTER is a silent no-op', () => {
150
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
151
+ const registrar = makeConn('c-reg', 'alice');
152
+ nickservReducer(
153
+ registrar,
154
+ privmsg('REGISTER hunter2 alice@example.com'),
155
+ makeCtx(registrar, services),
156
+ );
157
+
158
+ const conn = makeConn('c1', 'alice');
159
+ const ctx = makeCtx(conn, services);
160
+ const out = passReducer(conn, { command: 'PASS', params: ['alice:wrong'], tags: {} }, ctx);
161
+
162
+ expect(conn.account).toBeUndefined();
163
+ expect(out.effects).toEqual<EffectType[]>([]);
164
+ });
165
+
166
+ it('passBasedAccountAuth identifies via services.verifyNick at registration completion', () => {
167
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
168
+ const registrar = makeConn('c-reg', 'alice');
169
+ nickservReducer(
170
+ registrar,
171
+ privmsg('REGISTER hunter2 alice@example.com'),
172
+ makeCtx(registrar, services),
173
+ );
174
+
175
+ const conn = makeConn('c1', 'alice');
176
+ conn.registration = 'registering';
177
+ conn.passAttempt = 'alice:hunter2';
178
+ const effects = passBasedAccountAuth(conn, makeCtx(conn, services));
179
+
180
+ expect(conn.account).toBe('alice');
181
+ expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(true);
182
+ });
183
+
184
+ it('attemptPassAccountAuth consults services.verifyNick directly', () => {
185
+ const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
186
+ services.registerNick('alice', 'hunter2', 'alice@example.com');
187
+ const conn = makeConn('c1', 'alice');
188
+ const effects = attemptPassAccountAuth(conn, makeCtx(conn, services), 'alice:hunter2');
189
+ expect(conn.account).toBe('alice');
190
+ expect(sentTexts(effects).some((t) => t.includes(' 900 '))).toBe(true);
191
+ });
192
+ });
193
+
122
194
  // ============================================================================
123
195
  // AC: NickServ REGISTER → SASL PLAIN succeeds with the same password
124
196
  // ============================================================================
@@ -127,12 +199,12 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
127
199
  it('a freshly NickServ-registered nick authenticates via SASL PLAIN with no second registration', () => {
128
200
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
129
201
  const registrar = makeConn('c-reg', 'alice');
130
- const regCtx = makeCtx(registrar, undefined, services);
202
+ const regCtx = makeCtx(registrar, services);
131
203
  nickservReducer(registrar, privmsg('REGISTER hunter2 alice@example.com'), regCtx);
132
204
 
133
205
  // A second connection speaking SASL PLAIN with the same nick:password.
134
206
  const saslConn = saslReadyState('alice');
135
- const saslCtx = makeCtx(saslConn, undefined, services);
207
+ const saslCtx = makeCtx(saslConn, services);
136
208
  authenticateReducer(saslConn, authenticate('PLAIN'), saslCtx);
137
209
  const out = authenticateReducer(
138
210
  saslConn,
@@ -155,11 +227,11 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
155
227
  nickservReducer(
156
228
  registrar,
157
229
  privmsg('REGISTER hunter2 alice@example.com'),
158
- makeCtx(registrar, undefined, services),
230
+ makeCtx(registrar, services),
159
231
  );
160
232
 
161
233
  const saslConn = saslReadyState('alice');
162
- const saslCtx = makeCtx(saslConn, undefined, services);
234
+ const saslCtx = makeCtx(saslConn, services);
163
235
  authenticateReducer(saslConn, authenticate('PLAIN'), saslCtx);
164
236
  const out = authenticateReducer(
165
237
  saslConn,
@@ -179,10 +251,10 @@ describe('T156 unified account: NickServ REGISTER → SASL PLAIN', () => {
179
251
  describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', () => {
180
252
  it('NickServ INFO resolves an account seeded into the unified store', () => {
181
253
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
182
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
254
+ services.registerNick('alice', 'hunter2', '');
183
255
 
184
256
  const conn = makeConn('c1', 'bob');
185
- const out = nickservReducer(conn, privmsg('INFO alice'), makeCtx(conn, undefined, services));
257
+ const out = nickservReducer(conn, privmsg('INFO alice'), makeCtx(conn, services));
186
258
 
187
259
  const texts = sentTexts(out.effects);
188
260
  expect(texts).toContain(':NickServ!NickServ@services NOTICE bob :Nick: alice');
@@ -191,22 +263,18 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
191
263
 
192
264
  it('NickServ isRegisteredNick resolves a seeded SASL account', () => {
193
265
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
194
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
266
+ services.registerNick('alice', 'hunter2', '');
195
267
  expect(services.isRegisteredNick('alice')).toBe(true);
196
268
  expect(services.isRegisteredNick('ALICE')).toBe(true);
197
269
  });
198
270
 
199
271
  it('NickServ SET ENFORCE applies to a seeded SASL account once identified', () => {
200
272
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
201
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
273
+ services.registerNick('alice', 'hunter2', '');
202
274
  const conn = makeConn('c1', 'alice');
203
275
  conn.account = 'alice';
204
276
  conn.userModes.registered = true;
205
- const out = nickservReducer(
206
- conn,
207
- privmsg('SET ENFORCE kill'),
208
- makeCtx(conn, undefined, services),
209
- );
277
+ const out = nickservReducer(conn, privmsg('SET ENFORCE kill'), makeCtx(conn, services));
210
278
 
211
279
  expect(services.getNickEnforce('alice')).toBe<NickEnforcePolicy>('kill');
212
280
  expect(sentTexts(out.effects)).toContain(
@@ -216,13 +284,9 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
216
284
 
217
285
  it('NickServ IDENTIFY succeeds against a seeded SASL account', () => {
218
286
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
219
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
287
+ services.registerNick('alice', 'hunter2', '');
220
288
  const conn = makeConn('c1', 'alice');
221
- const out = nickservReducer(
222
- conn,
223
- privmsg('IDENTIFY hunter2'),
224
- makeCtx(conn, undefined, services),
225
- );
289
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), makeCtx(conn, services));
226
290
 
227
291
  expect(conn.account).toBe('alice');
228
292
  expect(conn.userModes.registered).toBe(true);
@@ -233,9 +297,9 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
233
297
 
234
298
  it('NickServ IDENTIFY with the wrong password fails against a seeded SASL account', () => {
235
299
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
236
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
300
+ services.registerNick('alice', 'hunter2', '');
237
301
  const conn = makeConn('c1', 'alice');
238
- const out = nickservReducer(conn, privmsg('IDENTIFY nope'), makeCtx(conn, undefined, services));
302
+ const out = nickservReducer(conn, privmsg('IDENTIFY nope'), makeCtx(conn, services));
239
303
 
240
304
  expect(conn.account).toBeUndefined();
241
305
  expect(sentTexts(out.effects)).toContain(
@@ -243,14 +307,14 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
243
307
  );
244
308
  });
245
309
 
246
- it('a NickServ REGISTER does not overwrite an ingested SASL credential', () => {
310
+ it('a NickServ REGISTER does not overwrite an existing credential', () => {
247
311
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
248
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
312
+ services.registerNick('alice', 'hunter2', '');
249
313
  const conn = makeConn('c1', 'alice');
250
314
  const out = nickservReducer(
251
315
  conn,
252
316
  privmsg('REGISTER otherpw alice@example.com'),
253
- makeCtx(conn, undefined, services),
317
+ makeCtx(conn, services),
254
318
  );
255
319
 
256
320
  expect(sentTexts(out.effects)).toContain(
@@ -260,14 +324,6 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
260
324
  expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
261
325
  expect(services.verifyNick('alice', 'otherpw').ok).toBe(false);
262
326
  });
263
-
264
- it('ingestAccountCredential returns false when the nick is already registered', () => {
265
- const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
266
- services.registerNick('alice', 'hunter2', 'alice@example.com');
267
- expect(services.ingestAccountCredential(hashAccountCredential('alice', 'otherpw'))).toBe(false);
268
- // The original credential still verifies.
269
- expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
270
- });
271
327
  });
272
328
 
273
329
  // ============================================================================
@@ -277,15 +333,11 @@ describe('T156 unified account: SASL account → NickServ INFO / SET ENFORCE', (
277
333
  describe('T156 unified account: IDENTIFY vs SASL downstream parity', () => {
278
334
  it('IDENTIFY delivers queued memos (parity with SASL success)', () => {
279
335
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
280
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
336
+ services.registerNick('alice', 'hunter2', '');
281
337
  services.recordMemo({ from: 'bob', to: 'alice', body: 'hello', sentAt: NOW });
282
338
 
283
339
  const conn = makeConn('c1', 'alice');
284
- const out = nickservReducer(
285
- conn,
286
- privmsg('IDENTIFY hunter2'),
287
- makeCtx(conn, undefined, services),
288
- );
340
+ const out = nickservReducer(conn, privmsg('IDENTIFY hunter2'), makeCtx(conn, services));
289
341
 
290
342
  expect(sentTexts(out.effects)).toContain(
291
343
  ':MemoServ!MemoServ@services NOTICE alice :[Memo #1 from bob] hello',
@@ -294,9 +346,9 @@ describe('T156 unified account: IDENTIFY vs SASL downstream parity', () => {
294
346
 
295
347
  it('both paths share applyAccountSuccess for +r stamping', () => {
296
348
  const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
297
- services.ingestAccountCredential(hashAccountCredential('alice', 'hunter2'));
349
+ services.registerNick('alice', 'hunter2', '');
298
350
  const conn = makeConn('c1', 'alice');
299
- const ctx = makeCtx(conn, undefined, services);
351
+ const ctx = makeCtx(conn, services);
300
352
 
301
353
  const effects = applyAccountSuccess(conn, ctx, 'alice');
302
354
 
@@ -356,6 +408,9 @@ describe('T156 unified account: scrypt-hashed credentials at rest', () => {
356
408
  email: 'l@x',
357
409
  createdAt: NOW,
358
410
  enforce: 'none',
411
+ certSubjects: [],
412
+ identifyFailures: [],
413
+ pendingIdentifyFailureNotice: 0,
359
414
  // Malformed credential — empty hash, rejected by verifyHashedPassword.
360
415
  credential: { account: 'legacy', algorithm: 'scrypt', salt: 's', hash: '' },
361
416
  },
@@ -379,38 +434,3 @@ describe('T156 unified account: scrypt-hashed credentials at rest', () => {
379
434
  expect(JSON.stringify(snap.nicks[0])).not.toContain('hunter2');
380
435
  });
381
436
  });
382
-
383
- // ============================================================================
384
- // Adapter ingestion contract: ingestAccountCredential accepts HashedAccountCredential
385
- // ============================================================================
386
-
387
- describe('T156 unified account: ingestAccountCredential', () => {
388
- it('creates a registered nick with default email / enforce from a hashed credential', () => {
389
- const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
390
- const cred = hashAccountCredential('alice', 'hunter2');
391
- expect(services.ingestAccountCredential(cred)).toBe(true);
392
- expect(services.isRegisteredNick('alice')).toBe(true);
393
- const rec = services.getNick('alice');
394
- expect(rec).toBeDefined();
395
- if (rec !== undefined) {
396
- expect(rec.account).toBe('alice');
397
- expect(rec.enforce).toBe<NickEnforcePolicy>('none');
398
- }
399
- // verifyNick goes through the same scrypt path as registerNick.
400
- expect(services.verifyNick('alice', 'hunter2')).toEqual({ ok: true, account: 'alice' });
401
- });
402
-
403
- it('preserves the original spelling of the account name', () => {
404
- const services = new InMemoryServicesStore({ clock: new FakeClock(NOW) });
405
- const cred: HashedAccountCredential = {
406
- account: 'Alice',
407
- algorithm: 'scrypt',
408
- salt: 's',
409
- hash: 'aGVsbG8=',
410
- };
411
- services.ingestAccountCredential(cred);
412
- const rec = services.getNick('Alice');
413
- expect(rec?.nick).toBe('Alice');
414
- expect(rec?.account).toBe('Alice');
415
- });
416
- });