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
@@ -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,24 @@
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
+
14
28
  /**
15
29
  * Per-deployment server configuration. Adapters load this from a KV store
16
30
  * (CF) or Secrets Manager / SSM (AWS); the core only reads it.
@@ -21,6 +35,24 @@ import type { ConnectionState } from './state/connection.js';
21
35
  export interface ServerConfig {
22
36
  serverName: string;
23
37
  networkName: string;
38
+ /**
39
+ * Server password gate. Empty string or undefined disables enforcement.
40
+ * When set, registration refuses to complete until the connection
41
+ * supplies the matching value via `PASS`.
42
+ *
43
+ * Typed `string | undefined` (rather than just `string`) so the field is
44
+ * structurally compatible with Zod's `ServerConfigSchema` output under
45
+ * `exactOptionalPropertyTypes: true` — `.optional()` yields `T | undefined`.
46
+ */
47
+ readonly serverPassword?: string | undefined;
48
+ /**
49
+ * Cloaking config. Undefined when cloaking is disabled (the default);
50
+ * reducers consult `enabled` + `secret` to compute the visible host.
51
+ *
52
+ * Typed `CloakingConfig | undefined` to mirror Zod's `.optional()` output
53
+ * (see `serverPassword` note).
54
+ */
55
+ readonly cloaking?: CloakingConfig | undefined;
24
56
  maxChannelsPerUser: number;
25
57
  maxTargetsPerCommand: number;
26
58
  maxListEntries: number;
@@ -28,6 +60,24 @@ export interface ServerConfig {
28
60
  channelLen: number;
29
61
  topicLen: number;
30
62
  quitMessage: string;
63
+ /**
64
+ * Per-connection message-rate flood control. `undefined` → the actor
65
+ * applies the documented default bucket; `null` → flood control disabled;
66
+ * an object → use those thresholds. The optional `commandCost` is wired
67
+ * in code when absent (control-plane commands are exempt by default) — it
68
+ * cannot be serialized from a config store.
69
+ */
70
+ readonly floodControl?: FloodControlConfig | null | undefined;
71
+ /**
72
+ * Maximum number of back-messages replayed on JOIN when the joiner has
73
+ * negotiated `draft/chathistory`. Defaults to 50 per the spec's
74
+ * recommendation when omitted. Only consulted when a {@link MessageStore}
75
+ * is bound.
76
+ *
77
+ * Typed `number | undefined` to mirror Zod's optional output shape under
78
+ * `exactOptionalPropertyTypes: true`.
79
+ */
80
+ chatHistoryPlaybackLimit?: number | undefined;
31
81
  }
32
82
 
33
83
  /** Per-invocation context handed to every reducer. */
@@ -41,6 +91,15 @@ export interface Ctx {
41
91
  readonly ids: IdFactory;
42
92
  /** Pre-loaded MOTD source for the `MOTD` reducer. */
43
93
  readonly motd: MotdProvider;
94
+ /** SASL account verification source (absent when no accounts are configured). */
95
+ readonly accounts?: AccountStore;
96
+ /**
97
+ * Chat-history persistence source (absent when chathistory playback is
98
+ * disabled for this deployment). When present, the PRIVMSG/NOTICE channel
99
+ * reducers record every accepted message and JOIN replays the backlog for
100
+ * cap-enabled joiners.
101
+ */
102
+ readonly messages?: MessageStore;
44
103
  /** The connection invoking the command. Reducers mutate this freely. */
45
104
  readonly connection: ConnectionState;
46
105
  /** Convenience: `connection.id`. */
@@ -65,12 +124,14 @@ export interface BuildCtxOptions {
65
124
  clock: Clock;
66
125
  ids: IdFactory;
67
126
  motd: MotdProvider;
127
+ accounts?: AccountStore;
128
+ messages?: MessageStore;
68
129
  connection: ConnectionState;
69
130
  }
70
131
 
71
132
  /** Constructs a {@link Ctx} from the required parts. */
72
133
  export function buildCtx(opts: BuildCtxOptions): Ctx {
73
- return {
134
+ const ctx: Ctx = {
74
135
  serverConfig: opts.serverConfig,
75
136
  serverName: opts.serverConfig.serverName,
76
137
  networkName: opts.serverConfig.networkName,
@@ -79,5 +140,8 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
79
140
  motd: opts.motd,
80
141
  connection: opts.connection,
81
142
  connId: opts.connection.id,
143
+ ...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
144
+ ...(opts.messages !== undefined ? { messages: opts.messages } : {}),
82
145
  };
146
+ return ctx;
83
147
  }
@@ -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,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
+ });
@@ -18,6 +18,7 @@ describe('SUPPORTED_CAPABILITIES', () => {
18
18
  'away-notify',
19
19
  'batch',
20
20
  'chghost',
21
+ 'draft/chathistory',
21
22
  'echo-message',
22
23
  'extended-join',
23
24
  'invite-notify',
@@ -73,6 +74,10 @@ describe('isSupportedCap', () => {
73
74
  expect(isSupportedCap('safelist')).toBe(true);
74
75
  });
75
76
 
77
+ it('returns true for draft/chathistory (bare name, no value)', () => {
78
+ expect(isSupportedCap('draft/chathistory')).toBe(true);
79
+ });
80
+
76
81
  it('returns false for an unknown cap name', () => {
77
82
  expect(isSupportedCap('does-not-exist')).toBe(false);
78
83
  });
@@ -111,6 +116,23 @@ describe('getLsString', () => {
111
116
  });
112
117
  });
113
118
 
119
+ describe('draft/chathistory advertisement', () => {
120
+ it('is advertised as a bare name (no value parameter)', () => {
121
+ const cap = SUPPORTED_CAPABILITIES.find((c) => c.name === 'draft/chathistory');
122
+ expect(cap).toBeDefined();
123
+ expect(cap?.value).toBeUndefined();
124
+ });
125
+
126
+ it('appears in CAP LS output as the bare token', () => {
127
+ expect(getLsString()).toContain('draft/chathistory');
128
+ expect(getLsString()).not.toContain('draft/chathistory=');
129
+ });
130
+
131
+ it('is ACKable via SUPPORTED_CAP_NAMES membership (CAP REQ round-trip)', () => {
132
+ expect(SUPPORTED_CAP_NAMES.has('draft/chathistory')).toBe(true);
133
+ });
134
+ });
135
+
114
136
  describe('splitCapsForWire', () => {
115
137
  it('returns a single chunk when everything fits', () => {
116
138
  const tokens = ['a', 'b', 'c'];
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { cloakHost } from '../src/cloak';
3
+
4
+ // ============================================================================
5
+ // cloakHost — basic deterministic cloaking
6
+ // ============================================================================
7
+
8
+ describe('cloakHost — determinism', () => {
9
+ it('returns the same cloak for the same IP + secret', () => {
10
+ const a = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
11
+ const b = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
12
+ expect(a).toBe(b);
13
+ });
14
+
15
+ it('returns different cloaks for different IPs with the same secret', () => {
16
+ const a = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
17
+ const b = cloakHost('198.51.100.7', { enabled: true, secret: 's3cret' }, 'irc.example.com');
18
+ expect(a).not.toBe(b);
19
+ });
20
+
21
+ it('returns different cloaks for the same IP with different secrets', () => {
22
+ const a = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
23
+ const b = cloakHost('203.0.113.5', { enabled: true, secret: 'other' }, 'irc.example.com');
24
+ expect(a).not.toBe(b);
25
+ });
26
+ });
27
+
28
+ // ============================================================================
29
+ // cloakHost — format
30
+ // ============================================================================
31
+
32
+ describe('cloakHost — format', () => {
33
+ it('produces a cloak ending in the configured suffix', () => {
34
+ const cloak = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
35
+ expect(cloak.endsWith('.irc.example.com')).toBe(true);
36
+ });
37
+
38
+ it('produces a cloak whose leading segment is a stable hex-encoded hash', () => {
39
+ const cloak = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
40
+ // Strip the suffix, leaving just the hash segment.
41
+ const segment = cloak.slice(0, cloak.indexOf('.'));
42
+ expect(segment).toMatch(/^[a-z0-9]+$/u);
43
+ expect(segment.length).toBeGreaterThanOrEqual(8);
44
+ });
45
+
46
+ it('produces a cloak no longer than 63 bytes per label (DNS limit)', () => {
47
+ const cloak = cloakHost('203.0.113.5', { enabled: true, secret: 's3cret' }, 'irc.example.com');
48
+ for (const label of cloak.split('.')) {
49
+ expect(label.length).toBeLessThanOrEqual(63);
50
+ }
51
+ });
52
+ });
53
+
54
+ // ============================================================================
55
+ // cloakHost — disabled
56
+ // ============================================================================
57
+
58
+ describe('cloakHost — disabled', () => {
59
+ it('returns the original host when cloaking is disabled', () => {
60
+ const cloak = cloakHost('203.0.113.5', { enabled: false, secret: 's3cret' }, 'irc.example.com');
61
+ expect(cloak).toBe('203.0.113.5');
62
+ });
63
+ });
64
+
65
+ // ============================================================================
66
+ // cloakHost — IPv6
67
+ // ============================================================================
68
+
69
+ describe('cloakHost — IPv6', () => {
70
+ it('cloaks an IPv6 address the same way as an IPv4 address', () => {
71
+ const cloak = cloakHost('2001:db8::1', { enabled: true, secret: 's3cret' }, 'irc.example.com');
72
+ expect(cloak.endsWith('.irc.example.com')).toBe(true);
73
+ // Re-cloaking deterministically yields the same value.
74
+ expect(cloakHost('2001:db8::1', { enabled: true, secret: 's3cret' }, 'irc.example.com')).toBe(
75
+ cloak,
76
+ );
77
+ });
78
+ });