serverless-ircd 0.4.0 → 0.6.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 (243) hide show
  1. package/.github/workflows/ci.yml +34 -2
  2. package/.github/workflows/deploy-aws.yml +1 -2
  3. package/.github/workflows/deploy-cf-tcp.yml +1 -5
  4. package/.github/workflows/deploy-cf.yml +1 -2
  5. package/CHANGELOG.md +435 -0
  6. package/README.md +206 -27
  7. package/apps/aws-stack/README.md +37 -3
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +106 -13
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +49 -3
  12. package/apps/aws-stack/tests/synth-no-bundle.test.ts +129 -0
  13. package/apps/aws-stack/tests/vitest.setup.ts +18 -0
  14. package/apps/aws-stack/vitest.config.ts +3 -0
  15. package/apps/cf-tcp-container/package.json +1 -1
  16. package/apps/cf-tcp-container/src/config-loader.ts +6 -7
  17. package/apps/cf-tcp-container/src/container-server.ts +3 -0
  18. package/apps/cf-tcp-container/tests/config-loader.test.ts +10 -3
  19. package/apps/cf-tcp-container/wrangler.toml +1 -1
  20. package/apps/cf-worker/package.json +2 -1
  21. package/apps/cf-worker/vitest.config.ts +1 -1
  22. package/apps/cf-worker/wrangler.toml +5 -3
  23. package/apps/local-cli/package.json +1 -1
  24. package/apps/local-cli/src/config-loader.ts +6 -6
  25. package/apps/local-cli/src/main.ts +20 -0
  26. package/apps/local-cli/src/server.ts +138 -28
  27. package/apps/local-cli/tests/e2e.test.ts +113 -29
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +262 -0
  30. package/biome.json +1 -0
  31. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  32. package/docs/ADR-002-location-of-authority.md +82 -0
  33. package/docs/ADR-003-durable-object-sharding.md +110 -0
  34. package/docs/ADR-004-dynamodb-schema.md +96 -0
  35. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  36. package/docs/ADR-006-sasl-mechanism-scope.md +96 -0
  37. package/docs/ADR-007-deterministic-ports.md +82 -0
  38. package/docs/ADR-008-monorepo-tooling.md +60 -0
  39. package/docs/ADR-009-dual-transport-wss-and-irc-tls.md +138 -0
  40. package/docs/ADR-Index.md +15 -0
  41. package/docs/AWS-Adapter-Architecture.md +496 -0
  42. package/docs/AWS-Deployment.md +1275 -0
  43. package/docs/AWS-TCP-Deployment.md +410 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +719 -0
  45. package/docs/Cloudflare-TCP-Deployment.md +510 -0
  46. package/docs/Home.md +15 -0
  47. package/docs/Observability.md +87 -0
  48. package/docs/PlanExtensions.md +812 -0
  49. package/docs/PlanIRCv3Websocket.md +503 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +2 -2
  53. package/packages/aws-adapter/package.json +1 -1
  54. package/packages/aws-adapter/src/aws-runtime.ts +69 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +36 -5
  56. package/packages/aws-adapter/src/handlers/default.ts +66 -5
  57. package/packages/aws-adapter/src/handlers/index.ts +41 -2
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +18 -0
  59. package/packages/aws-adapter/src/index.ts +2 -0
  60. package/packages/aws-adapter/src/serialize.ts +40 -2
  61. package/packages/aws-adapter/src/stats.ts +80 -0
  62. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  63. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  64. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  65. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  66. package/packages/aws-adapter/tests/aws-runtime.test.ts +140 -0
  67. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  68. package/packages/aws-adapter/tests/connect.test.ts +100 -4
  69. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  70. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  72. package/packages/aws-adapter/tests/handlers.test.ts +238 -4
  73. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  74. package/packages/aws-adapter/tests/nlb-stream.test.ts +62 -8
  75. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  76. package/packages/cf-adapter/package.json +6 -1
  77. package/packages/cf-adapter/src/cf-runtime.ts +66 -1
  78. package/packages/cf-adapter/src/channel-do.ts +2 -2
  79. package/packages/cf-adapter/src/connection-do.ts +185 -54
  80. package/packages/cf-adapter/src/env.ts +25 -6
  81. package/packages/cf-adapter/src/index.ts +2 -0
  82. package/packages/cf-adapter/src/registry-do.ts +22 -3
  83. package/packages/cf-adapter/src/serialize.ts +25 -4
  84. package/packages/cf-adapter/src/sharding.ts +1 -2
  85. package/packages/cf-adapter/src/stats.ts +65 -0
  86. package/packages/cf-adapter/tests/cf-harness.ts +1 -1
  87. package/packages/cf-adapter/tests/cf-integration.test.ts +4 -4
  88. package/packages/cf-adapter/tests/cf-runtime.test.ts +307 -2
  89. package/packages/cf-adapter/tests/channel-do.test.ts +119 -2
  90. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  91. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +2 -2
  92. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +2 -2
  93. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  94. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  95. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  96. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  97. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  98. package/packages/cf-adapter/tests/registry-do.test.ts +108 -4
  99. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  100. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  101. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  102. package/packages/cf-adapter/tests/worker/main.ts +7 -7
  103. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  104. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  105. package/packages/cf-adapter/vitest.config.ts +1 -1
  106. package/packages/cf-adapter/wrangler.test.toml +7 -0
  107. package/packages/in-memory-runtime/package.json +1 -1
  108. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  109. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  110. package/packages/irc-core/package.json +1 -1
  111. package/packages/irc-core/src/admission.ts +16 -15
  112. package/packages/irc-core/src/caps/capabilities.ts +38 -4
  113. package/packages/irc-core/src/caps/index.ts +1 -0
  114. package/packages/irc-core/src/caps/sts.ts +84 -0
  115. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  116. package/packages/irc-core/src/commands/away.ts +9 -3
  117. package/packages/irc-core/src/commands/cap.ts +23 -5
  118. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  119. package/packages/irc-core/src/commands/index.ts +42 -0
  120. package/packages/irc-core/src/commands/invite.ts +2 -4
  121. package/packages/irc-core/src/commands/isupport.ts +59 -2
  122. package/packages/irc-core/src/commands/kick.ts +2 -4
  123. package/packages/irc-core/src/commands/kill.ts +127 -0
  124. package/packages/irc-core/src/commands/list.ts +1 -1
  125. package/packages/irc-core/src/commands/lusers.ts +204 -0
  126. package/packages/irc-core/src/commands/mode.ts +12 -9
  127. package/packages/irc-core/src/commands/monitor.ts +327 -0
  128. package/packages/irc-core/src/commands/multiline.ts +256 -0
  129. package/packages/irc-core/src/commands/names.ts +3 -5
  130. package/packages/irc-core/src/commands/part.ts +2 -4
  131. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  132. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  133. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  134. package/packages/irc-core/src/commands/registration.ts +8 -0
  135. package/packages/irc-core/src/commands/rehash.ts +119 -0
  136. package/packages/irc-core/src/commands/sasl.ts +24 -1
  137. package/packages/irc-core/src/commands/setname.ts +109 -0
  138. package/packages/irc-core/src/commands/stats.ts +152 -0
  139. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  140. package/packages/irc-core/src/commands/topic.ts +2 -4
  141. package/packages/irc-core/src/commands/trace.ts +137 -0
  142. package/packages/irc-core/src/commands/wallops.ts +118 -0
  143. package/packages/irc-core/src/commands/whois.ts +5 -0
  144. package/packages/irc-core/src/config.ts +72 -10
  145. package/packages/irc-core/src/effects.ts +41 -1
  146. package/packages/irc-core/src/index.ts +2 -0
  147. package/packages/irc-core/src/ports.ts +568 -0
  148. package/packages/irc-core/src/protocol/index.ts +14 -0
  149. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  150. package/packages/irc-core/src/protocol/numerics.ts +57 -11
  151. package/packages/irc-core/src/protocol/outbound.ts +36 -4
  152. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  153. package/packages/irc-core/src/state/connection.ts +32 -1
  154. package/packages/irc-core/src/types.ts +120 -1
  155. package/packages/irc-core/src/ws-framing.ts +132 -0
  156. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  157. package/packages/irc-core/stryker.commands.conf.json +1 -2
  158. package/packages/irc-core/tests/admission.test.ts +18 -0
  159. package/packages/irc-core/tests/away-store.test.ts +73 -0
  160. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  161. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  162. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  163. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  164. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  165. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  166. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  167. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  168. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  169. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  170. package/packages/irc-core/tests/commands/mode.test.ts +129 -0
  171. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  172. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  173. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  174. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  175. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  176. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  177. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  178. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  179. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  180. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  181. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  182. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  183. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  184. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  185. package/packages/irc-core/tests/config.test.ts +103 -13
  186. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  187. package/packages/irc-core/tests/effects.test.ts +14 -0
  188. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  189. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  190. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  191. package/packages/irc-core/tests/numerics.test.ts +90 -0
  192. package/packages/irc-core/tests/outbound.test.ts +51 -0
  193. package/packages/irc-core/tests/ports.test.ts +22 -0
  194. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  195. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  196. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  197. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  198. package/packages/irc-core/tests/types.test.ts +27 -0
  199. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  200. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  201. package/packages/irc-server/package.json +1 -1
  202. package/packages/irc-server/src/actor.ts +699 -19
  203. package/packages/irc-server/src/dispatch.ts +109 -16
  204. package/packages/irc-server/src/routing.ts +3 -0
  205. package/packages/irc-server/src/runtime.ts +31 -0
  206. package/packages/irc-server/src/transport.ts +10 -7
  207. package/packages/irc-server/tests/actor.test.ts +2523 -42
  208. package/packages/irc-server/tests/dispatch.test.ts +300 -2
  209. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  210. package/packages/irc-server/tests/routing.test.ts +1 -0
  211. package/packages/irc-server/tests/runtime.test.ts +7 -0
  212. package/packages/irc-test-support/package.json +1 -1
  213. package/packages/irc-test-support/src/index.ts +6 -0
  214. package/packages/irc-test-support/src/scenarios.ts +9 -1
  215. package/packages/irc-test-support/src/test-config.ts +54 -0
  216. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +1 -1
  217. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/tools/ci-hardening/package.json +1 -1
  220. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  221. package/tools/load-test/package.json +33 -0
  222. package/tools/load-test/src/client.ts +351 -0
  223. package/tools/load-test/src/config.ts +313 -0
  224. package/tools/load-test/src/harness.ts +116 -0
  225. package/tools/load-test/src/main.ts +120 -0
  226. package/tools/load-test/src/metrics.ts +168 -0
  227. package/tools/load-test/src/report.ts +106 -0
  228. package/tools/load-test/tests/client.test.ts +212 -0
  229. package/tools/load-test/tests/config.test.ts +152 -0
  230. package/tools/load-test/tests/framing.test.ts +37 -0
  231. package/tools/load-test/tests/harness.test.ts +165 -0
  232. package/tools/load-test/tests/metrics.test.ts +174 -0
  233. package/tools/load-test/tests/report.test.ts +161 -0
  234. package/tools/load-test/tests/smoke.test.ts +67 -0
  235. package/tools/load-test/tsconfig.build.json +12 -0
  236. package/tools/load-test/tsconfig.test.json +10 -0
  237. package/tools/load-test/vitest.config.ts +29 -0
  238. package/tools/package.json +6 -1
  239. package/tools/seed-cf-accounts.ts +4 -1
  240. package/tools/tcp-ws-forwarder/package.json +1 -1
  241. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  242. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  243. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
@@ -33,13 +33,17 @@
33
33
  */
34
34
 
35
35
  import {
36
+ DEFAULT_MULTILINE_MAX_BYTES,
37
+ MULTILINE_CAP_NAME,
36
38
  SASL_CAP_NAME,
37
39
  SUPPORTED_CAPABILITIES,
38
40
  capToLsString,
39
41
  isSupportedCap,
42
+ multilineCapValue,
40
43
  saslCapValue,
41
44
  splitCapsForWire,
42
45
  } from '../caps/capabilities.js';
46
+ import { STS_CAP_NAME, stsCapValue } from '../caps/sts.js';
43
47
  import { Effect } from '../effects.js';
44
48
  import type { RawLine } from '../effects.js';
45
49
  import { Numerics } from '../protocol/numerics.js';
@@ -91,11 +95,21 @@ function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionSta
91
95
 
92
96
  const target = state.nick ?? '*';
93
97
  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
- );
98
+ const multilineMax = ctx.serverConfig.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
99
+ const tokens = SUPPORTED_CAPABILITIES.map((cap) => {
100
+ if (cap.name === SASL_CAP_NAME) {
101
+ return capToLsString({ ...cap, value: saslCapValue(hasMtls) });
102
+ }
103
+ if (cap.name === MULTILINE_CAP_NAME) {
104
+ return capToLsString({ ...cap, value: multilineCapValue(multilineMax) });
105
+ }
106
+ return capToLsString(cap);
107
+ });
108
+ // STS is conditionally advertised: appended only when the deployment
109
+ // supplies a policy, and the value is per-transport (plaintext sees the
110
+ // full `duration,port[,preload]`; TLS sees only `duration`).
111
+ const stsValue = stsCapValue(ctx.serverConfig.sts, ctx.connection.secure);
112
+ if (stsValue !== undefined) tokens.push(`${STS_CAP_NAME}=${stsValue}`);
99
113
  const maxLen = maxCapsContentLen(ctx, target);
100
114
  const chunks = splitCapsForWire(tokens, maxLen);
101
115
 
@@ -164,8 +178,12 @@ function handleReq(
164
178
  const requested = rawCaps.split(' ').filter((c) => c.length > 0);
165
179
  const target = state.nick ?? '*';
166
180
 
181
+ // STS is conditionally supported: ACKed only when the deployment supplies
182
+ // a policy. Other caps consult the static table.
183
+ const stsConfigured = ctx.serverConfig.sts !== undefined;
167
184
  const allSupported = requested.every((tok) => {
168
185
  const name = tok.startsWith('-') ? tok.slice(1) : tok;
186
+ if (name === STS_CAP_NAME) return stsConfigured;
169
187
  return isSupportedCap(name);
170
188
  });
171
189
 
@@ -141,7 +141,17 @@ function handleChannelSub(
141
141
  // LATEST / BEFORE / AFTER / AROUND
142
142
  const pivot = msg.params[2];
143
143
  const limitRaw = msg.params[3];
144
- if (sub !== 'LATEST' && pivot === undefined) {
144
+
145
+ // IRCv3 draft/read-marker integration: BEFORE without an explicit pivot
146
+ // falls back to the connection's per-channel last-read marker so a client
147
+ // can resume "everything before my last read position" without re-supplying
148
+ // the msgid. AFTER/AROUND still require an explicit pivot.
149
+ const markerPivot =
150
+ sub === 'BEFORE' && pivot === undefined
151
+ ? ctx.connection.lastReadMarkers?.get(chanLower)
152
+ : undefined;
153
+
154
+ if (sub !== 'LATEST' && pivot === undefined && markerPivot === undefined) {
145
155
  return { effects: [Effect.send(ctx.connId, [needMoreParamsLine(ctx)])] };
146
156
  }
147
157
  const limit = parseLimit(limitRaw);
@@ -163,12 +173,13 @@ function handleChannelSub(
163
173
  return { effects: buildChathistoryBatch(ctx, channel.name, result) };
164
174
  }
165
175
 
166
- // BEFORE / AFTER / AROUND — pivot is required. The `sub !== 'LATEST' &&
167
- // pivot === undefined` guard above already rejected the missing-pivot
168
- // case and LATEST returned early just above; assert non-null for the
169
- // type-checker without an unreachable branch.
170
- const pivotMsgid = pivot as string;
171
- if (!storeHas(ctx, chanLower, pivotMsgid)) {
176
+ // BEFORE / AFTER / AROUND — pivot is required. Resolve from the explicit
177
+ // msgid when supplied, otherwise from the marker fallback computed above.
178
+ // The guard near the top already guaranteed at least one is defined.
179
+ const pivotMsgid = (pivot ?? markerPivot) as string;
180
+ // An explicit pivot must reference a retained message; a marker fallback
181
+ // may point at an evicted message ( empty result, not INVALID_PARAMS).
182
+ if (pivot !== undefined && !storeHas(ctx, chanLower, pivotMsgid)) {
172
183
  return { effects: [Effect.send(ctx.connId, [invalidParamsLine(ctx)])] };
173
184
  }
174
185
  const result = storeQuery(ctx, {
@@ -228,16 +239,34 @@ export function buildChathistoryBatch(
228
239
  /**
229
240
  * Reconstructs the wire line for one stored message, decorated with the
230
241
  * original server-time and msgid tags. PRIVMSG/NOTICE carry the trailing
231
- * text; TAGMSG has no trailing parameter.
242
+ * text; TAGMSG has no trailing parameter. Optional `clientTags` recorded
243
+ * with the message (e.g. `+draft/multiline`) are appended to the tag
244
+ * section so chathistory clients can recognise the message kind.
232
245
  */
233
246
  export function formatReplayLine(m: StoredMessage, displayChan: string): RawLine {
234
247
  const stamp = formatServerTime(m.time);
235
248
  const prefix = storedHostmask(m);
236
- const tag = `@time=${stamp};msgid=${m.msgid}`;
249
+ const tags = buildReplayTags(stamp, m.msgid, m.clientTags);
237
250
  if (m.command === 'TAGMSG') {
238
- return { text: `${tag} :${prefix} TAGMSG ${displayChan}` };
251
+ return { text: `${tags} :${prefix} TAGMSG ${displayChan}` };
252
+ }
253
+ return { text: `${tags} :${prefix} ${m.command} ${displayChan} :${m.text}` };
254
+ }
255
+
256
+ /**
257
+ * Builds the `@time=…;msgid=…[;+client-tag…]` tag section for a replay line.
258
+ * Client tags (when present) are appended after the server tags, matching the
259
+ * IRCv3 message-tags wire order.
260
+ */
261
+ function buildReplayTags(
262
+ stamp: string,
263
+ msgid: string,
264
+ clientTags: readonly string[] | undefined,
265
+ ): string {
266
+ if (clientTags === undefined || clientTags.length === 0) {
267
+ return `@time=${stamp};msgid=${msgid}`;
239
268
  }
240
- return { text: `${tag} :${prefix} ${m.command} ${displayChan} :${m.text}` };
269
+ return `@time=${stamp};msgid=${msgid};${clientTags.join(';')}`;
241
270
  }
242
271
 
243
272
  /** Builds the `nick!user@host` source for a stored message. */
@@ -19,6 +19,8 @@ export { handleJoinZero, isValidChannelName, 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';
22
+ export { rehashReducer, rehashLine, buildRehashTrailing } from './rehash.js';
23
+ export type { RehashResult } from './rehash.js';
22
24
  export {
23
25
  isChannelTarget,
24
26
  noticeChannelReducer,
@@ -27,6 +29,8 @@ export {
27
29
  privmsgUserReducer,
28
30
  } from './privmsg.js';
29
31
  export { kickReducer } from './kick.js';
32
+ export { killReducer, formatKillReason } from './kill.js';
33
+ export { wallopsReducer, wallopsLine } from './wallops.js';
30
34
  export { topicReducer } from './topic.js';
31
35
  export { namesReducer } from './names.js';
32
36
  export { listReducer } from './list.js';
@@ -35,11 +39,17 @@ export { inviteReducer } from './invite.js';
35
39
  export { whoReducer } from './who.js';
36
40
  export { whoisReducer } from './whois.js';
37
41
  export { whowasReducer } from './whowas.js';
42
+ export { traceReducer } from './trace.js';
43
+ export { lusersReducer } from './lusers.js';
44
+ export { statsReducer } from './stats.js';
38
45
  export { generateIsupport } from './isupport.js';
39
46
  export { capReducer } from './cap.js';
40
47
  export { emitChghost } from './chghost.js';
41
48
  export type { ChgHostInput } from './chghost.js';
49
+ export { emitAccountNotify } from './account-notify.js';
50
+ export type { AccountNotifyInput } from './account-notify.js';
42
51
  export { awayReducer } from './away.js';
52
+ export { setnameReducer } from './setname.js';
43
53
  export { authenticateReducer } from './sasl.js';
44
54
  export {
45
55
  chathistoryReducer,
@@ -48,6 +58,18 @@ export {
48
58
  CHATHISTORY_CAP,
49
59
  } from './chathistory.js';
50
60
  export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
61
+ export {
62
+ DEFAULT_MULTILINE_MAX_BYTES,
63
+ MULTILINE_CAP,
64
+ type FinalizeMultilineResult,
65
+ type MultilineEntry,
66
+ type MultilineFanout,
67
+ buildMultilineFanout,
68
+ finalizeMultilineBatch,
69
+ joinMultiline,
70
+ multilineByteLength,
71
+ recordMultilineMessage,
72
+ } from './multiline.js';
51
73
  export {
52
74
  versionReducer,
53
75
  timeReducer,
@@ -56,3 +78,23 @@ export {
56
78
  } from './server-info.js';
57
79
  export { userhostReducer, formatUserhostReply } from './userhost.js';
58
80
  export { isonReducer } from './ison.js';
81
+ export {
82
+ monitorReducer,
83
+ MONITOR_CAP,
84
+ DEFAULT_MONITOR_LIMIT,
85
+ } from './monitor.js';
86
+ export {
87
+ READ_MARKER_CAP,
88
+ READ_MARKER_TAG,
89
+ isReadMarkerTagmsg,
90
+ persistReadMarker,
91
+ readMarkerMsgid,
92
+ seedReadMarkers,
93
+ } from './read-marker.js';
94
+ export {
95
+ PRE_AWAY_CAP,
96
+ clearPersistedAway,
97
+ nowAwayLine,
98
+ persistAway,
99
+ replayPersistedAway,
100
+ } from './pre-away.js';
@@ -42,12 +42,10 @@ function callerHostmask(conn: ConnectionState): string {
42
42
  }
43
43
 
44
44
  /** Formats a numeric error/reply line addressed to the connection's nick. */
45
- function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
45
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
46
46
  const nick = ctx.connection.nick ?? '*';
47
47
  const codeStr = code.toString().padStart(3, '0');
48
- const parts = [`:${ctx.serverName}`, codeStr, nick];
49
- if (middle !== undefined) parts.push(middle);
50
- parts.push(`:${trailing}`);
48
+ const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
51
49
  return { text: parts.join(' ') };
52
50
  }
53
51
 
@@ -15,8 +15,10 @@
15
15
  * per-deployment (PLAN §10 capacity planning).
16
16
  */
17
17
 
18
+ import { DEFAULT_MULTILINE_MAX_BYTES } from '../caps/capabilities.js';
18
19
  import type { RawLine } from '../effects.js';
19
20
  import type { ServerConfig } from '../types.js';
21
+ import { DEFAULT_MONITOR_LIMIT } from './monitor.js';
20
22
 
21
23
  /** Max bytes per IRC wire line, including trailing `\r\n`. */
22
24
  const LINE_MAX_BYTES = 510;
@@ -36,6 +38,32 @@ const CHANMODES = {
36
38
  /** Channel-user prefix sigils, in PREFIX order (highest rank first). */
37
39
  const PREFIX = '(ov)@+';
38
40
 
41
+ /**
42
+ * STATUSMSG delivery prefixes — the channel-user sigils a sender may prefix
43
+ * a target with (`@#channel` / `+#channel`) so only that rank and above
44
+ * receive the message. Mirrors the PREFIX sigil tail.
45
+ */
46
+ const STATUSMSG_PREFIXES = '@+';
47
+
48
+ /**
49
+ * Default extended-ban prefix when {@link ServerConfig.extbanPrefix} is
50
+ * unset. `~` is the de-facto IRC standard (matches nearly every daemon).
51
+ */
52
+ const DEFAULT_EXTBAN_PREFIX = '~';
53
+
54
+ /**
55
+ * Extended-ban types this server understands independent of services.
56
+ * `q` = quiet ban (prevents matching targets from speaking).
57
+ */
58
+ const EXTBAN_TYPES = 'q';
59
+
60
+ /**
61
+ * Account-name extended-ban type, advertised as a dedicated `ACCOUNTEXTBAN=a`
62
+ * token only when services are enabled, since account bans require registered
63
+ * nicks to be meaningful.
64
+ */
65
+ const ACCOUNTEXTBAN_TYPE = 'a';
66
+
39
67
  /** Channel name sigils this server recognises. */
40
68
  const CHANTYPES = '#';
41
69
 
@@ -56,6 +84,9 @@ const TRAILER = 'are supported by this server';
56
84
  * can diff between servers. Tokens are space-separated, never empty.
57
85
  */
58
86
  function buildTokens(cfg: ServerConfig): string[] {
87
+ const monitorLimit = cfg.monitorLimit ?? DEFAULT_MONITOR_LIMIT;
88
+ const extbanPrefix = cfg.extbanPrefix ?? DEFAULT_EXTBAN_PREFIX;
89
+ const multilineMax = cfg.multilineMaxBytes ?? DEFAULT_MULTILINE_MAX_BYTES;
59
90
  return [
60
91
  `CHANTYPES=${CHANTYPES}`,
61
92
  `CHANMODES=${CHANMODES.list},${CHANMODES.paramSet},${CHANMODES.paramAlways},${CHANMODES.boolean}`,
@@ -73,6 +104,28 @@ function buildTokens(cfg: ServerConfig): string[] {
73
104
  // per-connection opt-in). LIST is always non-destructive here so the
74
105
  // token is unconditional.
75
106
  'SAFELIST',
107
+ // IRCv3 MONITOR cap ceiling. The value matches the per-connection
108
+ // watchlist size enforced by the monitor reducer; deployments lower it
109
+ // via `serverConfig.monitorLimit`.
110
+ `MONITOR=${monitorLimit}`,
111
+ // STATUSMSG — prefixes a sender may target (`@#chan`/`+#chan`) for
112
+ // rank-scoped delivery. The sigil set mirrors PREFIX.
113
+ `STATUSMSG=${STATUSMSG_PREFIXES}`,
114
+ // EXTBAN — extended-ban prefix and the base type list (`q` = quiet).
115
+ // The prefix is config-derived. The account-name extban is advertised
116
+ // as a separate `ACCOUNTEXTBAN=a` token (below) when services ship.
117
+ `EXTBAN=${extbanPrefix},${EXTBAN_TYPES}`,
118
+ // draft/typing advertisement — the cap is always supported, so the
119
+ // token is unconditional. (Draft caps have no RFC ISUPPORT token; this
120
+ // is the local marker clients can key on.)
121
+ 'TYPING',
122
+ // draft/multiline byte budget — mirrors the `draft/multiline=<n>` cap
123
+ // value so the ceiling is visible pre-cap-negotiation.
124
+ `MULTILINE=${multilineMax}`,
125
+ // ACCOUNTEXTBAN — account-name extended ban. Meaningful only once
126
+ // services register nicks, so suppressed until a deployment wires
127
+ // services up via `serverConfig.servicesEnabled`.
128
+ ...(cfg.servicesEnabled ? [`ACCOUNTEXTBAN=${ACCOUNTEXTBAN_TYPE}`] : []),
76
129
  ];
77
130
  }
78
131
 
@@ -82,6 +135,10 @@ function buildTokens(cfg: ServerConfig): string[] {
82
135
  * batch. The caller appends the trailer separately.
83
136
  *
84
137
  * Each batch returns just the joined token string (no `005`, no trailer).
138
+ *
139
+ * Contract: `tokens` is non-empty. The only caller (`generateIsupport`)
140
+ * satisfies this — `buildTokens` always emits at least the network/casing
141
+ * tokens — so no defensive empty-input handling is needed here.
85
142
  */
86
143
  function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
87
144
  const batches: string[] = [];
@@ -96,7 +153,7 @@ function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
96
153
  current = candidate;
97
154
  }
98
155
  }
99
- if (current !== '') batches.push(current);
156
+ batches.push(current);
100
157
  return batches;
101
158
  }
102
159
 
@@ -106,7 +163,7 @@ function packTokens(tokens: ReadonlyArray<string>, baseLen: number): string[] {
106
163
  * @param cfg Server configuration (size limits, network name).
107
164
  * @param _connCaps IRCv3 capabilities negotiated by the connection
108
165
  * (reserved for `BOT=` and similar v3 tokens; currently
109
- * unused — kept in the signature per the ticket spec).
166
+ * unused — kept in the signature for forward compatibility).
110
167
  * @param nick Optional nick for the reply target; defaults to `*`.
111
168
  * @returns One or more {@link RawLine}s, each within the 510-byte ceiling.
112
169
  * Only the final line carries the `:are supported by this server`
@@ -35,12 +35,10 @@ function isValidChannelName(name: string, maxLen: number): boolean {
35
35
  * nick (or `*` when unregistered). Optional `middle` is emitted before the
36
36
  * trailing `:`-prefixed text.
37
37
  */
38
- function numericErr(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
38
+ function numericErr(ctx: Ctx, code: number, trailing: string, middle: string): RawLine {
39
39
  const nick = ctx.connection.nick ?? '*';
40
40
  const codeStr = code.toString().padStart(3, '0');
41
- const parts = [`:${ctx.serverName}`, codeStr, nick];
42
- if (middle !== undefined) parts.push(middle);
43
- parts.push(`:${trailing}`);
41
+ const parts = [`:${ctx.serverName}`, codeStr, nick, middle, `:${trailing}`];
44
42
  return { text: parts.join(' ') };
45
43
  }
46
44
 
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Pure reducer for the IRC `KILL` command (oper-gated force-disconnect).
3
+ *
4
+ * PLAN §2.1 location-of-authority: KILL runs against the *target*'s
5
+ * connection. The actor layer resolves `<nick>` to a live
6
+ * {@link ConnectionState} via the nick registry and passes it here. The
7
+ * reducer performs the oper gate, validates parameters, and emits a single
8
+ * {@link DisconnectEffect} for the target. The runtime's `disconnect`
9
+ * method is responsible for closing the target's transport and fanning the
10
+ * QUIT out to shared-channel peers — the reducer does not model that
11
+ * fanout itself.
12
+ *
13
+ * Wire format (RFC 2812 §4.6.1):
14
+ * - `KILL <nickname> <comment>`
15
+ * - The target receives an `ERROR` / `QUIT` carrying a reason formatted
16
+ * as `Killed (<oper> (<comment>))`.
17
+ * - `481 ERR_NOPRIVILEGES`: `:<server> 481 <nick> :Permission Denied - ...`
18
+ * - `461 ERR_NEEDMOREPARAMS`:
19
+ * `:<server> 461 <nick> KILL :Not enough parameters`
20
+ * - `401 ERR_NOSUCHNICK`: `:<server> 401 <nick> <target> :No such nick/channel`
21
+ *
22
+ * Behaviour:
23
+ * - Non-oper sender → `481` (checked before parameters so a non-oper
24
+ * cannot probe which nicks are online).
25
+ * - Missing `<nickname>` or `<comment>` → `461`.
26
+ * - Unknown nick → `401`.
27
+ * - Otherwise emit `Disconnect(targetConnId, "Killed (<oper> (<comment>))")`.
28
+ * - An oper may `KILL` themselves (RFC-permitted); no special-casing.
29
+ *
30
+ * Platform asymmetry: the emitted `Disconnect` is interpreted by the bound
31
+ * runtime. On CF the `disconnect` RPC closes the target DO's socket
32
+ * immediately; on AWS a `$default` invocation cannot close a remote APIGW
33
+ * WebSocket, so QUIT fanout to peers fires but the target's live socket
34
+ * only closes on its next `$disconnect`/sweeper cycle. The reducer is
35
+ * identical across adapters — only the runtime's `disconnect` semantics
36
+ * differ.
37
+ */
38
+
39
+ import { Effect } from '../effects.js';
40
+ import type { Effect as EffectType, RawLine } from '../effects.js';
41
+ import { Numerics } from '../protocol/numerics.js';
42
+ import type { ConnectionState } from '../state/connection.js';
43
+ import type { Ctx } from '../types.js';
44
+
45
+ /** Fixed text emitted for `481 ERR_NOPRIVILEGES`. */
46
+ const NOPRIVILEGES_TRAILING_TEXT = "Permission Denied - You're not an IRC operator";
47
+
48
+ /** Fixed text emitted for `461 ERR_NEEDMOREPARAMS`. */
49
+ const NEEDMOREPARAMS_TRAILING_TEXT = 'Not enough parameters';
50
+
51
+ /** Fixed text emitted for `401 ERR_NOSUCHNICK`. */
52
+ const NOSUCHNICK_TRAILING_TEXT = 'No such nick/channel';
53
+
54
+ /**
55
+ * Formats the canonical kill reason, `Killed (<oper> (<comment>))`, that
56
+ * the target receives on its closing transport. `<oper>` is the killing
57
+ * oper's display nick; `<comment>` is the oper-supplied reason verbatim.
58
+ */
59
+ export function formatKillReason(operNick: string, comment: string): string {
60
+ return `Killed (${operNick} (${comment}))`;
61
+ }
62
+
63
+ /**
64
+ * Handles `KILL <nick> <comment>`.
65
+ *
66
+ * `target` is the live {@link ConnectionState} the actor resolved for
67
+ * `<nick>`, or `undefined` when the nick is offline / unknown. The
68
+ * signature is bespoke (not {@link Reducer}) because the actor must
69
+ * resolve the target asynchronously — mirrors `whoisReducer`.
70
+ */
71
+ export function killReducer(
72
+ target: ConnectionState | undefined,
73
+ msg: { command: string; params: readonly string[] },
74
+ ctx: Ctx,
75
+ ): { effects: EffectType[] } {
76
+ ctx.connection.lastSeen = ctx.clock.now();
77
+
78
+ if (!ctx.connection.userModes.oper) {
79
+ return {
80
+ effects: [
81
+ Effect.send(ctx.connId, [
82
+ numericLine(ctx, Numerics.ERR_NOPRIVILEGES, NOPRIVILEGES_TRAILING_TEXT),
83
+ ]),
84
+ ],
85
+ };
86
+ }
87
+
88
+ const targetNick = msg.params[0];
89
+ const comment = msg.params[1];
90
+
91
+ if (targetNick === undefined || comment === undefined) {
92
+ return {
93
+ effects: [
94
+ Effect.send(ctx.connId, [
95
+ numericLine(ctx, Numerics.ERR_NEEDMOREPARAMS, NEEDMOREPARAMS_TRAILING_TEXT, 'KILL'),
96
+ ]),
97
+ ],
98
+ };
99
+ }
100
+
101
+ if (target === undefined) {
102
+ return {
103
+ effects: [
104
+ Effect.send(ctx.connId, [
105
+ numericLine(ctx, Numerics.ERR_NOSUCHNICK, NOSUCHNICK_TRAILING_TEXT, targetNick),
106
+ ]),
107
+ ],
108
+ };
109
+ }
110
+
111
+ const operNick = ctx.connection.nick ?? '*';
112
+ return { effects: [Effect.disconnect(target.id, formatKillReason(operNick, comment))] };
113
+ }
114
+
115
+ /**
116
+ * Builds a `:<server> <code> <nick> [<middle>] :<trailing>` line. The nick
117
+ * falls back to `*` when the connection has not yet registered one,
118
+ * matching the rest of the codebase's numeric formatting.
119
+ */
120
+ function numericLine(ctx: Ctx, code: number, trailing: string, middle?: string): RawLine {
121
+ const nick = ctx.connection.nick ?? '*';
122
+ const codeStr = code.toString().padStart(3, '0');
123
+ const parts = [`:${ctx.serverName}`, codeStr, nick];
124
+ if (middle !== undefined) parts.push(middle);
125
+ parts.push(`:${trailing}`);
126
+ return { text: parts.join(' ') };
127
+ }
@@ -11,7 +11,7 @@
11
11
  * - `322 RPL_LIST` — `:<server> 322 <nick> <chan> <#users> :<topic>`
12
12
  * - `323 RPL_LISTEND` — `:<server> 323 <nick> :End of /LIST`
13
13
  *
14
- * Visibility rules (ticket spec):
14
+ * Visibility rules:
15
15
  * - Secret (`+s`) channels are hidden from non-members.
16
16
  * - Membership is checked against `chan.members` using `ctx.connId`.
17
17
  *