serverless-ircd 0.1.0 → 0.3.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 (204) 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/CHANGELOG.md +436 -0
  6. package/README.md +127 -84
  7. package/apps/aws-stack/README.md +73 -0
  8. package/apps/aws-stack/bin/aws.ts +49 -0
  9. package/apps/aws-stack/cdk.json +10 -0
  10. package/apps/aws-stack/package.json +41 -0
  11. package/apps/aws-stack/scripts/smoke-helpers.d.mts +22 -0
  12. package/apps/aws-stack/scripts/smoke-helpers.mjs +89 -0
  13. package/apps/aws-stack/scripts/smoke.mjs +142 -0
  14. package/apps/aws-stack/src/aws-stack.ts +263 -0
  15. package/apps/aws-stack/src/tables.ts +10 -0
  16. package/apps/aws-stack/tests/localstack.test.ts +46 -0
  17. package/apps/aws-stack/tests/smoke-helpers.test.ts +98 -0
  18. package/apps/aws-stack/tests/stack.test.ts +464 -0
  19. package/apps/aws-stack/tsconfig.build.json +11 -0
  20. package/apps/aws-stack/tsconfig.test.json +8 -0
  21. package/apps/aws-stack/vitest.config.ts +20 -0
  22. package/apps/cf-worker/package.json +1 -1
  23. package/apps/cf-worker/scripts/smoke.mjs +0 -0
  24. package/apps/cf-worker/src/worker.ts +8 -2
  25. package/apps/cf-worker/tests/smoke.test.ts +1 -0
  26. package/apps/cf-worker/wrangler.test.toml +5 -1
  27. package/apps/cf-worker/wrangler.toml +66 -17
  28. package/apps/local-cli/package.json +1 -1
  29. package/apps/local-cli/src/config-loader.ts +57 -0
  30. package/apps/local-cli/src/main.ts +1 -1
  31. package/apps/local-cli/src/server.ts +267 -32
  32. package/apps/local-cli/tests/config-loader.test.ts +107 -0
  33. package/apps/local-cli/tests/e2e.test.ts +126 -0
  34. package/apps/local-cli/tests/security-e2e.test.ts +239 -0
  35. package/biome.json +3 -1
  36. package/docs/ADR-001-pure-reducers-and-effect-system.md +74 -0
  37. package/docs/ADR-002-location-of-authority.md +82 -0
  38. package/docs/ADR-003-durable-object-sharding.md +93 -0
  39. package/docs/ADR-004-dynamodb-schema.md +96 -0
  40. package/docs/ADR-005-wss-only-transport-v1.md +83 -0
  41. package/docs/ADR-006-sasl-mechanism-scope.md +86 -0
  42. package/docs/ADR-007-deterministic-ports.md +82 -0
  43. package/docs/ADR-008-monorepo-tooling.md +60 -0
  44. package/docs/AWS-Adapter-Architecture.md +496 -0
  45. package/docs/AWS-Deployment.md +1186 -0
  46. package/docs/Cloudflare-Deployment-Guide.md +660 -0
  47. package/docs/Home.md +11 -0
  48. package/docs/Observability.md +87 -0
  49. package/docs/PlanIRCv3Websocket.md +489 -0
  50. package/docs/PlanWebClient.md +451 -0
  51. package/docs/Release-Process.md +443 -0
  52. package/package.json +19 -14
  53. package/packages/aws-adapter/README.md +35 -0
  54. package/packages/aws-adapter/package.json +52 -0
  55. package/packages/aws-adapter/src/account-store.ts +121 -0
  56. package/packages/aws-adapter/src/aws-runtime.ts +871 -0
  57. package/packages/aws-adapter/src/cdk-table-defs.ts +73 -0
  58. package/packages/aws-adapter/src/config-loader.ts +126 -0
  59. package/packages/aws-adapter/src/dynamo-account-store.ts +156 -0
  60. package/packages/aws-adapter/src/dynamo.ts +61 -0
  61. package/packages/aws-adapter/src/handlers/connect.ts +44 -0
  62. package/packages/aws-adapter/src/handlers/default.ts +330 -0
  63. package/packages/aws-adapter/src/handlers/disconnect.ts +48 -0
  64. package/packages/aws-adapter/src/handlers/index.ts +293 -0
  65. package/packages/aws-adapter/src/handlers/ping-checker.ts +217 -0
  66. package/packages/aws-adapter/src/handlers/state.ts +13 -0
  67. package/packages/aws-adapter/src/handlers/sweeper.ts +84 -0
  68. package/packages/aws-adapter/src/index.ts +74 -0
  69. package/packages/aws-adapter/src/message-store.ts +34 -0
  70. package/packages/aws-adapter/src/serialize.ts +283 -0
  71. package/packages/aws-adapter/src/tables.ts +53 -0
  72. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +171 -0
  73. package/packages/aws-adapter/tests/account-store.test.ts +280 -0
  74. package/packages/aws-adapter/tests/aws-harness.ts +408 -0
  75. package/packages/aws-adapter/tests/aws-integration.test.ts +17 -0
  76. package/packages/aws-adapter/tests/aws-runtime.test.ts +654 -0
  77. package/packages/aws-adapter/tests/config-loader.test.ts +213 -0
  78. package/packages/aws-adapter/tests/disconnect-fanout.test.ts +336 -0
  79. package/packages/aws-adapter/tests/dynamo.test.ts +57 -0
  80. package/packages/aws-adapter/tests/global-setup.ts +301 -0
  81. package/packages/aws-adapter/tests/gone-exception.test.ts +271 -0
  82. package/packages/aws-adapter/tests/handlers.test.ts +473 -0
  83. package/packages/aws-adapter/tests/message-store.test.ts +55 -0
  84. package/packages/aws-adapter/tests/ping-checker.test.ts +571 -0
  85. package/packages/aws-adapter/tests/serialize.test.ts +249 -0
  86. package/packages/aws-adapter/tests/smoke.test.ts +48 -0
  87. package/packages/aws-adapter/tests/sweeper.test.ts +420 -0
  88. package/packages/aws-adapter/tests/tables.test.ts +74 -0
  89. package/packages/aws-adapter/tests/transactions.test.ts +446 -0
  90. package/packages/aws-adapter/tsconfig.build.json +16 -0
  91. package/packages/aws-adapter/tsconfig.test.json +14 -0
  92. package/packages/aws-adapter/vitest.config.ts +23 -0
  93. package/packages/cf-adapter/package.json +2 -1
  94. package/packages/cf-adapter/src/cf-runtime.ts +42 -22
  95. package/packages/cf-adapter/src/channel-do.ts +40 -1
  96. package/packages/cf-adapter/src/channel-registry-do.ts +58 -0
  97. package/packages/cf-adapter/src/config-loader.ts +135 -0
  98. package/packages/cf-adapter/src/connection-do.ts +119 -0
  99. package/packages/cf-adapter/src/env.ts +27 -1
  100. package/packages/cf-adapter/src/index.ts +2 -1
  101. package/packages/cf-adapter/tests/cf-harness.ts +2 -2
  102. package/packages/cf-adapter/tests/cf-integration.test.ts +2 -2
  103. package/packages/cf-adapter/tests/cf-runtime.test.ts +91 -0
  104. package/packages/cf-adapter/tests/config-loader.test.ts +149 -0
  105. package/packages/cf-adapter/tests/connection-do.test.ts +82 -0
  106. package/packages/cf-adapter/tests/worker/main.ts +2 -1
  107. package/packages/cf-adapter/tests/worker/stubs/channel-stub.ts +7 -1
  108. package/packages/cf-adapter/wrangler.test.toml +10 -1
  109. package/packages/in-memory-runtime/package.json +1 -1
  110. package/packages/in-memory-runtime/src/in-memory-runtime.ts +107 -16
  111. package/packages/in-memory-runtime/src/index.ts +1 -1
  112. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +134 -0
  113. package/packages/irc-core/package.json +13 -2
  114. package/packages/irc-core/src/admission.ts +216 -0
  115. package/packages/irc-core/src/caps/capabilities.ts +1 -0
  116. package/packages/irc-core/src/case-fold.ts +64 -0
  117. package/packages/irc-core/src/cloak.ts +81 -0
  118. package/packages/irc-core/src/commands/cap.ts +1 -2
  119. package/packages/irc-core/src/commands/chathistory.ts +305 -0
  120. package/packages/irc-core/src/commands/index.ts +9 -0
  121. package/packages/irc-core/src/commands/invite.ts +6 -9
  122. package/packages/irc-core/src/commands/isupport.ts +1 -1
  123. package/packages/irc-core/src/commands/join.ts +63 -10
  124. package/packages/irc-core/src/commands/kick.ts +4 -11
  125. package/packages/irc-core/src/commands/list.ts +5 -4
  126. package/packages/irc-core/src/commands/mode.ts +5 -9
  127. package/packages/irc-core/src/commands/motd-lines.ts +127 -0
  128. package/packages/irc-core/src/commands/motd.ts +6 -110
  129. package/packages/irc-core/src/commands/oper.ts +151 -0
  130. package/packages/irc-core/src/commands/privmsg.ts +24 -0
  131. package/packages/irc-core/src/commands/registration.ts +79 -21
  132. package/packages/irc-core/src/commands/sasl.ts +251 -0
  133. package/packages/irc-core/src/commands/tagmsg.ts +205 -0
  134. package/packages/irc-core/src/config.ts +270 -0
  135. package/packages/irc-core/src/flood-control.ts +175 -0
  136. package/packages/irc-core/src/index.ts +7 -0
  137. package/packages/irc-core/src/ports.ts +719 -0
  138. package/packages/irc-core/src/protocol/base64.ts +16 -0
  139. package/packages/irc-core/src/protocol/index.ts +2 -1
  140. package/packages/irc-core/src/protocol/numerics.ts +11 -0
  141. package/packages/irc-core/src/protocol/parser.ts +27 -2
  142. package/packages/irc-core/src/state/connection.ts +41 -0
  143. package/packages/irc-core/src/types.ts +88 -2
  144. package/packages/irc-core/stryker.commands.conf.json +41 -0
  145. package/packages/irc-core/stryker.protocol.conf.json +26 -0
  146. package/packages/irc-core/tests/account-store.test.ts +88 -0
  147. package/packages/irc-core/tests/admission.test.ts +229 -0
  148. package/packages/irc-core/tests/caps/capabilities.test.ts +22 -0
  149. package/packages/irc-core/tests/case-fold.test.ts +80 -0
  150. package/packages/irc-core/tests/cloak.test.ts +78 -0
  151. package/packages/irc-core/tests/commands/chathistory.test.ts +996 -0
  152. package/packages/irc-core/tests/commands/join.test.ts +246 -2
  153. package/packages/irc-core/tests/commands/kick.test.ts +15 -0
  154. package/packages/irc-core/tests/commands/oper.test.ts +257 -0
  155. package/packages/irc-core/tests/commands/privmsg.test.ts +146 -2
  156. package/packages/irc-core/tests/commands/registration.test.ts +380 -20
  157. package/packages/irc-core/tests/commands/sasl.test.ts +536 -0
  158. package/packages/irc-core/tests/commands/tagmsg.test.ts +688 -0
  159. package/packages/irc-core/tests/commands/who.test.ts +22 -4
  160. package/packages/irc-core/tests/commands/whois.test.ts +8 -1
  161. package/packages/irc-core/tests/config.test.ts +721 -0
  162. package/packages/irc-core/tests/flood-control.test.ts +394 -0
  163. package/packages/irc-core/tests/message-store.test.ts +530 -0
  164. package/packages/irc-core/tests/parser.test.ts +44 -1
  165. package/packages/irc-core/tests/ports.test.ts +263 -0
  166. package/packages/irc-server/package.json +1 -1
  167. package/packages/irc-server/src/actor.ts +186 -44
  168. package/packages/irc-server/src/dispatch.ts +89 -5
  169. package/packages/irc-server/src/index.ts +2 -0
  170. package/packages/irc-server/src/routing.ts +160 -0
  171. package/packages/irc-server/tests/actor.test.ts +690 -15
  172. package/packages/irc-server/tests/dispatch.test.ts +84 -0
  173. package/packages/irc-server/tests/routing.test.ts +204 -0
  174. package/packages/irc-test-support/README.md +32 -0
  175. package/packages/irc-test-support/package.json +37 -0
  176. package/packages/{cf-adapter/tests/integration → irc-test-support/src}/in-memory-harness.ts +6 -5
  177. package/packages/irc-test-support/src/index.ts +24 -0
  178. package/packages/{cf-adapter/tests/integration/harness.test.ts → irc-test-support/tests/in-memory-harness.test.ts} +1 -1
  179. package/packages/{cf-adapter/tests/integration/scenarios.test.ts → irc-test-support/tests/in-memory-scenarios.test.ts} +1 -2
  180. package/packages/irc-test-support/tests/smoke.test.ts +11 -0
  181. package/packages/irc-test-support/tsconfig.build.json +16 -0
  182. package/packages/irc-test-support/tsconfig.test.json +14 -0
  183. package/packages/irc-test-support/vitest.config.ts +20 -0
  184. package/pnpm-workspace.yaml +1 -0
  185. package/tools/ci-hardening/package.json +30 -0
  186. package/tools/ci-hardening/src/index.ts +6 -0
  187. package/tools/ci-hardening/src/validate.ts +103 -0
  188. package/tools/ci-hardening/tests/__no_thresholds__.txt +11 -0
  189. package/tools/ci-hardening/tests/__partial_thresholds__.txt +16 -0
  190. package/tools/ci-hardening/tests/__stryker_bad__.json +4 -0
  191. package/tools/ci-hardening/tests/validate.test.ts +177 -0
  192. package/tools/ci-hardening/tsconfig.build.json +12 -0
  193. package/tools/ci-hardening/tsconfig.test.json +10 -0
  194. package/tools/ci-hardening/vitest.config.ts +21 -0
  195. package/tools/seed-aws-accounts.ts +80 -0
  196. package/tools/tcp-ws-forwarder/package.json +1 -1
  197. package/tools/tcp-ws-forwarder/src/forwarder.ts +34 -23
  198. package/tools/tcp-ws-forwarder/src/logger.ts +155 -0
  199. package/tools/tcp-ws-forwarder/src/main.ts +33 -14
  200. package/tools/tcp-ws-forwarder/tests/forwarder.test.ts +86 -0
  201. package/tools/tcp-ws-forwarder/tests/logger.test.ts +136 -0
  202. package/packages/cf-adapter/tests/integration/index.ts +0 -17
  203. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/harness.ts +0 -0
  204. /package/packages/{cf-adapter/tests/integration → irc-test-support/src}/scenarios.ts +0 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Local-cli config loader.
3
+ *
4
+ * Both adapters (Cloudflare Workers via `loadServerConfigFromCfEnv`, and
5
+ * this local harness via `loadServerConfigFromCliArgs`) consume the
6
+ * single `ServerConfigSchema` shipped from `irc-core`. The local-cli
7
+ * accepts already-typed values (no env-string coercion needed) and
8
+ * validates them through the shared schema so misconfigurations are
9
+ * surfaced at boot rather than mid-session.
10
+ */
11
+
12
+ import { type ParsedServerConfig, parseServerConfig } from '@serverless-ircd/irc-core';
13
+
14
+ /**
15
+ * CLI / programmatic input for `loadServerConfigFromCliArgs`. Every
16
+ * field is optional except the two required schema fields
17
+ * (`serverName`, `networkName`); the schema applies defaults to the
18
+ * rest. Field types match the schema's input shape (numbers stay
19
+ * numbers, etc.) so callers don't need to coerce strings.
20
+ */
21
+ export interface CliConfigInput {
22
+ serverName: string;
23
+ networkName: string;
24
+ motdLines?: string[];
25
+ channelPrefixes?: string;
26
+ maxClients?: number;
27
+ operCreds?: Array<{ user: string; password: string }>;
28
+ maxChannelsPerUser?: number;
29
+ maxTargetsPerCommand?: number;
30
+ maxListEntries?: number;
31
+ nickLen?: number;
32
+ channelLen?: number;
33
+ topicLen?: number;
34
+ quitMessage?: string;
35
+ saslAccounts?: Array<{ username: string; password: string }>;
36
+ }
37
+
38
+ /**
39
+ * Validates CLI-supplied config against the shared
40
+ * {@link ServerConfigSchema} from `irc-core`.
41
+ *
42
+ * Throws a single readable `Error` listing every invalid field if the
43
+ * input fails validation. Adapters MUST call this once at boot —
44
+ * `startLocalServer` already calls it via its options normalisation.
45
+ */
46
+ export function loadServerConfigFromCliArgs(input: CliConfigInput): ParsedServerConfig {
47
+ return parseServerConfig(stripUndefined(input));
48
+ }
49
+
50
+ /** Returns a shallow copy of `obj` with `undefined` values dropped. */
51
+ function stripUndefined(obj: CliConfigInput): Record<string, unknown> {
52
+ const out: Record<string, unknown> = {};
53
+ for (const [k, v] of Object.entries(obj)) {
54
+ if (v !== undefined) out[k] = v;
55
+ }
56
+ return out;
57
+ }
@@ -14,7 +14,7 @@
14
14
  * Both listeners share one runtime, so a TCP client and a WS client can
15
15
  * see each other (cross-transport channel broadcast). Pass `--no-tcp` to
16
16
  * run WebSocket-only; the WebSocket port / hostname / MOTD are tunable via
17
- * flags (a fuller config-loader lands in a later ticket).
17
+ * flags.
18
18
  *
19
19
  * Usage:
20
20
  * pnpm --filter local-cli start -- --port 6667 --tcp-port 6668 --host 127.0.0.1
@@ -28,15 +28,54 @@ 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 AccountStore,
32
+ type AdmissionConfig,
31
33
  type ChanName,
32
34
  type ConnectionState,
35
+ ConsoleLogger,
36
+ InMemoryAccountStore,
37
+ InMemoryMessageStore,
38
+ LogLevel,
39
+ type Logger,
40
+ type MessageStore,
33
41
  type RawLine,
34
42
  createConnection,
35
43
  } from '@serverless-ircd/irc-core';
36
44
  import { ConnectionActor } from '@serverless-ircd/irc-server';
37
45
  import { WebSocket, WebSocketServer } from 'ws';
46
+ import { loadServerConfigFromCliArgs } from './config-loader.js';
38
47
  import { LineScanner } from './line-scanner.js';
39
48
 
49
+ /**
50
+ * The fully-resolved config object passed to `attachConnection`. Legacy
51
+ * fields always have values (sane defaults); the security knobs remain
52
+ * optional because `undefined` is meaningful (= feature disabled).
53
+ */
54
+ type ResolvedServerConfig = Omit<
55
+ Required<LocalServerConfig>,
56
+ | 'serverPassword'
57
+ | 'cloaking'
58
+ | 'maxConnectionsPerIp'
59
+ | 'maxConnectionsPerUser'
60
+ | 'perIpConnectionRate'
61
+ | 'historyMaxPerChannel'
62
+ | 'saslAccounts'
63
+ > &
64
+ Pick<
65
+ LocalServerConfig,
66
+ | 'serverPassword'
67
+ | 'cloaking'
68
+ | 'maxConnectionsPerIp'
69
+ | 'maxConnectionsPerUser'
70
+ | 'perIpConnectionRate'
71
+ | 'saslAccounts'
72
+ >;
73
+
74
+ /** Default per-IP / per-user caps used when callers don't override. */
75
+ const DEFAULT_MAX_CONNECTIONS_PER_IP = 10;
76
+ const DEFAULT_MAX_CONNECTIONS_PER_USER = 5;
77
+ const DEFAULT_PER_IP_CONNECTION_RATE = { max: 5, windowMs: 60_000 };
78
+
40
79
  /** Default server config; `startLocalServer` accepts overrides. */
41
80
  export interface LocalServerConfig {
42
81
  serverName?: string;
@@ -49,6 +88,50 @@ export interface LocalServerConfig {
49
88
  channelLen?: number;
50
89
  topicLen?: number;
51
90
  quitMessage?: string;
91
+ /**
92
+ * Server password gate. Undefined / empty disables enforcement;
93
+ * otherwise every connection must supply the matching value via
94
+ * `PASS <password>` (or SASL PLAIN) before registration completes.
95
+ */
96
+ readonly serverPassword?: string;
97
+ /**
98
+ * Hostmask cloaking policy. Undefined disables cloaking; passing
99
+ * `{ enabled: true, secret: '...' }` replaces every connection's
100
+ * visible host with a deterministic cloak derived from the secret
101
+ * and the connection's source identifier.
102
+ */
103
+ readonly cloaking?: { enabled: boolean; secret: string; cloakedSuffix?: string } | undefined;
104
+ /**
105
+ * Per-IP simultaneous-connection cap. The local server enforces this
106
+ * at WebSocket upgrade time. Defaults to disabled (no cap) when
107
+ * omitted; pass a positive integer to enforce.
108
+ */
109
+ readonly maxConnectionsPerIp?: number;
110
+ /**
111
+ * Per-user (SASL account, falling back to source IP) simultaneous-
112
+ * connection cap. Same enforcement path as `maxConnectionsPerIp`.
113
+ */
114
+ readonly maxConnectionsPerUser?: number;
115
+ /**
116
+ * Per-IP connection-rate limit (sliding window). Caps how fast one
117
+ * source IP may open new connections.
118
+ */
119
+ readonly perIpConnectionRate?: { max: number; windowMs: number };
120
+ /**
121
+ * Maximum back-log messages retained per channel for IRCv3
122
+ * `draft/chathistory` playback. When omitted, the shared
123
+ * {@link InMemoryMessageStore} default (100) applies. Only consulted
124
+ * when chathistory playback is enabled (it always is for the local
125
+ * CLI — the store is bound unconditionally).
126
+ */
127
+ historyMaxPerChannel?: number;
128
+ /**
129
+ * SASL PLAIN account credentials. When non-empty the server seeds an
130
+ * in-memory {@link AccountStore} so `AUTHENTICATE PLAIN` succeeds for the
131
+ * listed accounts. When omitted/empty, SASL account verification is
132
+ * disabled (`ctx.accounts` undefined → `904 ERR_SASLFAIL`).
133
+ */
134
+ saslAccounts?: Array<{ username: string; password: string }>;
52
135
  }
53
136
 
54
137
  export interface StartServerOptions extends LocalServerConfig {
@@ -73,6 +156,12 @@ export interface LocalServer {
73
156
  */
74
157
  readonly tcpPort?: number | undefined;
75
158
  readonly runtime: InMemoryRuntime;
159
+ /**
160
+ * The per-process chat-history store backing `draft/chathistory`
161
+ * playback. Shared across every connection so a message recorded by
162
+ * one client is replayable by another. Exposed for test assertions.
163
+ */
164
+ readonly messages: MessageStore;
76
165
  close(): Promise<void>;
77
166
  /**
78
167
  * Test-only seam: live server-side WebSockets. Exposed so tests can
@@ -92,7 +181,16 @@ interface ConnectionBindings {
92
181
  actor: ConnectionActor;
93
182
  }
94
183
 
95
- const DEFAULT_CONFIG: Required<LocalServerConfig> = {
184
+ const DEFAULT_CONFIG: Omit<
185
+ Required<LocalServerConfig>,
186
+ | 'serverPassword'
187
+ | 'cloaking'
188
+ | 'maxConnectionsPerIp'
189
+ | 'maxConnectionsPerUser'
190
+ | 'perIpConnectionRate'
191
+ | 'historyMaxPerChannel'
192
+ | 'saslAccounts'
193
+ > = {
96
194
  serverName: 'irc.example.com',
97
195
  networkName: 'ExampleNet',
98
196
  motdLines: ['Welcome to the local-cli ServerlessIRCd instance.'],
@@ -120,6 +218,7 @@ export const DEFAULT_ID_FACTORY = {
120
218
  batchId: () => randomUUID(),
121
219
  nonce: () => randomUUID(),
122
220
  sessionId: () => randomUUID(),
221
+ traceId: () => randomUUID(),
123
222
  };
124
223
 
125
224
  /**
@@ -132,20 +231,118 @@ export const DEFAULT_ID_FACTORY = {
132
231
  * don't leak a half-started server.
133
232
  */
134
233
  export function startLocalServer(opts: StartServerOptions): Promise<LocalServer> {
135
- const cfg: Required<LocalServerConfig> = { ...DEFAULT_CONFIG, ...stripUndefined(opts) };
234
+ // Validate the user-supplied config through the shared ServerConfigSchema
235
+ // (sourced from `irc-core`). Both adapters consume the same schema; here
236
+ // we discard the parsed result and only retain the side-effect of
237
+ // throwing on a malformed config at boot. The actor still consumes the
238
+ // `LocalServerConfig` projection so existing callers (and the e2e
239
+ // harness) keep their permissive defaults.
240
+ let parsed: ReturnType<typeof loadServerConfigFromCliArgs>;
241
+ try {
242
+ parsed = loadServerConfigFromCliArgs({
243
+ serverName: opts.serverName ?? DEFAULT_CONFIG.serverName,
244
+ networkName: opts.networkName ?? DEFAULT_CONFIG.networkName,
245
+ ...(opts.motdLines !== undefined ? { motdLines: opts.motdLines } : {}),
246
+ ...(opts.maxChannelsPerUser !== undefined
247
+ ? { maxChannelsPerUser: opts.maxChannelsPerUser }
248
+ : {}),
249
+ ...(opts.maxTargetsPerCommand !== undefined
250
+ ? { maxTargetsPerCommand: opts.maxTargetsPerCommand }
251
+ : {}),
252
+ ...(opts.maxListEntries !== undefined ? { maxListEntries: opts.maxListEntries } : {}),
253
+ ...(opts.nickLen !== undefined ? { nickLen: opts.nickLen } : {}),
254
+ ...(opts.channelLen !== undefined ? { channelLen: opts.channelLen } : {}),
255
+ ...(opts.topicLen !== undefined ? { topicLen: opts.topicLen } : {}),
256
+ ...(opts.quitMessage !== undefined ? { quitMessage: opts.quitMessage } : {}),
257
+ ...(opts.saslAccounts !== undefined ? { saslAccounts: opts.saslAccounts } : {}),
258
+ });
259
+ } catch (err) {
260
+ return Promise.reject(err);
261
+ }
262
+ const cfg: ResolvedServerConfig = {
263
+ serverName: parsed.serverName,
264
+ networkName: parsed.networkName,
265
+ motdLines: parsed.motdLines.length > 0 ? parsed.motdLines : DEFAULT_CONFIG.motdLines,
266
+ maxChannelsPerUser: parsed.maxChannelsPerUser,
267
+ maxTargetsPerCommand: parsed.maxTargetsPerCommand,
268
+ maxListEntries: parsed.maxListEntries,
269
+ nickLen: parsed.nickLen,
270
+ channelLen: parsed.channelLen,
271
+ topicLen: parsed.topicLen,
272
+ quitMessage: parsed.quitMessage,
273
+ ...(opts.serverPassword !== undefined ? { serverPassword: opts.serverPassword } : {}),
274
+ ...(opts.cloaking !== undefined ? { cloaking: opts.cloaking } : {}),
275
+ ...(opts.maxConnectionsPerIp !== undefined
276
+ ? { maxConnectionsPerIp: opts.maxConnectionsPerIp }
277
+ : {}),
278
+ ...(opts.maxConnectionsPerUser !== undefined
279
+ ? { maxConnectionsPerUser: opts.maxConnectionsPerUser }
280
+ : {}),
281
+ ...(opts.perIpConnectionRate !== undefined
282
+ ? { perIpConnectionRate: opts.perIpConnectionRate }
283
+ : {}),
284
+ ...(opts.saslAccounts !== undefined && opts.saslAccounts.length > 0
285
+ ? { saslAccounts: opts.saslAccounts }
286
+ : {}),
287
+ };
136
288
  const hostname = opts.hostname ?? '127.0.0.1';
137
289
 
138
- const runtime = new InMemoryRuntime({ clock: { now: () => Date.now() } });
290
+ // Admission gate: enabled as soon as any cap or rate limit is configured.
291
+ // Defaults mirror irc-core's ServerConfigSchema so local-CLI behaviour
292
+ // matches the production adapters.
293
+ const admission: AdmissionConfig | undefined =
294
+ opts.maxConnectionsPerIp !== undefined ||
295
+ opts.maxConnectionsPerUser !== undefined ||
296
+ opts.perIpConnectionRate !== undefined
297
+ ? {
298
+ maxConnectionsPerIp: opts.maxConnectionsPerIp ?? DEFAULT_MAX_CONNECTIONS_PER_IP,
299
+ maxConnectionsPerUser: opts.maxConnectionsPerUser ?? DEFAULT_MAX_CONNECTIONS_PER_USER,
300
+ perIpConnectionRate: opts.perIpConnectionRate ?? DEFAULT_PER_IP_CONNECTION_RATE,
301
+ }
302
+ : undefined;
303
+
304
+ const runtime = new InMemoryRuntime({
305
+ clock: { now: () => Date.now() },
306
+ ...(admission !== undefined ? { admission } : {}),
307
+ });
308
+
309
+ // Per-process chat-history store: one ring buffer shared by every
310
+ // connection so cross-connection playback works (a message alice sends
311
+ // is replayable when bob queries CHATHISTORY). The cap is config-driven.
312
+ const messages = new InMemoryMessageStore(opts.historyMaxPerChannel);
313
+
314
+ // SASL account store: seeded from config so `AUTHENTICATE PLAIN` works
315
+ // end-to-end. `undefined` (no accounts configured) leaves the actor's
316
+ // `ctx.accounts` unset, preserving the no-store behaviour.
317
+ const accounts =
318
+ cfg.saslAccounts !== undefined && cfg.saslAccounts.length > 0
319
+ ? new InMemoryAccountStore(cfg.saslAccounts)
320
+ : undefined;
139
321
 
140
322
  const wss = new WebSocketServer({ port: opts.port, host: hostname });
141
323
  const wsConnections = new Map<WebSocket, ConnectionBindings>();
142
324
 
143
- wss.on('connection', (ws) => {
144
- const { state, actor } = attachConnection(runtime, cfg, {
325
+ wss.on('connection', (ws, req) => {
326
+ // Admission gate: refuse the connection BEFORE we attach an actor / emit
327
+ // any IRC state. Keeps per-IP / per-user counters authoritative and
328
+ // avoids burning a nick or runtime slot on a denied client.
329
+ const ip = req.socket.remoteAddress ?? 'unknown';
330
+ const decision = runtime.admitConnection(ip, undefined);
331
+ if (!decision.ok) {
332
+ ws.send(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
333
+ ws.close();
334
+ return;
335
+ }
336
+ const admissionRecordId = decision.recordId;
337
+ runtime.commitAdmission(ip, undefined, admissionRecordId);
338
+
339
+ const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
145
340
  sendText: (text) => {
146
341
  if (ws.readyState === WebSocket.OPEN) ws.send(text);
147
342
  },
148
343
  closeTransport: () => ws.close(),
344
+ admissionRecordId,
345
+ sourceHost: ip,
149
346
  });
150
347
  wsConnections.set(ws, { state, actor });
151
348
 
@@ -161,7 +358,8 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
161
358
 
162
359
  // 'close' and 'error' both end in the same cleanup: drop the connection
163
360
  // map entry and unregister the conn from the runtime so it stops
164
- // receiving broadcasts.
361
+ // receiving broadcasts. unregisterConnection also releases the
362
+ // admission record (if any) so per-IP / per-user counters stay accurate.
165
363
  const cleanup = (): void => {
166
364
  wsConnections.delete(ws);
167
365
  runtime.unregisterConnection(state.id);
@@ -199,7 +397,23 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
199
397
  const tcpServer = createServer((socket) => {
200
398
  tcpSockets.add(socket);
201
399
  const scanner = new LineScanner();
202
- const { state, actor } = attachConnection(runtime, cfg, {
400
+
401
+ // Admission gate — same policy as the WS path: refuse before the
402
+ // actor is attached so per-IP / per-user counters stay authoritative.
403
+ const ip = socket.remoteAddress ?? 'unknown';
404
+ const decision = runtime.admitConnection(ip, undefined);
405
+ if (!decision.ok) {
406
+ if (socket.writable) {
407
+ socket.write(`ERROR :Closing link: (connection limit reached: ${decision.reason})\r\n`);
408
+ }
409
+ socket.destroy();
410
+ tcpSockets.delete(socket);
411
+ return;
412
+ }
413
+ const admissionRecordId = decision.recordId;
414
+ runtime.commitAdmission(ip, undefined, admissionRecordId);
415
+
416
+ const { state, actor } = attachConnection(runtime, cfg, messages, accounts, {
203
417
  sendText: (text) => {
204
418
  if (socket.writable) socket.write(text);
205
419
  },
@@ -209,6 +423,8 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
209
423
  // callers go through `ws.close()`; TCP callers go through here.
210
424
  socket.destroy();
211
425
  },
426
+ admissionRecordId,
427
+ sourceHost: ip,
212
428
  });
213
429
  tcpConnections.set(socket, { state, actor });
214
430
 
@@ -261,6 +477,7 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
261
477
  tcpPort,
262
478
  hostname,
263
479
  runtime,
480
+ messages,
264
481
  testSockets: wss.clients,
265
482
  testTcpSockets: tcpSockets,
266
483
  close: () =>
@@ -286,11 +503,29 @@ export function startLocalServer(opts: StartServerOptions): Promise<LocalServer>
286
503
  */
287
504
  function attachConnection(
288
505
  runtime: InMemoryRuntime,
289
- cfg: Required<LocalServerConfig>,
290
- transport: { sendText: (text: string) => void; closeTransport: () => void },
506
+ cfg: ResolvedServerConfig,
507
+ messages: MessageStore,
508
+ accounts: AccountStore | undefined,
509
+ transport: {
510
+ sendText: (text: string) => void;
511
+ closeTransport: () => void;
512
+ admissionRecordId?: string;
513
+ sourceHost?: string;
514
+ },
291
515
  ): ConnectionBindings {
292
516
  const id = randomUUID();
293
517
  const state = createConnection({ id, connectedSince: Date.now() });
518
+ // Record the transport-visible source host up front. Used as the cloak
519
+ // input when cloaking is enabled, and as the visible host otherwise.
520
+ if (transport.sourceHost !== undefined) {
521
+ state.host = transport.sourceHost;
522
+ }
523
+
524
+ // Per-connection logger: every record the actor emits
525
+ // carries this connection's id so the local-cli log stream can be
526
+ // filtered per client. traceId is generated per inbound frame by the
527
+ // actor itself.
528
+ const logger: Logger = new ConsoleLogger({ connectionId: id }, undefined, LogLevel.Info);
294
529
 
295
530
  const actor = new ConnectionActor({
296
531
  state,
@@ -306,27 +541,36 @@ function attachConnection(
306
541
  channelLen: cfg.channelLen,
307
542
  topicLen: cfg.topicLen,
308
543
  quitMessage: cfg.quitMessage,
544
+ ...(cfg.serverPassword !== undefined ? { serverPassword: cfg.serverPassword } : {}),
545
+ ...(cfg.cloaking !== undefined ? { cloaking: cfg.cloaking } : {}),
309
546
  },
310
547
  clock: { now: () => Date.now() },
311
548
  ids: DEFAULT_ID_FACTORY,
312
549
  motd: { lines: () => cfg.motdLines },
550
+ messages,
551
+ ...(accounts !== undefined ? { accounts } : {}),
552
+ logger,
313
553
  });
314
554
 
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();
555
+ runtime.registerConnection(
556
+ state,
557
+ {
558
+ send: (lines: RawLine[]) => {
559
+ const text = `${lines.map((l) => l.text).join('\r\n')}\r\n`;
560
+ transport.sendText(text);
561
+ },
562
+ disconnect: (reason?: string) => {
563
+ // If a reason was supplied, send the RFC-style ERROR notice before
564
+ // closing. (QUIT does not supply one; future paths like flood
565
+ // control will.)
566
+ if (reason !== undefined) {
567
+ transport.sendText(`ERROR :Closing link: (${reason})\r\n`);
568
+ }
569
+ transport.closeTransport();
570
+ },
328
571
  },
329
- });
572
+ transport.admissionRecordId,
573
+ );
330
574
 
331
575
  return { state, actor };
332
576
  }
@@ -382,15 +626,6 @@ function closeEverything(args: {
382
626
  return Promise.all([wsClosed, tcpClosed]).then(() => undefined);
383
627
  }
384
628
 
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
629
  function logError(msg: string, err: unknown): void {
395
630
  // Structured JSON per acceptance criteria.
396
631
  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
+ });