serverless-ircd 0.7.0 → 0.9.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 (168) hide show
  1. package/.github/workflows/ci.yml +7 -3
  2. package/.gitmodules +1 -1
  3. package/CHANGELOG.md +518 -29
  4. package/README.md +221 -161
  5. package/apps/aws-stack/package.json +1 -1
  6. package/apps/aws-stack/src/aws-stack.ts +186 -18
  7. package/apps/aws-stack/tests/stack.test.ts +400 -56
  8. package/apps/cf-tcp-container/package.json +1 -1
  9. package/apps/cf-tcp-container/src/container-server.ts +21 -1
  10. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  11. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  12. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  13. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  14. package/apps/cf-worker/package.json +1 -1
  15. package/apps/cf-worker/src/worker.ts +4 -4
  16. package/apps/cf-worker/tests/fixtures/web-dist/webclient/index.html +18 -0
  17. package/apps/cf-worker/tests/smoke.test.ts +5 -5
  18. package/apps/cf-worker/wrangler.test.toml +6 -6
  19. package/apps/cf-worker/wrangler.toml +7 -5
  20. package/apps/local-cli/package.json +1 -1
  21. package/apps/local-cli/src/config-loader.ts +8 -0
  22. package/apps/local-cli/src/main.ts +16 -0
  23. package/apps/local-cli/src/server.ts +95 -31
  24. package/apps/local-cli/tests/config-loader.test.ts +14 -0
  25. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  26. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  27. package/apps/local-cli/tests/rehash.test.ts +147 -0
  28. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  29. package/apps/local-cli/tests/tcp.test.ts +89 -0
  30. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  31. package/apps/web/landing/index.html +237 -16
  32. package/apps/web/package.json +3 -2
  33. package/apps/web/scripts/build.mjs +46 -16
  34. package/apps/web/src/build-env.ts +1 -1
  35. package/apps/web/src/config-schema.ts +6 -6
  36. package/apps/web/src/render-docs.ts +292 -0
  37. package/apps/web/tests/build-smoke.test.ts +43 -14
  38. package/apps/web/tests/config-schema.test.ts +1 -1
  39. package/apps/web/tests/landing-content.test.ts +103 -0
  40. package/apps/web/tests/render-docs.test.ts +198 -0
  41. package/docs/AWS-Adapter-Architecture.md +3 -2
  42. package/docs/AWS-Deployment.md +21 -8
  43. package/docs/Cloudflare-Deployment-Guide.md +22 -6
  44. package/docs/PlanExtensions.md +113 -3
  45. package/docs/Release-Process.md +23 -13
  46. package/docs/Services.md +578 -0
  47. package/docs/WebClientGuide.md +32 -31
  48. package/package.json +2 -2
  49. package/packages/aws-adapter/package.json +1 -1
  50. package/packages/aws-adapter/src/aws-runtime.ts +20 -1
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -0
  52. package/packages/aws-adapter/src/config-loader.ts +11 -0
  53. package/packages/aws-adapter/src/connection-counter.ts +89 -0
  54. package/packages/aws-adapter/src/dynamo-services-store.ts +649 -0
  55. package/packages/aws-adapter/src/handlers/connect.ts +55 -51
  56. package/packages/aws-adapter/src/handlers/default.ts +36 -4
  57. package/packages/aws-adapter/src/handlers/index.ts +15 -0
  58. package/packages/aws-adapter/src/handlers/nlb-stream.ts +25 -2
  59. package/packages/aws-adapter/src/handlers/sweeper.ts +5 -1
  60. package/packages/aws-adapter/src/index.ts +4 -0
  61. package/packages/aws-adapter/src/stats.ts +6 -1
  62. package/packages/aws-adapter/src/tables.ts +34 -4
  63. package/packages/aws-adapter/tests/aws-harness.ts +3 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  65. package/packages/aws-adapter/tests/config-loader.test.ts +8 -0
  66. package/packages/aws-adapter/tests/connect.test.ts +158 -32
  67. package/packages/aws-adapter/tests/connection-counter.test.ts +144 -0
  68. package/packages/aws-adapter/tests/dynamo-services-store-dynamo.test.ts +183 -0
  69. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +568 -0
  70. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  71. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  72. package/packages/aws-adapter/tests/handlers.test.ts +105 -3
  73. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  74. package/packages/aws-adapter/tests/sweeper.test.ts +20 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +6 -1
  76. package/packages/cf-adapter/package.json +1 -1
  77. package/packages/cf-adapter/src/config-loader.ts +11 -0
  78. package/packages/cf-adapter/src/connection-do.ts +130 -8
  79. package/packages/cf-adapter/src/d1-services-store.ts +703 -0
  80. package/packages/cf-adapter/src/env.ts +8 -0
  81. package/packages/cf-adapter/src/index.ts +5 -0
  82. package/packages/cf-adapter/tests/config-loader.test.ts +19 -0
  83. package/packages/cf-adapter/tests/connection-do-nickserv-d1.test.ts +128 -0
  84. package/packages/cf-adapter/tests/connection-do-pure.test.ts +130 -0
  85. package/packages/cf-adapter/tests/connection-do.test.ts +150 -2
  86. package/packages/cf-adapter/tests/d1-services-store.test.ts +582 -0
  87. package/packages/cf-adapter/tests/serialize.test.ts +1 -0
  88. package/packages/in-memory-runtime/package.json +1 -1
  89. package/packages/irc-core/package.json +1 -1
  90. package/packages/irc-core/scripts/generate-build-info.mjs +26 -5
  91. package/packages/irc-core/src/commands/account-auth.ts +200 -0
  92. package/packages/irc-core/src/commands/chanserv.ts +1166 -0
  93. package/packages/irc-core/src/commands/hostserv.ts +522 -0
  94. package/packages/irc-core/src/commands/index.ts +13 -0
  95. package/packages/irc-core/src/commands/join.ts +171 -9
  96. package/packages/irc-core/src/commands/markread.ts +202 -0
  97. package/packages/irc-core/src/commands/memoserv.ts +319 -0
  98. package/packages/irc-core/src/commands/mode.ts +96 -4
  99. package/packages/irc-core/src/commands/nickserv.ts +402 -0
  100. package/packages/irc-core/src/commands/oper.ts +18 -1
  101. package/packages/irc-core/src/commands/operserv.ts +346 -0
  102. package/packages/irc-core/src/commands/pre-away.ts +3 -1
  103. package/packages/irc-core/src/commands/privmsg.ts +42 -0
  104. package/packages/irc-core/src/commands/read-marker.ts +8 -8
  105. package/packages/irc-core/src/commands/registration.ts +78 -12
  106. package/packages/irc-core/src/commands/sasl.ts +18 -49
  107. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  108. package/packages/irc-core/src/commands/tagmsg.ts +41 -6
  109. package/packages/irc-core/src/commands/topic.ts +60 -10
  110. package/packages/irc-core/src/config.ts +36 -5
  111. package/packages/irc-core/src/effects.ts +56 -1
  112. package/packages/irc-core/src/ports.ts +1653 -84
  113. package/packages/irc-core/src/protocol/numerics.ts +8 -0
  114. package/packages/irc-core/src/state/channel.ts +38 -1
  115. package/packages/irc-core/src/state/connection.ts +25 -1
  116. package/packages/irc-core/src/types.ts +48 -12
  117. package/packages/irc-core/tests/commands/chanserv.test.ts +2335 -0
  118. package/packages/irc-core/tests/commands/chathistory.test.ts +6 -0
  119. package/packages/irc-core/tests/commands/hostserv.test.ts +1006 -0
  120. package/packages/irc-core/tests/commands/join.test.ts +572 -1
  121. package/packages/irc-core/tests/commands/markread.test.ts +361 -0
  122. package/packages/irc-core/tests/commands/memoserv.test.ts +654 -0
  123. package/packages/irc-core/tests/commands/mode.test.ts +381 -2
  124. package/packages/irc-core/tests/commands/nickserv.test.ts +990 -0
  125. package/packages/irc-core/tests/commands/oper.test.ts +13 -0
  126. package/packages/irc-core/tests/commands/operserv.test.ts +656 -0
  127. package/packages/irc-core/tests/commands/privmsg.test.ts +147 -0
  128. package/packages/irc-core/tests/commands/read-marker.test.ts +28 -28
  129. package/packages/irc-core/tests/commands/registration.test.ts +1015 -20
  130. package/packages/irc-core/tests/commands/sasl.test.ts +229 -12
  131. package/packages/irc-core/tests/commands/server-info.test.ts +9 -5
  132. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  133. package/packages/irc-core/tests/commands/tagmsg.test.ts +73 -33
  134. package/packages/irc-core/tests/commands/topic.test.ts +94 -2
  135. package/packages/irc-core/tests/commands/unified-account.test.ts +416 -0
  136. package/packages/irc-core/tests/config.test.ts +49 -5
  137. package/packages/irc-core/tests/effects.test.ts +19 -0
  138. package/packages/irc-core/tests/message-store.test.ts +63 -0
  139. package/packages/irc-core/tests/persistent-services-store.test.ts +582 -0
  140. package/packages/irc-core/tests/services-store.test.ts +1289 -0
  141. package/packages/irc-core/tests/state/channel.test.ts +3 -0
  142. package/packages/irc-server/package.json +1 -1
  143. package/packages/irc-server/src/actor.ts +121 -16
  144. package/packages/irc-server/src/dispatch.ts +94 -7
  145. package/packages/irc-server/src/routing.ts +19 -0
  146. package/packages/irc-server/tests/actor.test.ts +988 -15
  147. package/packages/irc-server/tests/dispatch.test.ts +270 -2
  148. package/packages/irc-server/tests/routing.test.ts +6 -0
  149. package/packages/irc-test-support/package.json +1 -1
  150. package/packages/irc-test-support/src/in-memory-harness.ts +37 -8
  151. package/packages/irc-test-support/src/index.ts +1 -0
  152. package/packages/irc-test-support/src/scenarios.ts +21 -6
  153. package/packages/irc-test-support/tests/in-memory-harness.test.ts +51 -0
  154. package/packages/irc-test-support/vitest.config.ts +6 -1
  155. package/tools/ci-hardening/package.json +1 -1
  156. package/tools/load-test/package.json +1 -1
  157. package/tools/load-test/src/client.ts +13 -13
  158. package/tools/load-test/tests/client.test.ts +258 -2
  159. package/tools/load-test/tests/config.test.ts +39 -0
  160. package/tools/load-test/tests/harness.test.ts +21 -0
  161. package/tools/load-test/tests/metrics.test.ts +7 -0
  162. package/tools/tcp-ws-forwarder/package.json +1 -1
  163. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  164. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  165. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +53 -2
  166. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  167. package/apps/cf-worker/tests/fixtures/web-dist/app/index.html +0 -18
  168. package/packages/irc-core/tests/read-marker-store.test.ts +0 -108
@@ -32,12 +32,9 @@ import type { Effect as EffectType, RawLine } from '../effects.js';
32
32
  import type { SaslResult } from '../ports.js';
33
33
  import { decodeBase64 } from '../protocol/base64.js';
34
34
  import { Numerics } from '../protocol/numerics.js';
35
- import { hostmaskOf } from '../state/connection.js';
36
35
  import type { ConnectionState } from '../state/connection.js';
37
36
  import type { Ctx, Reducer } from '../types.js';
38
- import { emitAccountNotify } from './account-notify.js';
39
- import { nowAwayLine, replayPersistedAway } from './pre-away.js';
40
- import { seedReadMarkers } from './read-marker.js';
37
+ import { applyAccountSuccess } from './account-auth.js';
41
38
 
42
39
  /** Per-ircdocs.horse: a single `AUTHENTICATE` payload chunk is at most 400 bytes. */
43
40
  const SASL_CHUNK_BYTES = 400;
@@ -172,18 +169,28 @@ function finalizePlain(
172
169
  return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
173
170
  }
174
171
 
172
+ // Prefer the static AccountStore; fall back to the ServicesStore so a
173
+ // nick registered via NickServ can authenticate via SASL PLAIN without a
174
+ // separate static credential table. When neither verifies, fail.
175
175
  const store = ctx.accounts;
176
- if (store === undefined) {
177
- clearSasl(state);
178
- return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
179
- }
180
-
181
- const result = store.verify('PLAIN', {
176
+ let result = store?.verify('PLAIN', {
182
177
  kind: 'PLAIN',
183
178
  username: parsed.username,
184
179
  password: parsed.password,
185
180
  });
186
181
 
182
+ if (result === undefined || !result.ok) {
183
+ const servicesResult = ctx.services?.verifyNick(parsed.username, parsed.password);
184
+ if (servicesResult?.ok) {
185
+ result = servicesResult;
186
+ }
187
+ }
188
+
189
+ if (result === undefined) {
190
+ clearSasl(state);
191
+ return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
192
+ }
193
+
187
194
  return applyResult(state, result, ctx);
188
195
  }
189
196
 
@@ -220,31 +227,7 @@ function applyResult(
220
227
  clearSasl(state);
221
228
 
222
229
  if (result.ok) {
223
- state.account = result.account;
224
- // IRCv3 draft/read-marker: restore the user's per-channel last-read
225
- // markers from the persisted store so a reconnect resumes at their
226
- // last read position. No-op when no ReadMarkerStore is bound.
227
- if (ctx.readMarkers !== undefined) {
228
- seedReadMarkers(state, ctx.readMarkers, result.account);
229
- }
230
- // IRCv3 draft/pre-away: replay the user's persisted away reason onto
231
- // the fresh connection so a reconnect restores the away state, and
232
- // emit the 306 numeric so the user sees they are still away. No-op
233
- // when no AwayStore is bound or no reason is stored.
234
- const replayedAway =
235
- ctx.away !== undefined ? replayPersistedAway(state, ctx.away, result.account) : undefined;
236
- const awayEffects: EffectType[] =
237
- replayedAway !== undefined
238
- ? [Effect.send(ctx.connId, [nowAwayLine(state, ctx.serverName)])]
239
- : [];
240
- return {
241
- state,
242
- effects: [
243
- Effect.send(ctx.connId, [loggedIn(ctx, result.account), saslSuccess(ctx)]),
244
- ...awayEffects,
245
- ...emitAccountNotify({ conn: state, account: result.account }),
246
- ],
247
- };
230
+ return { state, effects: applyAccountSuccess(state, ctx, result.account) };
248
231
  }
249
232
  return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
250
233
  }
@@ -274,20 +257,6 @@ function clearSasl(state: ConnectionState): void {
274
257
  delete state.saslBuffer;
275
258
  }
276
259
 
277
- function loggedIn(ctx: Ctx, account: string): RawLine {
278
- const nick = nickOf(ctx);
279
- const hostmask = hostmaskOf(ctx.connection) ?? nick;
280
- return L(
281
- `:${ctx.serverName} ${code(Numerics.RPL_LOGGEDIN)} ${nick} ${hostmask} ${account} :You are now logged in as ${account}`,
282
- );
283
- }
284
-
285
- function saslSuccess(ctx: Ctx): RawLine {
286
- return L(
287
- `:${ctx.serverName} ${code(Numerics.RPL_SASLSUCCESS)} ${nickOf(ctx)} :SASL authentication successful`,
288
- );
289
- }
290
-
291
260
  function saslFail(ctx: Ctx): RawLine {
292
261
  return L(
293
262
  `:${ctx.serverName} ${code(Numerics.ERR_SASLFAIL)} ${nickOf(ctx)} :SASL authentication failed`,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Service shortcut command aliases (Atheme/Anope convention).
3
+ *
4
+ * Clients and user expectations support shortcut verbs like
5
+ * `NICKSERV REGISTER pw` as an alias for `PRIVMSG NickServ :REGISTER pw`.
6
+ * These are not RFC-standardized but are a de facto convention from
7
+ * services packages (Atheme/Anope) and IRCd alias modules (InspIRCd
8
+ * `m_alias`, UnrealIRCd `alias{}`).
9
+ *
10
+ * The actor's dispatcher rewrites any of these verbs to a
11
+ * `PRIVMSG <ServiceNick> :<joined args>` and delegates to the existing
12
+ * `routeMessageTarget` path, so the service reducers see the canonical
13
+ * `params=[serviceNick, body]` shape unchanged.
14
+ *
15
+ * This module is the **single source of truth** for the alias → service
16
+ * nick mapping. Both the dispatcher rewrite (in `irc-server`'s actor) and
17
+ * the service-nick recognition in `serviceReducerFor` consult the derived
18
+ * {@link SERVICE_NICKS} list rather than maintaining their own copies.
19
+ */
20
+
21
+ import { CHANSERV_NICK } from './chanserv.js';
22
+ import { HOSTSERV_NICK } from './hostserv.js';
23
+ import { MEMOSERV_NICK } from './memoserv.js';
24
+ import { NICKSERV_NICK } from './nickserv.js';
25
+ import { OPERSERV_NICK } from './operserv.js';
26
+
27
+ /**
28
+ * Shortcut command verb (uppercased) → canonical service nick. Keyed by
29
+ * uppercased command so the dispatcher's `msg.command.toUpperCase()` lookup
30
+ * is case-insensitive. The full set of Atheme/Anope-convention aliases.
31
+ */
32
+ export const SHORTCUT_TO_SERVICE: ReadonlyMap<string, string> = new Map<string, string>([
33
+ ['NICKSERV', NICKSERV_NICK],
34
+ ['NS', NICKSERV_NICK],
35
+ ['CHANSERV', CHANSERV_NICK],
36
+ ['CS', CHANSERV_NICK],
37
+ ['HOSTSERV', HOSTSERV_NICK],
38
+ ['HS', HOSTSERV_NICK],
39
+ ['MEMOSERV', MEMOSERV_NICK],
40
+ ['MS', MEMOSERV_NICK],
41
+ ['OPERSERV', OPERSERV_NICK],
42
+ ['OS', OPERSERV_NICK],
43
+ ]);
44
+
45
+ /**
46
+ * The canonical, de-duplicated list of service nicks derived from
47
+ * {@link SHORTCUT_TO_SERVICE}'s values. Used by `serviceReducerFor` for the
48
+ * "is this target a service nick?" membership check so the service-nick list
49
+ * lives in exactly one place (this module), not duplicated across an
50
+ * `if`-chain.
51
+ */
52
+ export const SERVICE_NICKS: readonly string[] = Array.from(new Set(SHORTCUT_TO_SERVICE.values()));
@@ -202,13 +202,26 @@ export const tagmsgChannelReducer: Reducer<ChannelState> = (state, msg, ctx) =>
202
202
  // this channel. Persist the marker (advances the connection's in-memory
203
203
  // marker too); a valueless tag carries no msgid and is dropped here. The
204
204
  // TAGMSG still fans out below so the user's other connections learn it.
205
- if (isReadMarkerTagmsg(msg.tags)) {
205
+ const isReadMarker = isReadMarkerTagmsg(msg.tags);
206
+ if (isReadMarker) {
206
207
  const msgid = readMarkerMsgid(msg.tags);
207
208
  if (msgid !== undefined) {
208
209
  persistReadMarker(ctx, state.nameLower, msgid);
209
210
  }
210
211
  }
211
212
 
213
+ // IRCv3 draft/read-marker spec compliance: a read marker is private to
214
+ // the originator's account and MUST NOT be disclosed to other users. The
215
+ // mark TAGMSG fans out ONLY to the originator's own account (account-
216
+ // scoped broadcast); dispatch restricts the recipient set to same-account
217
+ // channel members. An unidentified originator has no account to share the
218
+ // mark with, so the mark stays session-local — no fanout at all (the
219
+ // originator's own client still observes its mark via echo-message below).
220
+ // Every other TAGMSG (typing, generic) keeps the channel-wide fanout.
221
+ const originAccount = ctx.connection.account;
222
+ const suppressFanout = isReadMarker && originAccount === undefined;
223
+ const accountFilter = isReadMarker ? originAccount : undefined;
224
+
212
225
  // Broadcast the tag-bearing line to message-tags-enabled members only.
213
226
  // When the TAGMSG carries `+draft/typing` or `+draft/read-marker`, the
214
227
  // audience widens to also include members that announced the matching
@@ -217,11 +230,33 @@ export const tagmsgChannelReducer: Reducer<ChannelState> = (state, msg, ctx) =>
217
230
  // `caps` so dispatch applies OR semantics per recipient.
218
231
  const tagPrefix = serializeTags(msg.tags);
219
232
  const line: RawLine = { text: `${tagPrefix}:${hostmask} TAGMSG ${state.name}` };
220
- const widenCaps = widenAudienceCaps(msg.tags);
221
- if (widenCaps !== undefined) {
222
- effects.push(Effect.broadcast(state.name, [line], ctx.connId, undefined, undefined, widenCaps));
223
- } else {
224
- effects.push(Effect.broadcast(state.name, [line], ctx.connId, MESSAGE_TAGS_CAP));
233
+ if (!suppressFanout) {
234
+ const widenCaps = widenAudienceCaps(msg.tags);
235
+ if (widenCaps !== undefined) {
236
+ effects.push(
237
+ Effect.broadcast(
238
+ state.name,
239
+ [line],
240
+ ctx.connId,
241
+ undefined,
242
+ undefined,
243
+ widenCaps,
244
+ accountFilter,
245
+ ),
246
+ );
247
+ } else {
248
+ effects.push(
249
+ Effect.broadcast(
250
+ state.name,
251
+ [line],
252
+ ctx.connId,
253
+ MESSAGE_TAGS_CAP,
254
+ undefined,
255
+ undefined,
256
+ accountFilter,
257
+ ),
258
+ );
259
+ }
225
260
  }
226
261
 
227
262
  // IRCv3 echo-message: echo the same line back to the sender, after
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { Effect } from '../effects.js';
22
22
  import type { Effect as EffectType, RawLine } from '../effects.js';
23
+ import type { PersistedTopic } from '../ports.js';
23
24
  import { Numerics } from '../protocol/numerics.js';
24
25
  import type { ChannelState, ChannelTopic } from '../state/channel.js';
25
26
  import { hostmaskOf } from '../state/connection.js';
@@ -121,6 +122,10 @@ export const topicReducer: Reducer<ChannelState> = (state, msg, ctx) => {
121
122
  // path that persists the change, so subsequent reads by other
122
123
  // connections observe the cleared topic.
123
124
  effects.push(Effect.applyChannelDelta(state.name, { topic: null }));
125
+ // ChanServ KEEPTOPIC: clear the persisted snapshot when the channel has
126
+ // KEEPTOPIC enabled, so a subsequent empty-recreate does not restore a
127
+ // stale topic.
128
+ persistKeepTopic(ctx, state.nameLower, undefined);
124
129
  } else {
125
130
  const topic: ChannelTopic = {
126
131
  text: truncated,
@@ -130,6 +135,9 @@ export const topicReducer: Reducer<ChannelState> = (state, msg, ctx) => {
130
135
  state.topic = topic;
131
136
  // Mirror the topic set to the channel authority (see comment above).
132
137
  effects.push(Effect.applyChannelDelta(state.name, { topic }));
138
+ // ChanServ KEEPTOPIC: persist the snapshot when the channel has
139
+ // KEEPTOPIC enabled, so a subsequent empty-recreate restores it.
140
+ persistKeepTopic(ctx, state.nameLower, topic);
133
141
  }
134
142
 
135
143
  const hostmask = callerHostmask(ctx.connection);
@@ -145,7 +153,8 @@ function readTopic(
145
153
  ctx: Ctx,
146
154
  effects: EffectType[],
147
155
  ): { state: ChannelState; effects: EffectType[] } {
148
- if (state.topic === undefined) {
156
+ const topicReply = topicNumerics(state, ctx);
157
+ if (topicReply === undefined) {
149
158
  effects.push(
150
159
  Effect.send(ctx.connId, [
151
160
  numericErr(ctx, Numerics.RPL_NOTOPIC, 'No topic is set', state.name),
@@ -153,17 +162,58 @@ function readTopic(
153
162
  );
154
163
  return { state, effects };
155
164
  }
165
+ effects.push(topicReply);
166
+ return { state, effects };
167
+ }
156
168
 
169
+ /**
170
+ * Builds the `332 RPL_TOPIC` + `333 RPL_TOPICWHOTIME` {@link Effect.send}
171
+ * for the connection's current nick, or `undefined` when no topic is set
172
+ * on `state.topic`.
173
+ *
174
+ * Shared by the TOPIC read-form ({@link readTopic}) and the JOIN success
175
+ * path so both emit an identical numeric block whenever a topic is
176
+ * present, regardless of whether the topic came from a ChanServ KEEPTOPIC
177
+ * restore, a deploy snapshot, or an earlier joiner.
178
+ */
179
+ export function topicNumerics(state: ChannelState, ctx: Ctx): EffectType | undefined {
180
+ if (state.topic === undefined) return undefined;
157
181
  const nick = ctx.connection.nick ?? '*';
158
182
  const codeStr332 = Numerics.RPL_TOPIC.toString().padStart(3, '0');
159
183
  const codeStr333 = Numerics.RPL_TOPICWHOTIME.toString().padStart(3, '0');
160
- effects.push(
161
- Effect.send(ctx.connId, [
162
- { text: `:${ctx.serverName} ${codeStr332} ${nick} ${state.name} :${state.topic.text}` },
163
- {
164
- text: `:${ctx.serverName} ${codeStr333} ${nick} ${state.name} ${state.topic.setter} ${state.topic.setAt}`,
165
- },
166
- ]),
167
- );
168
- return { state, effects };
184
+ return Effect.send(ctx.connId, [
185
+ { text: `:${ctx.serverName} ${codeStr332} ${nick} ${state.name} :${state.topic.text}` },
186
+ {
187
+ text: `:${ctx.serverName} ${codeStr333} ${nick} ${state.name} ${state.topic.setter} ${state.topic.setAt}`,
188
+ },
189
+ ]);
190
+ }
191
+
192
+ // ============================================================================
193
+ // ChanServ KEEPTOPIC persistence
194
+ // ============================================================================
195
+
196
+ /**
197
+ * Persists the topic snapshot for a ChanServ-registered channel with
198
+ * KEEPTOPIC enabled. Called by the topic reducer on every topic change
199
+ * (set or clear) so a subsequent empty-recreate can restore the snapshot.
200
+ *
201
+ * No-op when:
202
+ * - no services store is bound,
203
+ * - the channel is not registered,
204
+ * - KEEPTOPIC is off.
205
+ *
206
+ * `topic === undefined` clears the persisted snapshot; a {@link ChannelTopic}
207
+ * replaces it.
208
+ */
209
+ function persistKeepTopic(ctx: Ctx, nameLower: string, topic: ChannelTopic | undefined): void {
210
+ const services = ctx.services;
211
+ if (services === undefined) return;
212
+ const rec = services.getChannel(nameLower);
213
+ if (rec === undefined || !rec.keepTopic) return;
214
+ const persisted: PersistedTopic | undefined =
215
+ topic === undefined
216
+ ? undefined
217
+ : { text: topic.text, setter: topic.setter, setAt: topic.setAt };
218
+ services.setChannelTopic(nameLower, persisted);
169
219
  }
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { z } from 'zod';
20
- import { BUILD_DATE } from './build-info.js';
20
+ import { BUILD_DATE, PACKAGE_VERSION } from './build-info.js';
21
21
  import { DEFAULT_MULTILINE_MAX_BYTES } from './caps/capabilities.js';
22
22
 
23
23
  /**
@@ -144,11 +144,14 @@ export const DEFAULT_FLOOD_CONTROL_CONFIG: {
144
144
 
145
145
  /**
146
146
  * Default server version surfaced in `002`/`004`/`351`/`371` when a deployment
147
- * does not supply one. Kept in sync with `irc-core/package.json` `version`;
148
- * adapters inject the deploy-time version via the `serverVersion` config
149
- * knob (see the per-adapter config loaders).
147
+ * does not supply one. Sourced from `irc-core/package.json` at build time via
148
+ * `scripts/generate-build-info.mjs`, so a deployment that omits the
149
+ * `serverVersion` knob advertises the version of the package it was shipped
150
+ * with — no manual sync required. Adapters may still inject a deploy-time
151
+ * version via the `serverVersion` config knob (see the per-adapter config
152
+ * loaders).
150
153
  */
151
- export const DEFAULT_SERVER_VERSION = '0.3.0';
154
+ export const DEFAULT_SERVER_VERSION: string = PACKAGE_VERSION;
152
155
 
153
156
  /**
154
157
  * Default "created" text for `003 RPL_CREATED` when a deployment does not
@@ -162,6 +165,14 @@ export const DEFAULT_SERVER_VERSION = '0.3.0';
162
165
  */
163
166
  export const DEFAULT_CREATED_TEXT: string = BUILD_DATE;
164
167
 
168
+ /**
169
+ * Default grace period (ms) NickServ applies before force-disconnecting a
170
+ * client using a `ghost`-enforced registered nick without identifying.
171
+ * Matches the Atheme/Anope convention (~30s); overridable per deployment
172
+ * via `ServerConfig.nickEnforceGraceMs`.
173
+ */
174
+ export const DEFAULT_NICK_ENFORCE_GRACE_MS = 30_000;
175
+
165
176
  /**
166
177
  * The full server-config schema. Optional fields default to deployment-
167
178
  * sensible values so a minimal `{ serverName, networkName }` config is
@@ -337,6 +348,26 @@ export const ServerConfigSchema = z.object({
337
348
  * nicks make them meaningless.
338
349
  */
339
350
  servicesEnabled: z.boolean().optional(),
351
+
352
+ /**
353
+ * Grace period (ms) NickServ applies before force-disconnecting a client
354
+ * using a `ghost`-enforced registered nick without identifying. See
355
+ * `ServerConfig.nickEnforceGraceMs`. `0` disables the grace (ghost
356
+ * behaves like kill). Omit for the 30s default.
357
+ */
358
+ nickEnforceGraceMs: z.number().int().nonnegative().optional(),
359
+
360
+ /**
361
+ * HostServ approval mode for `REQUEST <vhost>`. `true` (the default)
362
+ * preserves the legacy auto-approve behaviour — the requested vhost is
363
+ * recorded against the account immediately (no oper review). `false`
364
+ * switches HostServ into oper-queue mode: `REQUEST` records a pending
365
+ * request that an oper must `APPROVE` (or `REJECT`) via HostServ. The
366
+ * queue + the `APPROVE` / `REJECT` / `LIST` (oper-only) commands are
367
+ * available in either mode, but only `false` routes `REQUEST` through
368
+ * them.
369
+ */
370
+ hostservAutoApproveVhosts: z.boolean().default(false),
340
371
  });
341
372
 
342
373
  /** Input type: what adapters supply (fields with defaults may be omitted). */
@@ -55,6 +55,17 @@ export interface BroadcastEffect {
55
55
  * path.
56
56
  */
57
57
  caps?: readonly string[];
58
+ /**
59
+ * Account-scoped recipient filter (IRCv3 `draft/read-marker` privacy).
60
+ * When set, dispatch restricts the recipient set to channel members
61
+ * authenticated as this account — a read marker is private to the
62
+ * originator's own account and MUST NOT be disclosed to other users.
63
+ * Comparison is case-insensitive (RFC 1459 fold). Recipients still must
64
+ * satisfy the {@link cap} / {@link caps} filter; the {@link except}
65
+ * originator skip still applies. Used only by the read-marker mark
66
+ * TAGMSG path; every other broadcast leaves this unset (channel-wide).
67
+ */
68
+ account?: string;
58
69
  }
59
70
 
60
71
  export interface DisconnectEffect {
@@ -121,6 +132,39 @@ export interface BroadcastWallopsEffect {
121
132
  except?: ConnId;
122
133
  }
123
134
 
135
+ /**
136
+ * NickServ nick-enforcement directive.
137
+ *
138
+ * Emitted by the NICK path when a client takes a registered nick they are
139
+ * not identified as, and the registered account's
140
+ * {@link NickEnforcePolicy} is `ghost` or `kill`. The actor layer (via
141
+ * `dispatch`) interprets the policy:
142
+ *
143
+ * - `none` — never emitted (the reducer sends the warning NOTICE only).
144
+ * - `kill` — `disconnect("Nick enforced by NickServ")` immediately.
145
+ * - `ghost` — schedule the same disconnect after {@link EnforceNickEffect.graceMs};
146
+ * when `graceMs === 0` the disconnect is immediate (matches `kill`).
147
+ *
148
+ * The grace duration is stamped by the reducer from the deployment's
149
+ * `nickEnforceGraceMs` config so `dispatch` stays free of clock access and
150
+ * the in-memory tests stay deterministic (the scheduler is injectable via
151
+ * {@link DispatchOptions.scheduler}).
152
+ */
153
+ export interface EnforceNickEffect {
154
+ tag: 'EnforceNick';
155
+ /** Connection using the enforced nick. */
156
+ conn: ConnId;
157
+ /** Display spelling of the enforced nick. */
158
+ nick: Nick;
159
+ /** Per-account enforcement policy. */
160
+ policy: import('./ports.js').NickEnforcePolicy;
161
+ /**
162
+ * Grace period in ms before the disconnect fires (`ghost` only).
163
+ * `0` for `kill` and for `ghost` when the deployment disables the grace.
164
+ */
165
+ graceMs: number;
166
+ }
167
+
124
168
  /** Discriminated union of every effect the dispatch interpreter knows. */
125
169
  export type Effect =
126
170
  | SendEffect
@@ -131,7 +175,8 @@ export type Effect =
131
175
  | ReleaseNickEffect
132
176
  | ApplyChannelDeltaEffect
133
177
  | SendToNickEffect
134
- | BroadcastWallopsEffect;
178
+ | BroadcastWallopsEffect
179
+ | EnforceNickEffect;
135
180
 
136
181
  /** Literal string tag of every {@link Effect} variant. */
137
182
  export type EffectTag = Effect['tag'];
@@ -163,12 +208,14 @@ export const Effect = {
163
208
  cap?: string,
164
209
  capLines?: RawLine[],
165
210
  caps?: readonly string[],
211
+ account?: string,
166
212
  ): Effect {
167
213
  const e: BroadcastEffect = { tag: 'Broadcast', chan, lines };
168
214
  if (except !== undefined) e.except = except;
169
215
  if (cap !== undefined) e.cap = cap;
170
216
  if (capLines !== undefined) e.capLines = capLines;
171
217
  if (caps !== undefined) e.caps = caps;
218
+ if (account !== undefined) e.account = account;
172
219
  return e;
173
220
  },
174
221
  disconnect(conn: ConnId, reason?: string): Effect {
@@ -198,4 +245,12 @@ export const Effect = {
198
245
  if (except !== undefined) e.except = except;
199
246
  return e;
200
247
  },
248
+ enforceNick(
249
+ conn: ConnId,
250
+ nick: Nick,
251
+ policy: import('./ports.js').NickEnforcePolicy,
252
+ graceMs: number,
253
+ ): Effect {
254
+ return { tag: 'EnforceNick', conn, nick, policy, graceMs };
255
+ },
201
256
  } as const;