serverless-ircd 0.10.0 → 0.11.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 (192) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-cf-tcp.yml +26 -2
  3. package/.github/workflows/deploy-cf.yml +26 -0
  4. package/CHANGELOG.md +289 -0
  5. package/README.md +153 -20
  6. package/apps/aws-stack/bin/aws.ts +36 -0
  7. package/apps/aws-stack/package.json +1 -1
  8. package/apps/aws-stack/src/aws-stack.ts +221 -15
  9. package/apps/aws-stack/tests/stack.test.ts +450 -16
  10. package/apps/cf-tcp-container/Dockerfile +37 -5
  11. package/apps/cf-tcp-container/package.json +7 -2
  12. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  13. package/apps/cf-tcp-container/src/container-server.ts +256 -79
  14. package/apps/cf-tcp-container/src/main.ts +22 -7
  15. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  16. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  17. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  18. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  19. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  20. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  21. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  22. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  23. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  24. package/apps/cf-tcp-container/wrangler.toml +17 -4
  25. package/apps/cf-worker/package.json +2 -2
  26. package/apps/cf-worker/src/worker.ts +77 -5
  27. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  28. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  29. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  30. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  31. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  32. package/apps/cf-worker/wrangler.test.toml +15 -1
  33. package/apps/cf-worker/wrangler.toml +86 -9
  34. package/apps/local-cli/package.json +1 -1
  35. package/apps/local-cli/src/config-loader.ts +14 -2
  36. package/apps/local-cli/src/line-scanner.ts +26 -0
  37. package/apps/local-cli/src/server.ts +23 -2
  38. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  39. package/apps/local-cli/tests/tcp.test.ts +29 -0
  40. package/apps/web/package.json +1 -1
  41. package/docs/AWS-Deployment.md +123 -22
  42. package/docs/AWS-TCP-Deployment.md +37 -2
  43. package/docs/Chat-History.md +55 -0
  44. package/docs/Cloudflare-Deployment-Guide.md +9 -2
  45. package/docs/Cloudflare-TCP-Deployment.md +135 -52
  46. package/docs/SASL-EXTERNAL.md +175 -0
  47. package/package.json +3 -3
  48. package/packages/aws-adapter/package.json +1 -1
  49. package/packages/aws-adapter/src/admission.ts +28 -13
  50. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  51. package/packages/aws-adapter/src/cdk-table-defs.ts +34 -6
  52. package/packages/aws-adapter/src/config-loader.ts +134 -6
  53. package/packages/aws-adapter/src/dynamo-services-store.ts +12 -0
  54. package/packages/aws-adapter/src/handlers/connect.ts +47 -1
  55. package/packages/aws-adapter/src/handlers/default.ts +95 -6
  56. package/packages/aws-adapter/src/handlers/index.ts +31 -2
  57. package/packages/aws-adapter/src/handlers/nlb-stream.ts +132 -8
  58. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  59. package/packages/aws-adapter/src/serialize.ts +8 -0
  60. package/packages/aws-adapter/src/tables.ts +9 -0
  61. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  62. package/packages/aws-adapter/tests/aws-harness.ts +23 -1
  63. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  64. package/packages/aws-adapter/tests/config-loader.test.ts +151 -0
  65. package/packages/aws-adapter/tests/connect.test.ts +199 -2
  66. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  67. package/packages/aws-adapter/tests/default-occ.test.ts +10 -3
  68. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +123 -1
  69. package/packages/aws-adapter/tests/handlers.test.ts +57 -1
  70. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  71. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  72. package/packages/cf-adapter/package.json +1 -1
  73. package/packages/cf-adapter/src/cf-runtime.ts +48 -9
  74. package/packages/cf-adapter/src/config-loader.ts +133 -8
  75. package/packages/cf-adapter/src/connection-do.ts +154 -21
  76. package/packages/cf-adapter/src/counter-do.ts +142 -0
  77. package/packages/cf-adapter/src/d1-services-store.ts +47 -5
  78. package/packages/cf-adapter/src/env.ts +88 -0
  79. package/packages/cf-adapter/src/index.ts +17 -1
  80. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  81. package/packages/cf-adapter/tests/cf-runtime.test.ts +104 -15
  82. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  83. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  84. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  85. package/packages/cf-adapter/tests/connection-do-pure.test.ts +74 -5
  86. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  87. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  88. package/packages/cf-adapter/tests/d1-services-store.test.ts +192 -1
  89. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  90. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  91. package/packages/cf-adapter/wrangler.test.toml +18 -1
  92. package/packages/in-memory-runtime/package.json +1 -1
  93. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  94. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  95. package/packages/irc-core/package.json +1 -1
  96. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  97. package/packages/irc-core/src/certfp.ts +178 -0
  98. package/packages/irc-core/src/commands/cap.ts +10 -2
  99. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  100. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  101. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  102. package/packages/irc-core/src/commands/index.ts +2 -1
  103. package/packages/irc-core/src/commands/invite.ts +1 -7
  104. package/packages/irc-core/src/commands/join.ts +1 -16
  105. package/packages/irc-core/src/commands/kick.ts +1 -8
  106. package/packages/irc-core/src/commands/list.ts +1 -8
  107. package/packages/irc-core/src/commands/mode.ts +1 -8
  108. package/packages/irc-core/src/commands/multiline.ts +4 -10
  109. package/packages/irc-core/src/commands/names.ts +53 -13
  110. package/packages/irc-core/src/commands/nickserv.ts +40 -1
  111. package/packages/irc-core/src/commands/oper.ts +361 -8
  112. package/packages/irc-core/src/commands/part.ts +4 -10
  113. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  114. package/packages/irc-core/src/commands/registration.ts +146 -2
  115. package/packages/irc-core/src/commands/sasl.ts +136 -19
  116. package/packages/irc-core/src/commands/topic.ts +10 -12
  117. package/packages/irc-core/src/commands/who.ts +1 -8
  118. package/packages/irc-core/src/config.ts +393 -20
  119. package/packages/irc-core/src/effects.ts +24 -0
  120. package/packages/irc-core/src/flood-control.ts +10 -10
  121. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  122. package/packages/irc-core/src/index.ts +8 -0
  123. package/packages/irc-core/src/oper-hashing.ts +43 -0
  124. package/packages/irc-core/src/oper-lockout.ts +87 -0
  125. package/packages/irc-core/src/ports.ts +395 -36
  126. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  127. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  128. package/packages/irc-core/src/protocol/index.ts +12 -1
  129. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  130. package/packages/irc-core/src/protocol/parser.ts +79 -10
  131. package/packages/irc-core/src/state/connection.ts +13 -0
  132. package/packages/irc-core/src/types.ts +228 -13
  133. package/packages/irc-core/src/ws-framing.ts +5 -4
  134. package/packages/irc-core/tests/bytes.test.ts +89 -0
  135. package/packages/irc-core/tests/certfp.test.ts +117 -0
  136. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  137. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  138. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  139. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  140. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  141. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  142. package/packages/irc-core/tests/commands/nickserv.test.ts +182 -2
  143. package/packages/irc-core/tests/commands/oper.test.ts +560 -2
  144. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  145. package/packages/irc-core/tests/commands/registration.test.ts +463 -1
  146. package/packages/irc-core/tests/commands/sasl.test.ts +596 -7
  147. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  148. package/packages/irc-core/tests/commands/unified-account.test.ts +2 -0
  149. package/packages/irc-core/tests/config.test.ts +534 -2
  150. package/packages/irc-core/tests/effects.test.ts +14 -0
  151. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  152. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  153. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  154. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  155. package/packages/irc-core/tests/outbound.test.ts +148 -0
  156. package/packages/irc-core/tests/parser.test.ts +287 -5
  157. package/packages/irc-core/tests/persistent-services-store.test.ts +141 -0
  158. package/packages/irc-core/tests/ports.test.ts +99 -7
  159. package/packages/irc-core/tests/services-store.test.ts +376 -14
  160. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  161. package/packages/irc-server/package.json +1 -1
  162. package/packages/irc-server/src/actor.ts +123 -8
  163. package/packages/irc-server/src/dispatch.ts +1 -0
  164. package/packages/irc-server/src/index.ts +7 -0
  165. package/packages/irc-server/src/redact.ts +159 -0
  166. package/packages/irc-server/src/runtime.ts +14 -0
  167. package/packages/irc-server/src/transport.ts +28 -1
  168. package/packages/irc-server/tests/actor.test.ts +544 -7
  169. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  170. package/packages/irc-server/tests/redact.test.ts +198 -0
  171. package/packages/irc-server/tests/runtime.test.ts +2 -0
  172. package/packages/irc-server/tests/transport.test.ts +66 -0
  173. package/packages/irc-test-support/package.json +1 -1
  174. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  175. package/scripts/package.json +1 -1
  176. package/tools/ci-hardening/package.json +2 -2
  177. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  178. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  179. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  180. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  181. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  182. package/tools/ci-hardening/src/index.ts +17 -0
  183. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  184. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  185. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  186. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  187. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  188. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  189. package/tools/ci-hardening/vitest.config.ts +5 -1
  190. package/tools/hash-oper-cred.ts +85 -0
  191. package/tools/load-test/package.json +1 -1
  192. package/tools/tcp-ws-forwarder/package.json +1 -1
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { Effect } from './effects.js';
10
10
  import type { FloodControlConfig } from './flood-control.js';
11
+ import type { OperFailureStats } from './oper-lockout.js';
11
12
  import type {
12
13
  AwayStore,
13
14
  Clock,
@@ -52,16 +53,67 @@ export interface StsConfig {
52
53
  }
53
54
 
54
55
  /**
55
- * A single IRC operator credential. The {@link operReducer} consults the
56
- * configured list to authenticate `OPER <name> <password>`. Both fields are
57
- * compared verbatim; adapters are responsible for any hashing at the auth
58
- * boundary if they layer one on top.
56
+ * Legacy plaintext IRC operator credential (DEPRECATED retained for a
57
+ * single deprecation-cycle window).
58
+ *
59
+ * The {@link operReducer} still verifies this shape via timing-safe byte
60
+ * comparison, but emits a one-shot `warn` per boot when it is encountered.
61
+ * Migrate to {@link OperCredHashed} via `tools/hash-oper-cred.ts`; this
62
+ * form will be removed in a future release.
59
63
  */
60
- export interface OperCred {
64
+ export interface OperCredPlaintext {
61
65
  readonly user: string;
62
66
  readonly password: string;
63
67
  }
64
68
 
69
+ /**
70
+ * At-rest hashed IRC operator credential (the recommended shape).
71
+ *
72
+ * `salt` and `hash` are base64 strings produced by
73
+ * `tools/hash-oper-cred.ts` (which wraps {@link hashAccountCredential}'s
74
+ * scrypt derivation). The {@link operReducer} verifies via the shared
75
+ * {@link verifyHashedPassword} with `timingSafeEqual`, mirroring the
76
+ * NickServ / SASL PLAIN credential path.
77
+ */
78
+ export interface OperCredHashed {
79
+ readonly user: string;
80
+ readonly salt: string;
81
+ readonly hash: string;
82
+ }
83
+
84
+ /**
85
+ * A single IRC operator credential. Either the at-rest hashed form
86
+ * ({@link OperCredHashed}, recommended) or the legacy plaintext form
87
+ * ({@link OperCredPlaintext}, deprecated — retained for a single
88
+ * deprecation-cycle window). The {@link operReducer} consults whichever
89
+ * shape is present; adapters are responsible for any hashing at the auth
90
+ * boundary if they layer one on top.
91
+ */
92
+ export type OperCred = OperCredHashed | OperCredPlaintext;
93
+
94
+ /**
95
+ * Per-IP failed-`OPER` lockout tunables as seen by reducers. Mirrors the
96
+ * `oper` sub-schema of {@link ServerConfigSchema}; the OPER reducer reads
97
+ * `maxFailures` / `failureWindowSeconds` (falling back to the documented
98
+ * defaults when unset).
99
+ *
100
+ * Optional fields are widened to `T | undefined` to mirror Zod's
101
+ * `.optional()` output under `exactOptionalPropertyTypes: true`.
102
+ */
103
+ export interface OperThrottleConfig {
104
+ /**
105
+ * Consecutive failed `OPER` attempts (per host, per window) that trip
106
+ * the lockout. Omitted → the reducer falls back to
107
+ * {@link DEFAULT_OPER_MAX_FAILURES} (3).
108
+ */
109
+ readonly maxFailures?: number | undefined;
110
+ /**
111
+ * Sliding window length in seconds. Omitted → the reducer falls back to
112
+ * {@link DEFAULT_OPER_FAILURE_WINDOW_SECONDS} (300).
113
+ */
114
+ readonly failureWindowSeconds?: number | undefined;
115
+ }
116
+
65
117
  /**
66
118
  * NickServ-specific tunables as seen by reducers. Mirrors the
67
119
  * `nickServ` sub-schema of {@link ServerConfigSchema}; the NickServ
@@ -78,6 +130,84 @@ export interface NickServConfig {
78
130
  * {@link DEFAULT_MIN_PASSWORD_LENGTH} (8).
79
131
  */
80
132
  readonly minPasswordLength?: number | undefined;
133
+ /**
134
+ * Failed-IDENTIFY threshold that freezes the account. Omitted → the
135
+ * NickServ IDENTIFY reducer falls back to
136
+ * {@link DEFAULT_MAX_IDENTIFY_FAILURES} (5).
137
+ */
138
+ readonly maxIdentifyFailures?: number | undefined;
139
+ /**
140
+ * Sliding window (seconds) the failed-IDENTIFY threshold counts
141
+ * within. Omitted → the reducer falls back to
142
+ * {@link DEFAULT_IDENTIFY_FREEZE_SECONDS} (300).
143
+ */
144
+ readonly identifyFreezeSeconds?: number | undefined;
145
+ }
146
+
147
+ /**
148
+ * SASL brute-force hardening tunables as seen by reducers. Mirrors the
149
+ * `sasl` sub-schema of {@link ServerConfigSchema}; the SASL reducer reads
150
+ * them to trip a per-connection lockout after too many failed
151
+ * `AUTHENTICATE` attempts (falling back to the documented defaults when
152
+ * unset).
153
+ *
154
+ * Optional fields are widened to `T | undefined` to mirror Zod's
155
+ * `.optional()` output under `exactOptionalPropertyTypes: true`.
156
+ */
157
+ export interface SaslConfig {
158
+ /**
159
+ * Failed `AUTHENTICATE` attempts (per connection, within the sliding
160
+ * window) before the connection is disconnected. Omitted → the reducer
161
+ * falls back to {@link DEFAULT_SASL_MAX_FAILURES} (3).
162
+ */
163
+ readonly maxFailures?: number | undefined;
164
+ /**
165
+ * Sliding-window length (seconds) the failure tracker counts within.
166
+ * Failures older than the window no longer count. Omitted → the reducer
167
+ * falls back to {@link DEFAULT_SASL_FAILURE_WINDOW_SECONDS} (60).
168
+ */
169
+ readonly failureWindowSeconds?: number | undefined;
170
+ /**
171
+ * Operator opt-in for the certificate-backed SASL EXTERNAL mechanism.
172
+ * Omitted → EXTERNAL is neither advertised nor accepted (the reducers
173
+ * treat absence as `false`). Enabling additionally requires a bound
174
+ * mTLS identity source and a secure (TLS) connection — see the
175
+ * AUTHENTICATE reducer. The adapters surface this as the
176
+ * `EXTERNAL_ENABLED` env var.
177
+ */
178
+ readonly externalEnabled?: boolean | undefined;
179
+ /**
180
+ * Transport-level EXTERNAL non-support override. When set (by an
181
+ * adapter whose transport cannot surface a client-cert identity —
182
+ * e.g. the Spectrum PROXY-v1 TCP container origin), SASL EXTERNAL is
183
+ * hard-disabled for the deployment: the `sasl` cap and `908
184
+ * ERR_SASLMECHS` list `PLAIN` only, and `AUTHENTICATE EXTERNAL` is
185
+ * rejected up front with `904 ERR_SASLFAIL` carrying this exact
186
+ * message (a stable, transport-specific refusal) — even if
187
+ * {@link externalEnabled} and every three-way-gate input is bound.
188
+ * The rejection is a protocol-level refusal, not a credential
189
+ * attempt: it does not count toward the per-connection SASL failure
190
+ * lockout.
191
+ */
192
+ readonly externalUnsupportedMessage?: string | undefined;
193
+ }
194
+
195
+ /**
196
+ * `draft/chathistory` tunables as seen by reducers. Mirrors the
197
+ * `chathistory` sub-schema of {@link ServerConfigSchema}; the CHATHISTORY
198
+ * reducer caps every client-supplied query limit at `maxLimit` (falling
199
+ * back to {@link DEFAULT_MAX_CHATHISTORY_LIMIT} when unset).
200
+ *
201
+ * Optional fields are widened to `T | undefined` to mirror Zod's
202
+ * `.optional()` output under `exactOptionalPropertyTypes: true`.
203
+ */
204
+ export interface ChathistoryConfig {
205
+ /**
206
+ * Maximum number of messages one `CHATHISTORY` query may return.
207
+ * Omitted → the reducer falls back to
208
+ * {@link DEFAULT_MAX_CHATHISTORY_LIMIT} (100).
209
+ */
210
+ readonly maxLimit?: number | undefined;
81
211
  }
82
212
 
83
213
  /**
@@ -131,6 +261,17 @@ export interface ServerConfig {
131
261
  * here.
132
262
  */
133
263
  readonly operCreds?: ReadonlyArray<OperCred> | undefined;
264
+ /**
265
+ * Per-IP failed-`OPER` lockout tunables consulted by the `OPER`
266
+ * reducer's brute-force gate. Omitted (or field-omitted) values fall
267
+ * back to the documented defaults (3 failures / 300s window); the
268
+ * parsed config always supplies both fields (schema defaults).
269
+ *
270
+ * Typed `OperThrottleConfig | undefined` to mirror Zod's `.optional()`
271
+ * output shape under `exactOptionalPropertyTypes: true` so inline
272
+ * `ServerConfig` literals in tests may omit it.
273
+ */
274
+ readonly oper?: OperThrottleConfig | undefined;
134
275
  maxChannelsPerUser: number;
135
276
  maxTargetsPerCommand: number;
136
277
  maxListEntries: number;
@@ -183,6 +324,18 @@ export interface ServerConfig {
183
324
  * literals in tests may omit it.
184
325
  */
185
326
  readonly multilineMaxBytes?: number | undefined;
327
+ /**
328
+ * Per-batch entry ceiling for IRCv3 `draft/multiline`. Enforced
329
+ * incrementally on each accumulated inner line — together with
330
+ * {@link ServerConfig.multilineMaxBytes} — so a client cannot hold an
331
+ * unbounded batch open. Defaults to
332
+ * {@link DEFAULT_MULTILINE_MAX_ENTRIES} (100) when omitted.
333
+ *
334
+ * Typed `number | undefined` to mirror Zod's optional output shape under
335
+ * `exactOptionalPropertyTypes: true` so inline `ServerConfig` literals in
336
+ * tests may omit it.
337
+ */
338
+ readonly multilineMaxEntries?: number | undefined;
186
339
  /**
187
340
  * Per-connection MONITOR watchlist ceiling, advertised as `MONITOR=<n>`
188
341
  * in `005 RPL_ISUPPORT`. Omitted → the reducer falls back to
@@ -240,18 +393,58 @@ export interface ServerConfig {
240
393
  */
241
394
  readonly nickServ?: NickServConfig | undefined;
242
395
  /**
243
- * HostServ approval mode for `REQUEST <vhost>`. `true` (the parsed
244
- * default) preserves the legacy auto-approve behaviour — the vhost is
245
- * recorded against the account immediately, no oper review. `false`
246
- * switches HostServ into oper-queue mode: `REQUEST` queues a pending
247
- * request that an oper must `APPROVE` (or `REJECT`); `LIST` enumerates
248
- * the pending queue. See `docs/Services.md` §6.
396
+ * SASL brute-force hardening tunables consulted by the SASL reducer:
397
+ * `maxFailures` failed `AUTHENTICATE` attempts within
398
+ * `failureWindowSeconds` disconnect the connection. Omitted entirely
399
+ * the reducer falls back to the documented defaults (3 failures / 60 s).
400
+ *
401
+ * Typed `SaslConfig | undefined` to mirror Zod's `.optional()` output
402
+ * under `exactOptionalPropertyTypes: true` so inline `ServerConfig`
403
+ * literals in tests may omit it.
404
+ */
405
+ readonly sasl?: SaslConfig | undefined;
406
+ /**
407
+ * `draft/chathistory` tunables consulted by the CHATHISTORY reducer.
408
+ * Every client-supplied query limit is silently capped at
409
+ * `maxLimit`; omitted entirely → the documented default ceiling (100).
410
+ *
411
+ * Typed `ChathistoryConfig | undefined` to mirror Zod's `.optional()`
412
+ * output shape under `exactOptionalPropertyTypes: true` so inline
413
+ * `ServerConfig` literals in tests may omit it.
414
+ */
415
+ readonly chathistory?: ChathistoryConfig | undefined;
416
+ /**
417
+ * HostServ approval mode for `REQUEST <vhost>`. `false` (the parsed
418
+ * default) is oper-queue mode: `REQUEST` queues a pending request that an
419
+ * oper must `APPROVE` (or `REJECT`); `LIST` enumerates the pending
420
+ * queue — the recommended mode. `true` preserves the legacy
421
+ * auto-approve behaviour (the vhost is recorded immediately, no oper
422
+ * review), hard-constrained by {@link hostservAutoApproveAllowlist} and
423
+ * a built-in denylist (reserved labels + this server's own hostname).
424
+ *
425
+ * @deprecated Auto-approve is deprecated; queue mode (the default) is
426
+ * the long-term path and a future release removes the auto-approve
427
+ * arm. See `docs/Services.md`.
249
428
  *
250
429
  * Typed `boolean | undefined` so inline `ServerConfig` literals in tests
251
- * may omit it (the reducer treats `undefined` as "auto-approve on",
252
- * matching the parsed default).
430
+ * may omit it (the reducer treats `undefined` as queue mode, matching
431
+ * the parsed default).
253
432
  */
254
433
  readonly hostservAutoApproveVhosts?: boolean | undefined;
434
+ /**
435
+ * Explicit allowlist constraining auto-approved vhosts when
436
+ * {@link hostservAutoApproveVhosts} is `true`: an entry is an exact
437
+ * hostname (`users.example.com`) or a wildcard suffix
438
+ * (`*.users.example.com`, any depth beneath it). Case-insensitive.
439
+ * Ignored in queue mode (opers vet requests). The parsed config always
440
+ * supplies an array (default `[]`), and config parsing refuses to boot
441
+ * auto-approve mode with an empty allowlist; the reducer additionally
442
+ * fails closed when the field is omitted from an inline literal.
443
+ *
444
+ * Typed `ReadonlyArray<string> | undefined` to mirror Zod's `.optional()`
445
+ * output under `exactOptionalPropertyTypes: true`.
446
+ */
447
+ readonly hostservAutoApproveAllowlist?: ReadonlyArray<string> | undefined;
255
448
  }
256
449
 
257
450
  /** Per-invocation context handed to every reducer. */
@@ -317,6 +510,21 @@ export interface Ctx {
317
510
  * `904` / silent no-op.
318
511
  */
319
512
  readonly services?: ServicesStore;
513
+ /**
514
+ * Per-IP failed-`OPER` counter (absent when the deployment's runtime
515
+ * does not maintain one). When present, the OPER reducer counts
516
+ * credential failures per source host: once
517
+ * `serverConfig.oper.maxFailures` failures land within
518
+ * `serverConfig.oper.failureWindowSeconds`, further OPER attempts from
519
+ * that host are rejected with `491 ERR_NOOPERHOST` without consulting
520
+ * the credential store, and a `BroadcastOperNotice` effect notifies
521
+ * every oper. A successful OPER resets the host's counter. Omitted (→
522
+ * `ctx.operFailures` undefined) preserves the unlocked behaviour for
523
+ * runtimes that have not wired the shared tracker (e.g. per-connection
524
+ * Durable Object adapters): every attempt still reaches the credential
525
+ * store.
526
+ */
527
+ readonly operFailures?: OperFailureStats;
320
528
  /** The connection invoking the command. Reducers mutate this freely. */
321
529
  readonly connection: ConnectionState;
322
530
  /** Convenience: `connection.id`. */
@@ -358,6 +566,12 @@ export interface BuildCtxOptions {
358
566
  history?: NickHistoryStore;
359
567
  away?: AwayStore;
360
568
  services?: ServicesStore;
569
+ /**
570
+ * Per-IP failed-`OPER` counter shared across every connection (see
571
+ * {@link Ctx.operFailures}). Omitted → the OPER brute-force gate is
572
+ * disabled for this actor.
573
+ */
574
+ operFailures?: OperFailureStats;
361
575
  connection: ConnectionState;
362
576
  /**
363
577
  * IRCv3 `labeled-response` label extracted from the inbound `+label` tag.
@@ -383,6 +597,7 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
383
597
  ...(opts.history !== undefined ? { history: opts.history } : {}),
384
598
  ...(opts.away !== undefined ? { away: opts.away } : {}),
385
599
  ...(opts.services !== undefined ? { services: opts.services } : {}),
600
+ ...(opts.operFailures !== undefined ? { operFailures: opts.operFailures } : {}),
386
601
  ...(opts.label !== undefined ? { label: opts.label } : {}),
387
602
  };
388
603
  return ctx;
@@ -25,6 +25,7 @@
25
25
  * every WebSocket runtime (Node `ws`, Cloudflare Durable Objects, Lambda).
26
26
  */
27
27
 
28
+ import { byteLength } from './protocol/bytes.js';
28
29
  import type { WsSubprotocol } from './ws-subprotocol.js';
29
30
 
30
31
  /**
@@ -103,12 +104,12 @@ export function frameToLines(frame: string, mode: WsFrameMode): string[] {
103
104
  /**
104
105
  * Returns `true` when `frame` fits within the IRCv3 WebSocket 510-byte
105
106
  * message budget, `false` when it exceeds it and MUST be rejected by the
106
- * inbound path. Measured in UTF-16 code units, consistent with how
107
- * {@link enforceLineLimit} and the input parser cap line length across this
108
- * package (every supported runtime exposes `.length`).
107
+ * inbound path. Measured in UTF-8 bytes (the wire encoding), consistent
108
+ * with how {@link enforceLineLimit} and the input parser cap line length
109
+ * across this package, so multi-byte content cannot double the budget.
109
110
  */
110
111
  export function isWithinWsByteBudget(frame: string): boolean {
111
- return frame.length <= MAX_WS_MESSAGE_BYTES;
112
+ return byteLength(frame) <= MAX_WS_MESSAGE_BYTES;
112
113
  }
113
114
 
114
115
  /**
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { byteLength, truncateToByteBudget } from '../src/protocol/bytes';
3
+
4
+ // ============================================================================
5
+ // byteLength — UTF-8 byte accounting for IRC wire budgets
6
+ //
7
+ // IRC line limits are byte budgets (RFC 1459 §2.3), but JavaScript strings
8
+ // expose UTF-16 code units via `.length`. These tests pin the semantics the
9
+ // line-budget seams rely on: one byte per ASCII char, 2–4 bytes per
10
+ // non-ASCII code point.
11
+ // ============================================================================
12
+
13
+ describe('byteLength', () => {
14
+ it('returns 0 for the empty string', () => {
15
+ expect(byteLength('')).toBe(0);
16
+ });
17
+
18
+ it('counts one byte per ASCII character (matches .length)', () => {
19
+ expect(byteLength('NICK bob')).toBe('NICK bob'.length);
20
+ });
21
+
22
+ it('counts 2 bytes for a 2-byte BMP character (é)', () => {
23
+ expect('é'.length).toBe(1);
24
+ expect(byteLength('é')).toBe(2);
25
+ });
26
+
27
+ it('returns 1022 for 511 é characters — not the UTF-16 count of 511', () => {
28
+ const s = 'é'.repeat(511);
29
+ expect(s.length).toBe(511);
30
+ expect(byteLength(s)).toBe(1022);
31
+ });
32
+
33
+ it('counts 4 bytes for an astral code point (surrogate pair)', () => {
34
+ const emoji = '😀';
35
+ expect(emoji.length).toBe(2);
36
+ expect(byteLength(emoji)).toBe(4);
37
+ });
38
+
39
+ it('accounts for mixed ASCII and multi-byte content', () => {
40
+ expect(byteLength('aé😀')).toBe(1 + 2 + 4);
41
+ });
42
+ });
43
+
44
+ // ============================================================================
45
+ // truncateToByteBudget — byte-bounded right-truncation
46
+ //
47
+ // Line-budget seams must not only DETECT over-budget multi-byte lines, they
48
+ // must emit output that fits the byte budget. Code-unit slicing cannot do
49
+ // that (510 'é' units = 1020 bytes) and can split surrogate pairs.
50
+ // ============================================================================
51
+
52
+ describe('truncateToByteBudget', () => {
53
+ it('returns the input unchanged when it already fits the budget', () => {
54
+ expect(truncateToByteBudget('NICK bob', 510)).toBe('NICK bob');
55
+ });
56
+
57
+ it('truncates ASCII input exactly like code-unit slicing', () => {
58
+ expect(truncateToByteBudget('x'.repeat(600), 510)).toBe('x'.repeat(510));
59
+ });
60
+
61
+ it('truncates multi-byte input so the result fits the byte budget', () => {
62
+ const out = truncateToByteBudget('é'.repeat(600), 510);
63
+ expect(byteLength(out)).toBeLessThanOrEqual(510);
64
+ // 510 bytes of 'é' = 255 chars; the budget is fully used.
65
+ expect(out).toBe('é'.repeat(255));
66
+ });
67
+
68
+ it('stops before the code point that would cross the budget', () => {
69
+ // Budget 511 with 'é' (2 bytes each): 255 chars = 510 bytes; the 256th
70
+ // 'é' would make 512, so it is dropped and one byte stays unused.
71
+ expect(truncateToByteBudget('é'.repeat(600), 511)).toBe('é'.repeat(255));
72
+ });
73
+
74
+ it('never splits a surrogate pair', () => {
75
+ const out = truncateToByteBudget('😀'.repeat(400), 510);
76
+ // 4-byte emoji: floor(510/4) = 127 complete pairs = 508 bytes.
77
+ expect(out).toBe('😀'.repeat(127));
78
+ expect(byteLength(out)).toBe(508);
79
+ });
80
+
81
+ it('accounts for mixed ASCII and multi-byte content', () => {
82
+ // 'ab' (2 bytes) + 3 'é' (6 bytes) = 8 bytes; budget 5 keeps 'abé'.
83
+ expect(truncateToByteBudget('abééé', 5)).toBe('abé');
84
+ });
85
+
86
+ it('returns the empty string for a zero budget', () => {
87
+ expect(truncateToByteBudget('abc', 0)).toBe('');
88
+ });
89
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CERT_FP_PREFIX,
4
+ canonicalizeCertSubject,
5
+ certFingerprintId,
6
+ certIdentityFromSubject,
7
+ } from '../src/certfp';
8
+
9
+ // ============================================================================
10
+ // canonicalizeCertSubject — RFC 4514-style DN canonicalisation
11
+ // ============================================================================
12
+
13
+ describe('canonicalizeCertSubject', () => {
14
+ it('lowercases the attribute type but preserves the value case', () => {
15
+ expect(canonicalizeCertSubject('CN=Alice')).toBe('cn=Alice');
16
+ });
17
+
18
+ it('collapses repeated whitespace inside a value', () => {
19
+ expect(canonicalizeCertSubject('CN=alice smith')).toBe('cn=alice smith');
20
+ });
21
+
22
+ it('trims whitespace around the type and value', () => {
23
+ expect(canonicalizeCertSubject(' CN = alice ')).toBe('cn=alice');
24
+ });
25
+
26
+ it('sorts RDNs into a stable order regardless of input ordering', () => {
27
+ expect(canonicalizeCertSubject('O=Example Inc,CN=alice')).toBe('cn=alice,o=Example Inc');
28
+ expect(canonicalizeCertSubject('CN=alice,O=Example Inc')).toBe('cn=alice,o=Example Inc');
29
+ });
30
+
31
+ it('sorts AVAs inside a multi-valued RDN regardless of input ordering', () => {
32
+ expect(canonicalizeCertSubject('O=Example+CN=alice')).toBe('cn=alice+o=Example');
33
+ expect(canonicalizeCertSubject('CN=alice+O=Example')).toBe('cn=alice+o=Example');
34
+ });
35
+
36
+ it('collapses two semantically-equal DNs (spacing + ordering differ) to one form', () => {
37
+ const a = canonicalizeCertSubject('CN=alice , O = Example Inc , C=US');
38
+ const b = canonicalizeCertSubject('C=US,O=Example Inc,CN=alice');
39
+ expect(a).toBe(b);
40
+ });
41
+
42
+ it('does not split on an escaped separator', () => {
43
+ expect(canonicalizeCertSubject('CN=a\\,b,O=x')).toBe('cn=a\\,b,o=x');
44
+ });
45
+
46
+ it('skips escaped characters when locating the type/value split', () => {
47
+ expect(canonicalizeCertSubject('CN\\=tag=y')).toBe('cn\\=tag=y');
48
+ });
49
+
50
+ it('does not trim an escaped trailing space from a value', () => {
51
+ expect(canonicalizeCertSubject('CN=alice\\ ')).toBe('cn=alice\\ ');
52
+ });
53
+
54
+ it('keeps an escape pair intact when a pending space precedes it', () => {
55
+ expect(canonicalizeCertSubject('CN=a \\, b')).toBe('cn=a \\, b');
56
+ });
57
+
58
+ it('keeps a trailing dangling backslash verbatim', () => {
59
+ expect(canonicalizeCertSubject('CN=a\\')).toBe('cn=a\\');
60
+ });
61
+
62
+ it('keeps a malformed AVA without an equals sign as trimmed collapsed text', () => {
63
+ expect(canonicalizeCertSubject(' alice smith ')).toBe('alice smith');
64
+ });
65
+
66
+ it('canonicalises an empty DN to the empty string', () => {
67
+ expect(canonicalizeCertSubject('')).toBe('');
68
+ });
69
+ });
70
+
71
+ // ============================================================================
72
+ // certFingerprintId — normalised DER SHA-256 fingerprint binding id
73
+ // ============================================================================
74
+
75
+ describe('certFingerprintId', () => {
76
+ it('prefixes the fingerprint with the binding prefix', () => {
77
+ expect(certFingerprintId('af363dc8')).toBe(`${CERT_FP_PREFIX}af363dc8`);
78
+ });
79
+
80
+ it('lowercases an uppercase hex fingerprint', () => {
81
+ expect(certFingerprintId('AF363DC8')).toBe('fp:af363dc8');
82
+ });
83
+
84
+ it('strips openssl-style colon separators', () => {
85
+ expect(certFingerprintId('AF:36:3D:C8')).toBe('fp:af363dc8');
86
+ });
87
+
88
+ it('trims surrounding whitespace', () => {
89
+ expect(certFingerprintId(' af363dc8 ')).toBe('fp:af363dc8');
90
+ });
91
+ });
92
+
93
+ // ============================================================================
94
+ // certIdentityFromSubject — CertIdentity construction
95
+ // ============================================================================
96
+
97
+ describe('certIdentityFromSubject', () => {
98
+ it('builds an identity with the canonical DN and no fingerprint key', () => {
99
+ expect(certIdentityFromSubject('CN=alice')).toEqual({
100
+ subject: 'CN=alice',
101
+ canonicalDn: 'cn=alice',
102
+ });
103
+ });
104
+
105
+ it('includes the fingerprint when the platform supplied one', () => {
106
+ expect(certIdentityFromSubject('CN=alice', 'af363dc8')).toEqual({
107
+ subject: 'CN=alice',
108
+ canonicalDn: 'cn=alice',
109
+ fingerprint: 'af363dc8',
110
+ });
111
+ });
112
+
113
+ it('carries the canonical DN of a multi-RDN subject', () => {
114
+ const id = certIdentityFromSubject('O=Example,CN=alice');
115
+ expect(id.canonicalDn).toBe('cn=alice,o=Example');
116
+ });
117
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { getLsString } from '../../src/caps/capabilities';
3
+ import { certIdentityFromSubject } from '../../src/certfp';
3
4
  import { capReducer } from '../../src/commands/cap';
4
5
  import { userReducer } from '../../src/commands/registration';
5
6
  import { Effect } from '../../src/effects';
@@ -131,8 +132,12 @@ describe('capReducer — LS', () => {
131
132
 
132
133
  it('advertises sasl=PLAIN,EXTERNAL when an mTLS provider is configured', () => {
133
134
  const state = makeState();
134
- const provider: MtlsIdentityProvider = { getIdentity: () => 'CN=alice' };
135
- const ctx = makeCtx(state, serverConfig, provider);
135
+ state.secure = true;
136
+ const provider: MtlsIdentityProvider = {
137
+ getIdentity: () => certIdentityFromSubject('CN=alice'),
138
+ };
139
+ const cfg: ServerConfig = { ...serverConfig, sasl: { externalEnabled: true } };
140
+ const ctx = makeCtx(state, cfg, provider);
136
141
  const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
137
142
  const send = out.effects[0];
138
143
  expect(send).toBeDefined();
@@ -142,6 +147,75 @@ describe('capReducer — LS', () => {
142
147
  }
143
148
  });
144
149
 
150
+ it('advertises sasl=PLAIN when EXTERNAL is not opted in, even with mTLS on a secure connection', () => {
151
+ // The operator opt-in gate: without sasl.externalEnabled the mechanism
152
+ // is not advertised, so clients never attempt an exchange the server
153
+ // would refuse with 908.
154
+ const state = makeState();
155
+ state.secure = true;
156
+ const provider: MtlsIdentityProvider = {
157
+ getIdentity: () => certIdentityFromSubject('CN=alice'),
158
+ };
159
+ const ctx = makeCtx(state, serverConfig, provider);
160
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
161
+ const send = out.effects[0];
162
+ expect(send).toBeDefined();
163
+ if (send?.tag === 'Send') {
164
+ const body = send.lines[0]?.text ?? '';
165
+ expect(body).toContain('sasl=PLAIN');
166
+ expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
167
+ }
168
+ });
169
+
170
+ it('advertises sasl=PLAIN on an insecure connection even when EXTERNAL is opted in', () => {
171
+ // SASL EXTERNAL is certificate-backed authentication: the client-cert
172
+ // identity only carries meaning over a transport the server trusts
173
+ // (TLS). A plaintext connection never sees the mechanism.
174
+ const state = makeState();
175
+ state.secure = false;
176
+ const provider: MtlsIdentityProvider = {
177
+ getIdentity: () => certIdentityFromSubject('CN=alice'),
178
+ };
179
+ const cfg: ServerConfig = { ...serverConfig, sasl: { externalEnabled: true } };
180
+ const ctx = makeCtx(state, cfg, provider);
181
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
182
+ const send = out.effects[0];
183
+ expect(send).toBeDefined();
184
+ if (send?.tag === 'Send') {
185
+ const body = send.lines[0]?.text ?? '';
186
+ expect(body).toContain('sasl=PLAIN');
187
+ expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
188
+ }
189
+ });
190
+
191
+ it('advertises sasl=PLAIN when the transport-level EXTERNAL override is set, even with mTLS + opt-in on a secure connection', () => {
192
+ // A transport that cannot surface a client-cert identity (the
193
+ // Spectrum TCP container origin) pins the mechanism off via
194
+ // sasl.externalUnsupportedMessage: the cap must never advertise a
195
+ // mechanism AUTHENTICATE would refuse with a transport-specific 904.
196
+ const state = makeState();
197
+ state.secure = true;
198
+ const provider: MtlsIdentityProvider = {
199
+ getIdentity: () => certIdentityFromSubject('CN=alice'),
200
+ };
201
+ const cfg: ServerConfig = {
202
+ ...serverConfig,
203
+ sasl: {
204
+ externalEnabled: true,
205
+ externalUnsupportedMessage: 'SASL EXTERNAL not supported on this transport',
206
+ },
207
+ };
208
+ const ctx = makeCtx(state, cfg, provider);
209
+ const out = capReducer(state, { command: 'CAP', params: ['LS'], tags: {} }, ctx);
210
+ const send = out.effects[0];
211
+ expect(send).toBeDefined();
212
+ if (send?.tag === 'Send') {
213
+ const body = send.lines[0]?.text ?? '';
214
+ expect(body).toContain('sasl=PLAIN');
215
+ expect(body).not.toContain('sasl=PLAIN,EXTERNAL');
216
+ }
217
+ });
218
+
145
219
  it('advertises draft/multiline=4096 with the default byte budget', () => {
146
220
  const state = makeState();
147
221
  const ctx = makeCtx(state);