serverless-ircd 0.2.0 → 0.4.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 (221) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +258 -18
  8. package/README.md +72 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -33,9 +33,11 @@
33
33
  */
34
34
 
35
35
  import {
36
+ SASL_CAP_NAME,
36
37
  SUPPORTED_CAPABILITIES,
37
38
  capToLsString,
38
39
  isSupportedCap,
40
+ saslCapValue,
39
41
  splitCapsForWire,
40
42
  } from '../caps/capabilities.js';
41
43
  import { Effect } from '../effects.js';
@@ -88,7 +90,12 @@ function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionSta
88
90
  state.capNegotiating = true;
89
91
 
90
92
  const target = state.nick ?? '*';
91
- const tokens = SUPPORTED_CAPABILITIES.map(capToLsString);
93
+ const hasMtls = ctx.mtlsIdentity !== undefined;
94
+ const tokens = SUPPORTED_CAPABILITIES.map((cap) =>
95
+ cap.name === SASL_CAP_NAME
96
+ ? capToLsString({ ...cap, value: saslCapValue(hasMtls) })
97
+ : capToLsString(cap),
98
+ );
92
99
  const maxLen = maxCapsContentLen(ctx, target);
93
100
  const chunks = splitCapsForWire(tokens, maxLen);
94
101
 
@@ -18,6 +18,7 @@ export { quitReducer } from './quit.js';
18
18
  export { handleJoinZero, isValidChannelName, joinReducer } from './join.js';
19
19
  export { partReducer } from './part.js';
20
20
  export { motdReducer } from './motd.js';
21
+ export { operReducer, matchOperCred } from './oper.js';
21
22
  export {
22
23
  isChannelTarget,
23
24
  noticeChannelReducer,
@@ -33,6 +34,7 @@ export { channelModeReducer, userModeReducer } from './mode.js';
33
34
  export { inviteReducer } from './invite.js';
34
35
  export { whoReducer } from './who.js';
35
36
  export { whoisReducer } from './whois.js';
37
+ export { whowasReducer } from './whowas.js';
36
38
  export { generateIsupport } from './isupport.js';
37
39
  export { capReducer } from './cap.js';
38
40
  export { emitChghost } from './chghost.js';
@@ -45,3 +47,12 @@ export {
45
47
  buildChathistoryBatch,
46
48
  CHATHISTORY_CAP,
47
49
  } from './chathistory.js';
50
+ export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
51
+ export {
52
+ versionReducer,
53
+ timeReducer,
54
+ adminReducer,
55
+ infoReducer,
56
+ } from './server-info.js';
57
+ export { userhostReducer, formatUserhostReply } from './userhost.js';
58
+ export { isonReducer } from './ison.js';
@@ -20,6 +20,7 @@
20
20
  * case-insensitively against the roster (443 if already a member).
21
21
  */
22
22
 
23
+ import { caseFold } from '../case-fold.js';
23
24
  import { Effect } from '../effects.js';
24
25
  import type { Effect as EffectType, RawLine } from '../effects.js';
25
26
  import { Numerics } from '../protocol/numerics.js';
@@ -50,15 +51,11 @@ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string):
50
51
  return { text: parts.join(' ') };
51
52
  }
52
53
 
53
- function lowerNick(nick: string): string {
54
- return nick.toLowerCase();
55
- }
56
-
57
- /** Finds a roster entry's connId by nick (case-insensitive). */
54
+ /** Finds a roster entry's connId by nick (case-insensitively, per `CASEMAPPING=rfc1459`). */
58
55
  function findConnIdByNick(state: ChannelState, nick: string): string | null {
59
- const target = lowerNick(nick);
56
+ const target = caseFold('rfc1459', nick);
60
57
  for (const entry of state.members.values()) {
61
- if (lowerNick(entry.nick) === target) return entry.conn;
58
+ if (caseFold('rfc1459', entry.nick) === target) return entry.conn;
62
59
  }
63
60
  return null;
64
61
  }
@@ -132,9 +129,9 @@ export const inviteReducer: Reducer<ChannelState> = (state, msg, ctx) => {
132
129
  return { state, effects };
133
130
  }
134
131
 
135
- // Success: record the pending invite (by lowercased nick) so a later
132
+ // Success: record the pending invite (by case-folded nick) so a later
136
133
  // JOIN bypasses +i, emit 341, and route the INVITE notice to the invitee.
137
- state.pendingInvites.add(targetNick.toLowerCase());
134
+ state.pendingInvites.add(caseFold('rfc1459', targetNick));
138
135
 
139
136
  const inviterNick = ctx.connection.nick ?? '*';
140
137
  effects.push(
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Pure reducer for the IRC `ISON` command.
3
+ *
4
+ * PLAN §2.1 location-of-authority: ISON is read-only — it checks which of the
5
+ * requested nicks are currently online and emits a single `303 RPL_ISON`
6
+ * line. The reducer itself does not perform lookups (those are async and
7
+ * belong to the actor / runtime layer); it receives the set of online nicks
8
+ * and the requested list, then reports the intersection.
9
+ *
10
+ * Wire format (RFC 2812 §5.8):
11
+ * - `ISON <nick>{ <nick>}`
12
+ * - `303 RPL_ISON`:
13
+ * `:<server> 303 <nick> :<online_nick1> <online_nick2> …`
14
+ *
15
+ * Matching is case-insensitive per rfc1459 case-mapping. The reply carries
16
+ * the registered (display) spelling from the online map, not the requester's
17
+ * spelling, so a request for `BOB` reports `Bob` when that is the registered
18
+ * form.
19
+ */
20
+
21
+ import { Effect } from '../effects.js';
22
+ import type { Effect as EffectType, RawLine } from '../effects.js';
23
+ import { Numerics } from '../protocol/numerics.js';
24
+ import type { ConnectionState } from '../state/connection.js';
25
+ import type { Ctx, ReducerResult } from '../types.js';
26
+
27
+ /**
28
+ * Handles `ISON <nick>{ <nick>}`.
29
+ *
30
+ * `online` maps each online nick's case-folded (lowercased) form to its
31
+ * display spelling. Each requested nick is folded and looked up; matches are
32
+ * reported using the display spelling from the map.
33
+ *
34
+ * Signature is bespoke (not {@link Reducer}) because the actor must resolve
35
+ * the nicks asynchronously before formatting — mirrors `whoReducer` /
36
+ * `whoisReducer`.
37
+ */
38
+ export function isonReducer(
39
+ online: ReadonlyMap<string, string>,
40
+ msg: { params: readonly string[] },
41
+ ctx: Ctx,
42
+ ): ReducerResult<ConnectionState> {
43
+ ctx.connection.lastSeen = ctx.clock.now();
44
+
45
+ const found: string[] = [];
46
+ for (const requested of msg.params) {
47
+ if (requested.length === 0) continue;
48
+ const display = online.get(requested.toLowerCase());
49
+ if (display !== undefined) {
50
+ found.push(display);
51
+ }
52
+ }
53
+
54
+ const nick = ctx.connection.nick ?? '*';
55
+ const codeStr = Numerics.RPL_ISON.toString().padStart(3, '0');
56
+ const trailing = found.join(' ');
57
+ const line: RawLine = { text: `:${ctx.serverName} ${codeStr} ${nick} :${trailing}` };
58
+
59
+ const effects: EffectType[] = [Effect.send(ctx.connId, [line])];
60
+ return { state: ctx.connection, effects };
61
+ }
@@ -39,7 +39,7 @@ const PREFIX = '(ov)@+';
39
39
  /** Channel name sigils this server recognises. */
40
40
  const CHANTYPES = '#';
41
41
 
42
- /** Case-mapping algorithm — v1 uses RFC 1459 (matches `lower.ts`). */
42
+ /** Case-mapping algorithm — v1 uses RFC 1459 (matches `case-fold.ts`). */
43
43
  const CASEMAPPING = 'rfc1459';
44
44
 
45
45
  /** Default max mode changes per single MODE command (PLAN §3). */
@@ -15,6 +15,7 @@
15
15
  * channel.
16
16
  */
17
17
 
18
+ import { caseFold } from '../case-fold.js';
18
19
  import { Effect } from '../effects.js';
19
20
  import type { Effect as EffectType, RawLine } from '../effects.js';
20
21
  import type { StoredMessage } from '../ports.js';
@@ -213,9 +214,9 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
213
214
  }
214
215
  }
215
216
 
216
- // Invite-only check. pendingInvites stores lowercased nicks.
217
+ // Invite-only check. pendingInvites stores case-folded nicks.
217
218
  const myNick = ctx.connection.nick;
218
- const invited = myNick !== undefined && state.pendingInvites.has(myNick.toLowerCase());
219
+ const invited = myNick !== undefined && state.pendingInvites.has(caseFold('rfc1459', myNick));
219
220
  if (state.modes.inviteOnly && !invited) {
220
221
  effects.push(
221
222
  Effect.send(ctx.connId, [
@@ -258,7 +259,7 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
258
259
  });
259
260
 
260
261
  // Pending invite is consumed on a successful join.
261
- if (myNick !== undefined) state.pendingInvites.delete(myNick.toLowerCase());
262
+ if (myNick !== undefined) state.pendingInvites.delete(caseFold('rfc1459', myNick));
262
263
 
263
264
  // Track the channel on the connection's joined set (cross-authority).
264
265
  ctx.connection.joinedChannels.add(state.nameLower);
@@ -13,6 +13,7 @@
13
13
  * one channel per invocation.
14
14
  */
15
15
 
16
+ import { caseFold } from '../case-fold.js';
16
17
  import { Effect } from '../effects.js';
17
18
  import type { Effect as EffectType, RawLine } from '../effects.js';
18
19
  import { Numerics } from '../protocol/numerics.js';
@@ -52,14 +53,6 @@ function kickerHostmask(conn: ConnectionState): string {
52
53
  return hostmaskOf(conn) ?? conn.nick ?? '?';
53
54
  }
54
55
 
55
- /**
56
- * Lowercases a nick using ASCII case folding (good enough for v1; rfc1459
57
- * case mapping lands with the isupport `CASEMAPPING` token).
58
- */
59
- function lowerNick(nick: string): string {
60
- return nick.toLowerCase();
61
- }
62
-
63
56
  /**
64
57
  * Handles `KICK <chan> <user> [:reason]`.
65
58
  *
@@ -117,11 +110,11 @@ export const kickReducer: Reducer<ChannelState> = (state, msg, ctx) => {
117
110
  return { state, effects };
118
111
  }
119
112
 
120
- // Find the target by nick (case-insensitive). IRC nicks fold by ASCII.
121
- const targetLower = lowerNick(targetNick);
113
+ // Find the target by nick (case-insensitively, per `CASEMAPPING=rfc1459`).
114
+ const targetLower = caseFold('rfc1459', targetNick);
122
115
  let targetConnId: string | undefined;
123
116
  for (const entry of state.members.values()) {
124
- if (lowerNick(entry.nick) === targetLower) {
117
+ if (caseFold('rfc1459', entry.nick) === targetLower) {
125
118
  targetConnId = entry.conn;
126
119
  break;
127
120
  }
@@ -26,6 +26,7 @@
26
26
  * the cap).
27
27
  */
28
28
 
29
+ import { caseFold } from '../case-fold.js';
29
30
  import { Effect } from '../effects.js';
30
31
  import type { Effect as EffectType, RawLine } from '../effects.js';
31
32
  import { Numerics } from '../protocol/numerics.js';
@@ -104,10 +105,10 @@ export const listReducer: Reducer<ChanSnapshot[]> = (state, msg, ctx) => {
104
105
  for (const rawName of requested) {
105
106
  if (lines.length >= max) break;
106
107
  if (!isValidChannelName(rawName, ctx.serverConfig.channelLen)) continue;
107
- const lower = rawName.toLowerCase();
108
- if (seen.has(lower)) continue;
109
- seen.add(lower);
110
- const chan = state.find((c) => c.nameLower === lower);
108
+ const key = caseFold('rfc1459', rawName);
109
+ if (seen.has(key)) continue;
110
+ seen.add(key);
111
+ const chan = state.find((c) => c.nameLower === key);
111
112
  if (chan === undefined) continue;
112
113
  if (!isVisible(chan, ctx)) continue;
113
114
  lines.push(listLine(chan, ctx, nick));
@@ -12,7 +12,7 @@
12
12
  * for its own user modes, so the reducer's `state` argument is the
13
13
  * caller's {@link ConnectionState}. A user may only change their own
14
14
  * modes (502 otherwise). The oper flag (`+o`) is granted via the
15
- * future OPER command, not via MODE — `+o` here is rejected with 481.
15
+ * OPER command, not via MODE — `+o` here is rejected with 481.
16
16
  *
17
17
  * Mode classes (per modern IRC de-facto convention):
18
18
  * - Type A (list mode, always takes arg): `b` (ban mask).
@@ -28,6 +28,7 @@
28
28
  * channel.
29
29
  */
30
30
 
31
+ import { caseFold } from '../case-fold.js';
31
32
  import { Effect } from '../effects.js';
32
33
  import type { Effect as EffectType, RawLine } from '../effects.js';
33
34
  import { Numerics } from '../protocol/numerics.js';
@@ -66,11 +67,6 @@ function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string):
66
67
  return { text: parts.join(' ') };
67
68
  }
68
69
 
69
- /** Lowercases a nick using ASCII case folding (matches kick.ts). */
70
- function lowerNick(nick: string): string {
71
- return nick.toLowerCase();
72
- }
73
-
74
70
  /** Boolean channel mode letters → mode-field name. */
75
71
  const BOOLEAN_LETTERS: Readonly<Record<string, BooleanChannelMode>> = {
76
72
  i: 'inviteOnly',
@@ -417,9 +413,9 @@ function findMemberByNick(
417
413
  state: ChannelState,
418
414
  nick: string,
419
415
  ): { conn: string; nick: string; op: boolean; voice: boolean } | null {
420
- const targetLower = lowerNick(nick);
416
+ const targetLower = caseFold('rfc1459', nick);
421
417
  for (const entry of state.members.values()) {
422
- if (lowerNick(entry.nick) === targetLower) {
418
+ if (caseFold('rfc1459', entry.nick) === targetLower) {
423
419
  return entry;
424
420
  }
425
421
  }
@@ -566,7 +562,7 @@ export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
566
562
 
567
563
  // Target must be the caller's own nick (case-insensitive).
568
564
  const myNick = state.nick;
569
- if (myNick === undefined || lowerNick(target) !== lowerNick(myNick)) {
565
+ if (myNick === undefined || caseFold('rfc1459', target) !== caseFold('rfc1459', myNick)) {
570
566
  effects.push(
571
567
  Effect.send(ctx.connId, [
572
568
  numericErr(ctx, Numerics.ERR_USERSDONTMATCH, 'Cannot change mode for other users'),
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Shared MOTD-lines → IRC numerics builder.
3
+ *
4
+ * Both the registration welcome block ({@link buildWelcomeLines}) and the
5
+ * standalone `MOTD` command ({@link motdReducer}) render the same
6
+ * `375`/`372`×N/`376` sequence (or `422 ERR_NOMOTD` when no MOTD is
7
+ * configured) from the lines supplied by a {@link MotdProvider}. This module
8
+ * owns that rendering so the two call sites cannot drift.
9
+ *
10
+ * Wire format (RFC 2812 §5):
11
+ * - `375 RPL_MOTDSTART` — `:<server> 375 <nick> :- <server> Message of the day -`
12
+ * - `372 RPL_MOTD` — `:<server> 372 <nick> :- <line>` (one per MOTD line)
13
+ * - `376 RPL_ENDOFMOTD` — `:<server> 376 <nick> :End of MOTD command`
14
+ * - `422 ERR_NOMOTD` — `:<server> 422 <nick> :MOTD File is missing`
15
+ *
16
+ * Per RFC 1459 §2.3 the maximum wire line length is 512 bytes including the
17
+ * trailing CR-LF. Over-long MOTD content lines are hard-wrapped at the
18
+ * 372-line content budget so every emitted line fits within the 512-byte
19
+ * limit (also leaving headroom for message-tags).
20
+ */
21
+
22
+ import type { RawLine } from '../effects.js';
23
+ import { Numerics } from '../protocol/numerics.js';
24
+
25
+ /** RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing CR-LF. */
26
+ const MAX_LINE_BYTES = 512;
27
+
28
+ /** Length of the trailing CR-LF that the wire layer appends to each line. */
29
+ const TRAILING_CRLF_LEN = 2;
30
+
31
+ /** Fixed text emitted for `375 RPL_MOTDSTART` (after `:- `). */
32
+ const MOTDSTART_TRAILING_TEXT = '- $server Message of the day -';
33
+
34
+ /** Fixed text emitted for `376 RPL_ENDOFMOTD`. */
35
+ const ENDOFMOTD_TRAILING_TEXT = 'End of MOTD command';
36
+
37
+ /** Fixed text emitted for `422 ERR_NOMOTD`. */
38
+ const NOMOTD_TRAILING_TEXT = 'MOTD File is missing';
39
+
40
+ /**
41
+ * Renders the MOTD numerics addressed to `nick` from the supplied `lines`.
42
+ *
43
+ * Returns `[422 ERR_NOMOTD]` when `lines` is empty (mirroring the standalone
44
+ * `MOTD` command), otherwise the `375`/`372`×N/`376` block with over-long
45
+ * lines hard-wrapped to fit the 512-byte wire limit.
46
+ *
47
+ * `nick` is taken verbatim from the caller — the welcome path passes the
48
+ * (guaranteed-defined) registering nick, the standalone command passes
49
+ * `connection.nick ?? '*'`.
50
+ */
51
+ export function buildMotdNumerics(serverName: string, nick: string, lines: string[]): RawLine[] {
52
+ if (lines.length === 0) {
53
+ return [numericLine(serverName, nick, Numerics.ERR_NOMOTD, NOMOTD_TRAILING_TEXT)];
54
+ }
55
+
56
+ const out: RawLine[] = [
57
+ numericLine(
58
+ serverName,
59
+ nick,
60
+ Numerics.RPL_MOTDSTART,
61
+ MOTDSTART_TRAILING_TEXT.replace('$server', serverName),
62
+ ),
63
+ ];
64
+
65
+ const maxContent = maxMotdContentLen(serverName, nick);
66
+ for (const raw of lines) {
67
+ for (const chunk of splitForMotd(raw, maxContent)) {
68
+ out.push(numericLine(serverName, nick, Numerics.RPL_MOTD, `- ${chunk}`));
69
+ }
70
+ }
71
+
72
+ out.push(numericLine(serverName, nick, Numerics.RPL_ENDOFMOTD, ENDOFMOTD_TRAILING_TEXT));
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * Builds a `:<server> <code> <nick> :<trailing>` line.
78
+ */
79
+ function numericLine(serverName: string, nick: string, code: number, trailing: string): RawLine {
80
+ const codeStr = code.toString().padStart(3, '0');
81
+ return { text: `:${serverName} ${codeStr} ${nick} :${trailing}` };
82
+ }
83
+
84
+ /**
85
+ * Returns the maximum content-byte length that fits in a single `372 RPL_MOTD`
86
+ * line for this connection, given the server name and nick.
87
+ *
88
+ * The 372 wire format is `:<server> 372 <nick> :- <chunk>\r\n`, so the
89
+ * per-chunk budget is:
90
+ *
91
+ * 512
92
+ * - 2 (trailing CR-LF)
93
+ * - 1 (leading `:`)
94
+ * - <server>.length
95
+ * - 5 (` 372 `)
96
+ * - <nick>.length
97
+ * - 4 (` :- `)
98
+ *
99
+ * The result is clamped at 0 so a pathologically long server/nick combo
100
+ * cannot drive the budget negative.
101
+ */
102
+ function maxMotdContentLen(serverName: string, nick: string): number {
103
+ const overhead = TRAILING_CRLF_LEN + 1 + serverName.length + 5 + nick.length + 4;
104
+ return Math.max(0, MAX_LINE_BYTES - overhead);
105
+ }
106
+
107
+ /**
108
+ * Splits a single MOTD content line into chunks that each fit the per-372
109
+ * content budget.
110
+ *
111
+ * Hard-wraps at the byte budget (no word-boundary awareness) — matching the
112
+ * behavior of mainstream IRC daemons and keeping the rendering deterministic.
113
+ *
114
+ * Edge cases:
115
+ * - Empty string → `['']` (one empty 372 line, still RFC-conformant).
116
+ * - Zero/negative budget (impossible in practice but defensive) → `['']`
117
+ * so the builder always emits at least one 372 per input line.
118
+ */
119
+ function splitForMotd(text: string, maxLen: number): string[] {
120
+ if (maxLen <= 0) return [''];
121
+ if (text.length === 0) return [''];
122
+ const chunks: string[] = [];
123
+ for (let i = 0; i < text.length; i += maxLen) {
124
+ chunks.push(text.slice(i, i + maxLen));
125
+ }
126
+ return chunks;
127
+ }
@@ -11,38 +11,16 @@
11
11
  * D1, or Secrets Manager) so the reducer itself stays pure and
12
12
  * deterministic — no IO in the handler.
13
13
  *
14
- * Wire format (RFC 2812 §5):
15
- * - `375 RPL_MOTDSTART` — `:<server> 375 <nick> :- <server> Message of the day -`
16
- * - `372 RPL_MOTD` — `:<server> 372 <nick> :- <line>` (one per MOTD line)
17
- * - `376 RPL_ENDOFMOTD` — `:<server> 376 <nick> :End of MOTD command`
18
- * - `422 ERR_NOMOTD` — `:<server> 422 <nick> :MOTD File is missing`
19
- *
20
- * Per RFC 1459 §2.3 the maximum wire line length is 512 bytes including the
21
- * trailing CR-LF. Over-long MOTD content lines are hard-wrapped at the
22
- * 372-line content budget so every emitted line fits within the 512-byte
23
- * limit (also leaving headroom for message-tags).
14
+ * The numeric rendering (`375`/`372`×N/`376`, or `422 ERR_NOMOTD`) is shared
15
+ * with the registration welcome block via {@link buildMotdNumerics} so the
16
+ * two paths cannot drift.
24
17
  */
25
18
 
26
19
  import { Effect } from '../effects.js';
27
20
  import type { Effect as EffectType, RawLine } from '../effects.js';
28
- import { Numerics } from '../protocol/numerics.js';
29
21
  import type { ConnectionState } from '../state/connection.js';
30
22
  import type { Ctx, Reducer } from '../types.js';
31
-
32
- /** RFC 1459 §2.3: maximum IRC line length in bytes, including the trailing CR-LF. */
33
- const MAX_LINE_BYTES = 512;
34
-
35
- /** Length of the trailing CR-LF that the wire layer appends to each line. */
36
- const TRAILING_CRLF_LEN = 2;
37
-
38
- /** Fixed text emitted for `375 RPL_MOTDSTART` (after `:- `). */
39
- const MOTDSTART_TRAILING_TEXT = '- $server Message of the day -';
40
-
41
- /** Fixed text emitted for `376 RPL_ENDOFMOTD`. */
42
- const ENDOFMOTD_TRAILING_TEXT = 'End of MOTD command';
43
-
44
- /** Fixed text emitted for `422 ERR_NOMOTD`. */
45
- const NOMOTD_TRAILING_TEXT = 'MOTD File is missing';
23
+ import { buildMotdNumerics } from './motd-lines.js';
46
24
 
47
25
  /**
48
26
  * Handles `MOTD [server]`.
@@ -56,91 +34,9 @@ const NOMOTD_TRAILING_TEXT = 'MOTD File is missing';
56
34
  export const motdReducer: Reducer<ConnectionState> = (state, _msg, ctx) => {
57
35
  state.lastSeen = ctx.clock.now();
58
36
 
59
- const lines = ctx.motd.lines();
60
-
61
- if (lines.length === 0) {
62
- return {
63
- state,
64
- effects: [
65
- Effect.send(ctx.connId, [numericLine(ctx, Numerics.ERR_NOMOTD, NOMOTD_TRAILING_TEXT)]),
66
- ],
67
- };
68
- }
69
-
70
- const out: RawLine[] = [
71
- numericLine(
72
- ctx,
73
- Numerics.RPL_MOTDSTART,
74
- MOTDSTART_TRAILING_TEXT.replace('$server', ctx.serverName),
75
- ),
76
- ];
77
-
78
- const maxContent = maxMotdContentLen(ctx);
79
- for (const raw of lines) {
80
- for (const chunk of splitForMotd(raw, maxContent)) {
81
- out.push(numericLine(ctx, Numerics.RPL_MOTD, `- ${chunk}`));
82
- }
83
- }
84
-
85
- out.push(numericLine(ctx, Numerics.RPL_ENDOFMOTD, ENDOFMOTD_TRAILING_TEXT));
37
+ const nick = ctx.connection.nick ?? '*';
38
+ const out: RawLine[] = buildMotdNumerics(ctx.serverName, nick, ctx.motd.lines());
86
39
 
87
40
  const effects: EffectType[] = [Effect.send(ctx.connId, out)];
88
41
  return { state, effects };
89
42
  };
90
-
91
- /**
92
- * Builds a `:<server> <code> <nick> :<trailing>` line. The nick falls back
93
- * to `*` when the connection has not yet registered one, matching the rest
94
- * of the codebase's numeric-error formatting.
95
- */
96
- function numericLine(ctx: Ctx, code: number, trailing: string): RawLine {
97
- const nick = ctx.connection.nick ?? '*';
98
- const codeStr = code.toString().padStart(3, '0');
99
- return { text: `:${ctx.serverName} ${codeStr} ${nick} :${trailing}` };
100
- }
101
-
102
- /**
103
- * Returns the maximum content-byte length that fits in a single `372 RPL_MOTD`
104
- * line for this connection, given the server name and the connection's nick.
105
- *
106
- * The 372 wire format is `:<server> 372 <nick> :- <chunk>\r\n`, so the
107
- * per-chunk budget is:
108
- *
109
- * 512
110
- * - 2 (trailing CR-LF)
111
- * - 1 (leading `:`)
112
- * - <server>.length
113
- * - 5 (` 372 `)
114
- * - <nick>.length
115
- * - 4 (` :- `)
116
- *
117
- * The result is clamped at 0 so a pathologically long server/nick combo
118
- * cannot drive the budget negative.
119
- */
120
- function maxMotdContentLen(ctx: Ctx): number {
121
- const nick = ctx.connection.nick ?? '*';
122
- const overhead = TRAILING_CRLF_LEN + 1 + ctx.serverName.length + 5 + nick.length + 4;
123
- return Math.max(0, MAX_LINE_BYTES - overhead);
124
- }
125
-
126
- /**
127
- * Splits a single MOTD content line into chunks that each fit the per-372
128
- * content budget.
129
- *
130
- * Hard-wraps at the byte budget (no word-boundary awareness) — matching the
131
- * behavior of mainstream IRC daemons and keeping the reducer deterministic.
132
- *
133
- * Edge cases:
134
- * - Empty string → `['']` (one empty 372 line, still RFC-conformant).
135
- * - Zero/negative budget (impossible in practice but defensive) → `['']`
136
- * so the reducer always emits at least one 372 per input line.
137
- */
138
- function splitForMotd(text: string, maxLen: number): string[] {
139
- if (maxLen <= 0) return [''];
140
- if (text.length === 0) return [''];
141
- const chunks: string[] = [];
142
- for (let i = 0; i < text.length; i += maxLen) {
143
- chunks.push(text.slice(i, i + maxLen));
144
- }
145
- return chunks;
146
- }