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
@@ -3,7 +3,7 @@
3
3
  * {@link ConnectionDO} into its `state.storage`. Tests inspect the call
4
4
  * log via `runInDurableObject` after exercising the connection.
5
5
  *
6
- * PLAN §2.1 — the real RegistryDO ships in 034. For 033 we
6
+ * PLAN §2.1 — the real RegistryDO ships separately. We
7
7
  * only need enough surface area to assert that ConnectionDO closes
8
8
  * release the nick and fan out QUIT correctly. The stub records the
9
9
  * requested operations verbatim; it does not enforce uniqueness.
@@ -12,7 +12,7 @@
12
12
  import { DurableObject } from 'cloudflare:workers';
13
13
 
14
14
  export interface RecordedRegistryCall {
15
- method: 'reserveNick' | 'changeNick' | 'releaseNick' | 'lookupNick';
15
+ method: 'reserveNick' | 'changeNick' | 'releaseNick' | 'lookupNick' | 'listEntries';
16
16
  args: unknown[];
17
17
  }
18
18
 
@@ -21,6 +21,7 @@ interface RegistryRpc {
21
21
  changeNick(conn: string, oldNick: string, newNick: string): Promise<boolean>;
22
22
  releaseNick(nick: string): Promise<void>;
23
23
  lookupNick(nick: string): Promise<string | null>;
24
+ listEntries(): Promise<Array<[nickLower: string, connId: string]>>;
24
25
  }
25
26
 
26
27
  const LOG_KEY = 'registry-call-log';
@@ -54,4 +55,9 @@ export class RecordingRegistryDO extends DurableObject implements RegistryRpc {
54
55
  await this.append({ method: 'lookupNick', args: [nick] });
55
56
  return null;
56
57
  }
58
+
59
+ async listEntries(): Promise<Array<[nickLower: string, connId: string]>> {
60
+ await this.append({ method: 'listEntries', args: [] });
61
+ return [];
62
+ }
57
63
  }
@@ -8,11 +8,26 @@ compatibility_flags = ["nodejs_compat"]
8
8
  # integration-test harness so scenario 18 (MOTD) asserts against the same
9
9
  # body text in every runtime factory.
10
10
  [vars]
11
+ SERVER_NAME = "irc.example.com"
12
+ NETWORK_NAME = "ServerlessIRCd"
11
13
  MOTD_LINES = "Welcome to the ServerlessIRCd integration-test harness.\nAll scenarios run parametrized over the IrcRuntime factory matrix."
12
14
  # SASL PLAIN accounts seeded into the in-worker AccountStore. Newline-
13
15
  # delimited `username:password` pairs; the connection-do parses these into
14
16
  # an InMemoryAccountStore so AUTHENTICATE PLAIN works end-to-end in tests.
15
17
  SASL_ACCOUNTS = "cf-sasl:s3cret"
18
+ # IRC operator credentials consulted by the OPER reducer. The DO must
19
+ # thread these from env into the reducer-facing ServerConfig so
20
+ # `OPER <user> <pass>` returns 381 rather than 491 ERR_NOOPERHOST.
21
+ OPER_USER = "cf-oper"
22
+ OPER_PASSWORD = "oper-secret"
23
+
24
+ # D1 database backing persistent SASL accounts. The D1 account-store suite
25
+ # creates the `accounts` table in a beforeEach and exercises the real
26
+ # load/put/resolve path.
27
+ [[d1_databases]]
28
+ binding = "ACCOUNTS_DB"
29
+ database_name = "cf-adapter-test-accounts"
30
+ database_id = "test-d1-accounts"
16
31
 
17
32
  # Production bindings: REGISTRY_DO and CHANNEL_DO point at the real DO
18
33
  # classes so CfRuntime, ConnectionDO, and the integration suite all
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/in-memory-runtime",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "Single-process reference implementation of the IrcRuntime port, backed by Maps",
6
6
  "license": "BSD-3-Clause",
@@ -26,6 +26,7 @@ import {
26
26
  type ConnectionState,
27
27
  type Nick,
28
28
  type RawLine,
29
+ type ServerConfig,
29
30
  applyChannelDelta as applyDelta,
30
31
  caseFold,
31
32
  createChannel,
@@ -64,6 +65,15 @@ export interface InMemoryRuntimeOptions {
64
65
  * deployment's `ServerConfig`.
65
66
  */
66
67
  readonly admission?: AdmissionConfig;
68
+ /**
69
+ * Config-reload source for `REHASH`. When bound, the runtime re-invokes
70
+ * it on every {@link InMemoryRuntime.reloadConfig} call and returns the
71
+ * fresh {@link ServerConfig}; the local-cli binds the loader that
72
+ * re-derives config from its original options. When omitted
73
+ * `reloadConfig` rejects so the actor's graceful failure path applies a
74
+ * consistent error-suffixed `382`.
75
+ */
76
+ readonly configLoader?: () => Promise<ServerConfig>;
67
77
  }
68
78
 
69
79
  export class InMemoryRuntime implements IrcRuntime {
@@ -73,9 +83,11 @@ export class InMemoryRuntime implements IrcRuntime {
73
83
  private readonly nicks = new Map<string, ConnId>();
74
84
  private readonly admissionStats: AdmissionStats | undefined;
75
85
  private readonly admissionConfig: AdmissionConfig | undefined;
86
+ private readonly configLoader: (() => Promise<ServerConfig>) | undefined;
76
87
 
77
88
  constructor(opts: InMemoryRuntimeOptions) {
78
89
  this.clock = opts.clock;
90
+ this.configLoader = opts.configLoader;
79
91
  if (opts.admission !== undefined) {
80
92
  this.admissionConfig = opts.admission;
81
93
  this.admissionStats = new AdmissionStats(this.clock);
@@ -242,6 +254,22 @@ export class InMemoryRuntime implements IrcRuntime {
242
254
  }
243
255
  }
244
256
 
257
+ /**
258
+ * Global cross-connection fanout for `WALLOPS`. Scans the connection map
259
+ * and delivers `lines` to every live connection whose `+w` user mode is
260
+ * set, skipping `except` (the originator). Unlike {@link broadcast} this
261
+ * is NOT channel-scoped — it reaches connections with no shared channel
262
+ * membership. This is the reference implementation the CF and AWS
263
+ * adapters mirror against their own enumeration primitives.
264
+ */
265
+ async broadcastWallops(lines: RawLine[], except?: ConnId): Promise<void> {
266
+ for (const [connId, tracked] of this.connections) {
267
+ if (connId === except) continue;
268
+ if (!tracked.state.userModes.wallops) continue;
269
+ tracked.handlers.send(lines);
270
+ }
271
+ }
272
+
245
273
  // ------------------------------------------------------------------
246
274
  // IrcRuntime — nick registry
247
275
  // ------------------------------------------------------------------
@@ -333,4 +361,15 @@ export class InMemoryRuntime implements IrcRuntime {
333
361
  async listChannels(): Promise<ChanSnapshot[]> {
334
362
  return Array.from(this.channels.values(), (c) => toChannelSnapshot(c));
335
363
  }
364
+
365
+ // ------------------------------------------------------------------
366
+ // IrcRuntime — config reload (REHASH)
367
+ // ------------------------------------------------------------------
368
+
369
+ async reloadConfig(): Promise<ServerConfig> {
370
+ if (this.configLoader === undefined) {
371
+ throw new Error('no config loader bound to InMemoryRuntime');
372
+ }
373
+ return this.configLoader();
374
+ }
336
375
  }
@@ -5,6 +5,7 @@ import {
5
5
  type ConnectionState,
6
6
  FakeClock,
7
7
  type RawLine,
8
+ type ServerConfig,
8
9
  createConnection,
9
10
  } from '@serverless-ircd/irc-core';
10
11
  import { describe, expect, it } from 'vitest';
@@ -124,6 +125,26 @@ describe('InMemoryRuntime — changeNick', () => {
124
125
  expect(await rt.lookupNick('alice')).toBe('c1');
125
126
  expect(await rt.lookupNick('bob')).toBe('c2');
126
127
  });
128
+
129
+ it('does not clobber a third party that grabbed the old nick via re-reserve (stale-release guard)', async () => {
130
+ // Covers the defensive branch on the old-mapping clear: c1 held
131
+ // `alice`, released it, c2 re-reserved it, then c1 reports a nick
132
+ // change FROM `alice` (stale state). The guard must NOT delete the
133
+ // alice mapping that now belongs to c2.
134
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
135
+ rt.registerConnection(makeConn('c1'), { send: () => {}, disconnect: () => {} });
136
+ rt.registerConnection(makeConn('c2'), { send: () => {}, disconnect: () => {} });
137
+
138
+ await rt.reserveNick('alice', 'c1');
139
+ await rt.releaseNick('alice'); // nicks[alice] deleted (still pointed at c1)
140
+ await rt.reserveNick('alice', 'c2'); // nicks[alice] = c2
141
+
142
+ const ok = await rt.changeNick('c1', 'alice', 'bob');
143
+ expect(ok).toBe(true);
144
+ // c1 got `bob`, c2 still holds `alice` (the stale clear was skipped).
145
+ expect(await rt.lookupNick('alice')).toBe('c2');
146
+ expect(await rt.lookupNick('bob')).toBe('c1');
147
+ });
127
148
  });
128
149
 
129
150
  describe('InMemoryRuntime — releaseNick', () => {
@@ -255,6 +276,100 @@ describe('InMemoryRuntime — applyChannelDelta / getChannelSnapshot', () => {
255
276
  });
256
277
  });
257
278
 
279
+ describe('InMemoryRuntime — getChannel (peek-only)', () => {
280
+ it('returns the authoritative ChannelState when the channel exists', async () => {
281
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
282
+ await rt.applyChannelDelta('#foo', {
283
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
284
+ });
285
+
286
+ const chan = rt.getChannel('#foo');
287
+ expect(chan).toBeDefined();
288
+ expect(chan?.name).toBe('#foo');
289
+ expect(chan?.members.has('c1')).toBe(true);
290
+ });
291
+
292
+ it('returns undefined for an unknown channel (does not create it)', async () => {
293
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
294
+ expect(rt.getChannel('#nope')).toBeUndefined();
295
+ // Peek-only: the channel map is still empty.
296
+ expect(await rt.listChannels()).toEqual([]);
297
+ });
298
+
299
+ it('folds the channel name with rfc1459 case-mapping on lookup', async () => {
300
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
301
+ await rt.applyChannelDelta('#Foo', {
302
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
303
+ });
304
+ expect(rt.getChannel('#foo')).toBeDefined();
305
+ expect(rt.getChannel('#FOO')).toBeDefined();
306
+ });
307
+ });
308
+
309
+ describe('InMemoryRuntime — getChannelConnections', () => {
310
+ it('returns the live ConnectionState for each registered member', async () => {
311
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
312
+ rt.registerConnection(makeConn('c1', 'alice'), { send: () => {}, disconnect: () => {} });
313
+ rt.registerConnection(makeConn('c2', 'bob'), { send: () => {}, disconnect: () => {} });
314
+ await rt.applyChannelDelta('#foo', {
315
+ memberships: [
316
+ { type: 'add', conn: 'c1', nick: 'alice' },
317
+ { type: 'add', conn: 'c2', nick: 'bob' },
318
+ ],
319
+ });
320
+
321
+ const conns = await rt.getChannelConnections('#foo');
322
+ expect(conns.size).toBe(2);
323
+ expect(conns.get('c1')?.nick).toBe('alice');
324
+ expect(conns.get('c2')?.nick).toBe('bob');
325
+ });
326
+
327
+ it('returns an empty map for an unknown channel', async () => {
328
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
329
+ expect(await rt.getChannelConnections('#nope')).toEqual(new Map());
330
+ });
331
+
332
+ it('skips members whose connection is no longer tracked', async () => {
333
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
334
+ rt.registerConnection(makeConn('c1', 'alice'), { send: () => {}, disconnect: () => {} });
335
+ // c2 is referenced by a membership but was never registered (and c1's
336
+ // registration is dropped mid-test to exercise the filter branch).
337
+ await rt.applyChannelDelta('#foo', {
338
+ memberships: [
339
+ { type: 'add', conn: 'c1', nick: 'alice' },
340
+ { type: 'add', conn: 'c2', nick: 'bob' },
341
+ ],
342
+ });
343
+
344
+ const conns = await rt.getChannelConnections('#foo');
345
+ expect(conns.size).toBe(1);
346
+ expect(conns.get('c1')?.nick).toBe('alice');
347
+ expect(conns.has('c2')).toBe(false);
348
+ });
349
+ });
350
+
351
+ describe('InMemoryRuntime — listChannels', () => {
352
+ it('returns an empty array when no channels exist', async () => {
353
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
354
+ expect(await rt.listChannels()).toEqual([]);
355
+ });
356
+
357
+ it('returns a snapshot per channel, preserving original casing', async () => {
358
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
359
+ await rt.applyChannelDelta('#Foo', {
360
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
361
+ });
362
+ await rt.applyChannelDelta('#bar', {
363
+ memberships: [{ type: 'add', conn: 'c1', nick: 'alice' }],
364
+ });
365
+
366
+ const list = await rt.listChannels();
367
+ expect(list).toHaveLength(2);
368
+ const names = list.map((c) => c.name).sort();
369
+ expect(names).toEqual(['#Foo', '#bar']);
370
+ });
371
+ });
372
+
258
373
  describe('InMemoryRuntime — send', () => {
259
374
  it('delivers lines to the target connection send handler', async () => {
260
375
  const sent: RawLine[][] = [];
@@ -430,6 +545,28 @@ describe('InMemoryRuntime — sendToNick', () => {
430
545
  const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
431
546
  await expect(rt.sendToNick('ghost', 'c1', [L('hi')], [L('401')])).resolves.toBeUndefined();
432
547
  });
548
+
549
+ it('falls through to notFoundLines when the owner nick is registered but its connection is gone', async () => {
550
+ // Covers the stale-nick defensive branch in sendToNick: `this.nicks`
551
+ // still maps the nick to a connId, but that conn is no longer tracked
552
+ // (unregisterConnection deletes the conn without releasing its nick —
553
+ // the comment on unregisterConnection documents this split).
554
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
555
+ const senderOut: RawLine[][] = [];
556
+ rt.registerConnection(makeConn('c1', 'alice'), { send: () => {}, disconnect: () => {} });
557
+ rt.registerConnection(makeConn('c2', 'bob'), {
558
+ send: (lines) => {
559
+ senderOut.push(lines);
560
+ },
561
+ disconnect: () => {},
562
+ });
563
+ await rt.reserveNick('alice', 'c1');
564
+ // Remove c1 from the connection map while leaving the nick mapping intact.
565
+ rt.unregisterConnection('c1');
566
+
567
+ await rt.sendToNick('alice', 'c2', [L('hi')], [L('401 bob alice :No such nick')]);
568
+ expect(senderOut).toEqual([[{ text: '401 bob alice :No such nick' }]]);
569
+ });
433
570
  });
434
571
 
435
572
  describe('InMemoryRuntime — disconnect', () => {
@@ -634,3 +771,125 @@ describe('InMemoryRuntime — admission gate', () => {
634
771
  }
635
772
  });
636
773
  });
774
+
775
+ describe('InMemoryRuntime — reloadConfig (REHASH)', () => {
776
+ const baseConfig: ServerConfig = {
777
+ serverName: 'irc.example.com',
778
+ networkName: 'ExampleNet',
779
+ maxChannelsPerUser: 30,
780
+ maxTargetsPerCommand: 10,
781
+ maxListEntries: 50,
782
+ nickLen: 30,
783
+ channelLen: 50,
784
+ topicLen: 390,
785
+ quitMessage: 'Client Quit',
786
+ };
787
+
788
+ it('re-invokes the bound loader and returns the fresh config', async () => {
789
+ let version = 1;
790
+ const rt = new InMemoryRuntime({
791
+ clock: new FakeClock(0),
792
+ configLoader: async () => ({ ...baseConfig, serverName: `v${version}.example.com` }),
793
+ });
794
+
795
+ const first = await rt.reloadConfig();
796
+ expect(first.serverName).toBe('v1.example.com');
797
+ version = 2;
798
+ const second = await rt.reloadConfig();
799
+ expect(second.serverName).toBe('v2.example.com');
800
+ });
801
+
802
+ it('propagates a loader rejection so the actor can apply the graceful failure path', async () => {
803
+ const rt = new InMemoryRuntime({
804
+ clock: new FakeClock(0),
805
+ configLoader: async () => {
806
+ throw new Error('store unreachable');
807
+ },
808
+ });
809
+
810
+ await expect(rt.reloadConfig()).rejects.toThrow('store unreachable');
811
+ });
812
+
813
+ it('rejects when no loader was bound at construction', async () => {
814
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
815
+
816
+ await expect(rt.reloadConfig()).rejects.toThrow('no config loader bound');
817
+ });
818
+ });
819
+
820
+ describe('InMemoryRuntime — broadcastWallops', () => {
821
+ it('delivers lines to every connection with the +w user mode set', async () => {
822
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
823
+ const received: Record<string, RawLine[][]> = { c1: [], c2: [], c3: [] };
824
+ const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
825
+ // c1 has NO +w; c2 and c3 do.
826
+ rt.registerConnection(makeConn('c1', 'alice'), { send: push('c1'), disconnect: () => {} });
827
+ const c2 = makeConn('c2', 'bob');
828
+ c2.userModes.wallops = true;
829
+ rt.registerConnection(c2, { send: push('c2'), disconnect: () => {} });
830
+ const c3 = makeConn('c3', 'carol');
831
+ c3.userModes.wallops = true;
832
+ rt.registerConnection(c3, { send: push('c3'), disconnect: () => {} });
833
+
834
+ await rt.broadcastWallops([L(':alice WALLOPS :reboot')]);
835
+
836
+ // c1 has no +w → no delivery.
837
+ expect(received.c1).toHaveLength(0);
838
+ expect(received.c2).toHaveLength(1);
839
+ expect(received.c3).toHaveLength(1);
840
+ });
841
+
842
+ it('skips the except connection even when it has +w', async () => {
843
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
844
+ const received: Record<string, RawLine[][]> = { c1: [], c2: [] };
845
+ const push = (id: string) => (lines: RawLine[]) => received[id]?.push(lines);
846
+ const oper = makeConn('c1', 'alice');
847
+ oper.userModes.wallops = true;
848
+ oper.userModes.oper = true;
849
+ rt.registerConnection(oper, { send: push('c1'), disconnect: () => {} });
850
+ const listener = makeConn('c2', 'bob');
851
+ listener.userModes.wallops = true;
852
+ rt.registerConnection(listener, { send: push('c2'), disconnect: () => {} });
853
+
854
+ await rt.broadcastWallops([L(':alice WALLOPS :hi')], 'c1');
855
+
856
+ // The oper (except) never receives their own wallops; the +w peer does.
857
+ expect(received.c1).toHaveLength(0);
858
+ expect(received.c2).toHaveLength(1);
859
+ });
860
+
861
+ it('does not deliver to +w connections that have unregistered', async () => {
862
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
863
+ let c2Received = 0;
864
+ rt.registerConnection(makeConn('c1', 'alice'), {
865
+ send: () => {},
866
+ disconnect: () => {},
867
+ });
868
+ const c2 = makeConn('c2', 'bob');
869
+ c2.userModes.wallops = true;
870
+ rt.registerConnection(c2, {
871
+ send: () => {
872
+ c2Received++;
873
+ },
874
+ disconnect: () => {},
875
+ });
876
+
877
+ rt.unregisterConnection('c2');
878
+ await rt.broadcastWallops([L(':alice WALLOPS :hi')]);
879
+ expect(c2Received).toBe(0);
880
+ });
881
+
882
+ it('is a no-op when no connection has +w', async () => {
883
+ const rt = new InMemoryRuntime({ clock: new FakeClock(0) });
884
+ let c1Received = 0;
885
+ rt.registerConnection(makeConn('c1', 'alice'), {
886
+ send: () => {
887
+ c1Received++;
888
+ },
889
+ disconnect: () => {},
890
+ });
891
+
892
+ await rt.broadcastWallops([L(':a WALLOPS :hi')]);
893
+ expect(c1Received).toBe(0);
894
+ });
895
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serverless-ircd/irc-core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "private": true,
5
5
  "description": "Platform-agnostic IRC protocol core: parser, serializer, command reducers, state shapes",
6
6
  "license": "BSD-3-Clause",
@@ -18,6 +18,10 @@
18
18
  }
19
19
  },
20
20
  "scripts": {
21
+ "prepare": "node scripts/generate-build-info.mjs",
22
+ "prebuild": "node scripts/generate-build-info.mjs",
23
+ "pretypecheck": "node scripts/generate-build-info.mjs",
24
+ "pretest": "node scripts/generate-build-info.mjs",
21
25
  "build": "tsc -p tsconfig.build.json",
22
26
  "typecheck": "tsc -p tsconfig.test.json --noEmit",
23
27
  "test": "vitest run",
@@ -35,6 +39,7 @@
35
39
  "devDependencies": {
36
40
  "@stryker-mutator/core": "^9.6.1",
37
41
  "@stryker-mutator/vitest-runner": "^9.6.1",
42
+ "@types/node": "^26.1.1",
38
43
  "vitest": "^4.1.0"
39
44
  }
40
45
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Generates `src/build-info.ts` containing the build timestamp.
3
+ *
4
+ * Runs as a `prebuild` / `pretest` / `pretypecheck` / `prepare` hook so the
5
+ * file always exists before tsc or vitest resolves `./build-info.js` imports.
6
+ * The emitted file is gitignored — it is regenerated on every build so the
7
+ * published `dist/index.js` carries the timestamp of the build that produced
8
+ * it, never a stale committed value.
9
+ *
10
+ * Output shape:
11
+ *
12
+ * ```ts
13
+ * // AUTO-GENERATED by scripts/generate-build-info.mjs — do not edit.
14
+ * export const BUILD_DATE = '2024-01-01T00:00:00.000Z';
15
+ * ```
16
+ *
17
+ * Failures here must abort the build rather than emit a half-file: a missing
18
+ * or malformed `build-info.ts` would break every downstream tsc invocation.
19
+ */
20
+ import { writeFileSync } from 'node:fs';
21
+ import { dirname, resolve } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ const target = resolve(here, '..', 'src', 'build-info.ts');
26
+
27
+ const iso = new Date().toISOString();
28
+ const contents = `// AUTO-GENERATED by scripts/generate-build-info.mjs — do not edit.\nexport const BUILD_DATE = ${JSON.stringify(iso)};\n`;
29
+
30
+ writeFileSync(target, contents, 'utf8');
31
+ console.log(`[generate-build-info] wrote ${target} (BUILD_DATE=${iso})`);
@@ -123,18 +123,18 @@ export class AdmissionStats {
123
123
  if (rec === undefined) return;
124
124
  this.records.delete(recordId);
125
125
 
126
- const ipCount = this.perIpCount.get(rec.ip);
127
- if (ipCount !== undefined) {
128
- const next = ipCount - 1;
129
- if (next <= 0) this.perIpCount.delete(rec.ip);
130
- else this.perIpCount.set(rec.ip, next);
131
- }
132
- const userCount = this.perUserCount.get(rec.user);
133
- if (userCount !== undefined) {
134
- const next = userCount - 1;
135
- if (next <= 0) this.perUserCount.delete(rec.user);
136
- else this.perUserCount.set(rec.user, next);
137
- }
126
+ // recordAdmission always sets both counters, so they are present for
127
+ // any id found in `records`. The decrement-and-delete-if-zero keeps
128
+ // the maps compact (no zero entries hanging around).
129
+ const ipCount = this.perIpCount.get(rec.ip) as number;
130
+ const ipNext = ipCount - 1;
131
+ if (ipNext <= 0) this.perIpCount.delete(rec.ip);
132
+ else this.perIpCount.set(rec.ip, ipNext);
133
+
134
+ const userCount = this.perUserCount.get(rec.user) as number;
135
+ const userNext = userCount - 1;
136
+ if (userNext <= 0) this.perUserCount.delete(rec.user);
137
+ else this.perUserCount.set(rec.user, userNext);
138
138
  }
139
139
 
140
140
  /**
@@ -147,9 +147,10 @@ export class AdmissionStats {
147
147
  if (times === undefined || times.length === 0) return 0;
148
148
  const cutoff = this.clock.now() - windowMs;
149
149
  let writeIdx = 0;
150
- for (let i = 0; i < times.length; i++) {
151
- const t = times[i];
152
- if (t === undefined) continue;
150
+ // `times` is dense (only ever appended to via push, never spliced), so
151
+ // iterating values via for-of avoids the noUncheckedIndexedAccess trap
152
+ // while still allowing in-place compaction of stale entries.
153
+ for (const t of times) {
153
154
  if (t >= cutoff) {
154
155
  times[writeIdx++] = t;
155
156
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The CAP *negotiation reducer* ships separately; this module is the
5
5
  * pure-data table that advertises what caps this server supports. PLAN §3
6
- * lists the v1 cap set; later tickets implement each cap's behavior.
6
+ * lists the v1 cap set; later phases implement each cap's behavior.
7
7
  *
8
8
  * The table is exposed as a frozen, sorted list so the `CAP LS` response
9
9
  * (and any future `CAP NEW` push) serializes deterministically.
@@ -13,7 +13,11 @@
13
13
  export interface Capability {
14
14
  /** Wire name (`server-time`, `sasl`, …). */
15
15
  name: string;
16
- /** Optional value (`PLAIN,EXTERNAL` for `sasl`); bare name when absent. */
16
+ /**
17
+ * Optional value parameter. For `sasl` this is the base mechanism list
18
+ * (`PLAIN`); the {@link saslCapValue} helper upgrades it to include
19
+ * `EXTERNAL` when mTLS is configured for a connection.
20
+ */
17
21
  value?: string;
18
22
  }
19
23
 
@@ -33,10 +37,27 @@ export const SUPPORTED_CAPABILITIES: ReadonlyArray<Capability> = Object.freeze([
33
37
  { name: 'message-tags' },
34
38
  { name: 'multi-prefix' },
35
39
  { name: 'safelist' },
36
- { name: 'sasl', value: 'PLAIN,EXTERNAL' },
40
+ { name: 'sasl', value: 'PLAIN' },
37
41
  { name: 'server-time' },
38
42
  ]);
39
43
 
44
+ /** Wire name of the SASL capability. */
45
+ export const SASL_CAP_NAME = 'sasl';
46
+
47
+ /**
48
+ * Returns the `sasl` cap value to advertise given whether an mTLS identity
49
+ * provider is bound for the connection.
50
+ *
51
+ * PLAIN is always available. EXTERNAL is advertised only when mTLS is
52
+ * configured (an `MtlsIdentityProvider` is bound) so clients on
53
+ * non-mTLS connections do not attempt a mechanism that cannot succeed.
54
+ * Operators enable EXTERNAL by configuring mTLS at the edge (CF API Shield
55
+ * mTLS, AWS custom-domain mTLS) — no code change required.
56
+ */
57
+ export function saslCapValue(hasMtls: boolean): string {
58
+ return hasMtls ? 'PLAIN,EXTERNAL' : 'PLAIN';
59
+ }
60
+
40
61
  /** Set form for fast membership checks during `CAP REQ`. */
41
62
  export const SUPPORTED_CAP_NAMES: ReadonlySet<string> = new Set<string>(
42
63
  SUPPORTED_CAPABILITIES.map((c) => c.name),
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Implementation note: this uses a self-contained FNV-1a 32-bit hash plus a
13
13
  * short base32 encoding so the module stays pure-TS and runs unchanged on
14
- * Node 20, Cloudflare Workers, and Lambda. It is NOT a cryptographic MAC:
14
+ * Node 24, Cloudflare Workers, and Lambda. It is NOT a cryptographic MAC:
15
15
  * an attacker who learns one cloak cannot recover the secret, but a
16
16
  * determined attacker with the secret can forge cloaks. Deployments that
17
17
  * need stronger unforgeability should override this module.
@@ -33,9 +33,11 @@
33
33
  */
34
34
 
35
35
  import {
36
+ SASL_CAP_NAME,
36
37
  SUPPORTED_CAPABILITIES,
37
38
  capToLsString,
38
39
  isSupportedCap,
40
+ saslCapValue,
39
41
  splitCapsForWire,
40
42
  } from '../caps/capabilities.js';
41
43
  import { Effect } from '../effects.js';
@@ -88,7 +90,12 @@ function handleLs(state: ConnectionState, ctx: Ctx): ReducerResult<ConnectionSta
88
90
  state.capNegotiating = true;
89
91
 
90
92
  const target = state.nick ?? '*';
91
- const tokens = SUPPORTED_CAPABILITIES.map(capToLsString);
93
+ const hasMtls = ctx.mtlsIdentity !== undefined;
94
+ const tokens = SUPPORTED_CAPABILITIES.map((cap) =>
95
+ cap.name === SASL_CAP_NAME
96
+ ? capToLsString({ ...cap, value: saslCapValue(hasMtls) })
97
+ : capToLsString(cap),
98
+ );
92
99
  const maxLen = maxCapsContentLen(ctx, target);
93
100
  const chunks = splitCapsForWire(tokens, maxLen);
94
101
 
@@ -19,6 +19,8 @@ export { handleJoinZero, isValidChannelName, joinReducer } from './join.js';
19
19
  export { partReducer } from './part.js';
20
20
  export { motdReducer } from './motd.js';
21
21
  export { operReducer, matchOperCred } from './oper.js';
22
+ export { rehashReducer, rehashLine, buildRehashTrailing } from './rehash.js';
23
+ export type { RehashResult } from './rehash.js';
22
24
  export {
23
25
  isChannelTarget,
24
26
  noticeChannelReducer,
@@ -27,6 +29,8 @@ export {
27
29
  privmsgUserReducer,
28
30
  } from './privmsg.js';
29
31
  export { kickReducer } from './kick.js';
32
+ export { killReducer, formatKillReason } from './kill.js';
33
+ export { wallopsReducer, wallopsLine } from './wallops.js';
30
34
  export { topicReducer } from './topic.js';
31
35
  export { namesReducer } from './names.js';
32
36
  export { listReducer } from './list.js';
@@ -34,11 +38,16 @@ export { channelModeReducer, userModeReducer } from './mode.js';
34
38
  export { inviteReducer } from './invite.js';
35
39
  export { whoReducer } from './who.js';
36
40
  export { whoisReducer } from './whois.js';
41
+ export { whowasReducer } from './whowas.js';
42
+ export { traceReducer } from './trace.js';
43
+ export { lusersReducer } from './lusers.js';
44
+ export { statsReducer } from './stats.js';
37
45
  export { generateIsupport } from './isupport.js';
38
46
  export { capReducer } from './cap.js';
39
47
  export { emitChghost } from './chghost.js';
40
48
  export type { ChgHostInput } from './chghost.js';
41
49
  export { awayReducer } from './away.js';
50
+ export { setnameReducer } from './setname.js';
42
51
  export { authenticateReducer } from './sasl.js';
43
52
  export {
44
53
  chathistoryReducer,
@@ -47,3 +56,11 @@ export {
47
56
  CHATHISTORY_CAP,
48
57
  } from './chathistory.js';
49
58
  export { tagmsgChannelReducer, tagmsgUserReducer } from './tagmsg.js';
59
+ export {
60
+ versionReducer,
61
+ timeReducer,
62
+ adminReducer,
63
+ infoReducer,
64
+ } from './server-info.js';
65
+ export { userhostReducer, formatUserhostReply } from './userhost.js';
66
+ export { isonReducer } from './ison.js';