serverless-ircd 0.1.0 → 0.2.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 (198) hide show
  1. package/.github/workflows/ci.yml +96 -2
  2. package/.github/workflows/deploy-aws.yml +129 -0
  3. package/.github/workflows/deploy-cf.yml +0 -2
  4. package/.gitmodules +3 -0
  5. package/AGENTS.md +5 -0
  6. package/CHANGELOG.md +352 -0
  7. package/MOTD.txt +3 -0
  8. package/PLAN-FIXES.md +358 -0
  9. package/PLAN.md +420 -0
  10. package/README.md +115 -81
  11. package/apps/aws-stack/README.md +73 -0
  12. package/apps/aws-stack/bin/aws.ts +49 -0
  13. package/apps/aws-stack/cdk.json +10 -0
  14. package/apps/aws-stack/package.json +41 -0
  15. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  16. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  17. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  18. package/apps/aws-stack/src/aws-stack.ts +263 -0
  19. package/apps/aws-stack/src/tables.ts +10 -0
  20. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  21. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  22. package/apps/aws-stack/tests/stack.test.ts +464 -0
  23. package/apps/aws-stack/tsconfig.build.json +11 -0
  24. package/apps/aws-stack/tsconfig.test.json +8 -0
  25. package/apps/aws-stack/vitest.config.ts +20 -0
  26. package/apps/cf-worker/package.json +1 -1
  27. package/apps/cf-worker/wrangler.toml +25 -0
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +56 -0
  30. package/apps/local-cli/src/server.ts +241 -32
  31. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  32. package/apps/local-cli/tests/e2e.test.ts +74 -0
  33. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  34. package/biome.json +3 -1
  35. package/dashboards/cloudwatch-irc.json +139 -0
  36. package/docs/AWS-Adapter-Architecture.md +494 -0
  37. package/docs/AWS-Deployment.md +1107 -0
  38. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  39. package/docs/Home.md +1 -0
  40. package/docs/Observability.md +87 -0
  41. package/docs/PlanIRCv3Websocket.md +489 -0
  42. package/docs/PlanWebClient.md +451 -0
  43. package/docs/Release-Process.md +440 -0
  44. package/package.json +8 -2
  45. package/packages/aws-adapter/README.md +35 -0
  46. package/packages/aws-adapter/package.json +52 -0
  47. package/packages/aws-adapter/src/aws-runtime.ts +783 -0
  48. package/packages/aws-adapter/src/cdk-table-defs.ts +69 -0
  49. package/packages/aws-adapter/src/config-loader.ts +96 -0
  50. package/packages/aws-adapter/src/dynamo.ts +61 -0
  51. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  52. package/packages/aws-adapter/src/handlers/default.ts +322 -0
  53. package/packages/aws-adapter/src/handlers/disconnect.ts +29 -0
  54. package/packages/aws-adapter/src/handlers/index.ts +248 -0
  55. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  56. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  57. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  58. package/packages/aws-adapter/src/index.ts +67 -0
  59. package/packages/aws-adapter/src/message-store.ts +34 -0
  60. package/packages/aws-adapter/src/serialize.ts +283 -0
  61. package/packages/aws-adapter/src/tables.ts +53 -0
  62. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  63. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  64. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  65. package/packages/aws-adapter/tests/config-loader.test.ts +158 -0
  66. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  67. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  68. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  69. package/packages/aws-adapter/tests/handlers.test.ts +427 -0
  70. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  71. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  72. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  73. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  74. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  75. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  76. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  77. package/packages/aws-adapter/tsconfig.build.json +16 -0
  78. package/packages/aws-adapter/tsconfig.test.json +14 -0
  79. package/packages/aws-adapter/vitest.config.ts +23 -0
  80. package/packages/cf-adapter/package.json +2 -1
  81. package/packages/cf-adapter/src/config-loader.ts +106 -0
  82. package/packages/cf-adapter/src/connection-do.ts +34 -0
  83. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  84. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  85. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  86. package/packages/cf-adapter/tests/connection-do.test.ts +42 -0
  87. package/packages/in-memory-runtime/package.json +1 -1
  88. package/packages/in-memory-runtime/src/in-memory-runtime.ts +108 -3
  89. package/packages/in-memory-runtime/src/index.ts +1 -1
  90. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +115 -0
  91. package/packages/irc-core/package.json +12 -2
  92. package/packages/irc-core/src/admission.ts +216 -0
  93. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  94. package/packages/irc-core/src/cloak.ts +81 -0
  95. package/packages/irc-core/src/commands/cap.ts +1 -2
  96. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  97. package/packages/irc-core/src/commands/index.ts +7 -0
  98. package/packages/irc-core/src/commands/join.ts +59 -7
  99. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  100. package/packages/irc-core/src/commands/registration.ts +71 -14
  101. package/packages/irc-core/src/commands/sasl.ts +251 -0
  102. package/packages/irc-core/src/config.ts +247 -0
  103. package/packages/irc-core/src/flood-control.ts +175 -0
  104. package/packages/irc-core/src/index.ts +5 -0
  105. package/packages/irc-core/src/ports.ts +655 -0
  106. package/packages/irc-core/src/protocol/base64.ts +16 -0
  107. package/packages/irc-core/src/protocol/index.ts +2 -1
  108. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  109. package/packages/irc-core/src/protocol/parser.ts +27 -2
  110. package/packages/irc-core/src/state/connection.ts +41 -0
  111. package/packages/irc-core/src/types.ts +66 -2
  112. package/packages/irc-core/stryker.commands.conf.json +41 -0
  113. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  114. package/packages/irc-core/tests/admission.test.ts +229 -0
  115. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  116. package/packages/irc-core/tests/cloak.test.ts +78 -0
  117. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  118. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  119. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  120. package/packages/irc-core/tests/commands/registration.test.ts +277 -9
  121. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  122. package/packages/irc-core/tests/config.test.ts +646 -0
  123. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  124. package/packages/irc-core/tests/message-store.test.ts +530 -0
  125. package/packages/irc-core/tests/parser.test.ts +44 -1
  126. package/packages/irc-core/tests/ports.test.ts +263 -0
  127. package/packages/irc-server/package.json +1 -1
  128. package/packages/irc-server/src/actor.ts +162 -44
  129. package/packages/irc-server/src/dispatch.ts +89 -5
  130. package/packages/irc-server/src/index.ts +2 -0
  131. package/packages/irc-server/src/routing.ts +151 -0
  132. package/packages/irc-server/tests/actor.test.ts +470 -14
  133. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  134. package/packages/irc-server/tests/routing.test.ts +201 -0
  135. package/packages/irc-test-support/README.md +32 -0
  136. package/packages/irc-test-support/package.json +37 -0
  137. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +1 -1
  138. package/packages/irc-test-support/src/index.ts +24 -0
  139. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  140. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  141. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  142. package/packages/irc-test-support/tsconfig.build.json +16 -0
  143. package/packages/irc-test-support/tsconfig.test.json +14 -0
  144. package/packages/irc-test-support/vitest.config.ts +20 -0
  145. package/progress.md +107 -0
  146. package/tickets.md +2485 -0
  147. package/tools/ci-hardening/package.json +30 -0
  148. package/tools/ci-hardening/src/index.ts +6 -0
  149. package/tools/ci-hardening/src/validate.ts +103 -0
  150. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  151. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  152. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  153. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  154. package/tools/ci-hardening/tsconfig.build.json +12 -0
  155. package/tools/ci-hardening/tsconfig.test.json +10 -0
  156. package/tools/ci-hardening/vitest.config.ts +21 -0
  157. package/tools/tcp-ws-forwarder/package.json +1 -1
  158. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  159. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  160. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  161. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  162. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  163. package/webircgateway/LICENSE +201 -0
  164. package/webircgateway/Makefile +44 -0
  165. package/webircgateway/README.md +134 -0
  166. package/webircgateway/config.conf.example +135 -0
  167. package/webircgateway/go.mod +16 -0
  168. package/webircgateway/go.sum +89 -0
  169. package/webircgateway/main.go +118 -0
  170. package/webircgateway/pkg/dnsbl/dnsbl.go +121 -0
  171. package/webircgateway/pkg/identd/identd.go +86 -0
  172. package/webircgateway/pkg/identd/rpcclient.go +59 -0
  173. package/webircgateway/pkg/irc/isupport.go +56 -0
  174. package/webircgateway/pkg/irc/message.go +217 -0
  175. package/webircgateway/pkg/irc/state.go +79 -0
  176. package/webircgateway/pkg/proxy/proxy.go +129 -0
  177. package/webircgateway/pkg/proxy/server.go +237 -0
  178. package/webircgateway/pkg/recaptcha/recaptcha.go +59 -0
  179. package/webircgateway/pkg/webircgateway/client.go +741 -0
  180. package/webircgateway/pkg/webircgateway/client_command_handlers.go +495 -0
  181. package/webircgateway/pkg/webircgateway/config.go +385 -0
  182. package/webircgateway/pkg/webircgateway/gateway.go +278 -0
  183. package/webircgateway/pkg/webircgateway/gateway_utils.go +133 -0
  184. package/webircgateway/pkg/webircgateway/hooks.go +152 -0
  185. package/webircgateway/pkg/webircgateway/letsencrypt.go +41 -0
  186. package/webircgateway/pkg/webircgateway/messagetags.go +103 -0
  187. package/webircgateway/pkg/webircgateway/transport_kiwiirc.go +206 -0
  188. package/webircgateway/pkg/webircgateway/transport_sockjs.go +107 -0
  189. package/webircgateway/pkg/webircgateway/transport_tcp.go +113 -0
  190. package/webircgateway/pkg/webircgateway/transport_websocket.go +126 -0
  191. package/webircgateway/pkg/webircgateway/utils.go +147 -0
  192. package/webircgateway/plugins/example/plugin.go +11 -0
  193. package/webircgateway/plugins/stats/plugin.go +52 -0
  194. package/webircgateway/staticcheck.conf +1 -0
  195. package/webircgateway/webircgateway.svg +3 -0
  196. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  197. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  198. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -28,15 +28,50 @@ import { randomUUID } from 'node:crypto';
28
28
  import { type Server, type Socket, createServer } from 'node:net';
29
29
  import { InMemoryRuntime } from '@serverless-ircd/in-memory-runtime';
30
30
  import {
31
+ type AdmissionConfig,
31
32
  type ChanName,
32
33
  type ConnectionState,
34
+ ConsoleLogger,
35
+ InMemoryMessageStore,
36
+ LogLevel,
37
+ type Logger,
38
+ type MessageStore,
33
39
  type RawLine,
34
40
  createConnection,
35
41
  } from '@serverless-ircd/irc-core';
36
42
  import { ConnectionActor } from '@serverless-ircd/irc-server';
37
43
  import { WebSocket, WebSocketServer } from 'ws';
44
+ import { loadServerConfigFromCliArgs } from './config-loader.js';
38
45
  import { LineScanner } from './line-scanner.js';
39
46
 
47
+ /**
48
+ * The fully-resolved config object passed to `attachConnection`. Legacy
49
+ * fields always have values (sane defaults); the security knobs remain
50
+ * optional because `undefined` is meaningful (= feature disabled).
51
+ */
52
+ type ResolvedServerConfig = Omit<
53
+ Required<LocalServerConfig>,
54
+ | 'serverPassword'
55
+ | 'cloaking'
56
+ | 'maxConnectionsPerIp'
57
+ | 'maxConnectionsPerUser'
58
+ | 'perIpConnectionRate'
59
+ | 'historyMaxPerChannel'
60
+ > &
61
+ Pick<
62
+ LocalServerConfig,
63
+ | 'serverPassword'
64
+ | 'cloaking'
65
+ | 'maxConnectionsPerIp'
66
+ | 'maxConnectionsPerUser'
67
+ | 'perIpConnectionRate'
68
+ >;
69
+
70
+ /** Default per-IP / per-user caps used when callers don't override. */
71
+ const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
72
+ const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
73
+ const DEFAULT_PER_IP_CONNECTION_RATE = { max: 5, windowMs: 60_000 };
74
+
40
75
  /** Default server config; `startLocalServer` accepts overrides. */
41
76
  export interface LocalServerConfig {
42
77
  serverName?: string;
@@ -49,6 +84,43 @@ export interface LocalServerConfig {
49
84
  channelLen?: number;
50
85
  topicLen?: number;
51
86
  quitMessage?: string;
87
+ /**
88
+ * Server password gate. Undefined / empty disables enforcement;
89
+ * otherwise every connection must supply the matching value via
90
+ * `PASS <password>` (or SASL PLAIN) before registration completes.
91
+ */
92
+ readonly serverPassword?: string;
93
+ /**
94
+ * Hostmask cloaking policy. Undefined disables cloaking; passing
95
+ * `{ enabled: true, secret: '...' }` replaces every connection's
96
+ * visible host with a deterministic cloak derived from the secret
97
+ * and the connection's source identifier.
98
+ */
99
+ readonly cloaking?: { enabled: boolean; secret: string; cloakedSuffix?: string } | undefined;
100
+ /**
101
+ * Per-IP simultaneous-connection cap. The local server enforces this
102
+ * at WebSocket upgrade time. Defaults to disabled (no cap) when
103
+ * omitted; pass a positive integer to enforce.
104
+ */
105
+ readonly maxConnectionsPerIp?: number;
106
+ /**
107
+ * Per-user (SASL account, falling back to source IP) simultaneous-
108
+ * connection cap. Same enforcement path as `maxConnectionsPerIp`.
109
+ */
110
+ readonly maxConnectionsPerUser?: number;
111
+ /**
112
+ * Per-IP connection-rate limit (sliding window). Caps how fast one
113
+ * source IP may open new connections.
114
+ */
115
+ readonly perIpConnectionRate?: { max: number; windowMs: number };
116
+ /**
117
+ * Maximum back-log messages retained per channel for IRCv3
118
+ * `draft/chathistory` playback. When omitted, the shared
119
+ * {@link InMemoryMessageStore} default (100) applies. Only consulted
120
+ * when chathistory playback is enabled (it always is for the local
121
+ * CLI — the store is bound unconditionally).
122
+ */
123
+ historyMaxPerChannel?: number;
52
124
  }
53
125
 
54
126
  export interface StartServerOptions extends LocalServerConfig {
@@ -73,6 +145,12 @@ export interface LocalServer {
73
145
  */
74
146
  readonly tcpPort?: number | undefined;
75
147
  readonly runtime: InMemoryRuntime;
148
+ /**
149
+ * The per-process chat-history store backing `draft/chathistory`
150
+ * playback. Shared across every connection so a message recorded by
151
+ * one client is replayable by another. Exposed for test assertions.
152
+ */
153
+ readonly messages: MessageStore;
76
154
  close(): Promise<void>;
77
155
  /**
78
156
  * Test-only seam: live server-side WebSockets. Exposed so tests can
@@ -92,7 +170,15 @@ interface ConnectionBindings {
92
170
  actor: ConnectionActor;
93
171
  }
94
172
 
95
- const DEFAULT_CONFIG: Required<LocalServerConfig> = {
173
+ const DEFAULT_CONFIG: Omit<
174
+ Required<LocalServerConfig>,
175
+ | 'serverPassword'
176
+ | 'cloaking'
177
+ | 'maxConnectionsPerIp'
178
+ | 'maxConnectionsPerUser'
179
+ | 'perIpConnectionRate'
180
+ | 'historyMaxPerChannel'
181
+ > = {
96
182
  serverName: 'irc.example.com',
97
183
  networkName: 'ExampleNet',
98
184
  motdLines: ['Welcome to the local-cli ServerlessIRCd instance.'],
@@ -120,6 +206,7 @@ export const DEFAULT_ID_FACTORY = {
120
206
  batchId: () => randomUUID(),
121
207
  nonce: () => randomUUID(),
122
208
  sessionId: () => randomUUID(),
209
+ traceId: () => randomUUID(),
123
210
  };
124
211
 
125
212
  /**
@@ -132,20 +219,106 @@ export const DEFAULT_ID_FACTORY = {
132
219
  * don't leak a half-started server.
133
220
  */
134
221
  export function startLocalServer(opts: StartServerOptions): Promise<LocalServer> {
135
- const cfg: Required<LocalServerConfig> = { ...DEFAULT_CONFIG, ...stripUndefined(opts) };
222
+ // Validate the user-supplied config through the shared ServerConfigSchema
223
+ // (sourced from `irc-core`). Both adapters consume the same schema; here
224
+ // we discard the parsed result and only retain the side-effect of
225
+ // throwing on a malformed config at boot. The actor still consumes the
226
+ // `LocalServerConfig` projection so existing callers (and the e2e
227
+ // harness) keep their permissive defaults.
228
+ let parsed: ReturnType<typeof loadServerConfigFromCliArgs>;
229
+ try {
230
+ parsed = loadServerConfigFromCliArgs({
231
+ serverName: opts.serverName ?? DEFAULT_CONFIG.serverName,
232
+ networkName: opts.networkName ?? DEFAULT_CONFIG.networkName,
233
+ ...(opts.motdLines !== undefined ? { motdLines: opts.motdLines } : {}),
234
+ ...(opts.maxChannelsPerUser !== undefined
235
+ ? { maxChannelsPerUser: opts.maxChannelsPerUser }
236
+ : {}),
237
+ ...(opts.maxTargetsPerCommand !== undefined
238
+ ? { maxTargetsPerCommand: opts.maxTargetsPerCommand }
239
+ : {}),
240
+ ...(opts.maxListEntries !== undefined ? { maxListEntries: opts.maxListEntries } : {}),
241
+ ...(opts.nickLen !== undefined ? { nickLen: opts.nickLen } : {}),
242
+ ...(opts.channelLen !== undefined ? { channelLen: opts.channelLen } : {}),
243
+ ...(opts.topicLen !== undefined ? { topicLen: opts.topicLen } : {}),
244
+ ...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
245
+ });
246
+ } catch (err) {
247
+ return Promise.reject(err);
248
+ }
249
+ const cfg: ResolvedServerConfig = {
250
+ serverName: parsed.serverName,
251
+ networkName: parsed.networkName,
252
+ motdLines: parsed.motdLines.length > 0 ? parsed.motdLines : DEFAULT_CONFIG.motdLines,
253
+ maxChannelsPerUser: parsed.maxChannelsPerUser,
254
+ maxTargetsPerCommand: parsed.maxTargetsPerCommand,
255
+ maxListEntries: parsed.maxListEntries,
256
+ nickLen: parsed.nickLen,
257
+ channelLen: parsed.channelLen,
258
+ topicLen: parsed.topicLen,
259
+ quitMessage: parsed.quitMessage,
260
+ ...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
261
+ ...(opts.cloaking !== undefined ? { cloaking: opts.cloaking } : {}),
262
+ ...(opts.maxConnectionsPerIp !== undefined
263
+ ? { maxConnectionsPerIp: opts.maxConnectionsPerIp }
264
+ : {}),
265
+ ...(opts.maxConnectionsPerUser !== undefined
266
+ ? { maxConnectionsPerUser: opts.maxConnectionsPerUser }
267
+ : {}),
268
+ ...(opts.perIpConnectionRate !== undefined
269
+ ? { perIpConnectionRate: opts.perIpConnectionRate }
270
+ : {}),
271
+ };
136
272
  const hostname = opts.hostname ?? '127.0.0.1';
137
273
 
138
- const runtime = new InMemoryRuntime({ clock: { now: () => Date.now() } });
274
+ // Admission gate: enabled as soon as any cap or rate limit is configured.
275
+ // Defaults mirror irc-core's ServerConfigSchema so local-CLI behaviour
276
+ // matches the production adapters.
277
+ const admission: AdmissionConfig | undefined =
278
+ opts.maxConnectionsPerIp !== undefined ||
279
+ opts.maxConnectionsPerUser !== undefined ||
280
+ opts.perIpConnectionRate !== undefined
281
+ ? {
282
+ maxConnectionsPerIp: opts.maxConnectionsPerIp ?? DEFAULT_MAX_CONNECTIONS_PER_IP,
283
+ maxConnectionsPerUser: opts.maxConnectionsPerUser ?? DEFAULT_MAX_CONNECTIONS_PER_USER,
284
+ perIpConnectionRate: opts.perIpConnectionRate ?? DEFAULT_PER_IP_CONNECTION_RATE,
285
+ }
286
+ : undefined;
287
+
288
+ const runtime = new InMemoryRuntime({
289
+ clock: { now: () => Date.now() },
290
+ ...(admission !== undefined ? { admission } : {}),
291
+ });
292
+
293
+ // Per-process chat-history store: one ring buffer shared by every
294
+ // connection so cross-connection playback works (a message alice sends
295
+ // is replayable when bob queries CHATHISTORY). The cap is config-driven.
296
+ const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
139
297
 
140
298
  const wss = new WebSocketServer({ port: opts.port, host: hostname });
141
299
  const wsConnections = new Map<WebSocket, ConnectionBindings>();
142
300
 
143
- wss.on('connection', (ws) => {
144
- const { state, actor } = attachConnection(runtime, cfg, {
301
+ wss.on('connection', (ws, req) => {
302
+ // Admission gate: refuse the connection BEFORE we attach an actor / emit
303
+ // any IRC state. Keeps per-IP / per-user counters authoritative and
304
+ // avoids burning a nick or runtime slot on a denied client.
305
+ const ip = req.socket.remoteAddress ?? 'unknown';
306
+ const decision = runtime.admitConnection(ip, undefined);
307
+ if (!decision.ok) {
308
+ ws.send(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
309
+ ws.close();
310
+ return;
311
+ }
312
+ const admissionRecordId = decision.recordId;
313
+ runtime.commitAdmission(ip, undefined, admissionRecordId);
314
+
315
+ const { state, actor } = attachConnection(runtime, cfg, messages, {
145
316
  sendText: (text) => {
146
317
  if (ws.readyState === WebSocket.OPEN) ws.send(text);
147
318
  },
148
319
  closeTransport: () => ws.close(),
320
+ admissionRecordId,
321
+ sourceHost: ip,
149
322
  });
150
323
  wsConnections.set(ws, { state, actor });
151
324
 
@@ -161,7 +334,8 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
161
334
 
162
335
  // 'close' and 'error' both end in the same cleanup: drop the connection
163
336
  // map entry and unregister the conn from the runtime so it stops
164
- // receiving broadcasts.
337
+ // receiving broadcasts. unregisterConnection also releases the
338
+ // admission record (if any) so per-IP / per-user counters stay accurate.
165
339
  const cleanup = (): void => {
166
340
  wsConnections.delete(ws);
167
341
  runtime.unregisterConnection(state.id);
@@ -199,7 +373,23 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
199
373
  const tcpServer = createServer((socket) => {
200
374
  tcpSockets.add(socket);
201
375
  const scanner = new LineScanner();
202
- const { state, actor } = attachConnection(runtime, cfg, {
376
+
377
+ // Admission gate — same policy as the WS path: refuse before the
378
+ // actor is attached so per-IP / per-user counters stay authoritative.
379
+ const ip = socket.remoteAddress ?? 'unknown';
380
+ const decision = runtime.admitConnection(ip, undefined);
381
+ if (!decision.ok) {
382
+ if (socket.writable) {
383
+ socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
384
+ }
385
+ socket.destroy();
386
+ tcpSockets.delete(socket);
387
+ return;
388
+ }
389
+ const admissionRecordId = decision.recordId;
390
+ runtime.commitAdmission(ip, undefined, admissionRecordId);
391
+
392
+ const { state, actor } = attachConnection(runtime, cfg, messages, {
203
393
  sendText: (text) => {
204
394
  if (socket.writable) socket.write(text);
205
395
  },
@@ -209,6 +399,8 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
209
399
  // callers go through `ws.close()`; TCP callers go through here.
210
400
  socket.destroy();
211
401
  },
402
+ admissionRecordId,
403
+ sourceHost: ip,
212
404
  });
213
405
  tcpConnections.set(socket, { state, actor });
214
406
 
@@ -261,6 +453,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
261
453
  tcpPort,
262
454
  hostname,
263
455
  runtime,
456
+ messages,
264
457
  testSockets: wss.clients,
265
458
  testTcpSockets: tcpSockets,
266
459
  close: () =>
@@ -286,11 +479,28 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
286
479
  */
287
480
  function attachConnection(
288
481
  runtime: InMemoryRuntime,
289
- cfg: Required<LocalServerConfig>,
290
- transport: { sendText: (text: string) => void; closeTransport: () => void },
482
+ cfg: ResolvedServerConfig,
483
+ messages: MessageStore,
484
+ transport: {
485
+ sendText: (text: string) => void;
486
+ closeTransport: () => void;
487
+ admissionRecordId?: string;
488
+ sourceHost?: string;
489
+ },
291
490
  ): ConnectionBindings {
292
491
  const id = randomUUID();
293
492
  const state = createConnection({ id, connectedSince: Date.now() });
493
+ // Record the transport-visible source host up front. Used as the cloak
494
+ // input when cloaking is enabled, and as the visible host otherwise.
495
+ if (transport.sourceHost !== undefined) {
496
+ state.host = transport.sourceHost;
497
+ }
498
+
499
+ // Per-connection logger: every record the actor emits
500
+ // carries this connection's id so the local-cli log stream can be
501
+ // filtered per client. traceId is generated per inbound frame by the
502
+ // actor itself.
503
+ const logger: Logger = new ConsoleLogger({ connectionId: id }, undefined, LogLevel.Info);
294
504
 
295
505
  const actor = new ConnectionActor({
296
506
  state,
@@ -306,27 +516,35 @@ function attachConnection(
306
516
  channelLen: cfg.channelLen,
307
517
  topicLen: cfg.topicLen,
308
518
  quitMessage: cfg.quitMessage,
519
+ ...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
520
+ ...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
309
521
  },
310
522
  clock: { now: () => Date.now() },
311
523
  ids: DEFAULT_ID_FACTORY,
312
524
  motd: { lines: () => cfg.motdLines },
525
+ messages,
526
+ logger,
313
527
  });
314
528
 
315
- runtime.registerConnection(state, {
316
- send: (lines: RawLine[]) => {
317
- const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
318
- transport.sendText(text);
319
- },
320
- disconnect: (reason?: string) => {
321
- // If a reason was supplied, send the RFC-style ERROR notice before
322
- // closing. (QUIT does not supply one; future paths like flood
323
- // control will.)
324
- if (reason !== undefined) {
325
- transport.sendText(`ERROR :Closing link: (${reason})\r\n`);
326
- }
327
- transport.closeTransport();
529
+ runtime.registerConnection(
530
+ state,
531
+ {
532
+ send: (lines: RawLine[]) => {
533
+ const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
534
+ transport.sendText(text);
535
+ },
536
+ disconnect: (reason?: string) => {
537
+ // If a reason was supplied, send the RFC-style ERROR notice before
538
+ // closing. (QUIT does not supply one; future paths like flood
539
+ // control will.)
540
+ if (reason !== undefined) {
541
+ transport.sendText(`ERROR :Closing link: (${reason})\r\n`);
542
+ }
543
+ transport.closeTransport();
544
+ },
328
545
  },
329
- });
546
+ transport.admissionRecordId,
547
+ );
330
548
 
331
549
  return { state, actor };
332
550
  }
@@ -382,15 +600,6 @@ function closeEverything(args: {
382
600
  return Promise.all([wsClosed, tcpClosed]).then(() => undefined);
383
601
  }
384
602
 
385
- function stripUndefined<T extends object>(obj: T): Partial<T> {
386
- const out: Partial<T> = {};
387
- for (const key of Object.keys(obj) as Array<keyof T>) {
388
- const v = obj[key];
389
- if (v !== undefined) out[key] = v;
390
- }
391
- return out;
392
- }
393
-
394
603
  function logError(msg: string, err: unknown): void {
395
604
  // Structured JSON per acceptance criteria.
396
605
  const payload = { ts: new Date().toISOString(), level: 'error', msg, err: stringifyErr(err) };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Config loading for the local-cli app.
3
+ *
4
+ * Verifies the shared `ServerConfigSchema` (from `irc-core`) is also
5
+ * consumed by the local-cli harness — both adapters consume the same
6
+ * schema, only the input plumbing differs.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+ import { loadServerConfigFromCliArgs } from '../src/config-loader';
11
+
12
+ describe('loadServerConfigFromCliArgs — minimal input', () => {
13
+ it('returns a parsed config from serverName + networkName alone', () => {
14
+ const cfg = loadServerConfigFromCliArgs({
15
+ serverName: 'irc.local.example.com',
16
+ networkName: 'LocalNet',
17
+ });
18
+ expect(cfg.serverName).toBe('irc.local.example.com');
19
+ expect(cfg.networkName).toBe('LocalNet');
20
+ expect(cfg.channelPrefixes.length).toBeGreaterThan(0);
21
+ expect(cfg.maxClients).toBeGreaterThan(0);
22
+ });
23
+
24
+ it('accepts motdLines and threads them through to the parsed config', () => {
25
+ const cfg = loadServerConfigFromCliArgs({
26
+ serverName: 's',
27
+ networkName: 'n',
28
+ motdLines: ['hello', 'world'],
29
+ });
30
+ expect(cfg.motdLines).toEqual(['hello', 'world']);
31
+ });
32
+
33
+ it('passes numeric limit overrides through', () => {
34
+ const cfg = loadServerConfigFromCliArgs({
35
+ serverName: 's',
36
+ networkName: 'n',
37
+ maxChannelsPerUser: 5,
38
+ nickLen: 12,
39
+ channelLen: 32,
40
+ });
41
+ expect(cfg.maxChannelsPerUser).toBe(5);
42
+ expect(cfg.nickLen).toBe(12);
43
+ expect(cfg.channelLen).toBe(32);
44
+ });
45
+
46
+ it('accepts channelPrefixes, maxClients and operCreds', () => {
47
+ const cfg = loadServerConfigFromCliArgs({
48
+ serverName: 's',
49
+ networkName: 'n',
50
+ channelPrefixes: '#&',
51
+ maxClients: 50,
52
+ operCreds: [{ user: 'op', password: 'p' }],
53
+ });
54
+ expect(cfg.channelPrefixes).toBe('#&');
55
+ expect(cfg.maxClients).toBe(50);
56
+ expect(cfg.operCreds).toEqual([{ user: 'op', password: 'p' }]);
57
+ });
58
+ });
59
+
60
+ describe('loadServerConfigFromCliArgs — failure modes', () => {
61
+ it('throws when serverName is missing', () => {
62
+ expect(() => loadServerConfigFromCliArgs({ serverName: '', networkName: 'n' })).toThrowError(
63
+ /serverName/u,
64
+ );
65
+ });
66
+
67
+ it('throws when networkName is empty', () => {
68
+ expect(() => loadServerConfigFromCliArgs({ serverName: 's', networkName: '' })).toThrowError(
69
+ /networkName/u,
70
+ );
71
+ });
72
+
73
+ it('throws when maxClients is non-positive', () => {
74
+ expect(() =>
75
+ loadServerConfigFromCliArgs({ serverName: 's', networkName: 'n', maxClients: 0 }),
76
+ ).toThrowError(/maxClients/u);
77
+ });
78
+
79
+ it('throws when operCreds entries lack password', () => {
80
+ expect(() =>
81
+ loadServerConfigFromCliArgs({
82
+ serverName: 's',
83
+ networkName: 'n',
84
+ operCreds: [{ user: 'op', password: '' }],
85
+ }),
86
+ ).toThrowError(/password/u);
87
+ });
88
+
89
+ it('aggregates multiple errors into a single readable Error', () => {
90
+ try {
91
+ loadServerConfigFromCliArgs({
92
+ serverName: '',
93
+ networkName: '',
94
+ maxClients: -1,
95
+ });
96
+ expect.fail('expected throw');
97
+ } catch (err) {
98
+ expect(err).toBeInstanceOf(Error);
99
+ const msg = (err as Error).message;
100
+ expect(msg).toMatch(/invalid server config/iu);
101
+ const hits = ['serverName', 'networkName', 'maxClients'].filter((f) =>
102
+ msg.includes(f),
103
+ ).length;
104
+ expect(hits).toBeGreaterThanOrEqual(2);
105
+ }
106
+ });
107
+ });
@@ -178,6 +178,19 @@ describe('local-cli error paths', () => {
178
178
  await first.close();
179
179
  });
180
180
 
181
+ it('validates the supplied config through the shared ServerConfigSchema at boot', async () => {
182
+ // nickLen=0 is rejected by the schema (must be a positive integer).
183
+ await expect(
184
+ startLocalServer({
185
+ port: 0,
186
+ hostname: '127.0.0.1',
187
+ serverName: 'irc.local.example.com',
188
+ networkName: 'LocalNet',
189
+ nickLen: 0,
190
+ }),
191
+ ).rejects.toThrowError(/invalid server config/iu);
192
+ });
193
+
181
194
  it('logs and recovers from a per-socket error without crashing the server', async () => {
182
195
  const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
183
196
  const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
@@ -344,3 +357,64 @@ describe('local-cli error paths', () => {
344
357
  await srv.close();
345
358
  });
346
359
  });
360
+
361
+ describe('local-cli chathistory end-to-end', () => {
362
+ const CHATHISTORY_CAPS = 'draft/chathistory server-time message-tags batch';
363
+
364
+ /** Registers a client that has negotiated the chathistory cap set. */
365
+ async function registerWithCaps(srv: LocalServer, nick: string): Promise<TestClient> {
366
+ const client = new TestClient(`ws://127.0.0.1:${srv.port}/`);
367
+ await client.opened();
368
+ client.send(`CAP REQ :${CHATHISTORY_CAPS}\r\n`);
369
+ await client.waitFor((l) => / CAP \S+ ACK :/.test(l));
370
+ client.send(`NICK ${nick}\r\n`);
371
+ client.send(`USER ${nick} 0 * :${nick}\r\n`);
372
+ client.send('CAP END\r\n');
373
+ await client.waitFor((l) => l.startsWith(':irc.example.com 001 '));
374
+ return client;
375
+ }
376
+
377
+ it('records a PRIVMSG and replays it via CHATHISTORY LATEST in a BATCH', async () => {
378
+ const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
379
+ const alice = await registerWithCaps(srv, 'ch-alice');
380
+
381
+ alice.send('JOIN #chathist-e2e\r\n');
382
+ await alice.waitFor((l) => l.includes(' 366 '));
383
+
384
+ alice.send('PRIVMSG #chathist-e2e :backlog hello\r\n');
385
+
386
+ alice.send('CHATHISTORY LATEST #chathist-e2e * 10\r\n');
387
+ const batchOpen = await alice.waitFor((l) => /^BATCH \+\S+ chathistory #chathist-e2e/.test(l));
388
+ expect(batchOpen).toMatch(/^BATCH \+\S+ chathistory #chathist-e2e/);
389
+
390
+ const replay = await alice.waitFor((l) => l.includes('PRIVMSG #chathist-e2e :backlog hello'));
391
+ expect(replay).toContain('PRIVMSG #chathist-e2e :backlog hello');
392
+
393
+ await alice.close();
394
+ await srv.close();
395
+ });
396
+
397
+ it('prepends a chathistory BATCH on rejoin after messages were sent', async () => {
398
+ const srv = await startLocalServer({ port: 0, hostname: '127.0.0.1' });
399
+
400
+ // Seed history as alice: join + send a message that bob has missed.
401
+ const alice = await registerWithCaps(srv, 'ch-seed');
402
+ alice.send('JOIN #rejoin-e2e\r\n');
403
+ await alice.waitFor((l) => l.includes(' 366 ') && l.includes('#rejoin-e2e'));
404
+ alice.send('PRIVMSG #rejoin-e2e :while you were away\r\n');
405
+ // Give the frame a moment to record before bob joins.
406
+ await new Promise((r) => setTimeout(r, 50));
407
+
408
+ // A second cap-enabled client joins the same channel and must receive
409
+ // the auto-playback BATCH before the normal JOIN/353/366 sequence.
410
+ const bob = await registerWithCaps(srv, 'ch-rejoin');
411
+ bob.send('JOIN #rejoin-e2e\r\n');
412
+ const playback = await bob.waitFor((l) => /^BATCH \+\S+ chathistory #rejoin-e2e/.test(l));
413
+ expect(playback).toMatch(/^BATCH \+\S+ chathistory #rejoin-e2e/);
414
+ await bob.waitFor((l) => l.includes('PRIVMSG #rejoin-e2e :while you were away'));
415
+
416
+ await alice.close();
417
+ await bob.close();
418
+ await srv.close();
419
+ });
420
+ });