serverless-ircd 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/.github/workflows/ci.yml +2 -2
  2. package/.github/workflows/deploy-aws.yml +1 -3
  3. package/.github/workflows/deploy-cf-tcp.yml +87 -0
  4. package/.github/workflows/deploy-cf.yml +1 -1
  5. package/.node-version +1 -0
  6. package/.nvmrc +1 -0
  7. package/CHANGELOG.md +174 -18
  8. package/README.md +63 -30
  9. package/apps/aws-stack/README.md +2 -2
  10. package/apps/aws-stack/bin/aws.ts +7 -0
  11. package/apps/aws-stack/package.json +4 -4
  12. package/apps/aws-stack/src/aws-stack.ts +118 -6
  13. package/apps/aws-stack/tests/stack.test.ts +98 -3
  14. package/apps/cf-tcp-container/Dockerfile +69 -0
  15. package/apps/cf-tcp-container/package.json +34 -0
  16. package/apps/cf-tcp-container/src/config-loader.ts +145 -0
  17. package/apps/cf-tcp-container/src/container-do.ts +38 -0
  18. package/apps/cf-tcp-container/src/container-server.ts +363 -0
  19. package/apps/cf-tcp-container/src/main.ts +77 -0
  20. package/apps/cf-tcp-container/src/persistence.ts +144 -0
  21. package/apps/cf-tcp-container/src/worker.ts +41 -0
  22. package/apps/cf-tcp-container/terraform/provider.tf +24 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +81 -0
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +217 -0
  25. package/apps/cf-tcp-container/tests/container-server.test.ts +465 -0
  26. package/apps/cf-tcp-container/tests/persistence.test.ts +227 -0
  27. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +275 -0
  28. package/apps/cf-tcp-container/tsconfig.build.json +17 -0
  29. package/apps/cf-tcp-container/tsconfig.test.json +15 -0
  30. package/apps/cf-tcp-container/vitest.config.ts +26 -0
  31. package/apps/cf-tcp-container/wrangler.toml +63 -0
  32. package/apps/cf-worker/package.json +1 -1
  33. package/apps/cf-worker/wrangler.test.toml +6 -0
  34. package/apps/cf-worker/wrangler.toml +28 -2
  35. package/apps/local-cli/package.json +1 -1
  36. package/apps/local-cli/src/config-loader.ts +10 -0
  37. package/apps/local-cli/src/server.ts +20 -3
  38. package/package.json +14 -10
  39. package/packages/aws-adapter/package.json +3 -3
  40. package/packages/aws-adapter/src/account-store.ts +1 -1
  41. package/packages/aws-adapter/src/admission.ts +74 -0
  42. package/packages/aws-adapter/src/aws-runtime.ts +6 -4
  43. package/packages/aws-adapter/src/config-loader.ts +32 -0
  44. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  45. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  46. package/packages/aws-adapter/src/handlers/default.ts +35 -2
  47. package/packages/aws-adapter/src/handlers/index.ts +69 -3
  48. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  49. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  50. package/packages/aws-adapter/src/index.ts +6 -0
  51. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  52. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  53. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  54. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  55. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  56. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  57. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  58. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  59. package/packages/aws-adapter/tests/handlers.test.ts +154 -53
  60. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  61. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  62. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  63. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  64. package/packages/cf-adapter/package.json +1 -1
  65. package/packages/cf-adapter/src/cf-runtime.ts +2 -4
  66. package/packages/cf-adapter/src/config-loader.ts +33 -0
  67. package/packages/cf-adapter/src/connection-do.ts +111 -46
  68. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  69. package/packages/cf-adapter/src/env.ts +33 -9
  70. package/packages/cf-adapter/src/index.ts +9 -8
  71. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  72. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  73. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  74. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  75. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  76. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  77. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  78. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  79. package/packages/cf-adapter/tests/worker/main.ts +8 -1
  80. package/packages/cf-adapter/wrangler.test.toml +8 -0
  81. package/packages/in-memory-runtime/package.json +1 -1
  82. package/packages/irc-core/package.json +6 -1
  83. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  84. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  85. package/packages/irc-core/src/cloak.ts +1 -1
  86. package/packages/irc-core/src/commands/cap.ts +8 -1
  87. package/packages/irc-core/src/commands/index.ts +9 -0
  88. package/packages/irc-core/src/commands/ison.ts +61 -0
  89. package/packages/irc-core/src/commands/quit.ts +12 -0
  90. package/packages/irc-core/src/commands/registration.ts +18 -12
  91. package/packages/irc-core/src/commands/sasl.ts +72 -9
  92. package/packages/irc-core/src/commands/server-info.ts +129 -0
  93. package/packages/irc-core/src/commands/userhost.ts +84 -0
  94. package/packages/irc-core/src/commands/whowas.ts +113 -0
  95. package/packages/irc-core/src/config.ts +58 -0
  96. package/packages/irc-core/src/credential-hashing.ts +124 -0
  97. package/packages/irc-core/src/effects.ts +6 -29
  98. package/packages/irc-core/src/index.ts +1 -0
  99. package/packages/irc-core/src/ports.ts +181 -12
  100. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  101. package/packages/irc-core/src/types.ts +38 -1
  102. package/packages/irc-core/tests/account-store.test.ts +45 -2
  103. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  104. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  105. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  106. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  107. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  108. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  109. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  110. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  111. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  112. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  113. package/packages/irc-core/tests/config.test.ts +95 -1
  114. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  115. package/packages/irc-core/tests/effects.test.ts +0 -27
  116. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  117. package/packages/irc-core/tests/numerics.test.ts +12 -0
  118. package/packages/irc-core/tests/types.test.ts +35 -1
  119. package/packages/irc-core/tsconfig.build.json +1 -1
  120. package/packages/irc-core/tsconfig.test.json +1 -1
  121. package/packages/irc-server/package.json +1 -1
  122. package/packages/irc-server/src/actor.ts +158 -14
  123. package/packages/irc-server/src/dispatch.ts +0 -3
  124. package/packages/irc-server/src/index.ts +10 -2
  125. package/packages/irc-server/src/routing.ts +12 -0
  126. package/packages/irc-server/src/transport.ts +101 -0
  127. package/packages/irc-server/tests/actor.test.ts +400 -3
  128. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  129. package/packages/irc-server/tests/routing.test.ts +4 -0
  130. package/packages/irc-server/tests/transport.test.ts +230 -0
  131. package/packages/irc-test-support/package.json +1 -1
  132. package/packages/irc-test-support/src/harness.ts +44 -9
  133. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  134. package/packages/irc-test-support/src/index.ts +3 -0
  135. package/packages/irc-test-support/src/scenarios.ts +132 -2
  136. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  137. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  138. package/pnpm-workspace.yaml +9 -1
  139. package/tools/ci-hardening/package.json +1 -1
  140. package/tools/package.json +4 -0
  141. package/tools/seed-aws-accounts.ts +5 -6
  142. package/tools/seed-cf-accounts.ts +104 -0
  143. package/tools/tcp-ws-forwarder/package.json +1 -1
  144. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  145. package/docs/ADR-002-location-of-authority.md +0 -82
  146. package/docs/ADR-003-durable-object-sharding.md +0 -93
  147. package/docs/ADR-004-dynamodb-schema.md +0 -96
  148. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  149. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  150. package/docs/ADR-007-deterministic-ports.md +0 -82
  151. package/docs/ADR-008-monorepo-tooling.md +0 -60
  152. package/docs/AWS-Adapter-Architecture.md +0 -496
  153. package/docs/AWS-Deployment.md +0 -1186
  154. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  155. package/docs/Home.md +0 -11
  156. package/docs/Observability.md +0 -87
  157. package/docs/PlanIRCv3Websocket.md +0 -489
  158. package/docs/PlanWebClient.md +0 -451
  159. package/docs/Release-Process.md +0 -443
@@ -14,8 +14,12 @@
14
14
  * - nick registry: {@link ReserveNickEffect}, {@link ChangeNickEffect},
15
15
  * {@link ReleaseNickEffect}
16
16
  * - channel ownership: {@link ApplyChannelDeltaEffect}
17
- * - lookups: {@link LookupNickEffect}, {@link GetConnectionInfoEffect},
18
- * {@link GetChannelSnapshotEffect}
17
+ *
18
+ * Lookup-shaped runtime methods (`lookupNick`, `getConnectionInfo`,
19
+ * `getChannelSnapshot`) are consumed directly inside `dispatch`'s
20
+ * broadcast helper and the actor — they are request/response and cannot
21
+ * be modelled as pure fire-and-forget effects, so no Effect variants
22
+ * wrap them.
19
23
  */
20
24
 
21
25
  import type { ChanName, ChannelDelta } from './state/channel.js';
@@ -71,21 +75,6 @@ export interface ApplyChannelDeltaEffect {
71
75
  delta: ChannelDelta;
72
76
  }
73
77
 
74
- export interface LookupNickEffect {
75
- tag: 'LookupNick';
76
- nick: Nick;
77
- }
78
-
79
- export interface GetConnectionInfoEffect {
80
- tag: 'GetConnectionInfo';
81
- conn: ConnId;
82
- }
83
-
84
- export interface GetChannelSnapshotEffect {
85
- tag: 'GetChannelSnapshot';
86
- chan: ChanName;
87
- }
88
-
89
78
  /**
90
79
  * Routes `lines` to whichever connection currently owns `nick`. If the nick
91
80
  * is offline, `notFoundLines` (when supplied) is sent back to `sender` —
@@ -109,9 +98,6 @@ export type Effect =
109
98
  | ChangeNickEffect
110
99
  | ReleaseNickEffect
111
100
  | ApplyChannelDeltaEffect
112
- | LookupNickEffect
113
- | GetConnectionInfoEffect
114
- | GetChannelSnapshotEffect
115
101
  | SendToNickEffect;
116
102
 
117
103
  /** Literal string tag of every {@link Effect} variant. */
@@ -167,15 +153,6 @@ export const Effect = {
167
153
  applyChannelDelta(chan: ChanName, delta: ChannelDelta): Effect {
168
154
  return { tag: 'ApplyChannelDelta', chan, delta };
169
155
  },
170
- lookupNick(nick: Nick): Effect {
171
- return { tag: 'LookupNick', nick };
172
- },
173
- getConnectionInfo(conn: ConnId): Effect {
174
- return { tag: 'GetConnectionInfo', conn };
175
- },
176
- getChannelSnapshot(chan: ChanName): Effect {
177
- return { tag: 'GetChannelSnapshot', chan };
178
- },
179
156
  sendToNick(nick: Nick, sender: ConnId, lines: RawLine[], notFoundLines?: RawLine[]): Effect {
180
157
  const e: SendToNickEffect = { tag: 'SendToNick', nick, sender, lines };
181
158
  if (notFoundLines !== undefined) e.notFoundLines = notFoundLines;
@@ -10,6 +10,7 @@ export * from './caps/index.js';
10
10
  export * from './commands/index.js';
11
11
  export * from './effects.js';
12
12
  export * from './ports.js';
13
+ export * from './credential-hashing.js';
13
14
  export * from './flood-control.js';
14
15
  export * from './cloak.js';
15
16
  export * from './case-fold.js';
@@ -57,7 +57,7 @@ export interface IdFactory {
57
57
 
58
58
  /**
59
59
  * Production id factory. Generates UUIDv4-shaped strings without relying
60
- * on `crypto` so the same code runs on Node 20, Cloudflare Workers, and
60
+ * on `crypto` so the same code runs on Node 24, Cloudflare Workers, and
61
61
  * Lambda without per-platform type wrangling. Uniqueness is statistical;
62
62
  * for cryptographic IDs, adapters can supply their own {@link IdFactory}.
63
63
  */
@@ -171,7 +171,8 @@ export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
171
171
  */
172
172
  export type SaslPayload =
173
173
  | { kind: 'PLAIN'; username: string; password: string }
174
- | { kind: 'RAW'; data: string };
174
+ | { kind: 'RAW'; data: string }
175
+ | { kind: 'EXTERNAL'; identity: string };
175
176
 
176
177
  /**
177
178
  * Outcome of an {@link AccountStore.verify} call: either the canonical
@@ -202,6 +203,12 @@ export interface AccountStore {
202
203
  export interface SaslAccountCredential {
203
204
  username: string;
204
205
  password: string;
206
+ /**
207
+ * Optional mTLS client-certificate subject (e.g. `CN=alice` or a
208
+ * fingerprint hash). When set, SASL `EXTERNAL` authentications that
209
+ * present this subject are mapped to {@link username} as the account.
210
+ */
211
+ certSubject?: string;
205
212
  }
206
213
 
207
214
  /**
@@ -221,23 +228,37 @@ export interface SaslAccountCredential {
221
228
  */
222
229
  export class InMemoryAccountStore implements AccountStore {
223
230
  private readonly creds: ReadonlyMap<string, string>;
231
+ private readonly certMap: ReadonlyMap<string, string>;
224
232
 
225
233
  constructor(credentials: ReadonlyArray<SaslAccountCredential>) {
226
234
  this.creds = new Map(credentials.map((c) => [c.username, c.password]));
235
+ this.certMap = new Map(
236
+ credentials
237
+ .filter((c) => c.certSubject !== undefined)
238
+ .map((c) => [c.certSubject as string, c.username]),
239
+ );
227
240
  }
228
241
 
229
242
  verify(mech: string, payload: SaslPayload): SaslResult {
230
- if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
231
- return { ok: false, reason: `unsupported mechanism: ${mech}` };
232
- }
233
- const expected = this.creds.get(payload.username);
234
- if (expected === undefined) {
235
- return { ok: false, reason: 'invalid credentials' };
243
+ const m = mech.toUpperCase();
244
+ if (m === 'PLAIN' && payload.kind === 'PLAIN') {
245
+ const expected = this.creds.get(payload.username);
246
+ if (expected === undefined) {
247
+ return { ok: false, reason: 'invalid credentials' };
248
+ }
249
+ if (!constantTimeEquals(payload.password, expected)) {
250
+ return { ok: false, reason: 'invalid credentials' };
251
+ }
252
+ return { ok: true, account: payload.username };
236
253
  }
237
- if (!constantTimeEquals(payload.password, expected)) {
238
- return { ok: false, reason: 'invalid credentials' };
254
+ if (m === 'EXTERNAL' && payload.kind === 'EXTERNAL') {
255
+ const account = this.certMap.get(payload.identity);
256
+ if (account === undefined) {
257
+ return { ok: false, reason: 'untrusted certificate' };
258
+ }
259
+ return { ok: true, account };
239
260
  }
240
- return { ok: true, account: payload.username };
261
+ return { ok: false, reason: `unsupported mechanism: ${mech}` };
241
262
  }
242
263
  }
243
264
 
@@ -256,6 +277,52 @@ function constantTimeEquals(a: string, b: string): boolean {
256
277
  return diff === 0;
257
278
  }
258
279
 
280
+ // ============================================================================
281
+ // mTLS identity (SASL EXTERNAL)
282
+ // ============================================================================
283
+
284
+ /**
285
+ * Port that resolves the verified client-certificate identity for a
286
+ * connection.
287
+ *
288
+ * At the edge (CF API Shield mTLS, AWS API Gateway custom-domain mTLS) the
289
+ * platform terminates TLS, validates the client certificate against the
290
+ * configured trust store, and surfaces the certificate subject to the
291
+ * Worker/Lambda. The adapter captures this at connection-admission time
292
+ * and stores it keyed by connection ID.
293
+ *
294
+ * The method is **synchronous** — adapters pre-populate the identity at
295
+ * admission time (before the first reducer runs), so the SASL EXTERNAL
296
+ * reducer can look it up without awaiting. When no client certificate was
297
+ * presented (or mTLS is not configured), `getIdentity` returns `undefined`
298
+ * and EXTERNAL authentication fails with `904`.
299
+ */
300
+ export interface MtlsIdentityProvider {
301
+ /**
302
+ * @returns the verified client-cert subject for `connId`, or `undefined`
303
+ * when no trusted certificate was presented.
304
+ */
305
+ getIdentity(connId: string): string | undefined;
306
+ }
307
+
308
+ /**
309
+ * Reference in-memory {@link MtlsIdentityProvider} backed by a static map
310
+ * of connection IDs to certificate subjects. Useful for tests and the
311
+ * local-cli adapter; cloud adapters replace this with a lookup keyed by
312
+ * the platform's connection identifier.
313
+ */
314
+ export class InMemoryMtlsIdentityProvider implements MtlsIdentityProvider {
315
+ private readonly identities: ReadonlyMap<string, string>;
316
+
317
+ constructor(entries: ReadonlyArray<{ connId: string; identity: string }>) {
318
+ this.identities = new Map(entries.map((e) => [e.connId, e.identity]));
319
+ }
320
+
321
+ getIdentity(connId: string): string | undefined {
322
+ return this.identities.get(connId);
323
+ }
324
+ }
325
+
259
326
  // ============================================================================
260
327
  // Chat-history persistence (IRCv3 draft/chathistory)
261
328
  // ============================================================================
@@ -501,6 +568,108 @@ function tail<T>(arr: readonly T[], n: number): T[] {
501
568
  return arr.slice(start);
502
569
  }
503
570
 
571
+ // ============================================================================
572
+ // Nick history (WHOWAS)
573
+ // ============================================================================
574
+
575
+ /**
576
+ * One persisted sign-off record for a nick, written by the QUIT and
577
+ * post-registration NICK-change reducers and replayed by `WHOWAS`.
578
+ *
579
+ * `nick` preserves the original case the client used; `connId` is the
580
+ * connection that owned the nick at sign-off. `username` / `hostname` /
581
+ * `realname` mirror the {@link ConnectionState} fields at sign-off time
582
+ * (optional because a never-fully-registered connection may still quit with
583
+ * a nick). `signoffTime` is epoch milliseconds from the injected
584
+ * {@link Clock}.
585
+ */
586
+ export interface NickHistoryEntry {
587
+ nick: string;
588
+ connId: string;
589
+ username?: string;
590
+ hostname?: string;
591
+ realname?: string;
592
+ signoffTime: number;
593
+ }
594
+
595
+ /**
596
+ * Persistence port for the IRC `WHOWAS` command.
597
+ *
598
+ * Mirrors the {@link MessageStore} pattern: the port is **synchronous**
599
+ * (reducers must stay pure and free of IO) and injected via {@link Ctx}.
600
+ * Adapters that need async storage (D1, DynamoDB) pre-load the relevant
601
+ * slice into a synchronously-readable store; Phase 8 ships the in-memory
602
+ * reference implementation only.
603
+ *
604
+ * `query` always returns matches most-recent-first (the order `WHOWAS`
605
+ * emits `314 RPL_WHOWASUSER` lines in), capped at `count`.
606
+ */
607
+ export interface NickHistoryStore {
608
+ /** Records a sign-off entry for a nick (TTL eviction is impl-defined). */
609
+ record(entry: NickHistoryEntry): void;
610
+ /** Returns up to `count` most-recent entries for `nick` (case-insensitive). */
611
+ query(nick: string, count: number): NickHistoryEntry[];
612
+ }
613
+
614
+ /** Default retention window for nick-history entries: 24 hours. */
615
+ const DEFAULT_NICK_HISTORY_MAX_AGE = 24 * 60 * 60 * 1000;
616
+
617
+ /**
618
+ * Reference in-memory {@link NickHistoryStore} backed by a per-nick
619
+ * chronological array with a configurable TTL.
620
+ *
621
+ * Used by the local CLI and by tests. Each nick keeps its sign-off records
622
+ * in arrival order; entries older than `maxAge` (measured against the
623
+ * injected {@link Clock}) are dropped lazily on `record` and `query` so the
624
+ * per-nick buffers do not grow unbounded.
625
+ *
626
+ * Nicks are keyed via {@link caseFold} (`rfc1459`) so lookups are
627
+ * case-insensitive, matching the IRC nick-comparison semantics advertised
628
+ * via `005 RPL_ISUPPORT CASEMAPPING=rfc1459`.
629
+ */
630
+ export class InMemoryNickHistoryStore implements NickHistoryStore {
631
+ private readonly byNick = new Map<string, NickHistoryEntry[]>();
632
+ private readonly clock: Clock;
633
+ private readonly maxAge: number;
634
+
635
+ constructor(clock: Clock, opts?: { maxAge?: number }) {
636
+ this.clock = clock;
637
+ this.maxAge = opts?.maxAge ?? DEFAULT_NICK_HISTORY_MAX_AGE;
638
+ }
639
+
640
+ record(entry: NickHistoryEntry): void {
641
+ const key = caseFold('rfc1459', entry.nick);
642
+ let arr = this.byNick.get(key);
643
+ if (arr === undefined) {
644
+ arr = [];
645
+ this.byNick.set(key, arr);
646
+ }
647
+ this.purgeExpired(arr);
648
+ arr.push(entry);
649
+ }
650
+
651
+ query(nick: string, count: number): NickHistoryEntry[] {
652
+ if (count <= 0) return [];
653
+ const arr = this.byNick.get(caseFold('rfc1459', nick));
654
+ if (arr === undefined) return [];
655
+ this.purgeExpired(arr);
656
+ if (arr.length === 0) return [];
657
+ return arr.slice(Math.max(0, arr.length - count)).reverse();
658
+ }
659
+
660
+ /**
661
+ * Drops entries whose `signoffTime` is older than `now - maxAge`, in place.
662
+ * Entries arrive in chronological order (append-only), so the first alive
663
+ * entry marks the boundary; everything before it is expired.
664
+ */
665
+ private purgeExpired(arr: NickHistoryEntry[]): void {
666
+ const cutoff = this.clock.now() - this.maxAge;
667
+ let firstAlive = arr.findIndex((e) => e.signoffTime >= cutoff);
668
+ if (firstAlive === -1) firstAlive = arr.length;
669
+ arr.splice(0, firstAlive);
670
+ }
671
+ }
672
+
504
673
  // ============================================================================
505
674
  // Observability — structured logger
506
675
  // ============================================================================
@@ -854,7 +1023,7 @@ export class ConsoleLogger implements Logger {
854
1023
 
855
1024
  /** Returns the global `console` as a {@link ConsoleLoggerSinks}. */
856
1025
  function globalThisConsoleSinks(): ConsoleLoggerSinks {
857
- // `globalThis.console` is universal across Node 20, Workers, and Lambda.
1026
+ // `globalThis.console` is universal across Node 24, Workers, and Lambda.
858
1027
  // Reference it lazily so tests that stub the logger do not need to
859
1028
  // replace the global.
860
1029
  const c = (globalThis as { console?: GlobalConsole }).console;
@@ -13,7 +13,7 @@ export const Numerics = {
13
13
  RPL_ISUPPORT: 5,
14
14
  RPL_BOUNCE: 10,
15
15
 
16
- // Capability / stats admin
16
+ // Capability / stats admin / version
17
17
  RPL_ADMINME: 256,
18
18
 
19
19
  // Away / userhost / ison
@@ -23,7 +23,10 @@ export const Numerics = {
23
23
  RPL_UNAWAY: 305,
24
24
  RPL_NOWAWAY: 306,
25
25
 
26
- // WHOIS
26
+ // Version
27
+ RPL_VERSION: 351,
28
+
29
+ // WHOIS / WHOWAS
27
30
  RPL_WHOISUSER: 311,
28
31
  RPL_WHOISSERVER: 312,
29
32
  RPL_WHOISOPERATOR: 313,
@@ -31,6 +34,7 @@ export const Numerics = {
31
34
  RPL_ENDOFWHOIS: 318,
32
35
  RPL_WHOISCHANNELS: 319,
33
36
  RPL_WHOISACCOUNT: 330,
37
+ RPL_WHOWASUSER: 314,
34
38
 
35
39
  // LIST
36
40
  RPL_LISTSTART: 321,
@@ -57,7 +61,8 @@ export const Numerics = {
57
61
  RPL_NAMREPLY: 353,
58
62
  RPL_ENDOFNAMES: 366,
59
63
 
60
- // Links / ban / whowas
64
+ // Links / ban / whowas. RPL_LINKS/RPL_ENDOFLINKS retained for the deferred
65
+ // LINKS verb (S2S server list — PLAN non-goal; see actor default-route triage).
61
66
  RPL_LINKS: 364,
62
67
  RPL_ENDOFLINKS: 365,
63
68
  RPL_BANLIST: 367,
@@ -71,7 +76,8 @@ export const Numerics = {
71
76
  RPL_MOTDSTART: 375,
72
77
  RPL_ENDOFMOTD: 376,
73
78
 
74
- // Oper
79
+ // Oper. RPL_REHASHING retained for the deferred REHASH verb (no runtime
80
+ // config-reload path in serverless; see actor default-route triage).
75
81
  RPL_YOUREOPER: 381,
76
82
  RPL_REHASHING: 382,
77
83
  RPL_YOURESERVICE: 383,
@@ -96,7 +102,7 @@ export const Numerics = {
96
102
  ERR_TOOMANYCHANNELS: 405,
97
103
  ERR_WASNOSUCHNICK: 406,
98
104
  ERR_TOOMANYTARGETS: 407,
99
- ERR_NOSUCHSERVICE: 408,
105
+ ERR_NOSUCHSERVICE: 408, // retained: deferred SERVICE verb (RFC 2812 obsolete)
100
106
  ERR_INVALIDCAPCMD: 410,
101
107
  ERR_NORECIPIENT: 411,
102
108
  ERR_NOTEXTTOSEND: 412,
@@ -115,8 +121,8 @@ export const Numerics = {
115
121
  ERR_NOTONCHANNEL: 442,
116
122
  ERR_USERONCHANNEL: 443,
117
123
  ERR_NOLOGIN: 444,
118
- ERR_SUMMONDISABLED: 445,
119
- ERR_USERSDISABLED: 446,
124
+ ERR_SUMMONDISABLED: 445, // retained: deferred SUMMON verb (RFC 2812 obsolete)
125
+ ERR_USERSDISABLED: 446, // retained: deferred USERS verb (RFC 2812 obsolete)
120
126
  ERR_NOTREGISTERED: 451,
121
127
  ERR_NEEDMOREPARAMS: 461,
122
128
  ERR_ALREADYREGISTRED: 462,
@@ -134,7 +140,7 @@ export const Numerics = {
134
140
  ERR_BANLISTFULL: 478,
135
141
  ERR_NOPRIVILEGES: 481,
136
142
  ERR_CHANOPRIVSNEEDED: 482,
137
- ERR_CANTKILLSERVER: 483,
143
+ ERR_CANTKILLSERVER: 483, // retained: deferred SQUIT verb (S2S, PLAN non-goal)
138
144
  ERR_NOOPERHOST: 491,
139
145
  ERR_UMODEUNKNOWNFLAG: 501,
140
146
  ERR_USERSDONTMATCH: 502,
@@ -8,7 +8,15 @@
8
8
 
9
9
  import type { Effect } from './effects.js';
10
10
  import type { FloodControlConfig } from './flood-control.js';
11
- import type { AccountStore, Clock, IdFactory, MessageStore, MotdProvider } from './ports.js';
11
+ import type {
12
+ AccountStore,
13
+ Clock,
14
+ IdFactory,
15
+ MessageStore,
16
+ MotdProvider,
17
+ MtlsIdentityProvider,
18
+ NickHistoryStore,
19
+ } from './ports.js';
12
20
  import type { IrcMessage } from './protocol/messages.js';
13
21
  import type { ConnectionState } from './state/connection.js';
14
22
 
@@ -46,6 +54,18 @@ export interface OperCred {
46
54
  export interface ServerConfig {
47
55
  serverName: string;
48
56
  networkName: string;
57
+ /**
58
+ * Server version surfaced in `002`/`004`/`351`/`371`. When undefined the
59
+ * reducer falls back to {@link DEFAULT_SERVER_VERSION} (the irc-core
60
+ * package version); adapters inject the deploy-time version via config.
61
+ */
62
+ readonly serverVersion?: string | undefined;
63
+ /**
64
+ * "Created" text for `003 RPL_CREATED`. A string is used verbatim; a
65
+ * number is treated as epoch-ms and formatted as a UTC timestamp. When
66
+ * undefined the reducer falls back to {@link DEFAULT_CREATED_TEXT}.
67
+ */
68
+ readonly createdAt?: string | number | undefined;
49
69
  /**
50
70
  * Server password gate. Empty string or undefined disables enforcement.
51
71
  * When set, registration refuses to complete until the connection
@@ -115,6 +135,12 @@ export interface Ctx {
115
135
  readonly motd: MotdProvider;
116
136
  /** SASL account verification source (absent when no accounts are configured). */
117
137
  readonly accounts?: AccountStore;
138
+ /**
139
+ * mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
140
+ * is not configured). When present, `AUTHENTICATE EXTERNAL` resolves the
141
+ * connection's verified certificate subject through this port.
142
+ */
143
+ readonly mtlsIdentity?: MtlsIdentityProvider;
118
144
  /**
119
145
  * Chat-history persistence source (absent when chathistory playback is
120
146
  * disabled for this deployment). When present, the PRIVMSG/NOTICE channel
@@ -122,6 +148,13 @@ export interface Ctx {
122
148
  * cap-enabled joiners.
123
149
  */
124
150
  readonly messages?: MessageStore;
151
+ /**
152
+ * Nick-history source for the `WHOWAS` command (absent when WHOWAS
153
+ * history retention is disabled for this deployment). When present, the
154
+ * QUIT and post-registration NICK-change reducers record sign-off
155
+ * entries and the WHOWAS reducer replays them.
156
+ */
157
+ readonly history?: NickHistoryStore;
125
158
  /** The connection invoking the command. Reducers mutate this freely. */
126
159
  readonly connection: ConnectionState;
127
160
  /** Convenience: `connection.id`. */
@@ -147,7 +180,9 @@ export interface BuildCtxOptions {
147
180
  ids: IdFactory;
148
181
  motd: MotdProvider;
149
182
  accounts?: AccountStore;
183
+ mtlsIdentity?: MtlsIdentityProvider;
150
184
  messages?: MessageStore;
185
+ history?: NickHistoryStore;
151
186
  connection: ConnectionState;
152
187
  }
153
188
 
@@ -163,7 +198,9 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
163
198
  connection: opts.connection,
164
199
  connId: opts.connection.id,
165
200
  ...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
201
+ ...(opts.mtlsIdentity !== undefined ? { mtlsIdentity: opts.mtlsIdentity } : {}),
166
202
  ...(opts.messages !== undefined ? { messages: opts.messages } : {}),
203
+ ...(opts.history !== undefined ? { history: opts.history } : {}),
167
204
  };
168
205
  return ctx;
169
206
  }
@@ -65,9 +65,9 @@ describe('InMemoryAccountStore — PLAIN', () => {
65
65
  });
66
66
 
67
67
  describe('InMemoryAccountStore — mechanism handling', () => {
68
- it('rejects any non-PLAIN mechanism with a failure result', () => {
68
+ it('rejects an unsupported mechanism with a failure result', () => {
69
69
  const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
70
- const out = store.verify('EXTERNAL', { kind: 'RAW', data: 'cert-subject' });
70
+ const out = store.verify('CRAM-MD5', { kind: 'RAW', data: 'whatever' });
71
71
  expect(out.ok).toBe(false);
72
72
  });
73
73
 
@@ -78,6 +78,49 @@ describe('InMemoryAccountStore — mechanism handling', () => {
78
78
  });
79
79
  });
80
80
 
81
+ describe('InMemoryAccountStore — EXTERNAL (mTLS)', () => {
82
+ it('maps a known cert subject to its account', () => {
83
+ const store = new InMemoryAccountStore([
84
+ { username: 'alice', password: 'secret', certSubject: 'CN=alice' },
85
+ ]);
86
+ const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=alice' });
87
+ expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
88
+ });
89
+
90
+ it('rejects an unknown cert subject', () => {
91
+ const store = new InMemoryAccountStore([
92
+ { username: 'alice', password: 'secret', certSubject: 'CN=alice' },
93
+ ]);
94
+ const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=evil' });
95
+ expect(out.ok).toBe(false);
96
+ });
97
+
98
+ it('rejects EXTERNAL when no cert subjects are configured', () => {
99
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
100
+ const out = store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=alice' });
101
+ expect(out.ok).toBe(false);
102
+ });
103
+
104
+ it('treats the mechanism name case-insensitively for EXTERNAL', () => {
105
+ const store = new InMemoryAccountStore([
106
+ { username: 'alice', password: 'secret', certSubject: 'CN=alice' },
107
+ ]);
108
+ const out = store.verify('external', { kind: 'EXTERNAL', identity: 'CN=alice' });
109
+ expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
110
+ });
111
+
112
+ it('maps multiple accounts with distinct cert subjects', () => {
113
+ const store = new InMemoryAccountStore([
114
+ { username: 'alice', password: 'a', certSubject: 'CN=alice' },
115
+ { username: 'bob', password: 'b', certSubject: 'CN=bob' },
116
+ ]);
117
+ expect(store.verify('EXTERNAL', { kind: 'EXTERNAL', identity: 'CN=bob' })).toEqual<SaslResult>({
118
+ ok: true,
119
+ account: 'bob',
120
+ });
121
+ });
122
+ });
123
+
81
124
  describe('InMemoryAccountStore — failure reason', () => {
82
125
  it('returns a human-readable reason on failure', () => {
83
126
  const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
@@ -31,10 +31,10 @@ describe('SUPPORTED_CAPABILITIES', () => {
31
31
  );
32
32
  });
33
33
 
34
- it('declares the sasl value as the advertised mechanism list', () => {
34
+ it('declares the sasl value as the base mechanism list (PLAIN only by default)', () => {
35
35
  const sasl = SUPPORTED_CAPABILITIES.find((c) => c.name === 'sasl');
36
36
  expect(sasl).toBeDefined();
37
- expect(sasl?.value).toBe('PLAIN,EXTERNAL');
37
+ expect(sasl?.value).toBe('PLAIN');
38
38
  });
39
39
 
40
40
  it('does not attach a value to caps that have no parameter', () => {
@@ -107,7 +107,8 @@ describe('getLsString', () => {
107
107
  });
108
108
 
109
109
  it('contains the sasl token with its value', () => {
110
- expect(getLsString()).toContain('sasl=PLAIN,EXTERNAL');
110
+ expect(getLsString()).toContain('sasl=PLAIN');
111
+ expect(getLsString()).not.toContain('sasl=PLAIN,EXTERNAL');
111
112
  });
112
113
 
113
114
  it('contains server-time as a bare name', () => {
@@ -4,6 +4,7 @@ import { capReducer } from '../../src/commands/cap';
4
4
  import { userReducer } from '../../src/commands/registration';
5
5
  import { Effect } from '../../src/effects';
6
6
  import type { Effect as EffectType, RawLine } from '../../src/effects';
7
+ import type { MtlsIdentityProvider } from '../../src/ports';
7
8
  import { EmptyMotdProvider, FakeClock, SequentialIdFactory } from '../../src/ports';
8
9
  import { type ConnectionState, createConnection } from '../../src/state/connection';
9
10
  import { type Ctx, type ServerConfig, buildCtx } from '../../src/types';
@@ -20,13 +21,18 @@ const serverConfig: ServerConfig = {
20
21
  quitMessage: 'Client Quit',
21
22
  };
22
23
 
23
- function makeCtx(state: ConnectionState, cfg: ServerConfig = serverConfig): Ctx {
24
+ function makeCtx(
25
+ state: ConnectionState,
26
+ cfg: ServerConfig = serverConfig,
27
+ mtlsIdentity?: MtlsIdentityProvider,
28
+ ): Ctx {
24
29
  return buildCtx({
25
30
  serverConfig: cfg,
26
31
  clock: new FakeClock(1_000),
27
32
  ids: new SequentialIdFactory(),
28
33
  motd: EmptyMotdProvider,
29
34
  connection: state,
35
+ ...(mtlsIdentity !== undefined ? { mtlsIdentity } : {}),
30
36
  });
31
37
  }
32
38
 
@@ -110,6 +116,32 @@ describe('capReducer — LS', () => {
110
116
  }
111
117
  });
112
118
 
119
+ it('advertises sasl=PLAIN when no mTLS provider is configured', () => {
120
+ const state = makeState();
121
+ const ctx = makeCtx(state);
122
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
123
+ const send = out.effects[0];
124
+ expect(send).toBeDefined();
125
+ if (send?.tag === 'Send') {
126
+ const body = send.lines[0]?.text ?? '';
127
+ expect(body).toContain('sasl=PLAIN');
128
+ expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
129
+ }
130
+ });
131
+
132
+ it('advertises sasl=PLAIN,EXTERNAL when an mTLS provider is configured', () => {
133
+ const state = makeState();
134
+ const provider: MtlsIdentityProvider = { getIdentity: () => 'CN=alice' };
135
+ const ctx = makeCtx(state, serverConfig, provider);
136
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
137
+ const send = out.effects[0];
138
+ expect(send).toBeDefined();
139
+ if (send?.tag === 'Send') {
140
+ const body = send.lines[0]?.text ?? '';
141
+ expect(body).toContain('sasl=PLAIN,EXTERNAL');
142
+ }
143
+ });
144
+
113
145
  it('marks the connection as in CAP negotiation so the welcome is deferred', () => {
114
146
  const state = makeState();
115
147
  const ctx = makeCtx(state);