serverless-ircd 0.8.0 → 0.10.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 (179) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +11 -9
  4. package/.github/workflows/deploy-cf.yml +14 -14
  5. package/CHANGELOG.md +550 -0
  6. package/README.md +275 -222
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +82 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +177 -52
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +267 -92
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/package.json +2 -3
  18. package/apps/cf-tcp-container/src/container-server.ts +33 -10
  19. package/apps/cf-tcp-container/tests/config-loader.test.ts +43 -0
  20. package/apps/cf-tcp-container/tests/container-server.test.ts +249 -1
  21. package/apps/cf-tcp-container/tests/persistence.test.ts +9 -0
  22. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +24 -5
  23. package/apps/cf-tcp-container/wrangler.toml +1 -10
  24. package/apps/cf-worker/package.json +3 -4
  25. package/apps/cf-worker/wrangler.toml +12 -71
  26. package/apps/local-cli/package.json +1 -1
  27. package/apps/local-cli/src/server.ts +115 -48
  28. package/apps/local-cli/tests/config-resolution.test.ts +65 -0
  29. package/apps/local-cli/tests/motd-file-non-error.test.ts +29 -0
  30. package/apps/local-cli/tests/rehash.test.ts +147 -0
  31. package/apps/local-cli/tests/server-helpers.test.ts +63 -0
  32. package/apps/local-cli/tests/tcp.test.ts +89 -0
  33. package/apps/local-cli/tests/ws-subprotocol.test.ts +92 -0
  34. package/apps/web/landing/favicon.ico +0 -0
  35. package/apps/web/landing/index.html +227 -3
  36. package/apps/web/package.json +3 -2
  37. package/apps/web/scripts/build.mjs +91 -6
  38. package/apps/web/src/build-env.ts +125 -4
  39. package/apps/web/src/config-schema.ts +20 -6
  40. package/apps/web/src/render-docs.ts +292 -0
  41. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  42. package/apps/web/tests/build-env.test.ts +210 -9
  43. package/apps/web/tests/build-smoke.test.ts +33 -4
  44. package/apps/web/tests/config-schema.test.ts +149 -25
  45. package/apps/web/tests/landing-content.test.ts +103 -0
  46. package/apps/web/tests/render-docs.test.ts +198 -0
  47. package/docs/AWS-Adapter-Architecture.md +3 -2
  48. package/docs/AWS-Deployment.md +670 -96
  49. package/docs/AWS-TCP-Deployment.md +20 -45
  50. package/docs/Cloudflare-Deployment-Guide.md +87 -113
  51. package/docs/Cloudflare-TCP-Deployment.md +25 -49
  52. package/docs/Release-Process.md +27 -23
  53. package/docs/Services.md +102 -23
  54. package/docs/WebClientGuide.md +35 -26
  55. package/package.json +7 -10
  56. package/packages/aws-adapter/package.json +1 -1
  57. package/packages/aws-adapter/src/aws-runtime.ts +15 -1
  58. package/packages/aws-adapter/src/cdk-table-defs.ts +6 -11
  59. package/packages/aws-adapter/src/config-loader.ts +19 -2
  60. package/packages/aws-adapter/src/dynamo-services-store.ts +7 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +26 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +190 -123
  63. package/packages/aws-adapter/src/handlers/index.ts +67 -23
  64. package/packages/aws-adapter/src/handlers/nlb-stream.ts +13 -8
  65. package/packages/aws-adapter/src/index.ts +5 -7
  66. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  67. package/packages/aws-adapter/src/serialize.ts +15 -0
  68. package/packages/aws-adapter/src/tables.ts +2 -12
  69. package/packages/aws-adapter/tests/aws-harness.ts +0 -1
  70. package/packages/aws-adapter/tests/aws-runtime.test.ts +23 -1
  71. package/packages/aws-adapter/tests/config-loader.test.ts +66 -0
  72. package/packages/aws-adapter/tests/connect.test.ts +124 -1
  73. package/packages/aws-adapter/tests/connection-counter.test.ts +17 -0
  74. package/packages/aws-adapter/tests/default-occ.test.ts +219 -0
  75. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +11 -0
  76. package/packages/aws-adapter/tests/global-setup.ts +28 -1
  77. package/packages/aws-adapter/tests/gone-exception.test.ts +21 -2
  78. package/packages/aws-adapter/tests/handlers.test.ts +117 -11
  79. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  80. package/packages/aws-adapter/tests/nlb-stream.test.ts +29 -1
  81. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  82. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  83. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  84. package/packages/aws-adapter/tests/sweeper.test.ts +20 -1
  85. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  86. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  87. package/packages/cf-adapter/package.json +1 -5
  88. package/packages/cf-adapter/src/cf-runtime.ts +59 -8
  89. package/packages/cf-adapter/src/channel-do.ts +13 -3
  90. package/packages/cf-adapter/src/connection-do.ts +284 -115
  91. package/packages/cf-adapter/src/d1-services-store.ts +63 -26
  92. package/packages/cf-adapter/src/env.ts +11 -10
  93. package/packages/cf-adapter/src/index.ts +0 -6
  94. package/packages/cf-adapter/tests/cf-runtime.test.ts +101 -1
  95. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  96. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  97. package/packages/cf-adapter/tests/connection-do-pure.test.ts +222 -51
  98. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  99. package/packages/cf-adapter/tests/d1-services-store.test.ts +53 -2
  100. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  101. package/packages/in-memory-runtime/package.json +1 -1
  102. package/packages/irc-core/package.json +1 -1
  103. package/packages/irc-core/src/account-migration.ts +140 -0
  104. package/packages/irc-core/src/commands/account-auth.ts +60 -35
  105. package/packages/irc-core/src/commands/chanserv.ts +288 -4
  106. package/packages/irc-core/src/commands/hostserv.ts +38 -3
  107. package/packages/irc-core/src/commands/index.ts +1 -0
  108. package/packages/irc-core/src/commands/join.ts +41 -35
  109. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  110. package/packages/irc-core/src/commands/nickserv.ts +138 -15
  111. package/packages/irc-core/src/commands/registration.ts +28 -17
  112. package/packages/irc-core/src/commands/sasl.ts +22 -31
  113. package/packages/irc-core/src/commands/service-aliases.ts +52 -0
  114. package/packages/irc-core/src/commands/topic.ts +23 -10
  115. package/packages/irc-core/src/config.ts +35 -9
  116. package/packages/irc-core/src/credential-hashing.ts +11 -54
  117. package/packages/irc-core/src/index.ts +1 -0
  118. package/packages/irc-core/src/ports.ts +159 -179
  119. package/packages/irc-core/src/state/channel.ts +17 -0
  120. package/packages/irc-core/src/types.ts +38 -10
  121. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  122. package/packages/irc-core/tests/commands/chanserv.test.ts +668 -1
  123. package/packages/irc-core/tests/commands/hostserv.test.ts +71 -0
  124. package/packages/irc-core/tests/commands/join.test.ts +179 -0
  125. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  126. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  127. package/packages/irc-core/tests/commands/nickserv.test.ts +422 -3
  128. package/packages/irc-core/tests/commands/oper.test.ts +15 -0
  129. package/packages/irc-core/tests/commands/registration.test.ts +336 -108
  130. package/packages/irc-core/tests/commands/sasl.test.ts +194 -169
  131. package/packages/irc-core/tests/commands/service-aliases.test.ts +52 -0
  132. package/packages/irc-core/tests/commands/unified-account.test.ts +102 -84
  133. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  134. package/packages/irc-core/tests/message-store.test.ts +5 -0
  135. package/packages/irc-core/tests/persistent-services-store.test.ts +71 -12
  136. package/packages/irc-core/tests/ports.test.ts +71 -0
  137. package/packages/irc-core/tests/services-store.test.ts +204 -0
  138. package/packages/irc-core/vitest.config.ts +6 -1
  139. package/packages/irc-server/package.json +1 -1
  140. package/packages/irc-server/src/actor.ts +80 -44
  141. package/packages/irc-server/tests/actor.test.ts +384 -50
  142. package/packages/irc-test-support/package.json +1 -1
  143. package/packages/irc-test-support/src/in-memory-harness.ts +8 -5
  144. package/packages/irc-test-support/src/scenarios.ts +21 -6
  145. package/packages/irc-test-support/tests/in-memory-harness.test.ts +19 -0
  146. package/packages/irc-test-support/vitest.config.ts +6 -1
  147. package/pnpm-workspace.yaml +1 -0
  148. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  149. package/scripts/deploy-web-aws.mjs +290 -0
  150. package/scripts/package.json +23 -0
  151. package/scripts/tsconfig.test.json +12 -0
  152. package/scripts/vitest.config.ts +19 -0
  153. package/tools/ci-hardening/package.json +1 -1
  154. package/tools/ci-hardening/src/index.ts +2 -0
  155. package/tools/ci-hardening/src/validate.ts +57 -0
  156. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  157. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  158. package/tools/load-test/package.json +1 -1
  159. package/tools/load-test/src/client.ts +13 -13
  160. package/tools/load-test/tests/client.test.ts +258 -2
  161. package/tools/load-test/tests/config.test.ts +39 -0
  162. package/tools/load-test/tests/harness.test.ts +21 -0
  163. package/tools/load-test/tests/metrics.test.ts +7 -0
  164. package/tools/migrate-accounts-to-services.ts +270 -0
  165. package/tools/package.json +2 -1
  166. package/tools/seed-aws-accounts.ts +35 -10
  167. package/tools/seed-cf-accounts.ts +42 -9
  168. package/tools/tcp-ws-forwarder/package.json +1 -1
  169. package/tools/tcp-ws-forwarder/tests/close-error.test.ts +40 -0
  170. package/tools/tcp-ws-forwarder/tests/defensive-branches.test.ts +78 -0
  171. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +51 -0
  172. package/tools/tcp-ws-forwarder/tests/logger.test.ts +31 -1
  173. package/packages/aws-adapter/src/account-store.ts +0 -121
  174. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  175. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  176. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  177. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  178. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  179. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -413,6 +413,31 @@ function handleList(
413
413
  // vhost application helpers
414
414
  // ============================================================================
415
415
 
416
+ /**
417
+ * Consults the bound {@link ServicesStore} for an assigned vhost for `account`
418
+ * and, when one is present, applies it to {@link ConnectionState} via
419
+ * {@link applyVhost}. This is the shared login-time hook invoked by every
420
+ * account-success path (SASL PLAIN, PASS-auth via {@link applyAccountSuccess},
421
+ * and NickServ `IDENTIFY` via {@link applyIdentify}) so a reconnecting user
422
+ * with an assigned vhost does not have to re-run `HostServ ON` every session.
423
+ *
424
+ * No-op (no effects emitted, no state change) when no vhost is recorded. The
425
+ * CHGHOST fanout appended to `effects` is empty at SASL / PASS-auth time
426
+ * (registration has not completed and/or no channels are joined yet) but fires
427
+ * for a post-registration NickServ `IDENTIFY` when the user is already in
428
+ * channels — reusing {@link applyVhost} so the wire shape matches `HostServ ON`.
429
+ */
430
+ export function applyAssignedVhost(
431
+ state: ConnectionState,
432
+ services: ServicesStore,
433
+ account: string,
434
+ effects: EffectType[],
435
+ ): void {
436
+ const vhost = services.getVhost(account);
437
+ if (vhost === undefined) return;
438
+ applyVhost(state, vhost, effects);
439
+ }
440
+
416
441
  /**
417
442
  * Applies `vhost` to {@link ConnectionState.host}, records the prior host
418
443
  * on {@link ConnectionState.preVhostHost}, flips
@@ -421,7 +446,7 @@ function handleList(
421
446
  * {@link emitChghost} helper so the wire shape matches every other
422
447
  * user/host mutation.
423
448
  */
424
- function applyVhost(state: ConnectionState, vhost: string, effects: EffectType[]): void {
449
+ export function applyVhost(state: ConnectionState, vhost: string, effects: EffectType[]): void {
425
450
  const oldUser = state.user ?? '';
426
451
  const oldHost = state.host ?? '';
427
452
  state.preVhostHost = oldHost;
@@ -457,13 +482,23 @@ function notice(state: ConnectionState, text: string): EffectType {
457
482
  return Effect.send(state.id, [servicesNotice(HOSTSERV_NICK, state, text)]);
458
483
  }
459
484
 
485
+ /** User-visible HostServ subcommands (shown to every caller). */
486
+ const USER_COMMANDS = 'ON, OFF, REQUEST';
487
+
488
+ /** Oper-only HostServ subcommands, appended to {@link USER_COMMANDS} for opers. */
489
+ const OPER_COMMANDS = 'SET, APPROVE, REJECT, LIST';
490
+
460
491
  /** Builds a help `:HostServ!HostServ@services NOTICE <nick> :…` line. */
461
492
  function helpNotice(state: ConnectionState): EffectType {
462
- return notice(state, 'Available commands: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST');
493
+ const commands =
494
+ state.userModes.oper === true ? `${USER_COMMANDS}, ${OPER_COMMANDS}` : USER_COMMANDS;
495
+ return notice(state, `Available commands: ${commands}`);
463
496
  }
464
497
 
465
498
  function unknownNotice(state: ConnectionState): EffectType {
466
- return notice(state, 'Unknown command. Available: ON, OFF, REQUEST, SET, APPROVE, REJECT, LIST');
499
+ const commands =
500
+ state.userModes.oper === true ? `${USER_COMMANDS}, ${OPER_COMMANDS}` : USER_COMMANDS;
501
+ return notice(state, `Unknown command. Available: ${commands}`);
467
502
  }
468
503
 
469
504
  /**
@@ -110,3 +110,4 @@ export {
110
110
  } from './chanserv.js';
111
111
  export { HOSTSERV_NICK, hostservReducer } from './hostserv.js';
112
112
  export { OPERSERV_NICK, operservReducer } from './operserv.js';
113
+ export { SERVICE_NICKS, SHORTCUT_TO_SERVICE } from './service-aliases.js';
@@ -27,6 +27,7 @@ import type { ConnectionState } from '../state/connection.js';
27
27
  import type { Ctx, Reducer } from '../types.js';
28
28
  import { CHANSERV_HOSTMASK, DEFAULT_CHANNEL_LEVELS } from './chanserv.js';
29
29
  import { CHATHISTORY_CAP, buildChathistoryBatch } from './chathistory.js';
30
+ import { topicNumerics } from './topic.js';
30
31
 
31
32
  /**
32
33
  * RFC 1459/2812 channel-name grammar. The first character must be a channel
@@ -265,16 +266,27 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
265
266
  }
266
267
  }
267
268
 
268
- // All checks passed: add to roster. First joiner gets op per RFC 2812.
269
+ // All checks passed: add to roster. First joiner gets op per RFC 2812,
270
+ // unless the channel is ChanServ-registered — in that case the first
271
+ // joiner is opped only when they are the founder or meet the AUTOOP
272
+ // level (computeChanservAutoPrefix), so a random user reconnecting
273
+ // first after a roster-wiping deploy cannot seize op in a channel they
274
+ // do not own. The real founder / access-list users who join later are
275
+ // opped by the same access check on the non-empty path.
269
276
  const wasEmpty = state.members.size === 0;
270
- // ChanServ ACCESS auto-prefix: when services are bound, the channel is
271
- // registered, and this is not the RFC 2812 first-joiner case, look up
272
- // the joiner's access level and apply the matching channel prefix.
273
- // `chanservPrefix` is non-null only when ChanServ (not the RFC 2812
274
- // rule) granted the prefix, so the JOIN hook knows to emit a ChanServ
275
- // MODE broadcast alongside the roster mutation.
276
- const autoPrefix = wasEmpty ? null : computeChanservAutoPrefix(ctx, state.nameLower);
277
- const grantOp = wasEmpty || (autoPrefix?.op ?? false);
277
+ // ChanServ ACCESS auto-prefix: always look up the joiner's access level
278
+ // so the empty-channel gating above and the non-empty case share one
279
+ // code path. `autoPrefix` is non-null only when ChanServ could resolve
280
+ // the joiner's access (services bound, channel registered, joiner
281
+ // identified); it carries {op:false,voice:false} when the joiner is on
282
+ // no list, and null when ChanServ is not authoritative for this join
283
+ // (services unbound / channel unregistered / joiner unidentified).
284
+ const autoPrefix = computeChanservAutoPrefix(ctx, state.nameLower);
285
+ const channelRegistered = ctx.services?.getChannel(state.nameLower) !== undefined;
286
+ // RFC 2812 first-joiner op applies only when the channel is NOT
287
+ // registered. A registered channel's first joiner gets op solely via
288
+ // the access check (founder / AUTOOP), never by the empty-channel rule.
289
+ const grantOp = (wasEmpty && !channelRegistered) || (autoPrefix?.op ?? false);
278
290
  const grantVoice = autoPrefix?.voice ?? false;
279
291
  const joinerNick = ctx.connection.nick ?? '?';
280
292
  state.members.set(ctx.connId, {
@@ -288,13 +300,12 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
288
300
  // before this join and services have a persisted topic snapshot for a
289
301
  // keep-topic-enabled registration, restore the topic onto the
290
302
  // authoritative ChannelState. The 332/333 numerics are emitted to the
291
- // joiner below alongside the NAMES list.
292
- let restoredTopic: ChannelTopic | undefined;
303
+ // joiner below alongside the NAMES list, keyed off `state.topic`.
293
304
  if (wasEmpty) {
294
- restoredTopic = restoreKeepTopic(ctx, state.nameLower);
295
- if (restoredTopic !== undefined) {
296
- state.topic = restoredTopic;
297
- effects.push(Effect.applyChannelDelta(state.name, { topic: restoredTopic }));
305
+ const restored = restoreKeepTopic(ctx, state.nameLower);
306
+ if (restored !== undefined) {
307
+ state.topic = restored;
308
+ effects.push(Effect.applyChannelDelta(state.name, { topic: restored }));
298
309
  }
299
310
  }
300
311
 
@@ -323,13 +334,15 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
323
334
  }),
324
335
  );
325
336
 
326
- // ChanServ auto-prefix broadcast: when ChanServ granted op or voice
327
- // (i.e. NOT the RFC 2812 first-joiner case), emit a
328
- // `:ChanServ!ChanServ@services MODE #chan +o nick` broadcast so every
329
- // client in the channel updates its prefix display. Supersedes the
330
- // implicit voice when op is granted (the channel-prefix grammar
331
- // already renders `@` for ops).
332
- if (autoPrefix !== null && (autoPrefix.op || autoPrefix.voice)) {
337
+ // ChanServ auto-prefix broadcast: when ChanServ granted op or voice on
338
+ // a non-empty channel, emit a `:ChanServ!ChanServ@services MODE #chan
339
+ // +o nick` broadcast so existing clients update their prefix display.
340
+ // Suppressed on the empty-channel first-join (wasEmpty) because the
341
+ // joiner is the channel's only client and already sees their prefix in
342
+ // the NAMES list the op grant is silent. Supersedes the implicit
343
+ // voice when op is granted (the channel-prefix grammar already renders
344
+ // `@` for ops).
345
+ if (!wasEmpty && autoPrefix !== null && (autoPrefix.op || autoPrefix.voice)) {
333
346
  effects.push(buildChanservPrefixBroadcast(state.name, joinerNick, autoPrefix));
334
347
  }
335
348
 
@@ -379,19 +392,12 @@ export const joinReducer: Reducer<ChannelState> = (state, msg, ctx) => {
379
392
 
380
393
  effects.push(Effect.send(ctx.connId, lines));
381
394
 
382
- // When ChanServ KEEPTOPIC restored a topic this JOIN, emit the 332/333
383
- // topic numerics to the joiner so their client renders the restored
384
- // topic (mirroring the TOPIC read-form reply shape).
385
- if (restoredTopic !== undefined) {
386
- effects.push(
387
- Effect.send(ctx.connId, [
388
- { text: `:${ctx.serverName} 332 ${nick} ${chanName} :${restoredTopic.text}` },
389
- {
390
- text: `:${ctx.serverName} 333 ${nick} ${chanName} ${restoredTopic.setter} ${restoredTopic.setAt}`,
391
- },
392
- ]),
393
- );
394
- }
395
+ // Emit 332/333 topic numerics to the joiner whenever a topic is set on
396
+ // the channel, regardless of whether it came from a ChanServ KEEPTOPIC
397
+ // restore this join, a deploy snapshot, or an earlier joiner. Mirrors
398
+ // the TOPIC read-form reply shape (RFC 2812 §3.2.1).
399
+ const topicReply = topicNumerics(state, ctx);
400
+ if (topicReply !== undefined) effects.push(topicReply);
395
401
 
396
402
  return { state, effects };
397
403
  };
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
21
21
  * emitted as {@link Effect}s. The services store is synchronous (mirrors
22
- * the {@link AccountStore} / {@link MessageStore} pattern).
22
+ * the {@link MessageStore} pattern).
23
23
  */
24
24
 
25
25
  import { caseFold } from '../case-fold.js';
@@ -26,16 +26,17 @@
26
26
  *
27
27
  * The reducer is a pure `Reducer<ConnectionState>`: all side effects are
28
28
  * emitted as {@link Effect}s. The services store is synchronous (mirrors
29
- * the {@link AccountStore} / {@link MessageStore} pattern).
29
+ * the {@link MessageStore} pattern).
30
30
  */
31
31
 
32
- import { DEFAULT_NICK_ENFORCE_GRACE_MS } from '../config.js';
32
+ import { DEFAULT_MIN_PASSWORD_LENGTH, DEFAULT_NICK_ENFORCE_GRACE_MS } from '../config.js';
33
33
  import { Effect } from '../effects.js';
34
34
  import type { Effect as EffectType, RawLine } from '../effects.js';
35
35
  import type { NickEnforcePolicy, ServicesStore } from '../ports.js';
36
36
  import type { ConnectionState } from '../state/connection.js';
37
37
  import type { Ctx, Reducer } from '../types.js';
38
38
  import { emitAccountNotify } from './account-notify.js';
39
+ import { applyAssignedVhost } from './hostserv.js';
39
40
  import { deliverUnreadMemos } from './memoserv.js';
40
41
 
41
42
  /** Canonical NickServ pseudo-client nick. */
@@ -47,6 +48,30 @@ const SERVICES_HOST = 'services';
47
48
  /** Policies accepted by `SET ENFORCE` (lowercased input forms). */
48
49
  const ENFORCE_POLICIES: ReadonlySet<NickEnforcePolicy> = new Set(['none', 'ghost', 'kill']);
49
50
 
51
+ /**
52
+ * Fixed maximum password length accepted by `SET PASSWORD`. Bounds the
53
+ * scrypt input budget (256 is the practical limit of the scrypt input);
54
+ * not configurable. Over-long passwords are rejected before hashing.
55
+ */
56
+ const MAX_PASSWORD_LENGTH = 256;
57
+
58
+ /**
59
+ * Gate NOTICE sent to an unidentified caller (or one identified as a
60
+ * different account) attempting `SET ENFORCE`. Wording kept verbatim from
61
+ * the original inline literal so the existing ENFORCE test suite is not
62
+ * churned by the addition of `SET PASSWORD` (a sibling constant covers the
63
+ * PASSWORD gate).
64
+ */
65
+ const ENFORCE_GATE_NOTICE = 'You must identify before changing enforcement.';
66
+
67
+ /**
68
+ * Gate NOTICE sent to an unidentified caller (or one identified as a
69
+ * different account) attempting `SET PASSWORD`. Wording mirrors the ENFORCE
70
+ * gate's intent but speaks to "password" rather than "enforcement" so a
71
+ * caller who hits it understands which subcommand they tripped.
72
+ */
73
+ const PASSWORD_GATE_NOTICE = 'You must identify before changing your password.';
74
+
50
75
  /**
51
76
  * Reducer for `PRIVMSG NickServ :<subcommand> <args…>`.
52
77
  * Authority: the invoking connection's {@link ConnectionState}.
@@ -74,6 +99,7 @@ export const nickservReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
74
99
  switch (sub) {
75
100
  case 'REGISTER':
76
101
  return handleRegister(state, args, ctx, effects);
102
+ case 'ID':
77
103
  case 'IDENTIFY':
78
104
  return handleIdentify(state, args, ctx, effects);
79
105
  case 'DROP':
@@ -166,9 +192,10 @@ function handleIdentify(
166
192
  }
167
193
 
168
194
  /**
169
- * Applies a successful identify: records the account, sets `+r`, emits the
170
- * confirmation NOTICE, fans `ACCOUNT` out to peers, and delivers queued
171
- * memos.
195
+ * Applies a successful identify: records the account, sets `+r`, re-applies any
196
+ * assigned HostServ vhost (so a reconnecting user does not have to re-run
197
+ * `HostServ ON`), emits the confirmation NOTICE, fans `ACCOUNT` out to peers,
198
+ * and delivers queued memos.
172
199
  */
173
200
  function applyIdentify(
174
201
  state: ConnectionState,
@@ -178,6 +205,10 @@ function applyIdentify(
178
205
  ): void {
179
206
  state.account = account;
180
207
  state.userModes.registered = true;
208
+ // Re-apply an assigned HostServ vhost. At identify time the connection is
209
+ // usually pre-JOIN so the CHGHOST fanout is empty, but a post-JOIN IDENTIFY
210
+ // fans CHGHOST out to the user's current `chghost`-capable peers.
211
+ applyAssignedVhost(state, services, account, effects);
181
212
  effects.push(notice(state, `You are now identified for nick ${account}.`));
182
213
  effects.push(...emitAccountNotify({ conn: state, account }));
183
214
  deliverUnreadMemos(state, services, account, effects);
@@ -241,7 +272,12 @@ function handleInfo(
241
272
 
242
273
  effects.push(notice(state, `Nick: ${rec.nick}`));
243
274
  effects.push(notice(state, `Account: ${rec.account}`));
244
- effects.push(notice(state, `Email: ${rec.email}`));
275
+ // Email is private: only the account owner or an oper may see it. Mirrors
276
+ // the `state.account === rec.account` gate used by SET ENFORCE / the
277
+ // enforcement hook, extended to opers.
278
+ if (state.account === rec.account || state.userModes.oper) {
279
+ effects.push(notice(state, `Email: ${rec.email}`));
280
+ }
245
281
  return { state, effects };
246
282
  }
247
283
 
@@ -306,12 +342,24 @@ export function enforceRegisteredNick(state: ConnectionState, ctx: Ctx): EffectT
306
342
  // ============================================================================
307
343
 
308
344
  /**
309
- * Handles `SET <subkey> <args…>`. Currently only `ENFORCE none|ghost|kill`
310
- * is supported; unknown subkeys fall back to the ENFORCE syntax notice.
345
+ * Handles `SET <subkey> <args…>`. Supported subkeys:
346
+ *
347
+ * - `ENFORCE none|ghost|kill` — sets the per-account enforcement policy.
348
+ * - `PASSWORD <old> <new>` — self-service password change. Requires the
349
+ * current password to be re-supplied as a defence against a hijacked
350
+ * identified session (a stolen `+r` session should not let an attacker
351
+ * silently rotate the password and lock the real owner out — this is
352
+ * Atheme's shape, not the alternative "trust the gate" one-arg form).
353
+ * The new password is re-hashed via `hashAccountCredential` and
354
+ * overwrites the stored credential via
355
+ * {@link ServicesStore.setNickPassword}; the current session stays
356
+ * `+r` and other sessions of the same account are NOT kicked.
311
357
  *
312
- * Requires the caller to be identified as the account bound to the current
313
- * nick: enforcement is a per-account policy, so an unidentified client
314
- * (or one identified as a different account) cannot mutate it.
358
+ * Unknown subkeys fall back to the ENFORCE syntax notice.
359
+ *
360
+ * Both subkeys require the caller to be identified as the account bound to
361
+ * the current nick: an unidentified client (or one identified as a
362
+ * different account) cannot mutate the account.
315
363
  */
316
364
  function handleSet(
317
365
  state: ConnectionState,
@@ -319,8 +367,10 @@ function handleSet(
319
367
  ctx: Ctx,
320
368
  effects: EffectType[],
321
369
  ): { state: ConnectionState; effects: EffectType[] } {
322
- const services = ctx.services as ServicesStore;
323
370
  const subkey = args[0]?.toUpperCase();
371
+ if (subkey === 'PASSWORD') {
372
+ return handleSetPassword(state, args.slice(1), ctx, effects);
373
+ }
324
374
  if (subkey !== 'ENFORCE') {
325
375
  effects.push(enforceSyntaxNotice(state));
326
376
  return { state, effects };
@@ -335,10 +385,11 @@ function handleSet(
335
385
 
336
386
  const nick = state.nick;
337
387
  if (nick === undefined || state.account === undefined) {
338
- effects.push(notice(state, 'You must identify before changing enforcement.'));
388
+ effects.push(notice(state, ENFORCE_GATE_NOTICE));
339
389
  return { state, effects };
340
390
  }
341
391
 
392
+ const services = ctx.services as ServicesStore;
342
393
  const rec = services.getNick(nick);
343
394
  if (rec === undefined) {
344
395
  effects.push(notice(state, `Nick ${nick} is not registered.`));
@@ -346,7 +397,7 @@ function handleSet(
346
397
  }
347
398
  // The caller must be identified as the account that owns this nick.
348
399
  if (state.account !== rec.account) {
349
- effects.push(notice(state, 'You must identify before changing enforcement.'));
400
+ effects.push(notice(state, ENFORCE_GATE_NOTICE));
350
401
  return { state, effects };
351
402
  }
352
403
 
@@ -355,11 +406,80 @@ function handleSet(
355
406
  return { state, effects };
356
407
  }
357
408
 
409
+ /**
410
+ * Handles `SET PASSWORD <old> <new>`. Mirrors the `SET ENFORCE` identify
411
+ * gate (`state.account === rec.account`), then re-verifies the current
412
+ * password (no-leak parity with `IDENTIFY`'s `Invalid password.` notice)
413
+ * and enforces the configurable minimum / fixed maximum length on the new
414
+ * password before re-hashing and persisting.
415
+ */
416
+ function handleSetPassword(
417
+ state: ConnectionState,
418
+ args: string[],
419
+ ctx: Ctx,
420
+ effects: EffectType[],
421
+ ): { state: ConnectionState; effects: EffectType[] } {
422
+ const oldPassword = args[0];
423
+ const newPassword = args[1];
424
+ if (oldPassword === undefined || newPassword === undefined) {
425
+ effects.push(passwordSyntaxNotice(state));
426
+ return { state, effects };
427
+ }
428
+
429
+ const nick = state.nick;
430
+ if (nick === undefined || state.account === undefined) {
431
+ effects.push(notice(state, PASSWORD_GATE_NOTICE));
432
+ return { state, effects };
433
+ }
434
+
435
+ const services = ctx.services as ServicesStore;
436
+ const rec = services.getNick(nick);
437
+ if (rec === undefined) {
438
+ effects.push(notice(state, `Nick ${nick} is not registered.`));
439
+ return { state, effects };
440
+ }
441
+ // The caller must be identified as the account that owns this nick.
442
+ if (state.account !== rec.account) {
443
+ effects.push(notice(state, PASSWORD_GATE_NOTICE));
444
+ return { state, effects };
445
+ }
446
+
447
+ // Re-verify the current password (defence against a hijacked `+r`
448
+ // session). Same `verifyNick` path `IDENTIFY` uses → same `Invalid
449
+ // password.` notice → no information leak about whether the account
450
+ // exists (the gate above already proved it does).
451
+ if (!services.verifyNick(nick, oldPassword).ok) {
452
+ effects.push(notice(state, 'Invalid password.'));
453
+ return { state, effects };
454
+ }
455
+
456
+ const minLen = ctx.serverConfig.nickServ?.minPasswordLength ?? DEFAULT_MIN_PASSWORD_LENGTH;
457
+ if (newPassword.length < minLen) {
458
+ effects.push(notice(state, `Password is too short (minimum ${minLen} characters).`));
459
+ return { state, effects };
460
+ }
461
+ if (newPassword.length > MAX_PASSWORD_LENGTH) {
462
+ effects.push(
463
+ notice(state, `Password is too long (maximum ${MAX_PASSWORD_LENGTH} characters).`),
464
+ );
465
+ return { state, effects };
466
+ }
467
+
468
+ services.setNickPassword(nick, newPassword);
469
+ effects.push(notice(state, 'Password changed.'));
470
+ return { state, effects };
471
+ }
472
+
358
473
  /** Builds the `SET ENFORCE` syntax NOTICE. */
359
474
  function enforceSyntaxNotice(state: ConnectionState): EffectType {
360
475
  return notice(state, 'Syntax: SET ENFORCE none|ghost|kill');
361
476
  }
362
477
 
478
+ /** Builds the `SET PASSWORD` syntax NOTICE. */
479
+ function passwordSyntaxNotice(state: ConnectionState): EffectType {
480
+ return notice(state, 'Syntax: SET PASSWORD <old-password> <new-password>');
481
+ }
482
+
363
483
  // ============================================================================
364
484
  // Notice helpers
365
485
  // ============================================================================
@@ -375,7 +495,10 @@ function helpNotice(state: ConnectionState): EffectType {
375
495
  }
376
496
 
377
497
  function unknownNotice(state: ConnectionState): EffectType {
378
- return notice(state, 'Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET');
498
+ return notice(
499
+ state,
500
+ 'Unknown command. Available: REGISTER, IDENTIFY, DROP, INFO, SET (ENFORCE, PASSWORD)',
501
+ );
379
502
  }
380
503
 
381
504
  /**
@@ -22,7 +22,7 @@ import type { ChanName } from '../state/channel.js';
22
22
  import { hostmaskOf } from '../state/connection.js';
23
23
  import type { ConnectionState } from '../state/connection.js';
24
24
  import type { Ctx, Reducer } from '../types.js';
25
- import { passBasedAccountAuth } from './account-auth.js';
25
+ import { attemptPassAccountAuth, passBasedAccountAuth } from './account-auth.js';
26
26
  import { generateIsupport } from './isupport.js';
27
27
  import { buildMotdNumerics } from './motd-lines.js';
28
28
  import { enforceRegisteredNick } from './nickserv.js';
@@ -119,7 +119,7 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
119
119
  // the account-login numerics (900/903 + downstream effects), satisfying
120
120
  // the gate as a side effect. On every other outcome it returns `[]` and
121
121
  // the gate below decides. No-op when already identified (SASL), when no
122
- // `<nick>:<password>` form is present, or when no AccountStore is bound.
122
+ // `<nick>:<password>` form is present, or when no ServicesStore is bound.
123
123
  const authEffects = passBasedAccountAuth(state, ctx);
124
124
 
125
125
  const gateFailure = passwordGateFailure(state, ctx);
@@ -161,12 +161,16 @@ export function emitWelcomeIfReady(state: ConnectionState, ctx: Ctx): EffectType
161
161
  * Replaces `state.host` with a deterministic cloak derived from the
162
162
  * deployment's cloaking secret + the current (real) host. No-op when
163
163
  * cloaking is disabled or no host is set yet (e.g. transport didn't supply
164
- * one, or the connection sets it later via CHGHOST).
164
+ * one, or the connection sets it later via CHGHOST). Also a no-op when a
165
+ * HostServ vhost is already active (`vhostActive`) — the vhost was re-applied
166
+ * by {@link applyAccountSuccess} during SASL / PASS-auth and must win over the
167
+ * cloak so it remains visible in the `001` welcome.
165
168
  */
166
169
  function applyCloakIfEnabled(state: ConnectionState, ctx: Ctx): void {
167
170
  const cloaking = ctx.serverConfig.cloaking;
168
171
  if (cloaking === undefined || !cloaking.enabled) return;
169
172
  if (state.host === undefined) return;
173
+ if (state.vhostActive === true) return;
170
174
  state.host = cloakHost(state.host, cloaking, ctx.networkName);
171
175
  }
172
176
 
@@ -340,33 +344,40 @@ export const userReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
340
344
  /**
341
345
  * Reducer for `PASS <password>`.
342
346
  *
343
- * Stashes the attempt as `state.passAttempt`. The pure-reducer gate
344
- * itself lives in {@link passwordGateFailure} (same file), which
345
- * {@link emitWelcomeIfReady} consults at registration completion:
346
- * it compares `state.passAttempt` against
347
- * `ctx.serverConfig.serverPassword` and, on mismatch, emits
348
- * `464 ERR_PASSWDMISMATCH` plus a `Disconnect("Bad Password")`. A
349
- * SASL-identified connection (`state.account !== undefined`) short-
350
- * circuits the gate, so PASS is unnecessary when SASL has already
351
- * authenticated an account.
347
+ * Pre-registration: stashes the attempt as `state.passAttempt` for the
348
+ * server-password gate ({@link passwordGateFailure}) and the at-completion
349
+ * PASS-auth ({@link passBasedAccountAuth}). A second `PASS` before
350
+ * registration completes is rejected with `462 ERR_ALREADYREGISTRED`.
351
+ *
352
+ * Post-registration: a bare server password is meaningless, so only the
353
+ * `<nick>:<password>` form is honoured — as a late account login via
354
+ * {@link attemptPassAccountAuth} against the bound `ctx.services`. Every
355
+ * other outcome (bare value, no store, already identified, verify failure)
356
+ * is a silent no-op: no `462`, no disconnect. This lets a client that
357
+ * completed `NICK`+`USER` before sending `PASS` identify after the fact.
352
358
  */
353
359
  export const passReducer: Reducer<ConnectionState> = (state, msg, ctx) => {
354
360
  const effects: EffectType[] = [];
355
361
 
356
- if (state.registration === 'registered') {
362
+ const password = msg.params[0];
363
+ if (password === undefined) {
357
364
  effects.push(
358
365
  Effect.send(ctx.connId, [
359
- numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
366
+ numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PASS'),
360
367
  ]),
361
368
  );
362
369
  return { state, effects };
363
370
  }
364
371
 
365
- const password = msg.params[0];
366
- if (password === undefined) {
372
+ if (state.registration === 'registered') {
373
+ effects.push(...attemptPassAccountAuth(state, ctx, password));
374
+ return { state, effects };
375
+ }
376
+
377
+ if (state.passAttempt !== undefined) {
367
378
  effects.push(
368
379
  Effect.send(ctx.connId, [
369
- numericErr(ctx, Numerics.ERR_NEEDMOREPARAMS, 'Not enough parameters', 'PASS'),
380
+ numericErr(ctx, Numerics.ERR_ALREADYREGISTRED, 'You may not reregister'),
370
381
  ]),
371
382
  );
372
383
  return { state, effects };
@@ -13,12 +13,14 @@
13
13
  * Anything else gets `908 ERR_SASLMECHS` listing what is available.
14
14
  * 2. `AUTHENTICATE <b64>` / `AUTHENTICATE +` — delivers the payload (whole
15
15
  * or in `<=400`-byte chunks; `+` marks an empty/final chunk). The
16
- * decoded bytes are handed to the injected {@link AccountStore}; on
17
- * success the connection records the account name and the reducer emits
18
- * `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure `904 ERR_SASLFAIL`.
16
+ * decoded bytes are verified against the bound {@link ServicesStore};
17
+ * on success the connection records the account name and the reducer
18
+ * emits `900 RPL_LOGGEDIN` + `903 RPL_SASLSUCCESS`, on failure
19
+ * `904 ERR_SASLFAIL`. PLAIN consults `services.verifyNick`, EXTERNAL
20
+ * consults `services.verifyCertFP` against the mTLS-resolved subject.
19
21
  * 3. `AUTHENTICATE *` — client abort → `906 ERR_SASLABORT`.
20
22
  *
21
- * The `AccountStore` port is **synchronous** (see {@link AccountStore} docs):
23
+ * The {@link ServicesStore} port is **synchronous** (reducers stay pure):
22
24
  * adapters pre-load credentials so the reducer stays pure. Async backends
23
25
  * resolve their lookups before the actor invokes this reducer.
24
26
  *
@@ -29,7 +31,7 @@
29
31
 
30
32
  import { Effect } from '../effects.js';
31
33
  import type { Effect as EffectType, RawLine } from '../effects.js';
32
- import type { SaslResult } from '../ports.js';
34
+ import type { ServicesAuthResult } from '../ports.js';
33
35
  import { decodeBase64 } from '../protocol/base64.js';
34
36
  import { Numerics } from '../protocol/numerics.js';
35
37
  import type { ConnectionState } from '../state/connection.js';
@@ -143,7 +145,7 @@ function handlePayload(
143
145
  return finalize(state, next, mech, ctx);
144
146
  }
145
147
 
146
- /** Decodes the accumulated payload and runs it through the AccountStore. */
148
+ /** Decodes the accumulated payload and verifies it against the ServicesStore. */
147
149
  function finalize(
148
150
  state: ConnectionState,
149
151
  payloadB64: string,
@@ -169,28 +171,16 @@ function finalizePlain(
169
171
  return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
170
172
  }
171
173
 
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
- const store = ctx.accounts;
176
- let result = store?.verify('PLAIN', {
177
- kind: 'PLAIN',
178
- username: parsed.username,
179
- password: parsed.password,
180
- });
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) {
174
+ // Single credential home: services.verifyNick covers every NickServ
175
+ // registration, every SASL_ACCOUNTS-seeded account (carried into
176
+ // services via the boot ingest), and every PASS-auth share. No separate
177
+ // static credential fallback — there is no second credential table.
178
+ const services = ctx.services;
179
+ if (services === undefined) {
190
180
  clearSasl(state);
191
181
  return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
192
182
  }
193
-
183
+ const result = services.verifyNick(parsed.username, parsed.password);
194
184
  return applyResult(state, result, ctx);
195
185
  }
196
186
 
@@ -207,21 +197,22 @@ function finalizeExternal(
207
197
  return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
208
198
  }
209
199
 
210
- const store = ctx.accounts;
211
- if (store === undefined) {
200
+ // Cert subjects are mapped to accounts via services.verifyCertFP — the
201
+ // same single credential home as SASL PLAIN / PASS-auth. No separate
202
+ // services lookup.
203
+ const services = ctx.services;
204
+ if (services === undefined) {
212
205
  clearSasl(state);
213
206
  return { state, effects: [Effect.send(ctx.connId, [saslFail(ctx)])] };
214
207
  }
215
-
216
- const result = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity });
217
-
208
+ const result = services.verifyCertFP(identity);
218
209
  return applyResult(state, result, ctx);
219
210
  }
220
211
 
221
212
  /** Shared success/failure handling after a `verify` call. */
222
213
  function applyResult(
223
214
  state: ConnectionState,
224
- result: SaslResult,
215
+ result: ServicesAuthResult,
225
216
  ctx: Ctx,
226
217
  ): { state: ConnectionState; effects: EffectType[] } {
227
218
  clearSasl(state);