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
@@ -17,6 +17,7 @@ import { cloakHost } from '../cloak.js';
17
17
  import { formatCreatedAt, resolveServerVersion } from '../config.js';
18
18
  import { Effect } from '../effects.js';
19
19
  import type { Effect as EffectType, RawLine } from '../effects.js';
20
+ import { constantTimeEquals } from '../ports.js';
20
21
  import { Numerics } from '../protocol/numerics.js';
21
22
  import type { ChanName } from '../state/channel.js';
22
23
  import { hostmaskOf } from '../state/connection.js';
@@ -28,6 +29,31 @@ import { buildMotdNumerics } from './motd-lines.js';
28
29
  import { enforceRegisteredNick } from './nickserv.js';
29
30
  import { checkAkillAdmission, enforceJupedNick } from './operserv.js';
30
31
 
32
+ /**
33
+ * Indirection over {@link constantTimeEquals} so tests can swap in a
34
+ * call-counting stub and prove the server-password gate funnels every
35
+ * comparison bucket (equal / unequal / different length) through exactly
36
+ * one constant-time comparison. Defaults to the real primitive; `null`
37
+ * restores the default.
38
+ */
39
+ let constantTimeEqualsFn: (a: string, b: string) => boolean = constantTimeEquals;
40
+
41
+ /**
42
+ * Test seam: replaces the server-password comparison primitive with `fn`
43
+ * (or restores the default when `fn === null`). Exported for tests;
44
+ * production callers MUST NOT use.
45
+ *
46
+ * @internal
47
+ */
48
+ export function setServerPasswordCompare(fn: ((a: string, b: string) => boolean) | null): void {
49
+ constantTimeEqualsFn = fn ?? constantTimeEquals;
50
+ }
51
+
52
+ /** @internal test seam: clears any spy registered above. */
53
+ export function resetServerPasswordCompareSpy(): void {
54
+ constantTimeEqualsFn = constantTimeEquals;
55
+ }
56
+
31
57
  /** User mode letters for `004 RPL_MYINFO` (invisible, oper, wallops, server-notices). */
32
58
  const USER_MODES_004 = 'iosw';
33
59
 
@@ -50,6 +76,79 @@ export function isValidNick(nick: string, maxLen: number): boolean {
50
76
  return NICK_GRAMMAR_RE.test(nick);
51
77
  }
52
78
 
79
+ // ============================================================================
80
+ // USER username/realname validation (charset + length + control chars)
81
+ // ----------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Maximum username (ident) length in bytes. Matches the de-facto IRC standard
85
+ * `USERLEN=16` advertised in `005 RPL_ISUPPORT` by mainstream daemons.
86
+ */
87
+ const USERLEN = 16;
88
+
89
+ /**
90
+ * Maximum realname (GECOS) length in bytes. Matches the common ircd-seven
91
+ * GECOS ceiling; enforced as a byte cap so multi-byte UTF-8 realnames are
92
+ * measured correctly.
93
+ */
94
+ const REALLEN = 64;
95
+
96
+ /**
97
+ * C0 control chars + NUL (\x00-\x1f). Covers `\0`, `\r`, `\n`, and the rest
98
+ * of the ASCII control block. These MUST NOT appear in either the username
99
+ * or the realname: a bare `\r` in the realname survives the line parser
100
+ * (which strips only trailing CR/LF) and produces CRLF injection; NUL bytes
101
+ * truncate display; other control chars corrupt terminal rendering.
102
+ */
103
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — this regex exists to detect C0 control chars (NUL/CR/LF/etc.) in USER params.
104
+ const CONTROL_CHAR_RE = /[\x00-\x1f]/u;
105
+
106
+ /**
107
+ * Username-specific forbidden chars: space, `!`, and `@`. These corrupt
108
+ * hostmask parsing (`nick!user@host`) — a username containing `!` or `@`
109
+ * makes the hostmask ambiguous, and a space breaks param delimiting on
110
+ * re-serialization. Not applied to realname (spaces are valid there).
111
+ */
112
+ const USERNAME_FORBIDDEN_RE = /[ !@]/u;
113
+
114
+ /** Canonical disconnect reason applied on USER validation failure. */
115
+ const USER_INVALID_REASON = 'invalid USER';
116
+
117
+ /** Tailored NOTICE text emitted before disconnect on an invalid username. */
118
+ const USERNAME_INVALID_TEXT =
119
+ 'Invalid USER: username must be 1-16 bytes with no spaces, !, @, or control characters';
120
+
121
+ /** Tailored NOTICE text emitted before disconnect on an invalid realname. */
122
+ const REALNAME_INVALID_TEXT =
123
+ 'Invalid USER: realname must be at most 64 bytes with no control characters';
124
+
125
+ /** Reused encoder for UTF-8 byte-length measurement (stateless, safe to share). */
126
+ const utf8Encoder = new TextEncoder();
127
+
128
+ /**
129
+ * Returns true iff `value` is a safe username: at most {@link USERLEN} UTF-8
130
+ * bytes, no hostmask-corrupting chars (`!`/`@`/space), and no C0 control
131
+ * chars.
132
+ */
133
+ function isValidUsername(value: string): boolean {
134
+ if (utf8Encoder.encode(value).length > USERLEN) return false;
135
+ if (USERNAME_FORBIDDEN_RE.test(value)) return false;
136
+ if (CONTROL_CHAR_RE.test(value)) return false;
137
+ return true;
138
+ }
139
+
140
+ /**
141
+ * Returns true iff `value` is a safe realname: at most {@link REALLEN} UTF-8
142
+ * bytes and no C0 control chars. Spaces ARE allowed (real names contain
143
+ * spaces); hostmask chars (`!`/`@`) are also allowed since the realname
144
+ * never appears in a hostmask.
145
+ */
146
+ function isValidRealname(value: string): boolean {
147
+ if (utf8Encoder.encode(value).length > REALLEN) return false;
148
+ if (CONTROL_CHAR_RE.test(value)) return false;
149
+ return true;
150
+ }
151
+
53
152
  /**
54
153
  * Builds the welcome numeric block emitted once registration completes:
55
154
  * `001`–`005` (with `005` generated from the server config) followed by
@@ -100,7 +199,9 @@ export function buildWelcomeLines(state: ConnectionState, ctx: Ctx): RawLine[] {
100
199
  * `serverConfig.serverPassword`) before completing registration:
101
200
  * - No password configured (undefined or empty) → gate skipped.
102
201
  * - SASL already authenticated (`state.account` set) → gate satisfied.
103
- * - Otherwise `state.passAttempt` must equal the configured password.
202
+ * - Otherwise `state.passAttempt` must equal the configured password,
203
+ * compared in constant time ({@link constantTimeEquals}) so response
204
+ * timing leaks neither the password length nor a matching prefix.
104
205
  *
105
206
  * On gate failure the connection is NOT registered; the returned effects
106
207
  * are `[Send(464 ERR_PASSWDMISMATCH), Disconnect("Bad Password")]` per
@@ -119,7 +220,7 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
119
220
  // the account-login numerics (900/903 + downstream effects), satisfying
120
221
  // the gate as a side effect. On every other outcome it returns `[]` and
121
222
  // the gate below decides. No-op when already identified (SASL), when no
122
- // `<nick>:<password>` form is present, or when no AccountStore is bound.
223
+ // `<nick>:<password>` form is present, or when no ServicesStore is bound.
123
224
  const authEffects = passBasedAccountAuth(state, ctx);
124
225
 
125
226
  const gateFailure = passwordGateFailure(state, ctx);
@@ -187,7 +288,11 @@ function passwordGateFailure(state: ConnectionState, ctx: Ctx): EffectType[] | n
187
288
  if (configured === undefined || configured === '') return null;
188
289
  // SASL success satisfies the password gate.
189
290
  if (state.account !== undefined) return null;
190
- if (state.passAttempt === configured) return null;
291
+ // Constant-time compare: a short-circuiting `===` would leak how much of
292
+ // the attempt matched (and the attempt length) through response timing.
293
+ // A missing attempt folds to the empty string — never equal, because a
294
+ // non-empty `configured` is guaranteed by the disabled-gate check above.
295
+ if (constantTimeEqualsFn(state.passAttempt ?? '', configured)) return null;
191
296
  return [
192
297
  Effect.send(ctx.connId, [numericErr(ctx, Numerics.ERR_PASSWDMISMATCH, 'Password mismatch')]),
193
298
  Effect.disconnect(ctx.connId, PASSWORD_MISMATCH_REASON),
@@ -212,6 +317,16 @@ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string):
212
317
  return { text: parts.join(' ') };
213
318
  }
214
319
 
320
+ /**
321
+ * Formats a `:<server> NOTICE <nick> :<text>` line addressed to the
322
+ * connection's current nick (or `*` when unregistered). Used for USER
323
+ * validation rejections where no RFC numeric fits the failure mode.
324
+ */
325
+ function serverNotice(ctx: Ctx, text: string): RawLine {
326
+ const nick = ctx.connection.nick ?? '*';
327
+ return { text: `:${ctx.serverName} NOTICE ${nick} :${text}` };
328
+ }
329
+
215
330
  /**
216
331
  * Reducer for `NICK <nickname>`.
217
332
  *
@@ -330,6 +445,35 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
330
445
  return { state, effects };
331
446
  }
332
447
 
448
+ // Validate username and realname BEFORE storing to avoid half-stored
449
+ // state on rejection. The username (params[0]) flows into every outbound
450
+ // hostmask (`nick!user@host`); a `!`/`@`/space in it corrupts hostmask
451
+ // parsing. The realname (params[3]) appears in `311 RPL_WHOISUSER` and,
452
+ // if it carries a bare `\r`, produces CRLF injection (the line parser
453
+ // strips only trailing CR/LF). Both fields are byte-capped to keep
454
+ // 001/311/005 replies within the 512-byte wire budget.
455
+ //
456
+ // Reply format: a tailored server NOTICE followed by Disconnect.
457
+ // No RFC numeric fits "invalid USER charset" — 461 ERR_NEEDMOREPARAMS is
458
+ // for missing params, and 432 ERR_ERRONEUSNICKNAME is nick-specific. The
459
+ // NOTICE + Disconnect mirrors the AKILL admission hook's pattern and
460
+ // matches mainstream IRC daemon behaviour for registration security
461
+ // failures. The adapter renders the reason as `ERROR :Closing link: …`.
462
+ if (!isValidUsername(user)) {
463
+ effects.push(
464
+ Effect.send(ctx.connId, [serverNotice(ctx, USERNAME_INVALID_TEXT)]),
465
+ Effect.disconnect(ctx.connId, USER_INVALID_REASON),
466
+ );
467
+ return { state, effects };
468
+ }
469
+ if (!isValidRealname(realname)) {
470
+ effects.push(
471
+ Effect.send(ctx.connId, [serverNotice(ctx, REALNAME_INVALID_TEXT)]),
472
+ Effect.disconnect(ctx.connId, USER_INVALID_REASON),
473
+ );
474
+ return { state, effects };
475
+ }
476
+
333
477
  state.user = user;
334
478
  state.realname = realname;
335
479
  if (state.registration === 'pre-registration') {
@@ -351,7 +495,7 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
351
495
  *
352
496
  * Post-registration: a bare server password is meaningless, so only the
353
497
  * `<nick>:<password>` form is honoured — as a late account login via
354
- * {@link attemptPassAccountAuth} against the bound `ctx.accounts`. Every
498
+ * {@link attemptPassAccountAuth} against the bound `ctx.services`. Every
355
499
  * other outcome (bare value, no store, already identified, verify failure)
356
500
  * is a silent no-op: no `462`, no disconnect. This lets a client that
357
501
  * completed `NICK`+`USER` before sending `PASS` identify after the fact.
@@ -6,30 +6,47 @@
6
6
  *
7
7
  * 1. `AUTHENTICATE <MECH>` — selects a mechanism. `PLAIN` is always
8
8
  * supported and answers with `AUTHENTICATE +` (requesting the payload).
9
- * `EXTERNAL` is supported only when an mTLS identity provider is
10
- * configured for the connection; without one, EXTERNAL falls through
11
- * to `908 ERR_SASLMECHS` (mechanism not available) listing what is.
9
+ * `EXTERNAL` is supported only when ALL of no transport-level
10
+ * non-support override (`sasl.externalUnsupportedMessage`), an mTLS
11
+ * identity source, the `sasl.externalEnabled` operator opt-in, and a
12
+ * secure (TLS) transport are in place; under the override the
13
+ * mechanism is rejected up front with a `904 ERR_SASLFAIL` carrying
14
+ * the configured transport-specific message, without it and without
15
+ * any one of the other three, EXTERNAL falls through to
16
+ * `908 ERR_SASLMECHS` (mechanism not available) listing what is.
12
17
  * The verified client-cert subject is resolved at finalise time.
13
18
  * Anything else gets `908 ERR_SASLMECHS` listing what is available.
14
19
  * 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
15
20
  * or in `<=400`-byte chunks; `+` marks an empty/final chunk). The
16
- * decoded bytes are handed to the injected {@link AccountStore}; on
17
- * success the connection records the account name and the reducer emits
18
- * `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure `904 ERR_SASLFAIL`.
21
+ * decoded bytes are verified against the bound {@link ServicesStore};
22
+ * on success the connection records the account name and the reducer
23
+ * emits `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure
24
+ * `904 ERR_SASLFAIL`. PLAIN consults `services.verifyNick`, EXTERNAL
25
+ * consults `services.verifyCertFP` with the mTLS-resolved cert
26
+ * identity (DER SHA-256 fingerprint preferred, canonical DN
27
+ * fallback).
19
28
  * 3. `AUTHENTICATE *` — client abort → `906 ERR_SASLABORT`.
20
29
  *
21
- * The `AccountStore` port is **synchronous** (see {@link AccountStore} docs):
30
+ * The {@link ServicesStore} port is **synchronous** (reducers stay pure):
22
31
  * adapters pre-load credentials so the reducer stays pure. Async backends
23
32
  * resolve their lookups before the actor invokes this reducer.
24
33
  *
34
+ * Brute-force hardening: every `904 ERR_SASLFAIL` records a timestamp on
35
+ * the per-connection tracker ({@link ConnectionState.saslFailures}); once
36
+ * the configured `sasl.maxFailures` ceiling is reached inside the
37
+ * configured `sasl.failureWindowSeconds` sliding window, the connection is
38
+ * disconnected (904 first, then the Disconnect). A successful login resets
39
+ * the tracker.
40
+ *
25
41
  * State: {@link ConnectionState.saslMech} tracks the mechanism in flight;
26
42
  * {@link ConnectionState.saslBuffer} accumulates chunked payloads. Both are
27
43
  * cleared on every terminal outcome.
28
44
  */
29
45
 
46
+ import { DEFAULT_SASL_FAILURE_WINDOW_SECONDS, DEFAULT_SASL_MAX_FAILURES } from '../config.js';
30
47
  import { Effect } from '../effects.js';
31
48
  import type { Effect as EffectType, RawLine } from '../effects.js';
32
- import type { SaslResult } from '../ports.js';
49
+ import type { ServicesAuthResult } from '../ports.js';
33
50
  import { decodeBase64 } from '../protocol/base64.js';
34
51
  import { Numerics } from '../protocol/numerics.js';
35
52
  import type { ConnectionState } from '../state/connection.js';
@@ -42,16 +59,51 @@ const SASL_CHUNK_BYTES = 400;
42
59
  /** Total decoded-payload cap before `905 ERR_SASLTOOLONG`. */
43
60
  const SASL_MAX_BYTES = 8192;
44
61
 
62
+ /**
63
+ * Disconnect reason stamped once the per-connection SASL failure lockout
64
+ * trips. The adapter renders it as `ERROR :Closing link: (reason)` — the
65
+ * same tailoring convention as the registration `invalid USER` path.
66
+ */
67
+ const SASL_LOCKOUT_REASON = 'too many failed SASL attempts';
68
+
45
69
  /**
46
70
  * Mechanisms advertised in `908 ERR_SASLMECHS` and the `sasl` cap value,
47
- * derived from whether mTLS is configured for this connection.
71
+ * derived from whether SASL EXTERNAL is available for this connection.
48
72
  *
49
- * PLAIN is always available; EXTERNAL only when an `MtlsIdentityProvider`
50
- * is bound. Operators enable EXTERNAL by configuring mTLS at the edge
51
- * — no code change required.
73
+ * PLAIN is always available. EXTERNAL requires ALL of:
74
+ * 1. no transport-level non-support override
75
+ * (`sasl.externalUnsupportedMessage` unset the seam a transport
76
+ * without any mTLS identity source uses to pin the mechanism off),
77
+ * 2. an mTLS identity source bound for the connection (edge mTLS
78
+ * configured — CF API Shield / AWS APIGW client certs),
79
+ * 3. the operator opt-in (`sasl.externalEnabled`, surfaced by the
80
+ * adapters as `EXTERNAL_ENABLED`; default off),
81
+ * 4. a secure (TLS) transport — the client-cert identity only carries
82
+ * meaning over a connection the server trusts.
83
+ *
84
+ * Without any one of these the mechanism is neither advertised nor
85
+ * accepted: clients see `908 ERR_SASLMECHS` listing `PLAIN` only —
86
+ * except under the override, which rejects `AUTHENTICATE EXTERNAL`
87
+ * outright with the configured `904 ERR_SASLFAIL` message.
52
88
  */
53
89
  function supportedMechs(ctx: Ctx): string {
54
- return ctx.mtlsIdentity !== undefined ? 'PLAIN,EXTERNAL' : 'PLAIN';
90
+ return externalAvailable(ctx) ? 'PLAIN,EXTERNAL' : 'PLAIN';
91
+ }
92
+
93
+ /**
94
+ * Whether SASL EXTERNAL may run on this connection: no transport-level
95
+ * non-support override AND mTLS identity source bound AND the operator
96
+ * opted in AND the transport is secure. The `sasl` cap value (see
97
+ * `capabilities.ts`) applies the same gate so advertisement and
98
+ * enforcement cannot drift apart.
99
+ */
100
+ function externalAvailable(ctx: Ctx): boolean {
101
+ return (
102
+ ctx.serverConfig.sasl?.externalUnsupportedMessage === undefined &&
103
+ ctx.mtlsIdentity !== undefined &&
104
+ (ctx.serverConfig.sasl?.externalEnabled ?? false) &&
105
+ ctx.connection.secure
106
+ );
55
107
  }
56
108
 
57
109
  /**
@@ -106,7 +158,20 @@ function handleMechanism(
106
158
  return { state, effects: [Effect.send(ctx.connId, [L('AUTHENTICATE +')])] };
107
159
  }
108
160
  if (mech === 'EXTERNAL') {
109
- if (ctx.mtlsIdentity === undefined) {
161
+ // Transport-level non-support override first: a transport that
162
+ // cannot surface a client-cert identity rejects up front with the
163
+ // configured stable `904` message — never the generic `908` — even
164
+ // when every three-way-gate input is bound. A protocol-level
165
+ // capability refusal, not a credential attempt: it does not count
166
+ // toward the failure lockout.
167
+ const unsupportedMessage = ctx.serverConfig.sasl?.externalUnsupportedMessage;
168
+ if (unsupportedMessage !== undefined) {
169
+ return {
170
+ state,
171
+ effects: [Effect.send(ctx.connId, [saslExternalUnsupported(ctx, unsupportedMessage)])],
172
+ };
173
+ }
174
+ if (!externalAvailable(ctx)) {
110
175
  return { state, effects: [Effect.send(ctx.connId, [saslMechs(ctx)])] };
111
176
  }
112
177
  state.saslMech = 'EXTERNAL';
@@ -143,7 +208,7 @@ function handlePayload(
143
208
  return finalize(state, next, mech, ctx);
144
209
  }
145
210
 
146
- /** Decodes the accumulated payload and runs it through the AccountStore. */
211
+ /** Decodes the accumulated payload and verifies it against the ServicesStore. */
147
212
  function finalize(
148
213
  state: ConnectionState,
149
214
  payloadB64: string,
@@ -166,35 +231,23 @@ function finalizePlain(
166
231
 
167
232
  if (parsed === null) {
168
233
  clearSasl(state);
169
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
170
- }
171
-
172
- // Prefer the static AccountStore; fall back to the ServicesStore so a
173
- // nick registered via NickServ can authenticate via SASL PLAIN without a
174
- // separate static credential table. When neither verifies, fail.
175
- const store = ctx.accounts;
176
- let result = store?.verify('PLAIN', {
177
- kind: 'PLAIN',
178
- username: parsed.username,
179
- password: parsed.password,
180
- });
181
-
182
- if (result === undefined || !result.ok) {
183
- const servicesResult = ctx.services?.verifyNick(parsed.username, parsed.password);
184
- if (servicesResult?.ok) {
185
- result = servicesResult;
186
- }
234
+ return { state, effects: saslFailEffects(state, ctx) };
187
235
  }
188
236
 
189
- if (result === undefined) {
237
+ // Single credential home: services.verifyNick covers every NickServ
238
+ // registration, every SASL_ACCOUNTS-seeded account (carried into
239
+ // services via the boot ingest), and every PASS-auth share. No separate
240
+ // static credential fallback — there is no second credential table.
241
+ const services = ctx.services;
242
+ if (services === undefined) {
190
243
  clearSasl(state);
191
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
244
+ return { state, effects: saslFailEffects(state, ctx) };
192
245
  }
193
-
246
+ const result = services.verifyNick(parsed.username, parsed.password);
194
247
  return applyResult(state, result, ctx);
195
248
  }
196
249
 
197
- /** SASL EXTERNAL: resolve the mTLS cert subject and verify it. */
250
+ /** SASL EXTERNAL: resolve the mTLS cert identity and verify it. */
198
251
  function finalizeExternal(
199
252
  state: ConnectionState,
200
253
  ctx: Ctx,
@@ -204,32 +257,78 @@ function finalizeExternal(
204
257
 
205
258
  if (identity === undefined) {
206
259
  clearSasl(state);
207
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
260
+ return { state, effects: saslFailEffects(state, ctx) };
208
261
  }
209
262
 
210
- const store = ctx.accounts;
211
- if (store === undefined) {
263
+ // Cert identities (fingerprint-preferred, canonical-DN fallback) are
264
+ // mapped to accounts via services.verifyCertFP — the same single
265
+ // credential home as SASL PLAIN / PASS-auth. No separate services
266
+ // lookup.
267
+ const services = ctx.services;
268
+ if (services === undefined) {
212
269
  clearSasl(state);
213
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
270
+ return { state, effects: saslFailEffects(state, ctx) };
214
271
  }
215
-
216
- const result = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity });
217
-
272
+ const result = services.verifyCertFP(identity);
218
273
  return applyResult(state, result, ctx);
219
274
  }
220
275
 
221
276
  /** Shared success/failure handling after a `verify` call. */
222
277
  function applyResult(
223
278
  state: ConnectionState,
224
- result: SaslResult,
279
+ result: ServicesAuthResult,
225
280
  ctx: Ctx,
226
281
  ): { state: ConnectionState; effects: EffectType[] } {
227
282
  clearSasl(state);
228
283
 
229
284
  if (result.ok) {
285
+ // A successful login forgives this connection's prior SASL failures:
286
+ // the holder of the credential is legitimate. (Further AUTHENTICATE
287
+ // frames are rejected with 907 anyway, but the tracker is cleared so
288
+ // persisted/live state never carries a stale count.)
289
+ // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
290
+ delete state.saslFailures;
230
291
  return { state, effects: applyAccountSuccess(state, ctx, result.account) };
231
292
  }
232
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
293
+ return { state, effects: saslFailEffects(state, ctx) };
294
+ }
295
+
296
+ /**
297
+ * Records one failed SASL attempt on the per-connection tracker and builds
298
+ * the failure effects: the `904 ERR_SASLFAIL` always, followed by a
299
+ * `Disconnect` once the configured threshold is crossed inside the
300
+ * configured sliding window (the 904 is delivered first so the client sees
301
+ * why it is being closed).
302
+ *
303
+ * What counts as a failed attempt: every outcome that emits 904 — a wrong
304
+ * password / unknown user (PLAIN verify), an unverified cert subject or
305
+ * missing client cert (EXTERNAL verify), an undecodable base64 payload,
306
+ * and a finalize with no ServicesStore bound. Protocol-level rejections
307
+ * that are not credential attempts do NOT count (`905 ERR_SASLTOOLONG`,
308
+ * `908 ERR_SASLMECHS`), and a client abort (`AUTHENTICATE *`) neither
309
+ * counts nor resets the tracker — an attacker must not launder failures
310
+ * away with an abort between guesses.
311
+ *
312
+ * Sliding window: timestamps older than the window (measured via the
313
+ * injected `Clock`, never wall time) are pruned before the new failure is
314
+ * appended, so only failures inside the window are compared against
315
+ * `sasl.maxFailures`.
316
+ */
317
+ function saslFailEffects(state: ConnectionState, ctx: Ctx): EffectType[] {
318
+ const maxFailures = ctx.serverConfig.sasl?.maxFailures ?? DEFAULT_SASL_MAX_FAILURES;
319
+ const windowMs =
320
+ (ctx.serverConfig.sasl?.failureWindowSeconds ?? DEFAULT_SASL_FAILURE_WINDOW_SECONDS) * 1_000;
321
+ const now = ctx.clock.now();
322
+ const failures = (state.saslFailures ?? []).filter((t) => t > now - windowMs);
323
+ failures.push(now);
324
+ state.saslFailures = failures;
325
+ if (failures.length >= maxFailures) {
326
+ return [
327
+ Effect.send(ctx.connId, [saslFail(ctx)]),
328
+ Effect.disconnect(ctx.connId, SASL_LOCKOUT_REASON),
329
+ ];
330
+ }
331
+ return [Effect.send(ctx.connId, [saslFail(ctx)])];
233
332
  }
234
333
 
235
334
  /**
@@ -263,6 +362,15 @@ function saslFail(ctx: Ctx): RawLine {
263
362
  );
264
363
  }
265
364
 
365
+ /**
366
+ * Transport-level EXTERNAL non-support refusal: the configured message
367
+ * verbatim (`sasl.externalUnsupportedMessage`), formatted like every
368
+ * other `904` so clients render it as a terminal SASL failure.
369
+ */
370
+ function saslExternalUnsupported(ctx: Ctx, message: string): RawLine {
371
+ return L(`:${ctx.serverName} ${code(Numerics.ERR_SASLFAIL)} ${nickOf(ctx)} :${message}`);
372
+ }
373
+
266
374
  function saslTooLong(ctx: Ctx): RawLine {
267
375
  return L(
268
376
  `:${ctx.serverName} ${code(Numerics.ERR_SASLTOOLONG)} ${nickOf(ctx)} :SASL message too long`,
@@ -13,28 +13,23 @@
13
13
  * - Sets `state.topic` (clearing it for an empty trailing param).
14
14
  * - Honors `+t` (topic lock): only ops may change the topic; non-op
15
15
  * members get `482 ERR_CHANOPRIVSNEEDED`.
16
- * - Truncates the topic to `serverConfig.topicLen` to keep wire lines
17
- * within the 512-byte budget.
16
+ * - Truncates the topic to `serverConfig.topicLen` UTF-8 bytes (the
17
+ * byte ceiling advertised as `TOPICLEN`) to keep wire lines within
18
+ * the 512-byte budget.
18
19
  * - Broadcasts `:hostmask TOPIC <chan> :<topic>` to the channel.
19
20
  */
20
21
 
21
22
  import { Effect } from '../effects.js';
22
23
  import type { Effect as EffectType, RawLine } from '../effects.js';
23
24
  import type { PersistedTopic } from '../ports.js';
25
+ import { byteLength, truncateToByteBudget } from '../protocol/bytes.js';
26
+ import { isValidChannelName } from '../protocol/channel-name.js';
24
27
  import { Numerics } from '../protocol/numerics.js';
25
28
  import type { ChannelState, ChannelTopic } from '../state/channel.js';
26
29
  import { hostmaskOf } from '../state/connection.js';
27
30
  import type { ConnectionState } from '../state/connection.js';
28
31
  import type { Ctx, Reducer } from '../types.js';
29
32
 
30
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
31
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
32
-
33
- function isValidChannelName(name: string, maxLen: number): boolean {
34
- if (name.length === 0 || name.length > maxLen) return false;
35
- return CHANNEL_NAME_RE.test(name);
36
- }
37
-
38
33
  /** Formats a numeric error/reply line addressed to the connection's nick. */
39
34
  function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
40
35
  const nick = ctx.connection.nick ?? '*';
@@ -107,9 +102,12 @@ export const topicReducer: Reducer<ChannelState> = (state, msg, ctx) => {
107
102
  return { state, effects };
108
103
  }
109
104
 
105
+ // TOPICLEN is a UTF-8 byte ceiling (as advertised via ISUPPORT): measure
106
+ // bytes, not UTF-16 code units, and shrink to a complete-char boundary so
107
+ // a CJK/emoji topic is never split mid-character.
110
108
  const truncated =
111
- newTopic.length > ctx.serverConfig.topicLen
112
- ? newTopic.slice(0, ctx.serverConfig.topicLen)
109
+ byteLength(newTopic) > ctx.serverConfig.topicLen
110
+ ? truncateToByteBudget(newTopic, ctx.serverConfig.topicLen)
113
111
  : newTopic;
114
112
 
115
113
  // Empty topic clears; non-empty sets.
@@ -33,19 +33,12 @@
33
33
 
34
34
  import { Effect } from '../effects.js';
35
35
  import type { Effect as EffectType, RawLine } from '../effects.js';
36
+ import { isValidChannelName } from '../protocol/channel-name.js';
36
37
  import { Numerics } from '../protocol/numerics.js';
37
38
  import type { ChannelState, RosterEntry } from '../state/channel.js';
38
39
  import type { ConnectionState } from '../state/connection.js';
39
40
  import type { Ctx } from '../types.js';
40
41
 
41
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
42
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
43
-
44
- function isValidChannelName(name: string, maxLen: number): boolean {
45
- if (name.length === 0 || name.length > maxLen) return false;
46
- return CHANNEL_NAME_RE.test(name);
47
- }
48
-
49
42
  /** Formats a numeric error/reply line addressed to the connection's nick. */
50
43
  function numericLine(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
51
44
  const nick = ctx.connection.nick ?? '*';