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
@@ -36,6 +36,14 @@
36
36
  * threshold the JOIN hook compares against for the `AUTO*` ops.
37
37
  * `SET <op> <level>` overrides; `LIST` enumerates; `RESET` clears
38
38
  * every override so the {@link DEFAULT_CHANNEL_LEVELS} take effect.
39
+ * - `OP|DEOP|VOICE|DEVOICE <#channel> <nick>` — grant or strip a channel
40
+ * prefix via a ChanServ-sourced MODE broadcast (founder or
41
+ * AUTOOP-threshold access entry). DEOP additionally enforces a
42
+ * caller-vs-target rank guard: the target must not outrank the caller
43
+ * (see {@link rankOf} / {@link FOUNDER_RANK}), so an access-list entry
44
+ * cannot strip the founder — or any higher-ranked entry — mid-session.
45
+ * - `KICK <#channel> <nick> [:<reason>]` — removes a member (founder
46
+ * only) under the same target-rank guard.
39
47
  *
40
48
  * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
41
49
  * emitted as {@link Effect}s. Channel-mode mutations are emitted as
@@ -47,6 +55,7 @@ import { caseFold } from '../case-fold.js';
47
55
  import { Effect } from '../effects.js';
48
56
  import type { Effect as EffectType, RawLine } from '../effects.js';
49
57
  import type { ChannelLevelOp, ServicesStore } from '../ports.js';
58
+ import { isValidChannelName } from '../protocol/channel-name.js';
50
59
  import type { ChannelDelta, Roster } from '../state/channel.js';
51
60
  import type { ConnId, ConnectionState } from '../state/connection.js';
52
61
  import type { Ctx, Reducer } from '../types.js';
@@ -60,15 +69,6 @@ const SERVICES_HOST = 'services';
60
69
  /** Hostmask used as the source of ChanServ-emitted MODE prefix grants. */
61
70
  export const CHANSERV_HOSTMASK = `${CHANSERV_NICK}!${CHANSERV_NICK}@${SERVICES_HOST}`;
62
71
 
63
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
64
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
65
-
66
- /** Returns true iff `name` looks like a channel target. */
67
- function isValidChannelName(name: string, maxLen: number): boolean {
68
- if (name.length === 0 || name.length > maxLen) return false;
69
- return CHANNEL_NAME_RE.test(name);
70
- }
71
-
72
72
  /** ON / OFF values accepted by SET RESTRICTED / SET KEEPTOPIC. */
73
73
  const ON_OFF = new Set<string>(['ON', 'OFF']);
74
74
 
@@ -111,6 +111,26 @@ export const SHORTHAND_LEVELS: Readonly<Record<'SOP' | 'AOP' | 'HOP' | 'VOP', nu
111
111
  /** Set of recognised shorthand verbs (uppercased). */
112
112
  const SHORTHAND_VERBS = new Set<string>(['SOP', 'AOP', 'HOP', 'VOP']);
113
113
 
114
+ /**
115
+ * Rank of the channel founder in the DEOP/KICK caller-vs-target hierarchy.
116
+ * `Infinity` outranks every numeric access level, so no access-list entry
117
+ * can ever reach it — the only caller who can affect a founder-ranked
118
+ * target is the founder (an equal-rank, self-targeting action).
119
+ */
120
+ export const FOUNDER_RANK = Number.POSITIVE_INFINITY;
121
+
122
+ /**
123
+ * Maps a numeric ChanServ access level to the rank ordering used by the
124
+ * DEOP/KICK target guard: founder ({@link FOUNDER_RANK}) > SOP (10) > AOP
125
+ * (5) > HOP (4) > VOP (3) > no entry (0). The mapping is the identity for
126
+ * positive levels today; it exists as the single seam where a future
127
+ * privilege tier (e.g. a distinct SOP rank scale) can be introduced
128
+ * without touching the comparison call sites.
129
+ */
130
+ export function rankOf(level: number): number {
131
+ return level > 0 ? level : 0;
132
+ }
133
+
114
134
  /** Ordered list of level op names, for LEVELS LIST and validation. */
115
135
  const LEVEL_OPS: readonly ChannelLevelOp[] = ['AUTOOP', 'AUTOHALFOP', 'AUTOVOICE'];
116
136
 
@@ -794,16 +814,24 @@ interface PrefixCommandOpts {
794
814
  * - `founderOnly: true` → "Permission denied."
795
815
  * - `founderOnly: false` → allowed when caller's access level meets the
796
816
  * founder-configured `AUTOOP` threshold, otherwise "Permission denied."
817
+ * - Target-rank guard (`guardTargetRankOf`): the nick named by the command
818
+ * must not outrank the caller, otherwise "You do not have sufficient
819
+ * privileges on <chan> to affect <nick>." Used by DEOP and KICK so an
820
+ * access-list entry cannot strip the founder (or any higher-ranked
821
+ * entry) mid-session. Equal ranks — self-targeting and peers — are
822
+ * permitted; only a strictly higher-ranked target is protected.
797
823
  *
798
824
  * Privilege model: OP/DEOP/VOICE/DEVOICE accept the founder OR any
799
- * AUTOOP-level access entry; KICK/BAN/UNBAN are founder-only.
825
+ * AUTOOP-level access entry; KICK/BAN/UNBAN are founder-only. The
826
+ * target-rank guard applies only where the caller names a nick whose
827
+ * powers would be *removed* (DEOP/KICK), not where powers are granted.
800
828
  */
801
829
  function requirePrivilegedChanServ(
802
830
  state: ConnectionState,
803
831
  channel: string,
804
832
  ctx: Ctx,
805
833
  effects: EffectType[],
806
- opts: { founderOnly: boolean },
834
+ opts: { founderOnly: boolean; guardTargetRankOf?: string },
807
835
  ): ServicesStore | null {
808
836
  const services = ctx.services as ServicesStore;
809
837
  if (state.account === undefined) {
@@ -815,18 +843,89 @@ function requirePrivilegedChanServ(
815
843
  effects.push(notice(state, `Channel ${channel} is not registered.`));
816
844
  return null;
817
845
  }
818
- if (caseFold('rfc1459', founder) === caseFold('rfc1459', state.account)) {
819
- return services;
846
+ const callerIsFounder = caseFold('rfc1459', founder) === caseFold('rfc1459', state.account);
847
+ if (callerIsFounder) {
848
+ return admitTargetRankGuard(state, services, FOUNDER_RANK, channel, founder, opts, effects);
820
849
  }
821
850
  if (!opts.founderOnly) {
822
851
  const level = services.getChannelAccess(channel, state.account);
823
852
  const autoOp = services.getChannelLevel(channel, 'AUTOOP') ?? DEFAULT_CHANNEL_LEVELS.AUTOOP;
824
- if (level >= autoOp) return services;
853
+ if (level >= autoOp) {
854
+ return admitTargetRankGuard(state, services, rankOf(level), channel, founder, opts, effects);
855
+ }
825
856
  }
826
857
  effects.push(notice(state, 'Permission denied.'));
827
858
  return null;
828
859
  }
829
860
 
861
+ /**
862
+ * Applies the DEOP/KICK target-rank guard at the tail of
863
+ * {@link requirePrivilegedChanServ}: the caller may affect the target nick
864
+ * only when the target's rank does not exceed theirs. Returns the store on
865
+ * success, `null` (with an insufficient-privileges NOTICE pushed) on
866
+ * failure.
867
+ *
868
+ * Rank rule: permitted iff `callerRank >= targetRank`. Equal ranks cover
869
+ * both self-targeting (the founder — or any access entry — releasing
870
+ * their own status) and peer-level actions (an AOP deopping a fellow
871
+ * AOP, who can simply re-op); any strictly higher-ranked target — the
872
+ * founder above every access entry, an SOP above an AOP — is protected.
873
+ */
874
+ function admitTargetRankGuard(
875
+ state: ConnectionState,
876
+ services: ServicesStore,
877
+ callerRank: number,
878
+ channel: string,
879
+ founder: string,
880
+ opts: { founderOnly: boolean; guardTargetRankOf?: string },
881
+ effects: EffectType[],
882
+ ): ServicesStore | null {
883
+ if (
884
+ opts.guardTargetRankOf !== undefined &&
885
+ callerRank < targetRankOf(services, channel, founder, opts.guardTargetRankOf)
886
+ ) {
887
+ effects.push(
888
+ notice(
889
+ state,
890
+ `You do not have sufficient privileges on ${channel} to affect ${opts.guardTargetRankOf}.`,
891
+ ),
892
+ );
893
+ return null;
894
+ }
895
+ return services;
896
+ }
897
+
898
+ /**
899
+ * Resolves the rank of a target NICK for the DEOP/KICK guard.
900
+ *
901
+ * The ChanServ reducer operates on the caller's ConnectionState and cannot
902
+ * see the connection behind the target nick, so the target's account is
903
+ * resolved via the nick registry (`getNick`):
904
+ * - a nick whose registered account matches the channel founder ranks as
905
+ * the founder ({@link FOUNDER_RANK}) and is protected from every
906
+ * non-founder caller;
907
+ * - any other registered nick ranks at its access-list level;
908
+ * - an unregistered (therefore unidentified) nick carries no account and
909
+ * ranks at the minimum.
910
+ *
911
+ * Documented simplification: protection is keyed on the nick's registered
912
+ * account, not on whether the connection currently using the nick is
913
+ * identified — the reducer has no way to observe the latter, and erring
914
+ * toward protection cannot be abused to escalate privileges.
915
+ */
916
+ function targetRankOf(
917
+ services: ServicesStore,
918
+ channel: string,
919
+ founder: string,
920
+ targetNick: string,
921
+ ): number {
922
+ const account = services.getNick(targetNick)?.account;
923
+ if (account !== undefined && caseFold('rfc1459', founder) === caseFold('rfc1459', account)) {
924
+ return FOUNDER_RANK;
925
+ }
926
+ return account === undefined ? rankOf(0) : rankOf(services.getChannelAccess(channel, account));
927
+ }
928
+
830
929
  /**
831
930
  * Handles `OP|DEOP|VOICE|DEVOICE <#channel> <nick>`.
832
931
  *
@@ -862,6 +961,9 @@ function handlePrefixCommand(
862
961
 
863
962
  const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
864
963
  founderOnly: false,
964
+ // Only DEOP removes powers, so only it carries the target-rank guard;
965
+ // OP/VOICE/DEVOICE never demote a higher-ranked user.
966
+ ...(opts.verb === 'DEOP' ? { guardTargetRankOf: targetNick } : {}),
865
967
  });
866
968
  if (services === null) return { state, effects };
867
969
 
@@ -915,6 +1017,7 @@ function handleKick(
915
1017
 
916
1018
  const services = requirePrivilegedChanServ(state, channel, ctx, effects, {
917
1019
  founderOnly: true,
1020
+ guardTargetRankOf: targetNick,
918
1021
  });
919
1022
  if (services === null) return { state, effects };
920
1023
 
@@ -20,6 +20,7 @@
20
20
  * actor layer); `undefined` means "no such channel" → `403`.
21
21
  */
22
22
 
23
+ import { DEFAULT_MAX_CHATHISTORY_LIMIT } from '../config.js';
23
24
  import { Effect } from '../effects.js';
24
25
  import type { Effect as EffectType, RawLine } from '../effects.js';
25
26
  import type { StoredMessage } from '../ports.js';
@@ -154,7 +155,8 @@ function handleChannelSub(
154
155
  if (sub !== 'LATEST' && pivot === undefined && markerPivot === undefined) {
155
156
  return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
156
157
  }
157
- const limit = parseLimit(limitRaw);
158
+ const maxLimit = ctx.serverConfig.chathistory?.maxLimit ?? DEFAULT_MAX_CHATHISTORY_LIMIT;
159
+ const limit = parseLimit(limitRaw, maxLimit);
158
160
  if (limit === undefined) {
159
161
  return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
160
162
  }
@@ -295,12 +297,18 @@ function invalidParamsLine(ctx: Ctx): RawLine {
295
297
  return numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Invalid parameters', 'CHATHISTORY');
296
298
  }
297
299
 
298
- /** Parses a non-negative integer limit; returns undefined on garbage. */
299
- function parseLimit(raw: string | undefined): number | undefined {
300
- if (raw === undefined) return DEFAULT_QUERY_LIMIT;
300
+ /**
301
+ * Parses a non-negative integer limit, silently capped at `maxLimit` (the
302
+ * deployment's chathistory ceiling); returns undefined on garbage. The cap
303
+ * keeps an oversized request (`CHATHISTORY LATEST #chan 999999999`) from
304
+ * asking the store to materialize a huge result set, regardless of the
305
+ * store's backing capacity.
306
+ */
307
+ function parseLimit(raw: string | undefined, maxLimit: number): number | undefined {
308
+ if (raw === undefined) return Math.min(DEFAULT_QUERY_LIMIT, maxLimit);
301
309
  const n = Number(raw);
302
310
  if (!Number.isInteger(n) || n < 0) return undefined;
303
- return n;
311
+ return Math.min(n, maxLimit);
304
312
  }
305
313
 
306
314
  /**
@@ -15,10 +15,14 @@
15
15
  * peers see the revert. Requires identification.
16
16
  * - `REQUEST <vhost>` — proposes a vhost for the account. Behaviour
17
17
  * depends on {@link ServerConfig.hostservAutoApproveVhosts}: when the
18
- * knob is on (the default) the vhost is recorded against the account
19
- * immediately (legacy auto-approve); when the knob is off the request
20
- * is queued for oper review via {@link ServicesStore.requestVhost}
21
- * and the requester is told it is pending. Requires identification.
18
+ * knob is off (the default) the request is queued for oper review via
19
+ * {@link ServicesStore.requestVhost} and the requester is told it is
20
+ * pending. When the knob is on (deprecated legacy auto-approve) the
21
+ * vhost is recorded against the account immediately, but only after
22
+ * clearing a built-in denylist (reserved labels such as `admin` /
23
+ * `staff` at any position, plus this server's own hostname) and
24
+ * matching the mandatory {@link ServerConfig.hostservAutoApproveAllowlist}
25
+ * (exact or `*.suffix` wildcard entries). Requires identification.
22
26
  * - `SET <account> <vhost>` — oper-only. Records (or, with the literal
23
27
  * `*` argument, clears) the vhost for `account` directly without
24
28
  * going through REQUEST. The target account must be a registered nick.
@@ -67,6 +71,59 @@ function isValidVhost(vhost: string): boolean {
67
71
  return VHOST_RE.test(vhost);
68
72
  }
69
73
 
74
+ /**
75
+ * Labels a user may never self-assign via auto-approved REQUEST, at any
76
+ * dot-separated position of the vhost (case-insensitive — DNS labels are).
77
+ * These names impersonate staff, operators, or off-network services, so the
78
+ * denylist is enforced even when the operator explicitly allowlists them.
79
+ */
80
+ const RESERVED_VHOST_LABELS = new Set(['admin', 'oper', 'staff', 'irc', 'www', 'root', 'security']);
81
+
82
+ /**
83
+ * Built-in denylist for auto-approved vhosts. Rejects any vhost containing
84
+ * a {@link RESERVED_VHOST_LABELS} label, plus the deployment's own hostname
85
+ * (exactly or as a suffix) so a user cannot cloak as
86
+ * `<anything>.<this-server>` and phish off its identity. Oper-vetted paths
87
+ * (SET, queue-mode APPROVE) intentionally bypass this — opers are trusted.
88
+ */
89
+ function isDeniedVhost(vhost: string, serverName: string): boolean {
90
+ const lower = vhost.toLowerCase();
91
+ for (const label of lower.split('.')) {
92
+ if (RESERVED_VHOST_LABELS.has(label)) {
93
+ return true;
94
+ }
95
+ }
96
+ const server = serverName.toLowerCase();
97
+ return lower === server || lower.endsWith(`.${server}`);
98
+ }
99
+
100
+ /**
101
+ * Allowlist check for auto-approved vhosts. An entry matches when it is
102
+ * exactly the vhost (`users.example.net`) or a wildcard suffix
103
+ * (`*.users.example.com` matches `a.users.example.com` and any depth
104
+ * beneath it, but NOT the bare suffix). Matching is case-insensitive
105
+ * (DNS semantics). A missing allowlist matches nothing — the reducer
106
+ * fails closed; the config layer separately refuses to boot auto-approve
107
+ * mode without a non-empty allowlist.
108
+ */
109
+ function isAllowlistedVhost(vhost: string, allowlist: ReadonlyArray<string> | undefined): boolean {
110
+ if (allowlist === undefined) {
111
+ return false;
112
+ }
113
+ const lower = vhost.toLowerCase();
114
+ for (const entry of allowlist) {
115
+ const normalized = entry.toLowerCase();
116
+ if (normalized.startsWith('*.')) {
117
+ if (lower.endsWith(normalized.slice(1))) {
118
+ return true;
119
+ }
120
+ } else if (lower === normalized) {
121
+ return true;
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+
70
127
  /**
71
128
  * Reducer for `PRIVMSG HostServ :<subcommand> <args…>`.
72
129
  * Authority: the invoking connection's {@link ConnectionState}.
@@ -185,13 +242,20 @@ function handleOff(
185
242
  /**
186
243
  * Handles `REQUEST <vhost>`. Behaviour is selected by
187
244
  * {@link ServerConfig.hostservAutoApproveVhosts}:
188
- * - `true` (auto-approve) — the vhost is recorded against the account
189
- * immediately (the path that shipped before the oper queue landed). The
190
- * user is notified that the vhost has been requested and approved.
191
245
  * - `false` or omitted (the default — queue mode) — the request is
192
246
  * queued for oper review via {@link ServicesStore.requestVhost}; the
193
247
  * vhost is NOT activated and no `CHGHOST` is emitted. The user is
194
- * notified that the request is pending oper approval.
248
+ * notified that the request is pending oper approval. Oper-vetted
249
+ * vhosts are exempt from the denylist/allowlist below.
250
+ * - `true` (auto-approve, deprecated) — the vhost is recorded against
251
+ * the account immediately, but only if it survives hardening: the
252
+ * built-in denylist ({@link isDeniedVhost}: reserved labels at any
253
+ * position + this deployment's own hostname) rejects impersonation
254
+ * vectors even when the operator allowlisted them, and the mandatory
255
+ * allowlist ({@link isAllowlistedVhost}) must match the vhost exactly
256
+ * or via a `*.suffix` wildcard entry. Config parsing refuses to boot
257
+ * auto-approve mode without a non-empty allowlist; the reducer fails
258
+ * closed regardless.
195
259
  *
196
260
  * Identified-only — otherwise a guest could plant a vhost (or a pending
197
261
  * request) against a registered account they do not own.
@@ -225,6 +289,18 @@ function handleRequest(
225
289
  return { state, effects };
226
290
  }
227
291
 
292
+ if (isDeniedVhost(vhost, ctx.serverName)) {
293
+ effects.push(
294
+ notice(state, `Vhost ${vhost} is not permitted (reserved label or this server's hostname).`),
295
+ );
296
+ return { state, effects };
297
+ }
298
+
299
+ if (!isAllowlistedVhost(vhost, ctx.serverConfig.hostservAutoApproveAllowlist)) {
300
+ effects.push(notice(state, `Vhost ${vhost} is not in the auto-approve allowlist.`));
301
+ return { state, effects };
302
+ }
303
+
228
304
  services.setVhost(state.account, vhost);
229
305
  effects.push(notice(state, `Vhost ${vhost} has been requested and approved.`));
230
306
  return { state, effects };
@@ -15,7 +15,7 @@ export {
15
15
  userReducer,
16
16
  } from './registration.js';
17
17
  export { quitReducer } from './quit.js';
18
- export { handleJoinZero, isValidChannelName, joinReducer } from './join.js';
18
+ export { handleJoinZero, joinReducer } from './join.js';
19
19
  export { partReducer } from './part.js';
20
20
  export { motdReducer } from './motd.js';
21
21
  export { operReducer, matchOperCred } from './oper.js';
@@ -61,6 +61,7 @@ export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
61
61
  export { markreadChannelReducer } from './markread.js';
62
62
  export {
63
63
  DEFAULT_MULTILINE_MAX_BYTES,
64
+ DEFAULT_MULTILINE_MAX_ENTRIES,
64
65
  MULTILINE_CAP,
65
66
  type FinalizeMultilineResult,
66
67
  type MultilineEntry,
@@ -23,19 +23,13 @@
23
23
  import { caseFold } from '../case-fold.js';
24
24
  import { Effect } from '../effects.js';
25
25
  import type { Effect as EffectType, RawLine } from '../effects.js';
26
+ import { isValidChannelName } from '../protocol/channel-name.js';
26
27
  import { Numerics } from '../protocol/numerics.js';
27
28
  import type { ChannelState } from '../state/channel.js';
28
29
  import { hostmaskOf } from '../state/connection.js';
29
30
  import type { ConnectionState } from '../state/connection.js';
30
31
  import type { Ctx, Reducer } from '../types.js';
31
32
 
32
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
33
-
34
- function isValidChannelName(name: string, maxLen: number): boolean {
35
- if (name.length === 0 || name.length > maxLen) return false;
36
- return CHANNEL_NAME_RE.test(name);
37
- }
38
-
39
33
  /** Returns the caller's hostmask, falling back to nick then `?`. */
40
34
  function callerHostmask(conn: ConnectionState): string {
41
35
  return hostmaskOf(conn) ?? conn.nick ?? '?';
@@ -20,6 +20,7 @@ import { Effect } from '../effects.js';
20
20
  import type { Effect as EffectType, RawLine } from '../effects.js';
21
21
  import type { PersistedTopic, StoredMessage } from '../ports.js';
22
22
  import { wrapBatch } from '../protocol/batch.js';
23
+ import { isValidChannelName } from '../protocol/channel-name.js';
23
24
  import { Numerics } from '../protocol/numerics.js';
24
25
  import { type ChanName, type ChannelState, type ChannelTopic, prefixOf } from '../state/channel.js';
25
26
  import { hostmaskOf } from '../state/connection.js';
@@ -29,22 +30,6 @@ import { CHANSERV_HOSTMASK, DEFAULT_CHANNEL_LEVELS } from './chanserv.js';
29
30
  import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
30
31
  import { topicNumerics } from './topic.js';
31
32
 
32
- /**
33
- * RFC 1459/2812 channel-name grammar. The first character must be a channel
34
- * prefix (`#` or `&`); the rest may be any printable char except space, comma,
35
- * or colon (which all carry protocol meaning).
36
- */
37
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
38
-
39
- /**
40
- * Returns true iff `name` matches the IRC channel-name grammar and is within
41
- * the configured length cap.
42
- */
43
- export function isValidChannelName(name: string, maxLen: number): boolean {
44
- if (name.length === 0 || name.length > maxLen) return false;
45
- return CHANNEL_NAME_RE.test(name);
46
- }
47
-
48
33
  /**
49
34
  * Returns the 353 NAMES sigil for a channel given its modes:
50
35
  * `@` for `+p` (private), `*` for `+s` (secret), `=` otherwise (public).
@@ -16,20 +16,13 @@
16
16
  import { caseFold } from '../case-fold.js';
17
17
  import { Effect } from '../effects.js';
18
18
  import type { Effect as EffectType, RawLine } from '../effects.js';
19
+ import { isValidChannelName } from '../protocol/channel-name.js';
19
20
  import { Numerics } from '../protocol/numerics.js';
20
21
  import type { ChannelState } from '../state/channel.js';
21
22
  import { hostmaskOf } from '../state/connection.js';
22
23
  import type { ConnectionState } from '../state/connection.js';
23
24
  import type { Ctx, Reducer } from '../types.js';
24
25
 
25
- /** Channel-name validation mirrors JOIN: same grammar, same length cap. */
26
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
27
-
28
- function isValidChannelName(name: string, maxLen: number): boolean {
29
- if (name.length === 0 || name.length > maxLen) return false;
30
- return CHANNEL_NAME_RE.test(name);
31
- }
32
-
33
26
  /**
34
27
  * Formats a single numeric error line addressed to the connection's current
35
28
  * nick (or `*` when unregistered). Optional `middle` is emitted before the
@@ -29,18 +29,11 @@
29
29
  import { caseFold } from '../case-fold.js';
30
30
  import { Effect } from '../effects.js';
31
31
  import type { Effect as EffectType, RawLine } from '../effects.js';
32
+ import { isValidChannelName } from '../protocol/channel-name.js';
32
33
  import { Numerics } from '../protocol/numerics.js';
33
34
  import type { ChanSnapshot } from '../state/channel.js';
34
35
  import type { Ctx, Reducer } from '../types.js';
35
36
 
36
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
37
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
38
-
39
- function isValidChannelName(name: string, maxLen: number): boolean {
40
- if (name.length === 0 || name.length > maxLen) return false;
41
- return CHANNEL_NAME_RE.test(name);
42
- }
43
-
44
37
  /**
45
38
  * Returns true iff the requester is permitted to see `chan` in LIST output.
46
39
  * Secret channels require membership; everything else is public.
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
21
21
  * emitted as {@link Effect}s. The services store is synchronous (mirrors
22
- * the {@link AccountStore} / {@link MessageStore} pattern).
22
+ * the {@link MessageStore} pattern).
23
23
  */
24
24
 
25
25
  import { caseFold } from '../case-fold.js';
@@ -31,6 +31,7 @@
31
31
  import { caseFold } from '../case-fold.js';
32
32
  import { Effect } from '../effects.js';
33
33
  import type { Effect as EffectType, RawLine } from '../effects.js';
34
+ import { isValidChannelName } from '../protocol/channel-name.js';
34
35
  import { Numerics } from '../protocol/numerics.js';
35
36
  import type { BooleanChannelMode, ChannelState } from '../state/channel.js';
36
37
  import type {
@@ -45,14 +46,6 @@ import type { ConnectionState } from '../state/connection.js';
45
46
  import type { Ctx, Reducer } from '../types.js';
46
47
  import { positiveMlockLetters } from './chanserv.js';
47
48
 
48
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
49
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
50
-
51
- function isValidChannelName(name: string, maxLen: number): boolean {
52
- if (name.length === 0 || name.length > maxLen) return false;
53
- return CHANNEL_NAME_RE.test(name);
54
- }
55
-
56
49
  /** Returns the caller's hostmask, falling back to nick then `?`. */
57
50
  function callerHostmask(conn: ConnectionState): string {
58
51
  return hostmaskOf(conn) ?? conn.nick ?? '?';
@@ -26,11 +26,13 @@
26
26
 
27
27
  import {
28
28
  DEFAULT_MULTILINE_MAX_BYTES,
29
+ DEFAULT_MULTILINE_MAX_ENTRIES,
29
30
  MULTILINE_CAP_NAME,
30
31
  multilineCapValue,
31
32
  } from '../caps/capabilities.js';
32
33
  import { Effect } from '../effects.js';
33
34
  import type { Effect as EffectType, RawLine } from '../effects.js';
35
+ import { byteLength } from '../protocol/bytes.js';
34
36
  import { hostmaskOf } from '../state/connection.js';
35
37
  import type { Ctx } from '../types.js';
36
38
 
@@ -41,7 +43,7 @@ import type { Ctx } from '../types.js';
41
43
  */
42
44
  export const MULTILINE_CAP = MULTILINE_CAP_NAME;
43
45
 
44
- export { DEFAULT_MULTILINE_MAX_BYTES, multilineCapValue };
46
+ export { DEFAULT_MULTILINE_MAX_BYTES, DEFAULT_MULTILINE_MAX_ENTRIES, multilineCapValue };
45
47
 
46
48
  /**
47
49
  * One accumulated inner line of a multi-line batch. The actor collects these
@@ -79,21 +81,13 @@ export function multilineByteLength(entries: readonly MultilineEntry[]): number
79
81
  if (entries.length === 0) return 0;
80
82
  let total = 0;
81
83
  for (const entry of entries) {
82
- total += utf8ByteLength(entry.text);
84
+ total += byteLength(entry.text);
83
85
  }
84
86
  // `(n - 1)` separators join `n` entries.
85
87
  total += entries.length - 1;
86
88
  return total;
87
89
  }
88
90
 
89
- /** Shared `TextEncoder` for UTF-8 byte-length accounting (Web standard). */
90
- const TEXT_ENCODER = new TextEncoder();
91
-
92
- /** Returns the UTF-8 byte length of `s` (the wire encoding for IRC content). */
93
- function utf8ByteLength(s: string): number {
94
- return TEXT_ENCODER.encode(s).length;
95
- }
96
-
97
91
  /** Resolves the sender's hostmask, falling back to nick then `?`. */
98
92
  function senderHostmask(conn: Ctx['connection']): string {
99
93
  return hostmaskOf(conn) ?? conn.nick ?? '?';
@@ -31,17 +31,22 @@
31
31
  import { Effect } from '../effects.js';
32
32
  import type { Effect as EffectType, RawLine } from '../effects.js';
33
33
  import { wrapBatch } from '../protocol/batch.js';
34
+ import { byteLength } from '../protocol/bytes.js';
35
+ import { isValidChannelName } from '../protocol/channel-name.js';
34
36
  import { Numerics } from '../protocol/numerics.js';
35
37
  import type { ChannelState, RosterEntry } from '../state/channel.js';
36
38
  import type { Ctx, Reducer } from '../types.js';
37
39
 
38
- /** Channel-name grammar; mirrors {@link isValidChannelName} in `join.ts`. */
39
- const CHANNEL_NAME_RE = /^[#&][^\s,:]+$/u;
40
+ /**
41
+ * RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing
42
+ * CR-LF. Transports append `\r\n` after the text produced here, so the
43
+ * budget for the line text itself is 512 − 2 = 510 bytes. Declared locally
44
+ * (mirroring `cap.ts` / `motd-lines.ts`) to keep this module self-contained.
45
+ */
46
+ const MAX_LINE_BYTES = 512;
40
47
 
41
- function isValidChannelName(name: string, maxLen: number): boolean {
42
- if (name.length === 0 || name.length > maxLen) return false;
43
- return CHANNEL_NAME_RE.test(name);
44
- }
48
+ /** Length of the trailing CR-LF the wire layer appends to each line. */
49
+ const TRAILING_CRLF_LEN = 2;
45
50
 
46
51
  /** Formats a numeric error/reply line addressed to the connection's nick. */
47
52
  function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
@@ -90,14 +95,50 @@ function prefixFormatter(ctx: Ctx): (entry: RosterEntry) => string {
90
95
  return ctx.connection.caps.has('multi-prefix') ? allPrefixes : highestPrefix;
91
96
  }
92
97
 
93
- /** Builds the space-separated NAMES list with mode prefixes for a channel. */
94
- function buildNamesList(state: ChannelState, ctx: Ctx): string {
98
+ /**
99
+ * Greedy-packs the formatted member list into one or more `353 RPL_NAMREPLY`
100
+ * lines so every emitted line fits the 510-byte text budget (RFC 2812 §3.2.5:
101
+ * long NAMES lists are split across multiple `353` replies rather than
102
+ * truncated mid-nick by the outbound line limiter).
103
+ *
104
+ * The full line is measured: the fixed `:{server} 353 {nick} {sigil} {chan} :`
105
+ * header, each member's mode-prefix bytes (`@`/`+`), the nicks themselves
106
+ * (UTF-8 bytes, not UTF-16 code units), and the separating spaces. A member
107
+ * that alone overflows the budget (only possible with pathological config —
108
+ * nicks are capped at `nickLen`) still gets its own line rather than being
109
+ * dropped, leaving any last-resort truncation to the outbound limiter.
110
+ *
111
+ * A channel with no members still yields exactly one `353` with an empty
112
+ * trailing parameter, so the reply shape (`353`…`366`) is stable for clients.
113
+ */
114
+ function packNamesLines(state: ChannelState, ctx: Ctx, nick: string, sigil: string): RawLine[] {
95
115
  const format = prefixFormatter(ctx);
96
- const names: string[] = [];
116
+ const header = `:${ctx.serverName} 353 ${nick} ${sigil} ${state.name} :`;
117
+ const budget = MAX_LINE_BYTES - TRAILING_CRLF_LEN;
118
+ const headerBytes = byteLength(header);
119
+
120
+ const lines: RawLine[] = [];
121
+ let current = '';
122
+ let usedBytes = 0;
123
+ let empty = true;
124
+
97
125
  for (const entry of state.members.values()) {
98
- names.push(format(entry) + entry.nick);
126
+ const token = format(entry) + entry.nick;
127
+ const tokenBytes = byteLength(token);
128
+ // One extra byte for the space separator when appending to a non-empty list.
129
+ const costBytes = empty ? tokenBytes : tokenBytes + 1;
130
+ if (!empty && headerBytes + usedBytes + costBytes > budget) {
131
+ lines.push({ text: `${header}${current}` });
132
+ current = token;
133
+ usedBytes = tokenBytes;
134
+ } else {
135
+ current = empty ? token : `${current} ${token}`;
136
+ usedBytes += costBytes;
137
+ }
138
+ empty = false;
99
139
  }
100
- return names.join(' ');
140
+ lines.push({ text: `${header}${current}` });
141
+ return lines;
101
142
  }
102
143
 
103
144
  /**
@@ -143,9 +184,8 @@ export const namesReducer: Reducer<ChannelState> = (state, msg, ctx) => {
143
184
 
144
185
  const nick = ctx.connection.nick ?? '*';
145
186
  const sigil = namesSigil(state);
146
- const names = buildNamesList(state, ctx);
147
187
  const replyLines: RawLine[] = [
148
- { text: `:${ctx.serverName} 353 ${nick} ${sigil} ${state.name} :${names}` },
188
+ ...packNamesLines(state, ctx, nick, sigil),
149
189
  { text: `:${ctx.serverName} 366 ${nick} ${state.name} :End of /NAMES list.` },
150
190
  ];
151
191