serverless-ircd 0.2.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 (221) 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 +258 -18
  8. package/README.md +72 -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/scripts/smoke.mjs +0 -0
  34. package/apps/cf-worker/src/worker.ts +8 -2
  35. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  36. package/apps/cf-worker/wrangler.test.toml +11 -1
  37. package/apps/cf-worker/wrangler.toml +69 -19
  38. package/apps/local-cli/package.json +1 -1
  39. package/apps/local-cli/src/config-loader.ts +11 -0
  40. package/apps/local-cli/src/main.ts +1 -1
  41. package/apps/local-cli/src/server.ts +46 -3
  42. package/apps/local-cli/tests/e2e.test.ts +52 -0
  43. package/package.json +19 -16
  44. package/packages/aws-adapter/package.json +3 -3
  45. package/packages/aws-adapter/src/account-store.ts +121 -0
  46. package/packages/aws-adapter/src/admission.ts +74 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +124 -34
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +5 -1
  49. package/packages/aws-adapter/src/config-loader.ts +62 -0
  50. package/packages/aws-adapter/src/dynamo-account-store.ts +95 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +64 -8
  52. package/packages/aws-adapter/src/handlers/default.ts +43 -2
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +27 -8
  54. package/packages/aws-adapter/src/handlers/index.ts +120 -9
  55. package/packages/aws-adapter/src/handlers/nlb-stream.ts +481 -0
  56. package/packages/aws-adapter/src/handlers/ping-checker.ts +1 -1
  57. package/packages/aws-adapter/src/index.ts +13 -0
  58. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +182 -0
  59. package/packages/aws-adapter/tests/account-store.test.ts +279 -0
  60. package/packages/aws-adapter/tests/admission.test.ts +70 -0
  61. package/packages/aws-adapter/tests/aws-harness.ts +13 -1
  62. package/packages/aws-adapter/tests/config-loader.test.ts +75 -0
  63. package/packages/aws-adapter/tests/connect.test.ts +78 -0
  64. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +343 -0
  65. package/packages/aws-adapter/tests/gone-exception.test.ts +31 -26
  66. package/packages/aws-adapter/tests/handlers.test.ts +194 -47
  67. package/packages/aws-adapter/tests/nlb-stream.test.ts +478 -0
  68. package/packages/aws-adapter/tests/ping-checker.test.ts +34 -29
  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 +1 -1
  72. package/packages/cf-adapter/src/cf-runtime.ts +40 -22
  73. package/packages/cf-adapter/src/channel-do.ts +40 -1
  74. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  75. package/packages/cf-adapter/src/config-loader.ts +62 -0
  76. package/packages/cf-adapter/src/connection-do.ts +181 -31
  77. package/packages/cf-adapter/src/d1-account-store.ts +198 -0
  78. package/packages/cf-adapter/src/env.ts +58 -8
  79. package/packages/cf-adapter/src/index.ts +11 -9
  80. package/packages/cf-adapter/tests/cf-harness.ts +11 -1
  81. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -1
  82. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  83. package/packages/cf-adapter/tests/config-loader.test.ts +22 -0
  84. package/packages/cf-adapter/tests/connection-do-channel-registration.test.ts +37 -0
  85. package/packages/cf-adapter/tests/connection-do-no-batching-reservation.test.ts +52 -0
  86. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +166 -0
  87. package/packages/cf-adapter/tests/connection-do.test.ts +40 -0
  88. package/packages/cf-adapter/tests/d1-account-store.test.ts +226 -0
  89. package/packages/cf-adapter/tests/raw-modules.d.ts +11 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +9 -1
  91. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  92. package/packages/cf-adapter/wrangler.test.toml +18 -1
  93. package/packages/in-memory-runtime/package.json +1 -1
  94. package/packages/in-memory-runtime/src/in-memory-runtime.ts +14 -28
  95. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +19 -0
  96. package/packages/irc-core/package.json +8 -2
  97. package/packages/irc-core/scripts/generate-build-info.mjs +31 -0
  98. package/packages/irc-core/src/caps/capabilities.ts +23 -2
  99. package/packages/irc-core/src/case-fold.ts +64 -0
  100. package/packages/irc-core/src/cloak.ts +1 -1
  101. package/packages/irc-core/src/commands/cap.ts +8 -1
  102. package/packages/irc-core/src/commands/index.ts +11 -0
  103. package/packages/irc-core/src/commands/invite.ts +6 -9
  104. package/packages/irc-core/src/commands/ison.ts +61 -0
  105. package/packages/irc-core/src/commands/isupport.ts +1 -1
  106. package/packages/irc-core/src/commands/join.ts +4 -3
  107. package/packages/irc-core/src/commands/kick.ts +4 -11
  108. package/packages/irc-core/src/commands/list.ts +5 -4
  109. package/packages/irc-core/src/commands/mode.ts +5 -9
  110. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  111. package/packages/irc-core/src/commands/motd.ts +6 -110
  112. package/packages/irc-core/src/commands/oper.ts +151 -0
  113. package/packages/irc-core/src/commands/quit.ts +12 -0
  114. package/packages/irc-core/src/commands/registration.ts +26 -19
  115. package/packages/irc-core/src/commands/sasl.ts +72 -9
  116. package/packages/irc-core/src/commands/server-info.ts +129 -0
  117. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  118. package/packages/irc-core/src/commands/userhost.ts +84 -0
  119. package/packages/irc-core/src/commands/whowas.ts +113 -0
  120. package/packages/irc-core/src/config.ts +84 -3
  121. package/packages/irc-core/src/credential-hashing.ts +124 -0
  122. package/packages/irc-core/src/effects.ts +6 -29
  123. package/packages/irc-core/src/index.ts +3 -0
  124. package/packages/irc-core/src/ports.ts +240 -7
  125. package/packages/irc-core/src/protocol/numerics.ts +14 -8
  126. package/packages/irc-core/src/types.ts +60 -1
  127. package/packages/irc-core/tests/account-store.test.ts +131 -0
  128. package/packages/irc-core/tests/caps/capabilities.test.ts +4 -3
  129. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  130. package/packages/irc-core/tests/commands/cap.test.ts +33 -1
  131. package/packages/irc-core/tests/commands/ison.test.ts +166 -0
  132. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  133. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  134. package/packages/irc-core/tests/commands/quit.test.ts +69 -2
  135. package/packages/irc-core/tests/commands/registration.test.ts +256 -19
  136. package/packages/irc-core/tests/commands/sasl.test.ts +118 -10
  137. package/packages/irc-core/tests/commands/server-info.test.ts +274 -0
  138. package/packages/irc-core/tests/commands/tagmsg.test.ts +662 -0
  139. package/packages/irc-core/tests/commands/userhost.test.ts +264 -0
  140. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  141. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  142. package/packages/irc-core/tests/commands/whowas.test.ts +312 -0
  143. package/packages/irc-core/tests/config.test.ts +170 -1
  144. package/packages/irc-core/tests/credential-hashing.test.ts +170 -0
  145. package/packages/irc-core/tests/effects.test.ts +0 -27
  146. package/packages/irc-core/tests/nick-history-store.test.ts +162 -0
  147. package/packages/irc-core/tests/numerics.test.ts +12 -0
  148. package/packages/irc-core/tests/types.test.ts +35 -1
  149. package/packages/irc-core/tsconfig.build.json +1 -1
  150. package/packages/irc-core/tsconfig.test.json +1 -1
  151. package/packages/irc-server/package.json +1 -1
  152. package/packages/irc-server/src/actor.ts +182 -14
  153. package/packages/irc-server/src/dispatch.ts +0 -3
  154. package/packages/irc-server/src/index.ts +10 -2
  155. package/packages/irc-server/src/routing.ts +21 -0
  156. package/packages/irc-server/src/transport.ts +101 -0
  157. package/packages/irc-server/tests/actor.test.ts +617 -1
  158. package/packages/irc-server/tests/dispatch.test.ts +0 -17
  159. package/packages/irc-server/tests/routing.test.ts +7 -0
  160. package/packages/irc-server/tests/transport.test.ts +230 -0
  161. package/packages/irc-test-support/package.json +1 -1
  162. package/packages/irc-test-support/src/harness.ts +44 -9
  163. package/packages/irc-test-support/src/in-memory-harness.ts +78 -13
  164. package/packages/irc-test-support/src/index.ts +3 -0
  165. package/packages/irc-test-support/src/scenarios.ts +132 -2
  166. package/packages/irc-test-support/tests/in-memory-harness.test.ts +2 -1
  167. package/packages/irc-test-support/tests/in-memory-scenarios.test.ts +23 -9
  168. package/pnpm-workspace.yaml +9 -0
  169. package/tools/ci-hardening/package.json +1 -1
  170. package/tools/package.json +4 -0
  171. package/tools/seed-aws-accounts.ts +79 -0
  172. package/tools/seed-cf-accounts.ts +104 -0
  173. package/tools/tcp-ws-forwarder/package.json +1 -1
  174. package/AGENTS.md +0 -5
  175. package/MOTD.txt +0 -3
  176. package/PLAN-FIXES.md +0 -358
  177. package/PLAN.md +0 -420
  178. package/dashboards/cloudwatch-irc.json +0 -139
  179. package/docs/AWS-Adapter-Architecture.md +0 -494
  180. package/docs/AWS-Deployment.md +0 -1107
  181. package/docs/Cloudflare-Deployment-Guide.md +0 -660
  182. package/docs/Home.md +0 -1
  183. package/docs/Observability.md +0 -87
  184. package/docs/PlanIRCv3Websocket.md +0 -489
  185. package/docs/PlanWebClient.md +0 -451
  186. package/docs/Release-Process.md +0 -440
  187. package/progress.md +0 -107
  188. package/tickets.md +0 -2485
  189. package/webircgateway/LICENSE +0 -201
  190. package/webircgateway/Makefile +0 -44
  191. package/webircgateway/README.md +0 -134
  192. package/webircgateway/config.conf.example +0 -135
  193. package/webircgateway/go.mod +0 -16
  194. package/webircgateway/go.sum +0 -89
  195. package/webircgateway/main.go +0 -118
  196. package/webircgateway/pkg/dnsbl/dnsbl.go +0 -121
  197. package/webircgateway/pkg/identd/identd.go +0 -86
  198. package/webircgateway/pkg/identd/rpcclient.go +0 -59
  199. package/webircgateway/pkg/irc/isupport.go +0 -56
  200. package/webircgateway/pkg/irc/message.go +0 -217
  201. package/webircgateway/pkg/irc/state.go +0 -79
  202. package/webircgateway/pkg/proxy/proxy.go +0 -129
  203. package/webircgateway/pkg/proxy/server.go +0 -237
  204. package/webircgateway/pkg/recaptcha/recaptcha.go +0 -59
  205. package/webircgateway/pkg/webircgateway/client.go +0 -741
  206. package/webircgateway/pkg/webircgateway/client_command_handlers.go +0 -495
  207. package/webircgateway/pkg/webircgateway/config.go +0 -385
  208. package/webircgateway/pkg/webircgateway/gateway.go +0 -278
  209. package/webircgateway/pkg/webircgateway/gateway_utils.go +0 -133
  210. package/webircgateway/pkg/webircgateway/hooks.go +0 -152
  211. package/webircgateway/pkg/webircgateway/letsencrypt.go +0 -41
  212. package/webircgateway/pkg/webircgateway/messagetags.go +0 -103
  213. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +0 -206
  214. package/webircgateway/pkg/webircgateway/transport_sockjs.go +0 -107
  215. package/webircgateway/pkg/webircgateway/transport_tcp.go +0 -113
  216. package/webircgateway/pkg/webircgateway/transport_websocket.go +0 -126
  217. package/webircgateway/pkg/webircgateway/utils.go +0 -147
  218. package/webircgateway/plugins/example/plugin.go +0 -11
  219. package/webircgateway/plugins/stats/plugin.go +0 -52
  220. package/webircgateway/staticcheck.conf +0 -1
  221. package/webircgateway/webircgateway.svg +0 -3
@@ -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,14 +10,17 @@ 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';
16
+ export * from './case-fold.js';
15
17
  export * from './admission.js';
16
18
  export * from './config.js';
17
19
  export type {
18
20
  BuildCtxOptions,
19
21
  CloakingConfig,
20
22
  Ctx,
23
+ OperCred,
21
24
  Reducer,
22
25
  ReducerResult,
23
26
  ServerConfig,
@@ -6,6 +6,8 @@
6
6
  * can pin the timeline and identity space (PLAN §8 determinism rules).
7
7
  */
8
8
 
9
+ import { caseFold } from './case-fold.js';
10
+
9
11
  /** Read-only wall clock, epoch milliseconds. */
10
12
  export interface Clock {
11
13
  now(): number;
@@ -55,7 +57,7 @@ export interface IdFactory {
55
57
 
56
58
  /**
57
59
  * Production id factory. Generates UUIDv4-shaped strings without relying
58
- * 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
59
61
  * Lambda without per-platform type wrangling. Uniqueness is statistical;
60
62
  * for cryptographic IDs, adapters can supply their own {@link IdFactory}.
61
63
  */
@@ -169,7 +171,8 @@ export const EmptyMotdProvider: MotdProvider = new StaticMotdProvider([]);
169
171
  */
170
172
  export type SaslPayload =
171
173
  | { kind: 'PLAIN'; username: string; password: string }
172
- | { kind: 'RAW'; data: string };
174
+ | { kind: 'RAW'; data: string }
175
+ | { kind: 'EXTERNAL'; identity: string };
173
176
 
174
177
  /**
175
178
  * Outcome of an {@link AccountStore.verify} call: either the canonical
@@ -192,6 +195,134 @@ export interface AccountStore {
192
195
  verify(mech: string, payload: SaslPayload): SaslResult;
193
196
  }
194
197
 
198
+ /**
199
+ * One SASL PLAIN credential entry seeded into an {@link InMemoryAccountStore}.
200
+ * The `username` is also the canonical account name recorded on the
201
+ * connection for `extended-join` / `account-tag`.
202
+ */
203
+ export interface SaslAccountCredential {
204
+ username: string;
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;
212
+ }
213
+
214
+ /**
215
+ * Reference in-memory {@link AccountStore} backed by a static list of PLAIN
216
+ * credentials seeded at construction.
217
+ *
218
+ * Mirrors the {@link InMemoryMessageStore} / {@link StaticMotdProvider}
219
+ * pattern: the port is synchronous, so adapters that need an async backend
220
+ * (D1, DynamoDB, Secrets Manager) pre-load their credentials into this store
221
+ * at boot. All three adapters (local-cli, CF, AWS) use this as the minimum
222
+ * viable `AccountStore`; a persistent variant (DynamoDB `Accounts` table, D1)
223
+ * is a documented follow-up that swaps in by replacing the construction call.
224
+ *
225
+ * Password comparison is constant-time to avoid the early-return timing
226
+ * oracle a naive `===` would expose. Only `PLAIN` is verified; any other
227
+ * mechanism returns failure (matching the reducer's mech gating).
228
+ */
229
+ export class InMemoryAccountStore implements AccountStore {
230
+ private readonly creds: ReadonlyMap<string, string>;
231
+ private readonly certMap: ReadonlyMap<string, string>;
232
+
233
+ constructor(credentials: ReadonlyArray<SaslAccountCredential>) {
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
+ );
240
+ }
241
+
242
+ verify(mech: string, payload: SaslPayload): SaslResult {
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 };
253
+ }
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 };
260
+ }
261
+ return { ok: false, reason: `unsupported mechanism: ${mech}` };
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Constant-time string equality. Compares every byte regardless of early
267
+ * mismatches so a remote attacker cannot short-circuit the comparison via a
268
+ * timing side-channel. Returns `false` when the lengths differ (the length
269
+ * is not considered secret for SASL PLAIN passwords).
270
+ */
271
+ function constantTimeEquals(a: string, b: string): boolean {
272
+ if (a.length !== b.length) return false;
273
+ let diff = 0;
274
+ for (let i = 0; i < a.length; i++) {
275
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
276
+ }
277
+ return diff === 0;
278
+ }
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
+
195
326
  // ============================================================================
196
327
  // Chat-history persistence (IRCv3 draft/chathistory)
197
328
  // ============================================================================
@@ -301,7 +432,7 @@ export class InMemoryMessageStore implements MessageStore {
301
432
  }
302
433
 
303
434
  record(message: StoredMessage): void {
304
- const key = message.chan.toLowerCase();
435
+ const key = caseFold('rfc1459', message.chan);
305
436
  let arr = this.byChan.get(key);
306
437
  if (arr === undefined) {
307
438
  arr = [];
@@ -315,7 +446,7 @@ export class InMemoryMessageStore implements MessageStore {
315
446
 
316
447
  query(q: HistoryQuery): StoredMessage[] {
317
448
  if (q.limit <= 0) return [];
318
- const arr = this.byChan.get(q.chan.toLowerCase());
449
+ const arr = this.byChan.get(caseFold('rfc1459', q.chan));
319
450
  if (arr === undefined || arr.length === 0) return [];
320
451
 
321
452
  switch (q.direction) {
@@ -335,14 +466,14 @@ export class InMemoryMessageStore implements MessageStore {
335
466
  }
336
467
 
337
468
  hasMsgid(chan: string, msgid: string): boolean {
338
- const arr = this.byChan.get(chan.toLowerCase());
469
+ const arr = this.byChan.get(caseFold('rfc1459', chan));
339
470
  if (arr === undefined) return false;
340
471
  return arr.some((m) => m.msgid === msgid);
341
472
  }
342
473
 
343
474
  recent(chan: string, sinceMsgid: string | undefined, limit: number): StoredMessage[] {
344
475
  if (limit <= 0) return [];
345
- const arr = this.byChan.get(chan.toLowerCase());
476
+ const arr = this.byChan.get(caseFold('rfc1459', chan));
346
477
  if (arr === undefined || arr.length === 0) return [];
347
478
  let start = 0;
348
479
  if (sinceMsgid !== undefined) {
@@ -437,6 +568,108 @@ function tail<T>(arr: readonly T[], n: number): T[] {
437
568
  return arr.slice(start);
438
569
  }
439
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
+
440
673
  // ============================================================================
441
674
  // Observability — structured logger
442
675
  // ============================================================================
@@ -790,7 +1023,7 @@ export class ConsoleLogger implements Logger {
790
1023
 
791
1024
  /** Returns the global `console` as a {@link ConsoleLoggerSinks}. */
792
1025
  function globalThisConsoleSinks(): ConsoleLoggerSinks {
793
- // `globalThis.console` is universal across Node 20, Workers, and Lambda.
1026
+ // `globalThis.console` is universal across Node 24, Workers, and Lambda.
794
1027
  // Reference it lazily so tests that stub the logger do not need to
795
1028
  // replace the global.
796
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
 
@@ -25,6 +33,17 @@ export interface CloakingConfig {
25
33
  readonly cloakedSuffix?: string | undefined;
26
34
  }
27
35
 
36
+ /**
37
+ * A single IRC operator credential. The {@link operReducer} consults the
38
+ * configured list to authenticate `OPER <name> <password>`. Both fields are
39
+ * compared verbatim; adapters are responsible for any hashing at the auth
40
+ * boundary if they layer one on top.
41
+ */
42
+ export interface OperCred {
43
+ readonly user: string;
44
+ readonly password: string;
45
+ }
46
+
28
47
  /**
29
48
  * Per-deployment server configuration. Adapters load this from a KV store
30
49
  * (CF) or Secrets Manager / SSM (AWS); the core only reads it.
@@ -35,6 +54,18 @@ export interface CloakingConfig {
35
54
  export interface ServerConfig {
36
55
  serverName: string;
37
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;
38
69
  /**
39
70
  * Server password gate. Empty string or undefined disables enforcement.
40
71
  * When set, registration refuses to complete until the connection
@@ -53,6 +84,17 @@ export interface ServerConfig {
53
84
  * (see `serverPassword` note).
54
85
  */
55
86
  readonly cloaking?: CloakingConfig | undefined;
87
+ /**
88
+ * IRC operator credentials consulted by the `OPER` reducer. `undefined`
89
+ * (or an empty array) disables oper authentication entirely — an `OPER`
90
+ * attempt then receives `491 ERR_NOOPERHOST`.
91
+ *
92
+ * Typed `ReadonlyArray<OperCred> | undefined` to mirror Zod's `.optional()`
93
+ * output under `exactOptionalPropertyTypes: true`; the parsed config always
94
+ * supplies an array (defaulting to `[]`), so a parsed config is assignable
95
+ * here.
96
+ */
97
+ readonly operCreds?: ReadonlyArray<OperCred> | undefined;
56
98
  maxChannelsPerUser: number;
57
99
  maxTargetsPerCommand: number;
58
100
  maxListEntries: number;
@@ -93,6 +135,12 @@ export interface Ctx {
93
135
  readonly motd: MotdProvider;
94
136
  /** SASL account verification source (absent when no accounts are configured). */
95
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;
96
144
  /**
97
145
  * Chat-history persistence source (absent when chathistory playback is
98
146
  * disabled for this deployment). When present, the PRIVMSG/NOTICE channel
@@ -100,6 +148,13 @@ export interface Ctx {
100
148
  * cap-enabled joiners.
101
149
  */
102
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;
103
158
  /** The connection invoking the command. Reducers mutate this freely. */
104
159
  readonly connection: ConnectionState;
105
160
  /** Convenience: `connection.id`. */
@@ -125,7 +180,9 @@ export interface BuildCtxOptions {
125
180
  ids: IdFactory;
126
181
  motd: MotdProvider;
127
182
  accounts?: AccountStore;
183
+ mtlsIdentity?: MtlsIdentityProvider;
128
184
  messages?: MessageStore;
185
+ history?: NickHistoryStore;
129
186
  connection: ConnectionState;
130
187
  }
131
188
 
@@ -141,7 +198,9 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
141
198
  connection: opts.connection,
142
199
  connId: opts.connection.id,
143
200
  ...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
201
+ ...(opts.mtlsIdentity !== undefined ? { mtlsIdentity: opts.mtlsIdentity } : {}),
144
202
  ...(opts.messages !== undefined ? { messages: opts.messages } : {}),
203
+ ...(opts.history !== undefined ? { history: opts.history } : {}),
145
204
  };
146
205
  return ctx;
147
206
  }