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,16 @@
1
+ /**
2
+ * Typed base64 helpers backed by the global `atob` / `btoa` available on
3
+ * every supported runtime (Node 16+, Cloudflare Workers, browsers). The
4
+ * project pins `lib: ES2022` which omits these names from the type table,
5
+ * so this module re-exports them with proper signatures.
6
+ */
7
+
8
+ /** Decodes a base64 string to a Latin1/binary string (char per byte). */
9
+ export function decodeBase64(input: string): string {
10
+ return (globalThis as unknown as { atob: (s: string) => string }).atob(input);
11
+ }
12
+
13
+ /** Encodes a Latin1/binary string (char per byte, 0–255) to base64. */
14
+ export function encodeBase64(input: string): string {
15
+ return (globalThis as unknown as { btoa: (s: string) => string }).btoa(input);
16
+ }
@@ -1,5 +1,5 @@
1
1
  export type { IrcMessage, IrcSource } from './messages.js';
2
- export { IrcParseError, parse, unescapeTagValue } from './parser.js';
2
+ export { MAX_INPUT_BYTES, IrcParseError, parse, unescapeTagValue } from './parser.js';
3
3
  export { escapeTagValue, serialize, serializeFrame } from './serializer.js';
4
4
  export {
5
5
  MAX_LINE_BYTES,
@@ -9,6 +9,7 @@ export {
9
9
  formatServerTime,
10
10
  } from './outbound.js';
11
11
  export { wrapBatch, type BatchFrame } from './batch.js';
12
+ export { decodeBase64, encodeBase64 } from './base64.js';
12
13
  export {
13
14
  formatNumeric,
14
15
  isNumericCommand,
@@ -77,6 +77,17 @@ export const Numerics = {
77
77
  RPL_YOURESERVICE: 383,
78
78
  RPL_TIME: 391,
79
79
 
80
+ // SASL (IRCv3 sasl-3.2)
81
+ RPL_LOGGEDIN: 900,
82
+ RPL_LOGGEDOUT: 901,
83
+ ERR_NICKLOCKED: 902,
84
+ RPL_SASLSUCCESS: 903,
85
+ ERR_SASLFAIL: 904,
86
+ ERR_SASLTOOLONG: 905,
87
+ ERR_SASLABORT: 906,
88
+ ERR_SASLALREADY: 907,
89
+ ERR_SASLMECHS: 908,
90
+
80
91
  // Errors
81
92
  ERR_NOSUCHNICK: 401,
82
93
  ERR_NOSUCHSERVER: 402,
@@ -16,6 +16,19 @@ export class IrcParseError extends Error {
16
16
  }
17
17
  }
18
18
 
19
+ /**
20
+ * RFC 1459 §2.3: maximum IRC message length in bytes. The spec counts the
21
+ * trailing CR-LF as part of the 512 budget; the parser strips CR-LF first,
22
+ * so this constant caps the post-strip body. Adopted verbatim by the
23
+ * output side (`outbound.ts` `MAX_LINE_BYTES`) — the same number everywhere
24
+ * so the wire contract reads "no IRC message exceeds 512 bytes".
25
+ *
26
+ * Applied to inputs as a hard cap: a single line longer than this is a
27
+ * protocol violation (the client is misbehaving or a downstream framer is
28
+ * broken) and is rejected with `IrcParseError` rather than parsed.
29
+ */
30
+ export const MAX_INPUT_BYTES = 512;
31
+
19
32
  const TAG_ESCAPES: Readonly<Record<string, string>> = {
20
33
  ':': ';',
21
34
  s: ' ',
@@ -97,11 +110,23 @@ const COMMAND_RE = /^(?:[A-Za-z]+|[0-9]{3})$/;
97
110
  export function parse(line: string): IrcMessage {
98
111
  // Strip any trailing CR/LF (frame tolerance for ws text frames or TCP lines).
99
112
  const stripped = line.replace(/[\r\n]+$/u, '');
100
- let rest = stripped;
101
- if (rest.length === 0) {
113
+ if (stripped.length === 0) {
102
114
  throw new IrcParseError('empty message', line);
103
115
  }
104
116
 
117
+ // RFC 1459 §2.3 hard cap: reject any single message whose post-CRLF body
118
+ // exceeds {@link MAX_INPUT_BYTES} bytes. Prevents memory and processing
119
+ // abuse from over-long inputs and matches the symmetric cap enforced on
120
+ // outbound lines by `outbound.ts`.
121
+ if (stripped.length > MAX_INPUT_BYTES) {
122
+ throw new IrcParseError(
123
+ `message exceeds ${MAX_INPUT_BYTES}-byte input cap (${stripped.length} bytes)`,
124
+ line,
125
+ );
126
+ }
127
+
128
+ let rest = stripped;
129
+
105
130
  let tags: Record<string, string> = {};
106
131
  if (rest.startsWith('@')) {
107
132
  const space = rest.indexOf(' ');
@@ -29,6 +29,20 @@ export interface UserModes {
29
29
  serverNotices: boolean;
30
30
  }
31
31
 
32
+ /**
33
+ * Per-connection token bucket used by the flood-control wrapper.
34
+ *
35
+ * Lazily initialized on the first observed message. `tokens` may go negative
36
+ * down to the configured `-disconnectThreshold`; once it crosses that line
37
+ * the wrapper emits `Disconnect("Excess Flood")`.
38
+ */
39
+ export interface FloodBucketState {
40
+ /** Current token balance; may be negative up to `-threshold`. */
41
+ tokens: number;
42
+ /** Epoch ms of the last refill (source: injected `Clock`). */
43
+ lastRefill: number;
44
+ }
45
+
32
46
  /**
33
47
  * Mutable, authoritative per-connection state.
34
48
  *
@@ -50,6 +64,19 @@ export interface ConnectionState {
50
64
  passAttempt?: string;
51
65
  /** SASL-authenticated account name, if any. */
52
66
  account?: string;
67
+ /**
68
+ * SASL mechanism currently being negotiated. Set on `AUTHENTICATE <MECH>`
69
+ * and cleared on success, failure, or abort. While set, the connection
70
+ * expects the next `AUTHENTICATE` frame to carry (or continue) the
71
+ * mechanism's payload.
72
+ */
73
+ saslMech?: string;
74
+ /**
75
+ * Buffered SASL payload chunks for multi-line `AUTHENTICATE` frames.
76
+ * Populated when the client splits a payload across `>400`-byte chunks
77
+ * and consumed once the final short chunk (or `+`) arrives.
78
+ */
79
+ saslBuffer?: string;
53
80
  registration: RegistrationState;
54
81
  /**
55
82
  * True once the client has sent `CAP LS` or `CAP REQ` and not yet sent
@@ -68,6 +95,20 @@ export interface ConnectionState {
68
95
  lastSeen: number;
69
96
  /** Connect-time epoch ms (drives WHOIS signon time). */
70
97
  connectedSince: number;
98
+ /**
99
+ * Per-connection token bucket consulted by the flood-control wrapper
100
+ * (`wrapWithFloodControl`). Absent until the wrapper sees its first
101
+ * message, so existing persisted state migrates without a schema bump.
102
+ */
103
+ floodBucket?: FloodBucketState;
104
+ /**
105
+ * Per-channel "last read" msgid marker for IRCv3 `draft/chathistory`
106
+ * playback. Keyed by lowercased channel name; the value is the msgid of
107
+ * the most recent message replayed (on JOIN) or queried (on CHATHISTORY).
108
+ * Subsequent JOINs only replay messages newer than this marker, so a
109
+ * PART/re-JOIN cycle with no new activity replays nothing.
110
+ */
111
+ lastReadMarkers?: Map<string, string>;
71
112
  }
72
113
 
73
114
  /**
@@ -7,10 +7,35 @@
7
7
  */
8
8
 
9
9
  import type { Effect } from './effects.js';
10
- import type { Clock, IdFactory, MotdProvider } from './ports.js';
10
+ import type { FloodControlConfig } from './flood-control.js';
11
+ import type { AccountStore, Clock, IdFactory, MessageStore, MotdProvider } from './ports.js';
11
12
  import type { IrcMessage } from './protocol/messages.js';
12
13
  import type { ConnectionState } from './state/connection.js';
13
14
 
15
+ /**
16
+ * Hostmask cloaking configuration as seen by reducers. Mirrors the
17
+ * {@link ServerConfigSchema} shape; reducers only read these fields.
18
+ *
19
+ * Optional fields are widened to `T | undefined` to mirror Zod's
20
+ * `.optional()` output under `exactOptionalPropertyTypes: true`.
21
+ */
22
+ export interface CloakingConfig {
23
+ readonly enabled: boolean;
24
+ readonly secret: string;
25
+ readonly cloakedSuffix?: string | undefined;
26
+ }
27
+
28
+ /**
29
+ * A single IRC operator credential. The {@link operReducer} consults the
30
+ * configured list to authenticate `OPER <name> <password>`. Both fields are
31
+ * compared verbatim; adapters are responsible for any hashing at the auth
32
+ * boundary if they layer one on top.
33
+ */
34
+ export interface OperCred {
35
+ readonly user: string;
36
+ readonly password: string;
37
+ }
38
+
14
39
  /**
15
40
  * Per-deployment server configuration. Adapters load this from a KV store
16
41
  * (CF) or Secrets Manager / SSM (AWS); the core only reads it.
@@ -21,6 +46,35 @@ import type { ConnectionState } from './state/connection.js';
21
46
  export interface ServerConfig {
22
47
  serverName: string;
23
48
  networkName: string;
49
+ /**
50
+ * Server password gate. Empty string or undefined disables enforcement.
51
+ * When set, registration refuses to complete until the connection
52
+ * supplies the matching value via `PASS`.
53
+ *
54
+ * Typed `string | undefined` (rather than just `string`) so the field is
55
+ * structurally compatible with Zod's `ServerConfigSchema` output under
56
+ * `exactOptionalPropertyTypes: true` — `.optional()` yields `T | undefined`.
57
+ */
58
+ readonly serverPassword?: string | undefined;
59
+ /**
60
+ * Cloaking config. Undefined when cloaking is disabled (the default);
61
+ * reducers consult `enabled` + `secret` to compute the visible host.
62
+ *
63
+ * Typed `CloakingConfig | undefined` to mirror Zod's `.optional()` output
64
+ * (see `serverPassword` note).
65
+ */
66
+ readonly cloaking?: CloakingConfig | undefined;
67
+ /**
68
+ * IRC operator credentials consulted by the `OPER` reducer. `undefined`
69
+ * (or an empty array) disables oper authentication entirely — an `OPER`
70
+ * attempt then receives `491 ERR_NOOPERHOST`.
71
+ *
72
+ * Typed `ReadonlyArray<OperCred> | undefined` to mirror Zod's `.optional()`
73
+ * output under `exactOptionalPropertyTypes: true`; the parsed config always
74
+ * supplies an array (defaulting to `[]`), so a parsed config is assignable
75
+ * here.
76
+ */
77
+ readonly operCreds?: ReadonlyArray<OperCred> | undefined;
24
78
  maxChannelsPerUser: number;
25
79
  maxTargetsPerCommand: number;
26
80
  maxListEntries: number;
@@ -28,6 +82,24 @@ export interface ServerConfig {
28
82
  channelLen: number;
29
83
  topicLen: number;
30
84
  quitMessage: string;
85
+ /**
86
+ * Per-connection message-rate flood control. `undefined` → the actor
87
+ * applies the documented default bucket; `null` → flood control disabled;
88
+ * an object → use those thresholds. The optional `commandCost` is wired
89
+ * in code when absent (control-plane commands are exempt by default) — it
90
+ * cannot be serialized from a config store.
91
+ */
92
+ readonly floodControl?: FloodControlConfig | null | undefined;
93
+ /**
94
+ * Maximum number of back-messages replayed on JOIN when the joiner has
95
+ * negotiated `draft/chathistory`. Defaults to 50 per the spec's
96
+ * recommendation when omitted. Only consulted when a {@link MessageStore}
97
+ * is bound.
98
+ *
99
+ * Typed `number | undefined` to mirror Zod's optional output shape under
100
+ * `exactOptionalPropertyTypes: true`.
101
+ */
102
+ chatHistoryPlaybackLimit?: number | undefined;
31
103
  }
32
104
 
33
105
  /** Per-invocation context handed to every reducer. */
@@ -41,6 +113,15 @@ export interface Ctx {
41
113
  readonly ids: IdFactory;
42
114
  /** Pre-loaded MOTD source for the `MOTD` reducer. */
43
115
  readonly motd: MotdProvider;
116
+ /** SASL account verification source (absent when no accounts are configured). */
117
+ readonly accounts?: AccountStore;
118
+ /**
119
+ * Chat-history persistence source (absent when chathistory playback is
120
+ * disabled for this deployment). When present, the PRIVMSG/NOTICE channel
121
+ * reducers record every accepted message and JOIN replays the backlog for
122
+ * cap-enabled joiners.
123
+ */
124
+ readonly messages?: MessageStore;
44
125
  /** The connection invoking the command. Reducers mutate this freely. */
45
126
  readonly connection: ConnectionState;
46
127
  /** Convenience: `connection.id`. */
@@ -65,12 +146,14 @@ export interface BuildCtxOptions {
65
146
  clock: Clock;
66
147
  ids: IdFactory;
67
148
  motd: MotdProvider;
149
+ accounts?: AccountStore;
150
+ messages?: MessageStore;
68
151
  connection: ConnectionState;
69
152
  }
70
153
 
71
154
  /** Constructs a {@link Ctx} from the required parts. */
72
155
  export function buildCtx(opts: BuildCtxOptions): Ctx {
73
- return {
156
+ const ctx: Ctx = {
74
157
  serverConfig: opts.serverConfig,
75
158
  serverName: opts.serverConfig.serverName,
76
159
  networkName: opts.serverConfig.networkName,
@@ -79,5 +162,8 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
79
162
  motd: opts.motd,
80
163
  connection: opts.connection,
81
164
  connId: opts.connection.id,
165
+ ...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
166
+ ...(opts.messages !== undefined ? { messages: opts.messages } : {}),
82
167
  };
168
+ return ctx;
83
169
  }
@@ -0,0 +1,41 @@
1
+ {
2
+ "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
3
+ "_comment": "Mutation-testing spot-check for irc-core/commands (coverage gate hardening). Targets every IRC command reducer shipped in Phase 1 (PING/USER/JOIN/PRIVMSG/MODE/KICK/INVITE/WHO/WHOIS/LIST/CAP/SASL/ISUPPORT/MOTD/CHATHISTORY/...). Reuses vitest as the test runner and asserts a mutation score >=80% (see tools/ci-hardening/src/validate.ts for the canonical threshold).",
4
+ "packageManager": "npm",
5
+ "testRunner": "vitest",
6
+ "plugins": ["@stryker-mutator/vitest-runner"],
7
+ "coverageAnalysis": "off",
8
+ "mutate": [
9
+ "src/commands/ping.ts",
10
+ "src/commands/quit.ts",
11
+ "src/commands/registration.ts",
12
+ "src/commands/join.ts",
13
+ "src/commands/part.ts",
14
+ "src/commands/privmsg.ts",
15
+ "src/commands/topic.ts",
16
+ "src/commands/names.ts",
17
+ "src/commands/motd.ts",
18
+ "src/commands/mode.ts",
19
+ "src/commands/kick.ts",
20
+ "src/commands/invite.ts",
21
+ "src/commands/who.ts",
22
+ "src/commands/whois.ts",
23
+ "src/commands/list.ts",
24
+ "src/commands/cap.ts",
25
+ "src/commands/sasl.ts",
26
+ "src/commands/isupport.ts",
27
+ "src/commands/chathistory.ts",
28
+ "src/commands/away.ts",
29
+ "src/commands/chghost.ts"
30
+ ],
31
+ "disableTypeChecks": "{src,tests}/**/*.ts",
32
+ "thresholds": {
33
+ "high": 80,
34
+ "low": 60,
35
+ "break": 80
36
+ },
37
+ "reporters": ["clear-text", "html"],
38
+ "timeoutMS": 5000,
39
+ "concurrency": 2,
40
+ "ignoreStatic": true
41
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
3
+ "_comment": "Mutation-testing spot-check for irc-core/protocol (coverage gate hardening). Targets the parser, serializer, numerics, message framing, batch framing, base64 and outbound helpers. Runs the existing vitest suite against generated mutants and fails the build below MUTATION_SCORE_THRESHOLD=80 (see tools/ci-hardening/src/validate.ts).",
4
+ "packageManager": "npm",
5
+ "testRunner": "vitest",
6
+ "plugins": ["@stryker-mutator/vitest-runner"],
7
+ "coverageAnalysis": "off",
8
+ "mutate": [
9
+ "src/protocol/parser.ts",
10
+ "src/protocol/serializer.ts",
11
+ "src/protocol/numerics.ts",
12
+ "src/protocol/messages.ts",
13
+ "src/protocol/outbound.ts",
14
+ "src/protocol/batch.ts",
15
+ "src/protocol/base64.ts"
16
+ ],
17
+ "disableTypeChecks": "{src,tests}/**/*.ts",
18
+ "thresholds": {
19
+ "high": 80,
20
+ "low": 60,
21
+ "break": 80
22
+ },
23
+ "reporters": ["clear-text", "html"],
24
+ "timeoutMS": 5000,
25
+ "concurrency": 2
26
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ type AccountStore,
4
+ InMemoryAccountStore,
5
+ type SaslPayload,
6
+ type SaslResult,
7
+ } from '../src/ports';
8
+
9
+ /** Convenience: a PLAIN payload. */
10
+ function plain(username: string, password: string): SaslPayload {
11
+ return { kind: 'PLAIN', username, password };
12
+ }
13
+
14
+ describe('InMemoryAccountStore — PLAIN', () => {
15
+ it('implements the AccountStore port', () => {
16
+ const store: AccountStore = new InMemoryAccountStore([
17
+ { username: 'alice', password: 'secret' },
18
+ ]);
19
+ expect(store).toBeDefined();
20
+ });
21
+
22
+ it('accepts valid PLAIN credentials and returns the account name', () => {
23
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
24
+ const out = store.verify('PLAIN', plain('alice', 'secret'));
25
+ expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
26
+ });
27
+
28
+ it('rejects a wrong password', () => {
29
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
30
+ const out = store.verify('PLAIN', plain('alice', 'wrong'));
31
+ expect(out.ok).toBe(false);
32
+ });
33
+
34
+ it('rejects an unknown username', () => {
35
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
36
+ const out = store.verify('PLAIN', plain('bob', 'secret'));
37
+ expect(out.ok).toBe(false);
38
+ });
39
+
40
+ it('matches the first of several configured accounts', () => {
41
+ const store = new InMemoryAccountStore([
42
+ { username: 'alice', password: 'a-secret' },
43
+ { username: 'bob', password: 'b-secret' },
44
+ ]);
45
+ expect(store.verify('PLAIN', plain('bob', 'b-secret'))).toEqual<SaslResult>({
46
+ ok: true,
47
+ account: 'bob',
48
+ });
49
+ });
50
+
51
+ it('rejects when constructed with an empty account list', () => {
52
+ const store = new InMemoryAccountStore([]);
53
+ const out = store.verify('PLAIN', plain('alice', 'secret'));
54
+ expect(out.ok).toBe(false);
55
+ });
56
+
57
+ it('does not confuse credentials across accounts (no password cross-match)', () => {
58
+ const store = new InMemoryAccountStore([
59
+ { username: 'alice', password: 'a-secret' },
60
+ { username: 'bob', password: 'b-secret' },
61
+ ]);
62
+ // bob's password with alice's username must fail.
63
+ expect(store.verify('PLAIN', plain('alice', 'b-secret')).ok).toBe(false);
64
+ });
65
+ });
66
+
67
+ describe('InMemoryAccountStore — mechanism handling', () => {
68
+ it('rejects any non-PLAIN mechanism with a failure result', () => {
69
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
70
+ const out = store.verify('EXTERNAL', { kind: 'RAW', data: 'cert-subject' });
71
+ expect(out.ok).toBe(false);
72
+ });
73
+
74
+ it('treats the mechanism name case-insensitively for PLAIN', () => {
75
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
76
+ const out = store.verify('plain', plain('alice', 'secret'));
77
+ expect(out).toEqual<SaslResult>({ ok: true, account: 'alice' });
78
+ });
79
+ });
80
+
81
+ describe('InMemoryAccountStore — failure reason', () => {
82
+ it('returns a human-readable reason on failure', () => {
83
+ const store = new InMemoryAccountStore([{ username: 'alice', password: 'secret' }]);
84
+ const out = store.verify('PLAIN', plain('alice', 'wrong'));
85
+ if (out.ok) throw new Error('expected failure');
86
+ expect(out.reason.length).toBeGreaterThan(0);
87
+ });
88
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Tests for the per-IP / per-user connection admission gate.
3
+ *
4
+ * These tests cover the pure decision helpers in `admission.ts`. The
5
+ * runtime-level integration (tracking live connections per IP, sweeping
6
+ * them on disconnect) lives in the in-memory runtime and the CF / AWS
7
+ * adapters; this module asserts the policy those callers feed.
8
+ */
9
+
10
+ import { describe, expect, it } from 'vitest';
11
+ import {
12
+ type AdmissionConfig,
13
+ type AdmissionDecision,
14
+ AdmissionStats,
15
+ decideAdmission,
16
+ } from '../src/admission';
17
+ import { FakeClock } from '../src/ports';
18
+
19
+ const BASE_CONFIG: AdmissionConfig = {
20
+ maxConnectionsPerIp: 3,
21
+ maxConnectionsPerUser: 2,
22
+ perIpConnectionRate: { max: 5, windowMs: 10_000 },
23
+ };
24
+
25
+ /** Per-IP-cap-isolated config: rate limit + per-user cap set high enough to never bind. */
26
+ const IP_CAP_CONFIG: AdmissionConfig = {
27
+ maxConnectionsPerIp: 3,
28
+ maxConnectionsPerUser: 100,
29
+ perIpConnectionRate: { max: 1_000, windowMs: 10_000 },
30
+ };
31
+
32
+ /** Per-user-cap-isolated config: rate limit + per-IP cap set high enough to never bind. */
33
+ const USER_CAP_CONFIG: AdmissionConfig = {
34
+ maxConnectionsPerIp: 100,
35
+ maxConnectionsPerUser: 2,
36
+ perIpConnectionRate: { max: 1_000, windowMs: 10_000 },
37
+ };
38
+
39
+ /** Rate-limit-isolated config: per-IP + per-user caps set high enough to never bind. */
40
+ const RATE_CONFIG: AdmissionConfig = {
41
+ maxConnectionsPerIp: 100,
42
+ maxConnectionsPerUser: 100,
43
+ perIpConnectionRate: { max: 5, windowMs: 10_000 },
44
+ };
45
+
46
+ function makeStats(clock: FakeClock): AdmissionStats {
47
+ return new AdmissionStats(clock);
48
+ }
49
+
50
+ describe('decideAdmission — per-IP connection cap', () => {
51
+ it('admits the first connection from an IP', () => {
52
+ const clock = new FakeClock(1_000);
53
+ const stats = makeStats(clock);
54
+ const decision = decideAdmission('1.2.3.4', 'alice', stats, IP_CAP_CONFIG);
55
+ expect(decision.ok).toBe(true);
56
+ });
57
+
58
+ it('admits connections up to the per-IP cap', () => {
59
+ const clock = new FakeClock(1_000);
60
+ const stats = makeStats(clock);
61
+ for (let i = 0; i < 3; i++) {
62
+ const d = decideAdmission('1.2.3.4', `user${i}`, stats, IP_CAP_CONFIG);
63
+ expect(d.ok).toBe(true);
64
+ if (d.ok) stats.recordAdmission('1.2.3.4', `user${i}`, d.recordId);
65
+ }
66
+ });
67
+
68
+ it('rejects a connection that would exceed the per-IP cap', () => {
69
+ const clock = new FakeClock(1_000);
70
+ const stats = makeStats(clock);
71
+ for (let i = 0; i < 3; i++) {
72
+ const d = decideAdmission('1.2.3.4', `user${i}`, stats, IP_CAP_CONFIG);
73
+ if (d.ok) stats.recordAdmission('1.2.3.4', `user${i}`, d.recordId);
74
+ }
75
+ const decision = decideAdmission('1.2.3.4', 'user3', stats, IP_CAP_CONFIG);
76
+ expect(decision.ok).toBe(false);
77
+ if (!decision.ok) {
78
+ expect(decision.reason).toBe('max_connections_per_ip');
79
+ }
80
+ });
81
+
82
+ it('counts per-IP independently for different IPs', () => {
83
+ const clock = new FakeClock(1_000);
84
+ const stats = makeStats(clock);
85
+ for (let i = 0; i < 3; i++) {
86
+ const d = decideAdmission('1.1.1.1', `a${i}`, stats, IP_CAP_CONFIG);
87
+ if (d.ok) stats.recordAdmission('1.1.1.1', `a${i}`, d.recordId);
88
+ }
89
+ // A different IP still has its full budget.
90
+ const d = decideAdmission('2.2.2.2', 'b', stats, IP_CAP_CONFIG);
91
+ expect(d.ok).toBe(true);
92
+ });
93
+ });
94
+
95
+ describe('decideAdmission — per-user connection cap', () => {
96
+ it('rejects a connection that would exceed the per-user cap', () => {
97
+ const clock = new FakeClock(1_000);
98
+ const stats = makeStats(clock);
99
+ for (let i = 0; i < 2; i++) {
100
+ const d = decideAdmission(`10.0.0.${i}`, 'alice', stats, USER_CAP_CONFIG);
101
+ if (d.ok) stats.recordAdmission(`10.0.0.${i}`, 'alice', d.recordId);
102
+ }
103
+ const decision = decideAdmission('10.0.0.5', 'alice', stats, USER_CAP_CONFIG);
104
+ expect(decision.ok).toBe(false);
105
+ if (!decision.ok) {
106
+ expect(decision.reason).toBe('max_connections_per_user');
107
+ }
108
+ });
109
+
110
+ it('treats an undefined user as IP-keyed (does not collapse anonymous connections together across IPs)', () => {
111
+ const clock = new FakeClock(1_000);
112
+ const stats = makeStats(clock);
113
+ // Anonymous connection from IP #1.
114
+ const d1 = decideAdmission('1.1.1.1', undefined, stats, USER_CAP_CONFIG);
115
+ expect(d1.ok).toBe(true);
116
+ if (d1.ok) stats.recordAdmission('1.1.1.1', undefined, d1.recordId);
117
+ // Anonymous connection from a different IP is admitted (per-user cap is
118
+ // scoped to the IP when no SASL identity is available).
119
+ const d2 = decideAdmission('2.2.2.2', undefined, stats, USER_CAP_CONFIG);
120
+ expect(d2.ok).toBe(true);
121
+ });
122
+ });
123
+
124
+ describe('decideAdmission — per-IP connection-rate limit', () => {
125
+ it('admits bursts within the rate cap', () => {
126
+ const clock = new FakeClock(1_000);
127
+ const stats = makeStats(clock);
128
+ for (let i = 0; i < 5; i++) {
129
+ const d = decideAdmission('1.2.3.4', `u${i}`, stats, RATE_CONFIG);
130
+ expect(d.ok).toBe(true);
131
+ if (d.ok) stats.recordAdmission('1.2.3.4', `u${i}`, d.recordId);
132
+ }
133
+ });
134
+
135
+ it('rejects a connection exceeding the rate cap inside the window', () => {
136
+ const clock = new FakeClock(1_000);
137
+ const stats = makeStats(clock);
138
+ // Use distinct users so per-user cap doesn't kick in first.
139
+ for (let i = 0; i < 5; i++) {
140
+ const d = decideAdmission('1.2.3.4', `u${i}`, stats, RATE_CONFIG);
141
+ if (d.ok) stats.recordAdmission('1.2.3.4', `u${i}`, d.recordId);
142
+ }
143
+ const decision = decideAdmission('1.2.3.4', 'u5', stats, RATE_CONFIG);
144
+ expect(decision.ok).toBe(false);
145
+ if (!decision.ok) {
146
+ expect(decision.reason).toBe('rate_limited_per_ip');
147
+ }
148
+ });
149
+
150
+ it('admits again once the window has elapsed', () => {
151
+ const clock = new FakeClock(1_000);
152
+ const stats = makeStats(clock);
153
+ for (let i = 0; i < 5; i++) {
154
+ const d = decideAdmission('1.2.3.4', `u${i}`, stats, RATE_CONFIG);
155
+ if (d.ok) stats.recordAdmission('1.2.3.4', `u${i}`, d.recordId);
156
+ }
157
+ // Within the window: rejected.
158
+ expect(decideAdmission('1.2.3.4', 'u5', stats, RATE_CONFIG).ok).toBe(false);
159
+ // Advance past the window: the stale rate entries are dropped.
160
+ clock.advance(10_001);
161
+ const after = decideAdmission('1.2.3.4', 'u5', stats, RATE_CONFIG);
162
+ expect(after.ok).toBe(true);
163
+ });
164
+ });
165
+
166
+ describe('decideAdmission — combined precedence', () => {
167
+ it('checks the rate limit before the per-IP cap', () => {
168
+ // Tight rate cap (2 in 10s) and a generous per-IP cap (100).
169
+ const cfg: AdmissionConfig = {
170
+ maxConnectionsPerIp: 100,
171
+ maxConnectionsPerUser: 100,
172
+ perIpConnectionRate: { max: 2, windowMs: 10_000 },
173
+ };
174
+ const clock = new FakeClock(1_000);
175
+ const stats = makeStats(clock);
176
+ for (let i = 0; i < 2; i++) {
177
+ const d = decideAdmission('5.5.5.5', `u${i}`, stats, cfg);
178
+ if (d.ok) stats.recordAdmission('5.5.5.5', `u${i}`, d.recordId);
179
+ }
180
+ const decision = decideAdmission('5.5.5.5', 'u2', stats, cfg);
181
+ expect(decision.ok).toBe(false);
182
+ if (!decision.ok) {
183
+ expect(decision.reason).toBe('rate_limited_per_ip');
184
+ }
185
+ });
186
+ });
187
+
188
+ describe('AdmissionStats — release', () => {
189
+ it('decrements per-IP and per-user counts when a connection is released', () => {
190
+ const clock = new FakeClock(1_000);
191
+ const stats = makeStats(clock);
192
+ const d1 = decideAdmission('1.2.3.4', 'alice', stats, BASE_CONFIG);
193
+ expect(d1.ok).toBe(true);
194
+ if (!d1.ok) throw new Error('precondition: d1 should admit');
195
+ stats.recordAdmission('1.2.3.4', 'alice', d1.recordId);
196
+ const d2 = decideAdmission('1.2.3.4', 'alice', stats, BASE_CONFIG);
197
+ expect(d2.ok).toBe(true);
198
+ if (!d2.ok) throw new Error('precondition: d2 should admit');
199
+ stats.recordAdmission('1.2.3.4', 'alice', d2.recordId);
200
+ // At cap now.
201
+ const d3 = decideAdmission('1.2.3.4', 'alice', stats, BASE_CONFIG);
202
+ expect(d3.ok).toBe(false);
203
+ // Release one; next attempt succeeds.
204
+ stats.release(d1.recordId);
205
+ const d4 = decideAdmission('1.2.3.4', 'alice', stats, BASE_CONFIG);
206
+ expect(d4.ok).toBe(true);
207
+ });
208
+
209
+ it('releasing an unknown record id is a no-op', () => {
210
+ const clock = new FakeClock(1_000);
211
+ const stats = makeStats(clock);
212
+ expect(() => stats.release('unknown')).not.toThrow();
213
+ });
214
+ });
215
+
216
+ describe('AdmissionStats — determinism', () => {
217
+ it('returns identical decisions for identical Clock + input streams', () => {
218
+ const run = (): AdmissionDecision => {
219
+ const clock = new FakeClock(1_000);
220
+ const stats = makeStats(clock);
221
+ for (let i = 0; i < 4; i++) {
222
+ const d = decideAdmission('1.1.1.1', `u${i}`, stats, BASE_CONFIG);
223
+ if (d.ok) stats.recordAdmission('1.1.1.1', `u${i}`, d.recordId);
224
+ }
225
+ return decideAdmission('1.1.1.1', 'u4', stats, BASE_CONFIG);
226
+ };
227
+ expect(run()).toEqual(run());
228
+ });
229
+ });