serverless-ircd 0.3.0 → 0.5.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 (223) 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 +349 -18
  8. package/README.md +132 -30
  9. package/apps/aws-stack/README.md +6 -5
  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 +150 -10
  13. package/apps/aws-stack/tests/stack.test.ts +145 -4
  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 +149 -24
  38. package/apps/local-cli/tests/e2e.test.ts +1 -1
  39. package/apps/local-cli/tests/ws-subprotocol.test.ts +257 -0
  40. package/package.json +14 -10
  41. package/packages/aws-adapter/package.json +3 -3
  42. package/packages/aws-adapter/src/account-store.ts +1 -1
  43. package/packages/aws-adapter/src/admission.ts +74 -0
  44. package/packages/aws-adapter/src/aws-runtime.ts +75 -4
  45. package/packages/aws-adapter/src/config-loader.ts +32 -0
  46. package/packages/aws-adapter/src/dynamo-account-store.ts +35 -96
  47. package/packages/aws-adapter/src/handlers/connect.ts +96 -9
  48. package/packages/aws-adapter/src/handlers/default.ts +98 -7
  49. package/packages/aws-adapter/src/handlers/index.ts +109 -4
  50. package/packages/aws-adapter/src/handlers/nlb-stream.ts +490 -0
  51. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  52. package/packages/aws-adapter/src/index.ts +8 -0
  53. package/packages/aws-adapter/src/serialize.ts +11 -1
  54. package/packages/aws-adapter/src/stats.ts +80 -0
  55. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +45 -34
  56. package/packages/aws-adapter/tests/account-store.test.ts +19 -20
  57. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  58. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  59. package/packages/aws-adapter/tests/aws-integration.test.ts +1 -1
  60. package/packages/aws-adapter/tests/aws-runtime.test.ts +61 -0
  61. package/packages/aws-adapter/tests/config-loader.test.ts +20 -0
  62. package/packages/aws-adapter/tests/connect.test.ts +174 -0
  63. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +47 -40
  64. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  65. package/packages/aws-adapter/tests/handlers.test.ts +302 -53
  66. package/packages/aws-adapter/tests/nlb-stream.test.ts +480 -0
  67. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  68. package/packages/aws-adapter/tests/stats.test.ts +317 -0
  69. package/packages/aws-adapter/tests/sweeper.test.ts +25 -18
  70. package/packages/aws-adapter/tests/transactions.test.ts +25 -20
  71. package/packages/cf-adapter/package.json +5 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +68 -5
  73. package/packages/cf-adapter/src/channel-do.ts +2 -2
  74. package/packages/cf-adapter/src/config-loader.ts +33 -0
  75. package/packages/cf-adapter/src/connection-do.ts +278 -85
  76. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  77. package/packages/cf-adapter/src/env.ts +54 -11
  78. package/packages/cf-adapter/src/index.ts +11 -8
  79. package/packages/cf-adapter/src/registry-do.ts +22 -3
  80. package/packages/cf-adapter/src/sharding.ts +1 -2
  81. package/packages/cf-adapter/src/stats.ts +65 -0
  82. package/packages/cf-adapter/tests/cf-harness.ts +12 -2
  83. package/packages/cf-adapter/tests/cf-integration.test.ts +6 -5
  84. package/packages/cf-adapter/tests/cf-runtime.test.ts +38 -2
  85. package/packages/cf-adapter/tests/channel-do.test.ts +2 -2
  86. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  87. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  88. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  89. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  90. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +289 -0
  91. package/packages/cf-adapter/tests/connection-do-ws-subprotocol.test.ts +184 -0
  92. package/packages/cf-adapter/tests/connection-do.test.ts +27 -2
  93. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  94. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  95. package/packages/cf-adapter/tests/registry-do.test.ts +4 -4
  96. package/packages/cf-adapter/tests/sharding.test.ts +1 -1
  97. package/packages/cf-adapter/tests/stats.test.ts +120 -0
  98. package/packages/cf-adapter/tests/worker/main.ts +15 -8
  99. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +2 -2
  100. package/packages/cf-adapter/tests/worker/stubs/registry-stub.ts +8 -2
  101. package/packages/cf-adapter/wrangler.test.toml +15 -0
  102. package/packages/in-memory-runtime/package.json +1 -1
  103. package/packages/in-memory-runtime/src/in-memory-runtime.ts +39 -0
  104. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +259 -0
  105. package/packages/irc-core/package.json +6 -1
  106. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  107. package/packages/irc-core/src/admission.ts +16 -15
  108. package/packages/irc-core/src/caps/capabilities.ts +24 -3
  109. package/packages/irc-core/src/cloak.ts +1 -1
  110. package/packages/irc-core/src/commands/cap.ts +8 -1
  111. package/packages/irc-core/src/commands/index.ts +17 -0
  112. package/packages/irc-core/src/commands/invite.ts +2 -4
  113. package/packages/irc-core/src/commands/ison.ts +61 -0
  114. package/packages/irc-core/src/commands/isupport.ts +6 -2
  115. package/packages/irc-core/src/commands/kick.ts +2 -4
  116. package/packages/irc-core/src/commands/kill.ts +127 -0
  117. package/packages/irc-core/src/commands/list.ts +1 -1
  118. package/packages/irc-core/src/commands/lusers.ts +204 -0
  119. package/packages/irc-core/src/commands/mode.ts +4 -8
  120. package/packages/irc-core/src/commands/names.ts +3 -5
  121. package/packages/irc-core/src/commands/part.ts +2 -4
  122. package/packages/irc-core/src/commands/quit.ts +12 -0
  123. package/packages/irc-core/src/commands/registration.ts +18 -12
  124. package/packages/irc-core/src/commands/rehash.ts +119 -0
  125. package/packages/irc-core/src/commands/sasl.ts +72 -9
  126. package/packages/irc-core/src/commands/server-info.ts +129 -0
  127. package/packages/irc-core/src/commands/setname.ts +109 -0
  128. package/packages/irc-core/src/commands/stats.ts +152 -0
  129. package/packages/irc-core/src/commands/topic.ts +2 -4
  130. package/packages/irc-core/src/commands/trace.ts +137 -0
  131. package/packages/irc-core/src/commands/userhost.ts +84 -0
  132. package/packages/irc-core/src/commands/wallops.ts +118 -0
  133. package/packages/irc-core/src/commands/whowas.ts +113 -0
  134. package/packages/irc-core/src/config.ts +65 -0
  135. package/packages/irc-core/src/credential-hashing.ts +124 -0
  136. package/packages/irc-core/src/effects.ts +33 -30
  137. package/packages/irc-core/src/index.ts +3 -0
  138. package/packages/irc-core/src/ports.ts +360 -12
  139. package/packages/irc-core/src/protocol/numerics.ts +48 -11
  140. package/packages/irc-core/src/protocol/outbound.ts +20 -3
  141. package/packages/irc-core/src/types.ts +46 -2
  142. package/packages/irc-core/src/ws-framing.ts +132 -0
  143. package/packages/irc-core/src/ws-subprotocol.ts +66 -0
  144. package/packages/irc-core/tests/account-store.test.ts +45 -2
  145. package/packages/irc-core/tests/admission.test.ts +18 -0
  146. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  147. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  148. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  149. package/packages/irc-core/tests/commands/kill.test.ts +243 -0
  150. package/packages/irc-core/tests/commands/lusers.test.ts +368 -0
  151. package/packages/irc-core/tests/commands/mode.test.ts +57 -0
  152. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  153. package/packages/irc-core/tests/commands/registration.test.ts +151 -6
  154. package/packages/irc-core/tests/commands/rehash.test.ts +171 -0
  155. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  156. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  157. package/packages/irc-core/tests/commands/setname.test.ts +225 -0
  158. package/packages/irc-core/tests/commands/stats.test.ts +294 -0
  159. package/packages/irc-core/tests/commands/tagmsg.test.ts +9 -35
  160. package/packages/irc-core/tests/commands/trace.test.ts +282 -0
  161. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  162. package/packages/irc-core/tests/commands/wallops.test.ts +231 -0
  163. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  164. package/packages/irc-core/tests/config.test.ts +95 -1
  165. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  166. package/packages/irc-core/tests/dropped-s2s-and-obsolete-verbs.test.ts +90 -0
  167. package/packages/irc-core/tests/effects.test.ts +14 -27
  168. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  169. package/packages/irc-core/tests/numerics.test.ts +102 -0
  170. package/packages/irc-core/tests/outbound.test.ts +51 -0
  171. package/packages/irc-core/tests/ports.test.ts +22 -0
  172. package/packages/irc-core/tests/raw-modules.d.ts +11 -0
  173. package/packages/irc-core/tests/stats-store.test.ts +222 -0
  174. package/packages/irc-core/tests/types.test.ts +35 -1
  175. package/packages/irc-core/tests/ws-framing.test.ts +213 -0
  176. package/packages/irc-core/tests/ws-subprotocol.test.ts +111 -0
  177. package/packages/irc-core/tsconfig.build.json +1 -1
  178. package/packages/irc-core/tsconfig.test.json +1 -1
  179. package/packages/irc-server/package.json +1 -1
  180. package/packages/irc-server/src/actor.ts +393 -16
  181. package/packages/irc-server/src/dispatch.ts +1 -3
  182. package/packages/irc-server/src/index.ts +10 -2
  183. package/packages/irc-server/src/routing.ts +15 -0
  184. package/packages/irc-server/src/runtime.ts +31 -0
  185. package/packages/irc-server/src/transport.ts +104 -0
  186. package/packages/irc-server/tests/actor.test.ts +1489 -4
  187. package/packages/irc-server/tests/dispatch.test.ts +37 -17
  188. package/packages/irc-server/tests/raw-modules.d.ts +11 -0
  189. package/packages/irc-server/tests/routing.test.ts +5 -0
  190. package/packages/irc-server/tests/runtime.test.ts +7 -0
  191. package/packages/irc-server/tests/transport.test.ts +230 -0
  192. package/packages/irc-test-support/package.json +1 -1
  193. package/packages/irc-test-support/src/harness.ts +44 -9
  194. package/packages/irc-test-support/src/in-memory-harness.ts +73 -9
  195. package/packages/irc-test-support/src/index.ts +3 -0
  196. package/packages/irc-test-support/src/scenarios.ts +141 -3
  197. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  198. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  199. package/pnpm-workspace.yaml +10 -1
  200. package/tools/ci-hardening/package.json +1 -1
  201. package/tools/package.json +9 -0
  202. package/tools/seed-aws-accounts.ts +5 -6
  203. package/tools/seed-cf-accounts.ts +107 -0
  204. package/tools/tcp-ws-forwarder/package.json +1 -1
  205. package/tools/tcp-ws-forwarder/src/forwarder.ts +57 -9
  206. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +34 -1
  207. package/tools/tcp-ws-forwarder/tests/framing.test.ts +65 -1
  208. package/docs/ADR-001-pure-reducers-and-effect-system.md +0 -74
  209. package/docs/ADR-002-location-of-authority.md +0 -82
  210. package/docs/ADR-003-durable-object-sharding.md +0 -93
  211. package/docs/ADR-004-dynamodb-schema.md +0 -96
  212. package/docs/ADR-005-wss-only-transport-v1.md +0 -83
  213. package/docs/ADR-006-sasl-mechanism-scope.md +0 -86
  214. package/docs/ADR-007-deterministic-ports.md +0 -82
  215. package/docs/ADR-008-monorepo-tooling.md +0 -60
  216. package/docs/AWS-Adapter-Architecture.md +0 -496
  217. package/docs/AWS-Deployment.md +0 -1186
  218. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  219. package/docs/Home.md +0 -11
  220. package/docs/Observability.md +0 -87
  221. package/docs/PlanIRCv3Websocket.md +0 -489
  222. package/docs/PlanWebClient.md +0 -451
  223. 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` —
@@ -100,6 +89,26 @@ export interface SendToNickEffect {
100
89
  notFoundLines?: RawLine[];
101
90
  }
102
91
 
92
+ /**
93
+ * Global cross-connection fanout for `WALLOPS`. Unlike
94
+ * {@link BroadcastEffect} (channel-scoped via the roster), this reaches
95
+ * every live connection whose `+w` user mode is set, regardless of shared
96
+ * channel membership. `except` skips a single connection — by convention
97
+ * the originator, so an oper never receives their own wallops back
98
+ * (matching NOTICE/PRIVMSG channel-broadcast semantics).
99
+ *
100
+ * The reducer does not know which connections carry `+w`; the bound
101
+ * runtime's `broadcastWallops` method owns that enumeration. On the
102
+ * in-memory runtime that is a scan of its connection map; on CF it walks
103
+ * the registry shards; on AWS it scans the `Connections` table. Each
104
+ * adapter applies its own cost cap (documented at the method).
105
+ */
106
+ export interface BroadcastWallopsEffect {
107
+ tag: 'BroadcastWallops';
108
+ lines: RawLine[];
109
+ except?: ConnId;
110
+ }
111
+
103
112
  /** Discriminated union of every effect the dispatch interpreter knows. */
104
113
  export type Effect =
105
114
  | SendEffect
@@ -109,10 +118,8 @@ export type Effect =
109
118
  | ChangeNickEffect
110
119
  | ReleaseNickEffect
111
120
  | ApplyChannelDeltaEffect
112
- | LookupNickEffect
113
- | GetConnectionInfoEffect
114
- | GetChannelSnapshotEffect
115
- | SendToNickEffect;
121
+ | SendToNickEffect
122
+ | BroadcastWallopsEffect;
116
123
 
117
124
  /** Literal string tag of every {@link Effect} variant. */
118
125
  export type EffectTag = Effect['tag'];
@@ -167,18 +174,14 @@ export const Effect = {
167
174
  applyChannelDelta(chan: ChanName, delta: ChannelDelta): Effect {
168
175
  return { tag: 'ApplyChannelDelta', chan, delta };
169
176
  },
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
177
  sendToNick(nick: Nick, sender: ConnId, lines: RawLine[], notFoundLines?: RawLine[]): Effect {
180
178
  const e: SendToNickEffect = { tag: 'SendToNick', nick, sender, lines };
181
179
  if (notFoundLines !== undefined) e.notFoundLines = notFoundLines;
182
180
  return e;
183
181
  },
182
+ broadcastWallops(lines: RawLine[], except?: ConnId): Effect {
183
+ const e: BroadcastWallopsEffect = { tag: 'BroadcastWallops', lines };
184
+ if (except !== undefined) e.except = except;
185
+ return e;
186
+ },
184
187
  } as const;
@@ -10,9 +10,12 @@ 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';
17
+ export * from './ws-subprotocol.js';
18
+ export * from './ws-framing.js';
16
19
  export * from './admission.js';
17
20
  export * from './config.js';
18
21
  export type {
@@ -7,6 +7,8 @@
7
7
  */
8
8
 
9
9
  import { caseFold } from './case-fold.js';
10
+ import type { ChannelState } from './state/channel.js';
11
+ import type { ConnId, ConnectionState } from './state/connection.js';
10
12
 
11
13
  /** Read-only wall clock, epoch milliseconds. */
12
14
  export interface Clock {
@@ -57,7 +59,7 @@ export interface IdFactory {
57
59
 
58
60
  /**
59
61
  * Production id factory. Generates UUIDv4-shaped strings without relying
60
- * on `crypto` so the same code runs on Node 20, Cloudflare Workers, and
62
+ * on `crypto` so the same code runs on Node 24, Cloudflare Workers, and
61
63
  * Lambda without per-platform type wrangling. Uniqueness is statistical;
62
64
  * for cryptographic IDs, adapters can supply their own {@link IdFactory}.
63
65
  */
@@ -171,7 +173,8 @@ export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
171
173
  */
172
174
  export type SaslPayload =
173
175
  | { kind: 'PLAIN'; username: string; password: string }
174
- | { kind: 'RAW'; data: string };
176
+ | { kind: 'RAW'; data: string }
177
+ | { kind: 'EXTERNAL'; identity: string };
175
178
 
176
179
  /**
177
180
  * Outcome of an {@link AccountStore.verify} call: either the canonical
@@ -202,6 +205,12 @@ export interface AccountStore {
202
205
  export interface SaslAccountCredential {
203
206
  username: string;
204
207
  password: string;
208
+ /**
209
+ * Optional mTLS client-certificate subject (e.g. `CN=alice` or a
210
+ * fingerprint hash). When set, SASL `EXTERNAL` authentications that
211
+ * present this subject are mapped to {@link username} as the account.
212
+ */
213
+ certSubject?: string;
205
214
  }
206
215
 
207
216
  /**
@@ -221,23 +230,37 @@ export interface SaslAccountCredential {
221
230
  */
222
231
  export class InMemoryAccountStore implements AccountStore {
223
232
  private readonly creds: ReadonlyMap<string, string>;
233
+ private readonly certMap: ReadonlyMap<string, string>;
224
234
 
225
235
  constructor(credentials: ReadonlyArray<SaslAccountCredential>) {
226
236
  this.creds = new Map(credentials.map((c) => [c.username, c.password]));
237
+ this.certMap = new Map(
238
+ credentials
239
+ .filter((c) => c.certSubject !== undefined)
240
+ .map((c) => [c.certSubject as string, c.username]),
241
+ );
227
242
  }
228
243
 
229
244
  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' };
245
+ const m = mech.toUpperCase();
246
+ if (m === 'PLAIN' && payload.kind === 'PLAIN') {
247
+ const expected = this.creds.get(payload.username);
248
+ if (expected === undefined) {
249
+ return { ok: false, reason: 'invalid credentials' };
250
+ }
251
+ if (!constantTimeEquals(payload.password, expected)) {
252
+ return { ok: false, reason: 'invalid credentials' };
253
+ }
254
+ return { ok: true, account: payload.username };
236
255
  }
237
- if (!constantTimeEquals(payload.password, expected)) {
238
- return { ok: false, reason: 'invalid credentials' };
256
+ if (m === 'EXTERNAL' && payload.kind === 'EXTERNAL') {
257
+ const account = this.certMap.get(payload.identity);
258
+ if (account === undefined) {
259
+ return { ok: false, reason: 'untrusted certificate' };
260
+ }
261
+ return { ok: true, account };
239
262
  }
240
- return { ok: true, account: payload.username };
263
+ return { ok: false, reason: `unsupported mechanism: ${mech}` };
241
264
  }
242
265
  }
243
266
 
@@ -256,6 +279,52 @@ function constantTimeEquals(a: string, b: string): boolean {
256
279
  return diff === 0;
257
280
  }
258
281
 
282
+ // ============================================================================
283
+ // mTLS identity (SASL EXTERNAL)
284
+ // ============================================================================
285
+
286
+ /**
287
+ * Port that resolves the verified client-certificate identity for a
288
+ * connection.
289
+ *
290
+ * At the edge (CF API Shield mTLS, AWS API Gateway custom-domain mTLS) the
291
+ * platform terminates TLS, validates the client certificate against the
292
+ * configured trust store, and surfaces the certificate subject to the
293
+ * Worker/Lambda. The adapter captures this at connection-admission time
294
+ * and stores it keyed by connection ID.
295
+ *
296
+ * The method is **synchronous** — adapters pre-populate the identity at
297
+ * admission time (before the first reducer runs), so the SASL EXTERNAL
298
+ * reducer can look it up without awaiting. When no client certificate was
299
+ * presented (or mTLS is not configured), `getIdentity` returns `undefined`
300
+ * and EXTERNAL authentication fails with `904`.
301
+ */
302
+ export interface MtlsIdentityProvider {
303
+ /**
304
+ * @returns the verified client-cert subject for `connId`, or `undefined`
305
+ * when no trusted certificate was presented.
306
+ */
307
+ getIdentity(connId: string): string | undefined;
308
+ }
309
+
310
+ /**
311
+ * Reference in-memory {@link MtlsIdentityProvider} backed by a static map
312
+ * of connection IDs to certificate subjects. Useful for tests and the
313
+ * local-cli adapter; cloud adapters replace this with a lookup keyed by
314
+ * the platform's connection identifier.
315
+ */
316
+ export class InMemoryMtlsIdentityProvider implements MtlsIdentityProvider {
317
+ private readonly identities: ReadonlyMap<string, string>;
318
+
319
+ constructor(entries: ReadonlyArray<{ connId: string; identity: string }>) {
320
+ this.identities = new Map(entries.map((e) => [e.connId, e.identity]));
321
+ }
322
+
323
+ getIdentity(connId: string): string | undefined {
324
+ return this.identities.get(connId);
325
+ }
326
+ }
327
+
259
328
  // ============================================================================
260
329
  // Chat-history persistence (IRCv3 draft/chathistory)
261
330
  // ============================================================================
@@ -501,6 +570,285 @@ function tail<T>(arr: readonly T[], n: number): T[] {
501
570
  return arr.slice(start);
502
571
  }
503
572
 
573
+ // ============================================================================
574
+ // Nick history (WHOWAS)
575
+ // ============================================================================
576
+
577
+ /**
578
+ * One persisted sign-off record for a nick, written by the QUIT and
579
+ * post-registration NICK-change reducers and replayed by `WHOWAS`.
580
+ *
581
+ * `nick` preserves the original case the client used; `connId` is the
582
+ * connection that owned the nick at sign-off. `username` / `hostname` /
583
+ * `realname` mirror the {@link ConnectionState} fields at sign-off time
584
+ * (optional because a never-fully-registered connection may still quit with
585
+ * a nick). `signoffTime` is epoch milliseconds from the injected
586
+ * {@link Clock}.
587
+ */
588
+ export interface NickHistoryEntry {
589
+ nick: string;
590
+ connId: string;
591
+ username?: string;
592
+ hostname?: string;
593
+ realname?: string;
594
+ signoffTime: number;
595
+ }
596
+
597
+ /**
598
+ * Persistence port for the IRC `WHOWAS` command.
599
+ *
600
+ * Mirrors the {@link MessageStore} pattern: the port is **synchronous**
601
+ * (reducers must stay pure and free of IO) and injected via {@link Ctx}.
602
+ * Adapters that need async storage (D1, DynamoDB) pre-load the relevant
603
+ * slice into a synchronously-readable store; Phase 8 ships the in-memory
604
+ * reference implementation only.
605
+ *
606
+ * `query` always returns matches most-recent-first (the order `WHOWAS`
607
+ * emits `314 RPL_WHOWASUSER` lines in), capped at `count`.
608
+ */
609
+ export interface NickHistoryStore {
610
+ /** Records a sign-off entry for a nick (TTL eviction is impl-defined). */
611
+ record(entry: NickHistoryEntry): void;
612
+ /** Returns up to `count` most-recent entries for `nick` (case-insensitive). */
613
+ query(nick: string, count: number): NickHistoryEntry[];
614
+ }
615
+
616
+ /** Default retention window for nick-history entries: 24 hours. */
617
+ const DEFAULT_NICK_HISTORY_MAX_AGE = 24 * 60 * 60 * 1000;
618
+
619
+ /**
620
+ * Reference in-memory {@link NickHistoryStore} backed by a per-nick
621
+ * chronological array with a configurable TTL.
622
+ *
623
+ * Used by the local CLI and by tests. Each nick keeps its sign-off records
624
+ * in arrival order; entries older than `maxAge` (measured against the
625
+ * injected {@link Clock}) are dropped lazily on `record` and `query` so the
626
+ * per-nick buffers do not grow unbounded.
627
+ *
628
+ * Nicks are keyed via {@link caseFold} (`rfc1459`) so lookups are
629
+ * case-insensitive, matching the IRC nick-comparison semantics advertised
630
+ * via `005 RPL_ISUPPORT CASEMAPPING=rfc1459`.
631
+ */
632
+ export class InMemoryNickHistoryStore implements NickHistoryStore {
633
+ private readonly byNick = new Map<string, NickHistoryEntry[]>();
634
+ private readonly clock: Clock;
635
+ private readonly maxAge: number;
636
+
637
+ constructor(clock: Clock, opts?: { maxAge?: number }) {
638
+ this.clock = clock;
639
+ this.maxAge = opts?.maxAge ?? DEFAULT_NICK_HISTORY_MAX_AGE;
640
+ }
641
+
642
+ record(entry: NickHistoryEntry): void {
643
+ const key = caseFold('rfc1459', entry.nick);
644
+ let arr = this.byNick.get(key);
645
+ if (arr === undefined) {
646
+ arr = [];
647
+ this.byNick.set(key, arr);
648
+ }
649
+ this.purgeExpired(arr);
650
+ arr.push(entry);
651
+ }
652
+
653
+ query(nick: string, count: number): NickHistoryEntry[] {
654
+ if (count <= 0) return [];
655
+ const arr = this.byNick.get(caseFold('rfc1459', nick));
656
+ if (arr === undefined) return [];
657
+ this.purgeExpired(arr);
658
+ if (arr.length === 0) return [];
659
+ return arr.slice(Math.max(0, arr.length - count)).reverse();
660
+ }
661
+
662
+ /**
663
+ * Drops entries whose `signoffTime` is older than `now - maxAge`, in place.
664
+ * Entries arrive in chronological order (append-only), so the first alive
665
+ * entry marks the boundary; everything before it is expired.
666
+ */
667
+ private purgeExpired(arr: NickHistoryEntry[]): void {
668
+ const cutoff = this.clock.now() - this.maxAge;
669
+ let firstAlive = arr.findIndex((e) => e.signoffTime >= cutoff);
670
+ if (firstAlive === -1) firstAlive = arr.length;
671
+ arr.splice(0, firstAlive);
672
+ }
673
+ }
674
+
675
+ // ============================================================================
676
+ // Network statistics (LUSERS / STATS)
677
+ // ============================================================================
678
+
679
+ /**
680
+ * Snapshot of network-wide counts returned by a {@link ServerStats} backend.
681
+ *
682
+ * The shape mirrors the fields the `LUSERS` and `STATS` reducers need to
683
+ * format their numerics:
684
+ * - `users` / `invisible`: the split registered-user counts that drive
685
+ * `251 RPL_LUSERCLIENT` ("There are X users and Y invisible on Z
686
+ * servers"). `users` is the *visible* (non-`+i`) registered count;
687
+ * `invisible` is the `+i` registered count. Total registered users
688
+ * is `users + invisible`.
689
+ * - `opers`: count of IRC operators; emitted as `252 RPL_LUSEROP` when
690
+ * non-zero. An oper counts here regardless of `+i` (an `+o` oper who
691
+ * is also `+i` still bumps `opers`, `invisible`, and `users` is
692
+ * computed as visible = total-registered − invisible).
693
+ * - `unknownConnections`: pre-registration connection count; emitted as
694
+ * `253 RPL_LUSERUNKNOWN` when non-zero.
695
+ * - `channels`: live channel count; emitted as `254 RPL_LUSERCHANNELS`
696
+ * when non-zero.
697
+ * - `servers`: server count seen by `251`/`255`. Always 1 for the
698
+ * single-server serverless deployment; S2S aggregation is a PLAN
699
+ * non-goal.
700
+ * - `localConns` / `globalConns`: total connection counts (registered
701
+ * + pre-registration) for the optional `265 RPL_LOCALUSERS` /
702
+ * `266 RPL_GLOBALUSERS` numerics. Equal for a single-server network.
703
+ * - `maxLocalConns` / `maxGlobalConns`: high-water marks accompanying
704
+ * `265`/`266`. When the backend has no recorded max it reports the
705
+ * current count (so the wire numeric never claims a max below live).
706
+ * - `uptimeStartedAt`: epoch-ms the server (or stats backend) started
707
+ * counting from. Drives the `STATS u` uptime line.
708
+ */
709
+ export interface ServerStatsSnapshot {
710
+ users: number;
711
+ invisible: number;
712
+ opers: number;
713
+ unknownConnections: number;
714
+ channels: number;
715
+ servers: number;
716
+ localConns: number;
717
+ globalConns: number;
718
+ maxLocalConns: number;
719
+ maxGlobalConns: number;
720
+ uptimeStartedAt: number;
721
+ }
722
+
723
+ /**
724
+ * Port that aggregates network-wide statistics for `LUSERS` and `STATS`.
725
+ *
726
+ * Unlike the synchronous {@link MotdProvider} / {@link AccountStore} ports
727
+ * (which reducers consume directly via {@link Ctx}), this port is
728
+ * **asynchronous** (`Promise`-returning) because the aggregation crosses
729
+ * every connection and channel in the deployment — it cannot be pre-loaded
730
+ * into a synchronous view the way MOTD or a credential table can. The
731
+ * actor layer awaits {@link getStats} once per `LUSERS` / `STATS`
732
+ * invocation, then passes the resulting {@link ServerStatsSnapshot} to the
733
+ * pure reducers (`lusersReducer`, `statsReducer`). This mirrors the
734
+ * `listChannels` → `listReducer` pattern.
735
+ *
736
+ * Each adapter implements this against its authoritative store:
737
+ * - in-memory runtime: derive from its `Map`s (reference impl
738
+ * {@link InMemoryStats}).
739
+ * - CF: aggregate via `listChannels` / connection-enumeration RPCs.
740
+ * - AWS: `Scan` / `Count` queries against `Connections` / `ChannelMeta`.
741
+ */
742
+ export interface ServerStats {
743
+ getStats(): Promise<ServerStatsSnapshot>;
744
+ }
745
+
746
+ /**
747
+ * Aggregates a snapshot from already-collected connection state and a
748
+ * channel count.
749
+ *
750
+ * Pure: backends that gather their data through async I/O (CF RPC fan-out,
751
+ * AWS DynamoDB `Scan`) call this once they have the raw inputs. The
752
+ * {@link InMemoryStats} reference backend delegates here so the
753
+ * classification logic (oper / invisible / unknown) lives in exactly one
754
+ * place and is unit-tested independently of any adapter's I/O.
755
+ *
756
+ * Counting rules (RFC 2812 §4.6.2 + modern practice):
757
+ * - `users` is the **visible** registered count (total registered minus
758
+ * `+i`); the first number in "There are X users and Y invisible".
759
+ * - `invisible` is the `+i` registered count; the second number.
760
+ * - An oper who is also `+i` increments `opers`, `invisible`, and
761
+ * reduces `users` (because they are invisible) — matching charybdis.
762
+ * - `localConns` is every tracked connection (registered + unknown).
763
+ */
764
+ export function computeStatsSnapshot(
765
+ connections: Iterable<ConnectionState>,
766
+ channelCount: number,
767
+ uptimeStartedAt: number,
768
+ opts?: { maxLocalConns?: number; maxGlobalConns?: number; servers?: number },
769
+ ): ServerStatsSnapshot {
770
+ let registered = 0;
771
+ let invisible = 0;
772
+ let opers = 0;
773
+ let unknown = 0;
774
+ let total = 0;
775
+ for (const conn of connections) {
776
+ total++;
777
+ if (conn.registration !== 'registered') {
778
+ unknown++;
779
+ continue;
780
+ }
781
+ registered++;
782
+ if (conn.userModes.invisible) invisible++;
783
+ if (conn.userModes.oper) opers++;
784
+ }
785
+ return {
786
+ users: registered - invisible,
787
+ invisible,
788
+ opers,
789
+ unknownConnections: unknown,
790
+ channels: channelCount,
791
+ servers: opts?.servers ?? 1,
792
+ localConns: total,
793
+ globalConns: total,
794
+ // The wire numeric must never claim a max below the current live count;
795
+ // when the backend's recorded max is stale (or never seeded) the live
796
+ // count is the lower bound.
797
+ maxLocalConns: Math.max(opts?.maxLocalConns ?? 0, total),
798
+ maxGlobalConns: Math.max(opts?.maxGlobalConns ?? 0, total),
799
+ uptimeStartedAt,
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Reference in-memory {@link ServerStats} backed by the live `Map`s the
805
+ * {@link InMemoryRuntime} (and tests) already maintain.
806
+ *
807
+ * The constructor takes *references* to the runtime's mutable maps; later
808
+ * mutations are visible on the next {@link getStats} call without
809
+ * re-binding. Used by the local CLI, by integration tests, and as the
810
+ * contract spec the CF / AWS backends mirror.
811
+ *
812
+ * `uptimeStartedAt` is supplied at construction (typically the runtime's
813
+ * boot time). `maxLocalConns` / `maxGlobalConns` are optional high-water
814
+ * marks; when omitted the live count is reported as the max so `265`/`266`
815
+ * never regress below the current connection total.
816
+ */
817
+ export class InMemoryStats implements ServerStats {
818
+ private readonly connections: ReadonlyMap<ConnId, ConnectionState>;
819
+ private readonly channels: ReadonlyMap<string, ChannelState>;
820
+ private readonly uptimeStartedAt: number;
821
+ private readonly maxLocalConns: number | undefined;
822
+ private readonly maxGlobalConns: number | undefined;
823
+
824
+ constructor(opts: {
825
+ connections: ReadonlyMap<ConnId, ConnectionState>;
826
+ channels: ReadonlyMap<string, ChannelState>;
827
+ uptimeStartedAt: number;
828
+ clock?: Clock;
829
+ maxLocalConns?: number;
830
+ maxGlobalConns?: number;
831
+ }) {
832
+ this.connections = opts.connections;
833
+ this.channels = opts.channels;
834
+ this.uptimeStartedAt = opts.uptimeStartedAt;
835
+ this.maxLocalConns = opts.maxLocalConns;
836
+ this.maxGlobalConns = opts.maxGlobalConns;
837
+ }
838
+
839
+ async getStats(): Promise<ServerStatsSnapshot> {
840
+ return computeStatsSnapshot(
841
+ this.connections.values(),
842
+ this.channels.size,
843
+ this.uptimeStartedAt,
844
+ {
845
+ ...(this.maxLocalConns !== undefined ? { maxLocalConns: this.maxLocalConns } : {}),
846
+ ...(this.maxGlobalConns !== undefined ? { maxGlobalConns: this.maxGlobalConns } : {}),
847
+ },
848
+ );
849
+ }
850
+ }
851
+
504
852
  // ============================================================================
505
853
  // Observability — structured logger
506
854
  // ============================================================================
@@ -854,7 +1202,7 @@ export class ConsoleLogger implements Logger {
854
1202
 
855
1203
  /** Returns the global `console` as a {@link ConsoleLoggerSinks}. */
856
1204
  function globalThisConsoleSinks(): ConsoleLoggerSinks {
857
- // `globalThis.console` is universal across Node 20, Workers, and Lambda.
1205
+ // `globalThis.console` is universal across Node 24, Workers, and Lambda.
858
1206
  // Reference it lazily so tests that stub the logger do not need to
859
1207
  // replace the global.
860
1208
  const c = (globalThis as { console?: GlobalConsole }).console;
@@ -13,9 +13,39 @@ 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
+ // LUSERS (RFC 2812 §4.6.2). Network-wide user/channel/server counts.
20
+ // 251 is the human-readable summary; 252–254 are emitted only when their
21
+ // count is non-zero (per RFC). 255 is the local-server summary. 265/266
22
+ // carry the optional local/global max-connection context.
23
+ RPL_LUSERCLIENT: 251,
24
+ RPL_LUSEROP: 252,
25
+ RPL_LUSERUNKNOWN: 253,
26
+ RPL_LUSERCHANNELS: 254,
27
+ RPL_LUSERME: 255,
28
+ RPL_LOCALUSERS: 265,
29
+ RPL_GLOBALUSERS: 266,
30
+
31
+ // STATS (RFC 2812 §4.6.3 / §5.1). 211 is link-info (the `l` query),
32
+ // 242 is the uptime line (the `u` query), and 219 terminates every
33
+ // STATS reply regardless of query letter. The remaining RPL_STATS*
34
+ // codes (212–218, 240–248) are deferred behind per-letter tests.
35
+ RPL_STATSLINKINFO: 211,
36
+ RPL_ENDOFSTATS: 219,
37
+ RPL_STATSUPTIME: 242,
38
+
39
+ // TRACE (RFC 2812 §4.6.3). In a single-server deployment TRACE collapses
40
+ // to a local-server line (200/206) plus optional per-connection detail
41
+ // (204 oper / 205 user), terminated by 262. Multi-hop S2S tracing is a
42
+ // PLAN non-goal.
43
+ RPL_TRACELINK: 200,
44
+ RPL_TRACEOPERATOR: 204,
45
+ RPL_TRACEUSER: 205,
46
+ RPL_TRACESERVER: 206,
47
+ RPL_ENDOFTRACE: 262,
48
+
19
49
  // Away / userhost / ison
20
50
  RPL_AWAY: 301,
21
51
  RPL_USERHOST: 302,
@@ -23,7 +53,10 @@ export const Numerics = {
23
53
  RPL_UNAWAY: 305,
24
54
  RPL_NOWAWAY: 306,
25
55
 
26
- // WHOIS
56
+ // Version
57
+ RPL_VERSION: 351,
58
+
59
+ // WHOIS / WHOWAS
27
60
  RPL_WHOISUSER: 311,
28
61
  RPL_WHOISSERVER: 312,
29
62
  RPL_WHOISOPERATOR: 313,
@@ -31,6 +64,7 @@ export const Numerics = {
31
64
  RPL_ENDOFWHOIS: 318,
32
65
  RPL_WHOISCHANNELS: 319,
33
66
  RPL_WHOISACCOUNT: 330,
67
+ RPL_WHOWASUSER: 314,
34
68
 
35
69
  // LIST
36
70
  RPL_LISTSTART: 321,
@@ -57,9 +91,8 @@ export const Numerics = {
57
91
  RPL_NAMREPLY: 353,
58
92
  RPL_ENDOFNAMES: 366,
59
93
 
60
- // Links / ban / whowas
61
- RPL_LINKS: 364,
62
- RPL_ENDOFLINKS: 365,
94
+ // Ban / whowas. (The S2S `LINKS` verb and its `RPL_LINKS`/`RPL_ENDOFLINKS`
95
+ // numerics were dropped — S2S is a PLAN non-goal.)
63
96
  RPL_BANLIST: 367,
64
97
  RPL_ENDOFBANLIST: 368,
65
98
  RPL_ENDOFWHOWAS: 369,
@@ -71,10 +104,12 @@ export const Numerics = {
71
104
  RPL_MOTDSTART: 375,
72
105
  RPL_ENDOFMOTD: 376,
73
106
 
74
- // Oper
107
+ // Oper. RPL_REHASHING is emitted by the oper-gated `REHASH` reducer when a
108
+ // config reload is requested (the actor re-invokes the adapter's loader).
109
+ // (The RFC 2812 `SERVICE` verb and its `RPL_YOURESERVICE` numeric were
110
+ // dropped — obsolete, never widely implemented.)
75
111
  RPL_YOUREOPER: 381,
76
112
  RPL_REHASHING: 382,
77
- RPL_YOURESERVICE: 383,
78
113
  RPL_TIME: 391,
79
114
 
80
115
  // SASL (IRCv3 sasl-3.2)
@@ -96,7 +131,8 @@ export const Numerics = {
96
131
  ERR_TOOMANYCHANNELS: 405,
97
132
  ERR_WASNOSUCHNICK: 406,
98
133
  ERR_TOOMANYTARGETS: 407,
99
- ERR_NOSUCHSERVICE: 408,
134
+ // `ERR_NOSUCHSERVICE` (408) was dropped with the obsolete RFC 2812 `SERVICE`
135
+ // verb.
100
136
  ERR_INVALIDCAPCMD: 410,
101
137
  ERR_NORECIPIENT: 411,
102
138
  ERR_NOTEXTTOSEND: 412,
@@ -115,8 +151,8 @@ export const Numerics = {
115
151
  ERR_NOTONCHANNEL: 442,
116
152
  ERR_USERONCHANNEL: 443,
117
153
  ERR_NOLOGIN: 444,
118
- ERR_SUMMONDISABLED: 445,
119
- ERR_USERSDISABLED: 446,
154
+ // `ERR_SUMMONDISABLED` (445) / `ERR_USERSDISABLED` (446) were dropped with
155
+ // the obsolete RFC 2812 `SUMMON`/`USERS` verbs.
120
156
  ERR_NOTREGISTERED: 451,
121
157
  ERR_NEEDMOREPARAMS: 461,
122
158
  ERR_ALREADYREGISTRED: 462,
@@ -134,7 +170,8 @@ export const Numerics = {
134
170
  ERR_BANLISTFULL: 478,
135
171
  ERR_NOPRIVILEGES: 481,
136
172
  ERR_CHANOPRIVSNEEDED: 482,
137
- ERR_CANTKILLSERVER: 483,
173
+ // `ERR_CANTKILLSERVER` (483) was dropped with the S2S `SQUIT` verb — S2S is
174
+ // a PLAN non-goal.
138
175
  ERR_NOOPERHOST: 491,
139
176
  ERR_UMODEUNKNOWNFLAG: 501,
140
177
  ERR_USERSDONTMATCH: 502,