serverless-ircd 0.5.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 (167) 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 +260 -0
  6. package/README.md +132 -22
  7. package/apps/aws-stack/README.md +33 -0
  8. package/apps/aws-stack/package.json +1 -1
  9. package/apps/aws-stack/src/aws-stack.ts +74 -9
  10. package/apps/aws-stack/tests/coverage-command.test.ts +155 -0
  11. package/apps/aws-stack/tests/stack.test.ts +2 -2
  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 +9 -7
  27. package/apps/local-cli/tests/e2e.test.ts +112 -28
  28. package/apps/local-cli/tests/tcp.test.ts +71 -19
  29. package/apps/local-cli/tests/ws-subprotocol.test.ts +12 -7
  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/handlers/default.ts +3 -0
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +9 -0
  56. package/packages/aws-adapter/src/serialize.ts +29 -1
  57. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +49 -8
  58. package/packages/aws-adapter/tests/account-store.test.ts +4 -7
  59. package/packages/aws-adapter/tests/aws-harness.ts +5 -2
  60. package/packages/aws-adapter/tests/aws-integration.test.ts +8 -4
  61. package/packages/aws-adapter/tests/aws-runtime.test.ts +79 -0
  62. package/packages/aws-adapter/tests/config-loader.test.ts +11 -2
  63. package/packages/aws-adapter/tests/connect.test.ts +9 -9
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +3 -4
  65. package/packages/aws-adapter/tests/global-setup.ts +55 -30
  66. package/packages/aws-adapter/tests/gone-exception.test.ts +4 -9
  67. package/packages/aws-adapter/tests/handlers.test.ts +90 -4
  68. package/packages/aws-adapter/tests/message-store.test.ts +6 -5
  69. package/packages/aws-adapter/tests/nlb-stream.test.ts +60 -8
  70. package/packages/cf-adapter/package.json +2 -1
  71. package/packages/cf-adapter/src/connection-do.ts +3 -0
  72. package/packages/cf-adapter/src/serialize.ts +25 -4
  73. package/packages/cf-adapter/tests/cf-runtime.test.ts +269 -0
  74. package/packages/cf-adapter/tests/channel-do.test.ts +117 -0
  75. package/packages/cf-adapter/tests/config-loader.test.ts +49 -0
  76. package/packages/cf-adapter/tests/connection-do-pure.test.ts +91 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +48 -0
  78. package/packages/cf-adapter/tests/registry-do.test.ts +104 -0
  79. package/packages/cf-adapter/tests/serialize.test.ts +308 -0
  80. package/packages/cf-adapter/vitest.config.ts +1 -1
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +1 -1
  83. package/packages/irc-core/src/caps/capabilities.ts +37 -3
  84. package/packages/irc-core/src/caps/index.ts +1 -0
  85. package/packages/irc-core/src/caps/sts.ts +84 -0
  86. package/packages/irc-core/src/commands/account-notify.ts +53 -0
  87. package/packages/irc-core/src/commands/away.ts +9 -3
  88. package/packages/irc-core/src/commands/cap.ts +23 -5
  89. package/packages/irc-core/src/commands/chathistory.ts +40 -11
  90. package/packages/irc-core/src/commands/index.ts +34 -0
  91. package/packages/irc-core/src/commands/isupport.ts +53 -0
  92. package/packages/irc-core/src/commands/mode.ts +8 -1
  93. package/packages/irc-core/src/commands/monitor.ts +327 -0
  94. package/packages/irc-core/src/commands/multiline.ts +256 -0
  95. package/packages/irc-core/src/commands/pre-away.ts +112 -0
  96. package/packages/irc-core/src/commands/privmsg.ts +19 -12
  97. package/packages/irc-core/src/commands/read-marker.ts +108 -0
  98. package/packages/irc-core/src/commands/registration.ts +8 -0
  99. package/packages/irc-core/src/commands/sasl.ts +24 -1
  100. package/packages/irc-core/src/commands/tagmsg.ts +71 -1
  101. package/packages/irc-core/src/commands/whois.ts +5 -0
  102. package/packages/irc-core/src/config.ts +65 -10
  103. package/packages/irc-core/src/effects.ts +14 -0
  104. package/packages/irc-core/src/ports.ts +389 -0
  105. package/packages/irc-core/src/protocol/index.ts +14 -0
  106. package/packages/irc-core/src/protocol/labeled-response.ts +133 -0
  107. package/packages/irc-core/src/protocol/numerics.ts +15 -0
  108. package/packages/irc-core/src/protocol/outbound.ts +16 -1
  109. package/packages/irc-core/src/protocol/standard-replies.ts +277 -0
  110. package/packages/irc-core/src/state/connection.ts +32 -1
  111. package/packages/irc-core/src/types.ts +112 -0
  112. package/packages/irc-core/stryker.commands.conf.json +1 -2
  113. package/packages/irc-core/tests/away-store.test.ts +73 -0
  114. package/packages/irc-core/tests/caps/capabilities.test.ts +119 -2
  115. package/packages/irc-core/tests/caps/sts.test.ts +123 -0
  116. package/packages/irc-core/tests/commands/account-notify.test.ts +171 -0
  117. package/packages/irc-core/tests/commands/away.test.ts +85 -2
  118. package/packages/irc-core/tests/commands/cap.test.ts +165 -0
  119. package/packages/irc-core/tests/commands/chathistory.test.ts +148 -1
  120. package/packages/irc-core/tests/commands/echo-message.test.ts +9 -3
  121. package/packages/irc-core/tests/commands/isupport.test.ts +80 -0
  122. package/packages/irc-core/tests/commands/mode.test.ts +72 -0
  123. package/packages/irc-core/tests/commands/monitor.test.ts +411 -0
  124. package/packages/irc-core/tests/commands/multiline.test.ts +428 -0
  125. package/packages/irc-core/tests/commands/pre-away.test.ts +241 -0
  126. package/packages/irc-core/tests/commands/privmsg.test.ts +154 -3
  127. package/packages/irc-core/tests/commands/read-marker.test.ts +211 -0
  128. package/packages/irc-core/tests/commands/registration.test.ts +64 -0
  129. package/packages/irc-core/tests/commands/sasl.test.ts +116 -2
  130. package/packages/irc-core/tests/commands/tagmsg.test.ts +357 -0
  131. package/packages/irc-core/tests/commands/whois.test.ts +34 -0
  132. package/packages/irc-core/tests/config.test.ts +103 -13
  133. package/packages/irc-core/tests/labeled-response.test.ts +181 -0
  134. package/packages/irc-core/tests/message-tags.test.ts +114 -0
  135. package/packages/irc-core/tests/monitor-store.test.ts +200 -0
  136. package/packages/irc-core/tests/read-marker-store.test.ts +108 -0
  137. package/packages/irc-core/tests/standard-replies.test.ts +412 -0
  138. package/packages/irc-core/tests/types.test.ts +27 -0
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +450 -3
  141. package/packages/irc-server/src/dispatch.ts +108 -16
  142. package/packages/irc-server/tests/actor.test.ts +1432 -39
  143. package/packages/irc-server/tests/dispatch.test.ts +263 -2
  144. package/packages/irc-test-support/package.json +1 -1
  145. package/packages/irc-test-support/src/index.ts +6 -0
  146. package/packages/irc-test-support/src/test-config.ts +54 -0
  147. package/packages/irc-test-support/tests/test-config.test.ts +51 -0
  148. package/tools/ci-hardening/package.json +1 -1
  149. package/tools/ci-hardening/tests/ddb-local.test.ts +82 -0
  150. package/tools/load-test/package.json +33 -0
  151. package/tools/load-test/src/client.ts +351 -0
  152. package/tools/load-test/src/config.ts +313 -0
  153. package/tools/load-test/src/harness.ts +116 -0
  154. package/tools/load-test/src/main.ts +120 -0
  155. package/tools/load-test/src/metrics.ts +168 -0
  156. package/tools/load-test/src/report.ts +106 -0
  157. package/tools/load-test/tests/client.test.ts +212 -0
  158. package/tools/load-test/tests/config.test.ts +152 -0
  159. package/tools/load-test/tests/framing.test.ts +37 -0
  160. package/tools/load-test/tests/harness.test.ts +165 -0
  161. package/tools/load-test/tests/metrics.test.ts +174 -0
  162. package/tools/load-test/tests/report.test.ts +161 -0
  163. package/tools/load-test/tests/smoke.test.ts +67 -0
  164. package/tools/load-test/tsconfig.build.json +12 -0
  165. package/tools/load-test/tsconfig.test.json +10 -0
  166. package/tools/load-test/vitest.config.ts +29 -0
  167. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -27,6 +27,7 @@ import type { ChanName } from '../state/channel.js';
27
27
  import { hostmaskOf } from '../state/connection.js';
28
28
  import type { ConnectionState } from '../state/connection.js';
29
29
  import type { Reducer } from '../types.js';
30
+ import { clearPersistedAway, persistAway } from './pre-away.js';
30
31
 
31
32
  /**
32
33
  * Handles `AWAY [:<reason>]`.
@@ -45,7 +46,10 @@ export const awayReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
45
46
  const nick = state.nick ?? '*';
46
47
 
47
48
  if (isSettingAway) {
48
- state.away = reason;
49
+ // IRCv3 draft/pre-away: persist the reason keyed by account when an
50
+ // AwayStore is bound and the connection is identified. The in-memory
51
+ // away is also set so the current session tracks the new reason.
52
+ persistAway(ctx, reason as string);
49
53
  effects.push(
50
54
  Effect.send(ctx.connId, [
51
55
  {
@@ -54,8 +58,10 @@ export const awayReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
54
58
  ]),
55
59
  );
56
60
  } else {
57
- // biome-ignore lint/performance/noDelete: exactOptionalPropertyTypes forbids `= undefined`.
58
- delete state.away;
61
+ // IRCv3 draft/pre-away: clear the persisted reason on unset so a
62
+ // reconnect does not re-mark the user away. The in-memory away is
63
+ // also cleared so the session's view stays consistent.
64
+ clearPersistedAway(ctx);
59
65
  effects.push(
60
66
  Effect.send(ctx.connId, [
61
67
  {
@@ -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. */
@@ -46,6 +46,8 @@ export { generateIsupport } from './isupport.js';
46
46
  export { capReducer } from './cap.js';
47
47
  export { emitChghost } from './chghost.js';
48
48
  export type { ChgHostInput } from './chghost.js';
49
+ export { emitAccountNotify } from './account-notify.js';
50
+ export type { AccountNotifyInput } from './account-notify.js';
49
51
  export { awayReducer } from './away.js';
50
52
  export { setnameReducer } from './setname.js';
51
53
  export { authenticateReducer } from './sasl.js';
@@ -56,6 +58,18 @@ export {
56
58
  CHATHISTORY_CAP,
57
59
  } from './chathistory.js';
58
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';
59
73
  export {
60
74
  versionReducer,
61
75
  timeReducer,
@@ -64,3 +78,23 @@ export {
64
78
  } from './server-info.js';
65
79
  export { userhostReducer, formatUserhostReply } from './userhost.js';
66
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';
@@ -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
 
@@ -528,7 +528,7 @@ const USER_MODE_LETTERS: Readonly<Record<string, keyof ConnectionState['userMode
528
528
  };
529
529
 
530
530
  /** User-mode letters in canonical 221 output order. */
531
- const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o'] as const;
531
+ const USER_MODE_LETTER_ORDER = ['i', 'w', 's', 'o', 'S'] as const;
532
532
 
533
533
  /**
534
534
  * Handles `MODE <nick> [<modestring>]`.
@@ -640,6 +640,13 @@ export const userModeReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
640
640
  function buildUserModeString(state: ConnectionState): string {
641
641
  let modeStr = '+';
642
642
  for (const letter of USER_MODE_LETTER_ORDER) {
643
+ // `S` (TLS connected) is read-only: it is not in USER_MODE_LETTERS
644
+ // (the write-side map the parser consults) so `MODE nick +S` rejects
645
+ // with 501. Surface it on the read side directly from its field.
646
+ if (letter === 'S') {
647
+ if (state.userModes.tls) modeStr += 'S';
648
+ continue;
649
+ }
643
650
  const field = USER_MODE_LETTERS[letter];
644
651
  if (field !== undefined && state.userModes[field]) modeStr += letter;
645
652
  }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * Pure reducer for the IRCv3 `MONITOR` command.
3
+ *
4
+ * IRCv3 MONITOR spec <https://ircv3.net/specs/extensions/monitor-3.2.html>:
5
+ *
6
+ * - `MONITOR + <nick>{,<nick>}` — add targets to the watchlist.
7
+ * - `MONITOR - <nick>{,<nick>}` — remove targets from the watchlist.
8
+ * - `MONITOR C` — clear the watchlist.
9
+ * - `MONITOR L` — list the watchlist (`732` per line +
10
+ * `733` terminator).
11
+ * - `MONITOR S` — status: `730 RPL_MONONLINE` for the
12
+ * online subset, `731 RPL_MONOFFLINE`
13
+ * for the rest.
14
+ *
15
+ * Numerics:
16
+ * - `730 RPL_MONONLINE` `:<server> 730 <nick> :<online>{,<online>}`
17
+ * - `731 RPL_MONOFFLINE` `:<server> 731 <nick> :<offline>{,<offline>}`
18
+ * - `732 RPL_MONLIST` `:<server> 732 <nick> :<entry>`
19
+ * - `733 RPL_ENDOFMONLIST` `:<server> 733 <nick> :End of MONITOR list`
20
+ * - `734 ERR_MONLISTFULL` `:<server> 734 <nick> <limit> :<rejected>{,<rejected>}`
21
+ *
22
+ * The watchlist lives on {@link ConnectionState.monitorList} (folded nicks,
23
+ * case-insensitive membership). The reducer resolves each requested nick
24
+ * against the supplied `online` map (folded-keyed → display spelling); the
25
+ * actor builds that map from the nick registry before invoking the reducer,
26
+ * mirroring the {@link isonReducer} pattern.
27
+ *
28
+ * Asynchronous `730`/`731` pushes on online/offline transitions are emitted
29
+ * by the actor layer (it watches `ReserveNick` / `ReleaseNick` effects
30
+ * against the watchlists); the pure reducer only handles the request/reply
31
+ * subcommands above.
32
+ */
33
+
34
+ import { Effect } from '../effects.js';
35
+ import type { Effect as EffectType, RawLine } from '../effects.js';
36
+ import { Numerics } from '../protocol/numerics.js';
37
+ import type { ConnectionState } from '../state/connection.js';
38
+ import type { Ctx, ReducerResult } from '../types.js';
39
+
40
+ /** Wire name of the IRCv3 MONITOR capability. */
41
+ export const MONITOR_CAP = 'monitor';
42
+
43
+ /** Default monitor cap (max watchlist size) advertised via `MONITOR=<n>`. */
44
+ export const DEFAULT_MONITOR_LIMIT = 30;
45
+
46
+ /**
47
+ * Handles the IRCv3 `MONITOR` subcommands.
48
+ *
49
+ * Signature is bespoke (not {@link Reducer}) because the actor must resolve
50
+ * the requested nicks asynchronously before formatting — mirrors
51
+ * {@link isonReducer} / {@link whoisReducer}.
52
+ *
53
+ * @param online Folded-keyed → display-spelling map of currently
54
+ * online nicks (built by the actor from the nick
55
+ * registry).
56
+ * @param maxMonitor Server-wide cap on the per-connection watchlist size
57
+ * (advertised via `MONITOR=<n>` in `005`).
58
+ * @param msg Parsed `MONITOR <sub> [<args>]` message.
59
+ * @param ctx Per-invocation context.
60
+ */
61
+ export function monitorReducer(
62
+ online: ReadonlyMap<string, string>,
63
+ maxMonitor: number,
64
+ msg: { params: readonly string[] },
65
+ ctx: Ctx,
66
+ ): ReducerResult<ConnectionState> {
67
+ ctx.connection.lastSeen = ctx.clock.now();
68
+
69
+ const state = ctx.connection;
70
+ const nick = state.nick ?? '*';
71
+ const serverName = ctx.serverName;
72
+
73
+ const sub = (msg.params[0] ?? '').toUpperCase();
74
+
75
+ // No subcommand at all → 461 NEEDMOREPARAMS (the parser hands us an
76
+ // empty string when the sender wrote bare `MONITOR`). A non-empty but
77
+ // unknown subcommand (e.g. `MONITOR Z`) is also 461 with a different
78
+ // trailing string per the IRCv3 MONITOR dispatch table.
79
+ if (sub.length === 0) {
80
+ return {
81
+ state,
82
+ effects: [
83
+ Effect.send(ctx.connId, [
84
+ numericLine(
85
+ serverName,
86
+ Numerics.ERR_NEEDMOREPARAMS,
87
+ nick,
88
+ 'MONITOR',
89
+ 'Not enough parameters',
90
+ ),
91
+ ]),
92
+ ],
93
+ };
94
+ }
95
+
96
+ switch (sub) {
97
+ case '+':
98
+ return handleAdd(state, online, maxMonitor, msg.params[1] ?? '', nick, serverName);
99
+ case '-':
100
+ return handleRemove(state, msg.params[1] ?? '');
101
+ case 'C':
102
+ return handleClear(state);
103
+ case 'L':
104
+ return handleList(state, nick, serverName);
105
+ case 'S':
106
+ return handleStatus(state, online, nick, serverName);
107
+ default:
108
+ return {
109
+ state,
110
+ effects: [
111
+ Effect.send(ctx.connId, [
112
+ numericLine(
113
+ serverName,
114
+ Numerics.ERR_NEEDMOREPARAMS,
115
+ nick,
116
+ 'MONITOR',
117
+ 'Unknown command',
118
+ ),
119
+ ]),
120
+ ],
121
+ };
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Subcommand handlers
127
+ // ---------------------------------------------------------------------------
128
+
129
+ function handleAdd(
130
+ state: ConnectionState,
131
+ online: ReadonlyMap<string, string>,
132
+ maxMonitor: number,
133
+ targetList: string,
134
+ nick: string,
135
+ serverName: string,
136
+ ): ReducerResult<ConnectionState> {
137
+ if (targetList.length === 0) {
138
+ return {
139
+ state,
140
+ effects: [
141
+ Effect.send(state.id, [
142
+ numericLine(
143
+ serverName,
144
+ Numerics.ERR_NEEDMOREPARAMS,
145
+ nick,
146
+ 'MONITOR',
147
+ 'Not enough parameters',
148
+ ),
149
+ ]),
150
+ ],
151
+ };
152
+ }
153
+
154
+ const watchlist = ensureWatchlist(state);
155
+
156
+ const added: { folded: string; display: string }[] = [];
157
+ const rejected: string[] = [];
158
+ for (const requested of splitNicks(targetList)) {
159
+ const folded = requested.toLowerCase();
160
+ if (watchlist.has(folded)) continue; // already monitored; no output
161
+ if (watchlist.size >= maxMonitor) {
162
+ rejected.push(requested);
163
+ continue;
164
+ }
165
+ watchlist.add(folded);
166
+ // For the display spelling prefer the online map (canonical) and fall
167
+ // back to the requester spelling when the nick is offline.
168
+ const display = online.get(folded) ?? requested;
169
+ added.push({ folded, display });
170
+ }
171
+
172
+ const effects: EffectType[] = [];
173
+
174
+ const onlineDisplays = added.filter((a) => online.has(a.folded)).map((a) => a.display);
175
+ const offlineDisplays = added.filter((a) => !online.has(a.folded)).map((a) => a.display);
176
+
177
+ if (onlineDisplays.length > 0) {
178
+ effects.push(
179
+ Effect.send(state.id, [
180
+ monLine(Numerics.RPL_MONONLINE, serverName, nick, onlineDisplays.join(',')),
181
+ ]),
182
+ );
183
+ }
184
+ if (offlineDisplays.length > 0) {
185
+ effects.push(
186
+ Effect.send(state.id, [
187
+ monLine(Numerics.RPL_MONOFFLINE, serverName, nick, offlineDisplays.join(',')),
188
+ ]),
189
+ );
190
+ }
191
+ if (rejected.length > 0) {
192
+ effects.push(
193
+ Effect.send(state.id, [monListFullLine(serverName, nick, maxMonitor, rejected.join(','))]),
194
+ );
195
+ }
196
+
197
+ return { state, effects };
198
+ }
199
+
200
+ function handleRemove(state: ConnectionState, targetList: string): ReducerResult<ConnectionState> {
201
+ if (state.monitorList === undefined) return { state, effects: [] };
202
+ if (targetList.length === 0) return { state, effects: [] };
203
+ for (const requested of splitNicks(targetList)) {
204
+ state.monitorList.delete(requested.toLowerCase());
205
+ }
206
+ return { state, effects: [] };
207
+ }
208
+
209
+ function handleClear(state: ConnectionState): ReducerResult<ConnectionState> {
210
+ // Allocate the set on first clear so callers observe a stable post-state
211
+ // (size 0) regardless of whether the watchlist previously existed.
212
+ if (state.monitorList === undefined) {
213
+ state.monitorList = new Set<string>();
214
+ } else {
215
+ state.monitorList.clear();
216
+ }
217
+ return { state, effects: [] };
218
+ }
219
+
220
+ function handleList(
221
+ state: ConnectionState,
222
+ nick: string,
223
+ serverName: string,
224
+ ): ReducerResult<ConnectionState> {
225
+ const effects: EffectType[] = [];
226
+ if (state.monitorList !== undefined) {
227
+ for (const entry of state.monitorList) {
228
+ effects.push(Effect.send(state.id, [monLine(Numerics.RPL_MONLIST, serverName, nick, entry)]));
229
+ }
230
+ }
231
+ effects.push(
232
+ Effect.send(state.id, [
233
+ monLine(Numerics.RPL_ENDOFMONLIST, serverName, nick, 'End of MONITOR list'),
234
+ ]),
235
+ );
236
+ return { state, effects };
237
+ }
238
+
239
+ function handleStatus(
240
+ state: ConnectionState,
241
+ online: ReadonlyMap<string, string>,
242
+ nick: string,
243
+ serverName: string,
244
+ ): ReducerResult<ConnectionState> {
245
+ if (state.monitorList === undefined || state.monitorList.size === 0) {
246
+ return { state, effects: [] };
247
+ }
248
+ const onlineDisplays: string[] = [];
249
+ const offlineDisplays: string[] = [];
250
+ for (const folded of state.monitorList) {
251
+ const display = online.get(folded);
252
+ if (display !== undefined) {
253
+ onlineDisplays.push(display);
254
+ } else {
255
+ offlineDisplays.push(folded);
256
+ }
257
+ }
258
+ const effects: EffectType[] = [];
259
+ if (onlineDisplays.length > 0) {
260
+ effects.push(
261
+ Effect.send(state.id, [
262
+ monLine(Numerics.RPL_MONONLINE, serverName, nick, onlineDisplays.join(',')),
263
+ ]),
264
+ );
265
+ }
266
+ if (offlineDisplays.length > 0) {
267
+ effects.push(
268
+ Effect.send(state.id, [
269
+ monLine(Numerics.RPL_MONOFFLINE, serverName, nick, offlineDisplays.join(',')),
270
+ ]),
271
+ );
272
+ }
273
+ return { state, effects };
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Helpers
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Returns the connection's watchlist, lazily allocating it on first use.
282
+ * Existing persisted state migrates without a schema bump (the field stays
283
+ * optional; reducers that pre-date MONITOR never touch it).
284
+ */
285
+ function ensureWatchlist(state: ConnectionState): Set<string> {
286
+ if (state.monitorList === undefined) {
287
+ state.monitorList = new Set<string>();
288
+ }
289
+ return state.monitorList;
290
+ }
291
+
292
+ /** Splits a comma-separated nick list, dropping empty entries. */
293
+ function splitNicks(csv: string): string[] {
294
+ return csv
295
+ .split(',')
296
+ .map((s) => s.trim())
297
+ .filter((s) => s.length > 0);
298
+ }
299
+
300
+ /** `:<server> <code> <nick> :<trailing>` — used by 730/731/732/733. */
301
+ function monLine(code: number, serverName: string, nick: string, trailing: string): RawLine {
302
+ const codeStr = code.toString().padStart(3, '0');
303
+ return { text: `:${serverName} ${codeStr} ${nick} :${trailing}` };
304
+ }
305
+
306
+ /** `:<server> 734 <nick> <limit> :<rejected>` (ERR_MONLISTFULL). */
307
+ function monListFullLine(
308
+ serverName: string,
309
+ nick: string,
310
+ limit: number,
311
+ rejected: string,
312
+ ): RawLine {
313
+ const codeStr = Numerics.ERR_MONLISTFULL.toString().padStart(3, '0');
314
+ return { text: `:${serverName} ${codeStr} ${nick} ${limit} :${rejected}` };
315
+ }
316
+
317
+ /** `:<server> <code> <nick> <cmd> :<trailing>` — used by 461-style errors. */
318
+ function numericLine(
319
+ serverName: string,
320
+ code: number,
321
+ nick: string,
322
+ cmd: string,
323
+ trailing: string,
324
+ ): RawLine {
325
+ const codeStr = code.toString().padStart(3, '0');
326
+ return { text: `:${serverName} ${codeStr} ${nick} ${cmd} :${trailing}` };
327
+ }