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
@@ -18,18 +18,119 @@
18
18
 
19
19
  import { z } from 'zod';
20
20
  import { BUILD_DATE, PACKAGE_VERSION } from './build-info.js';
21
- import { DEFAULT_MULTILINE_MAX_BYTES } from './caps/capabilities.js';
21
+ import { DEFAULT_MULTILINE_MAX_BYTES, DEFAULT_MULTILINE_MAX_ENTRIES } from './caps/capabilities.js';
22
+ import type { OperCred } from './types.js';
23
+
24
+ /**
25
+ * Narrows the schema's loose `{user, password?, salt?, hash?}` parse
26
+ * output into the discriminated {@link OperCred} union so the parsed
27
+ * config is directly assignable to `ServerConfig.operCreds`.
28
+ *
29
+ * When `password` is present the entry narrows to the legacy plaintext
30
+ * shape; otherwise the entry narrows to the hashed shape. The
31
+ * {@link OperCredSchema} `superRefine` guarantees exactly one of the two
32
+ * arms holds for any input that reaches parse success; the `?? ''`
33
+ * fallbacks below are defensive only (unreachable through
34
+ * `parseServerConfig`) and exist so the helper is total.
35
+ */
36
+ export function narrowOperCred(data: {
37
+ user: string;
38
+ password?: string | undefined;
39
+ salt?: string | undefined;
40
+ hash?: string | undefined;
41
+ }): OperCred {
42
+ if (data.password !== undefined) {
43
+ return { user: data.user, password: data.password };
44
+ }
45
+ return {
46
+ user: data.user,
47
+ salt: data.salt ?? '',
48
+ hash: data.hash ?? '',
49
+ };
50
+ }
22
51
 
23
52
  /**
24
53
  * Schema for a single IRC operator credential.
25
54
  *
26
- * Both fields are non-empty strings; the adapter is responsible for any
27
- * hashing/constant-time comparison at the OPER auth boundary.
55
+ * Accepts EITHER the at-rest hashed form (`{ user, salt, hash }`,
56
+ * recommended) OR the legacy plaintext form (`{ user, password }`,
57
+ * deprecated). The two shapes are mutually exclusive: a credential that
58
+ * supplies both `password` and `{salt, hash}` is rejected so operators
59
+ * cannot accidentally configure an ambiguous credential. The OPER auth
60
+ * path consults whichever shape is present.
61
+ *
62
+ * The plaintext form is DEPRECATED: retained for a single
63
+ * deprecation-cycle window so deployments can rotate to the hashed form
64
+ * without downtime. Adapters and `OPER` authentication still verify it,
65
+ * but emit a one-shot `warn` per boot when it is encountered. Migrate
66
+ * via `tools/hash-oper-cred.ts`; the plaintext form will be removed in a
67
+ * future release.
68
+ *
69
+ * Implemented as a unified object schema with `superRefine` rather than
70
+ * `z.union` so the rejection error messages name the offending field
71
+ * (Zod unions emit a generic "Invalid input" that hides which half is
72
+ * malformed). The trailing {@link narrowOperCred} transform narrows the
73
+ * loose parse output back into the {@link OperCred} union so
74
+ * `ParsedServerConfig.operCreds` is assignable to `ServerConfig`.
28
75
  */
29
- export const OperCredSchema = z.object({
30
- user: z.string().min(1),
31
- password: z.string().min(1),
32
- });
76
+ export const OperCredSchema = z
77
+ .object({
78
+ user: z.string().min(1),
79
+ password: z.string().min(1).optional(),
80
+ salt: z.string().min(1).optional(),
81
+ hash: z.string().min(1).optional(),
82
+ })
83
+ .superRefine((data, ctx) => {
84
+ const hasPassword = data.password !== undefined;
85
+ const hasSalt = data.salt !== undefined;
86
+ const hasHash = data.hash !== undefined;
87
+
88
+ // The plaintext form requires `password` and forbids `salt`/`hash`.
89
+ // The hashed form requires BOTH `salt` and `hash` and forbids
90
+ // `password`. Anything else is ambiguous and rejected with a
91
+ // field-named error so operators can fix the config without
92
+ // round-tripping through Zod's error tree.
93
+ if (hasPassword && (hasSalt || hasHash)) {
94
+ ctx.addIssue({
95
+ code: z.ZodIssueCode.custom,
96
+ message:
97
+ 'Oper cred must be either {user,password} (legacy plaintext) or {user,salt,hash} (hashed), not a mix',
98
+ path: ['password'],
99
+ });
100
+ return;
101
+ }
102
+ if (!hasPassword && !(hasSalt && hasHash)) {
103
+ // Either salt or hash (or both) is missing on the hashed arm.
104
+ if (!hasSalt) {
105
+ ctx.addIssue({
106
+ code: z.ZodIssueCode.custom,
107
+ message:
108
+ 'Required for the hashed form (or supply `password` for the legacy plaintext form)',
109
+ path: ['salt'],
110
+ });
111
+ }
112
+ if (!hasHash) {
113
+ ctx.addIssue({
114
+ code: z.ZodIssueCode.custom,
115
+ message:
116
+ 'Required for the hashed form (or supply `password` for the legacy plaintext form)',
117
+ path: ['hash'],
118
+ });
119
+ }
120
+ if (!hasSalt && !hasHash) {
121
+ // Neither salt nor hash AND no password: the cred is missing
122
+ // every authentication field. Surface `password` so operators
123
+ // coming from the legacy schema see the familiar error.
124
+ ctx.addIssue({
125
+ code: z.ZodIssueCode.custom,
126
+ message:
127
+ 'Required for the legacy plaintext form (or supply `salt` + `hash` for the hashed form)',
128
+ path: ['password'],
129
+ });
130
+ }
131
+ }
132
+ })
133
+ .transform(narrowOperCred);
33
134
 
34
135
  /**
35
136
  * Schema for a single SASL PLAIN account credential. The `username` is also
@@ -142,6 +243,22 @@ export const DEFAULT_FLOOD_CONTROL_CONFIG: {
142
243
  disconnectThreshold: 0,
143
244
  };
144
245
 
246
+ /**
247
+ * Default number of consecutive failed `OPER` attempts (from one source
248
+ * host, within {@link DEFAULT_OPER_FAILURE_WINDOW_SECONDS}) before the
249
+ * per-IP lockout gate starts rejecting `OPER` with `491 ERR_NOOPERHOST`
250
+ * without consulting the credential store. Overridable per deployment via
251
+ * `ServerConfig.oper.maxFailures`.
252
+ */
253
+ export const DEFAULT_OPER_MAX_FAILURES = 3;
254
+
255
+ /**
256
+ * Default sliding window (seconds) for the per-IP failed-`OPER` counter.
257
+ * Failures older than the window no longer count towards the lockout.
258
+ * Overridable per deployment via `ServerConfig.oper.failureWindowSeconds`.
259
+ */
260
+ export const DEFAULT_OPER_FAILURE_WINDOW_SECONDS = 300;
261
+
145
262
  /**
146
263
  * Default server version surfaced in `002`/`004`/`351`/`371` when a deployment
147
264
  * does not supply one. Sourced from `irc-core/package.json` at build time via
@@ -182,12 +299,130 @@ export const DEFAULT_NICK_ENFORCE_GRACE_MS = 30_000;
182
299
  */
183
300
  export const DEFAULT_MIN_PASSWORD_LENGTH = 8;
184
301
 
302
+ /**
303
+ * Default number of failed `AUTHENTICATE` attempts (per connection, within
304
+ * the sliding failure window) before the SASL reducer disconnects the
305
+ * connection. Overridable per deployment via
306
+ * `ServerConfig.sasl.maxFailures`.
307
+ */
308
+ export const DEFAULT_SASL_MAX_FAILURES = 3;
309
+
310
+ /**
311
+ * Default sliding-window length (seconds) the SASL failure tracker counts
312
+ * within; older failures no longer count toward the lockout. Overridable per
313
+ * deployment via `ServerConfig.sasl.failureWindowSeconds`.
314
+ */
315
+ export const DEFAULT_SASL_FAILURE_WINDOW_SECONDS = 60;
316
+
317
+ /**
318
+ * Default failed-IDENTIFY threshold that freezes a NickServ account.
319
+ * After this many failed `IDENTIFY` attempts against one registered nick
320
+ * inside the freeze window, further attempts are rejected with a freeze
321
+ * notice BEFORE the scrypt verification runs. Overridable per deployment
322
+ * via `ServerConfig.nickServ.maxIdentifyFailures`.
323
+ */
324
+ export const DEFAULT_MAX_IDENTIFY_FAILURES = 5;
325
+
326
+ /**
327
+ * Default identify-freeze window (seconds). Failed `IDENTIFY` attempts
328
+ * older than this window stop counting towards the freeze (sliding
329
+ * window), so the freeze lifts once the failures age out. Overridable per
330
+ * deployment via `ServerConfig.nickServ.identifyFreezeSeconds`.
331
+ */
332
+ export const DEFAULT_IDENTIFY_FREEZE_SECONDS = 300;
333
+
334
+ /**
335
+ * Default ceiling for the per-connection inbound frame window enforced
336
+ * at the adapter boundary (see `frame-rate-limit.ts`). Overridable per
337
+ * deployment via `ServerConfig.adapter.maxFramesPerWindow`.
338
+ */
339
+ export const DEFAULT_MAX_FRAMES_PER_WINDOW = 50;
340
+
341
+ /**
342
+ * Default sliding-window length (seconds) for the per-connection inbound
343
+ * frame limit at the adapter boundary. Frames older than the window stop
344
+ * counting, so a connection that pauses longer than the window starts
345
+ * fresh. Overridable per deployment via
346
+ * `ServerConfig.adapter.frameWindowSeconds`.
347
+ */
348
+ export const DEFAULT_FRAME_WINDOW_SECONDS = 5;
349
+
350
+ /**
351
+ * Default ceiling for a client-supplied `CHATHISTORY` limit. A request
352
+ * like `CHATHISTORY LATEST #chan * 999999999` is silently capped at this
353
+ * many messages so a future higher-capacity `MessageStore` cannot be
354
+ * tricked into materializing a huge result set. Overridable per
355
+ * deployment via `ServerConfig.chathistory.maxLimit`.
356
+ */
357
+ export const DEFAULT_MAX_CHATHISTORY_LIMIT = 100;
358
+
359
+ /**
360
+ * `draft/chathistory` tunables. The client-supplied query limit is
361
+ * silently capped at `maxLimit`; the cap guards the `MessageStore.query`
362
+ * seam against oversized materialization regardless of the store's
363
+ * backing capacity.
364
+ */
365
+ export const ChathistoryConfigSchema = z.object({
366
+ /**
367
+ * Maximum number of messages one `CHATHISTORY` query may return.
368
+ * Defaults to {@link DEFAULT_MAX_CHATHISTORY_LIMIT} (100).
369
+ */
370
+ maxLimit: z.number().int().positive().default(DEFAULT_MAX_CHATHISTORY_LIMIT),
371
+ });
372
+
373
+ /**
374
+ * Adapter-boundary tunables. These knobs are consumed by the adapters'
375
+ * inbound-frame gates (the CF Durable Object's `webSocketMessage` and the
376
+ * AWS `$default` handler) — NOT by the reducers — so they live in their
377
+ * own section rather than on the reducer-facing limit list. The
378
+ * per-connection frame window fires BEFORE the actor / storage write,
379
+ * compounding the in-actor flood control with a hard cost ceiling.
380
+ */
381
+ export const AdapterConfigSchema = z.object({
382
+ /**
383
+ * Maximum inbound frames one connection may deliver inside the sliding
384
+ * window before the adapter closes it with RFC 6455 close code 1008
385
+ * after an `ERROR :Closing link (flooding)` line. Defaults to
386
+ * {@link DEFAULT_MAX_FRAMES_PER_WINDOW} (50).
387
+ */
388
+ maxFramesPerWindow: z.number().int().positive().default(DEFAULT_MAX_FRAMES_PER_WINDOW),
389
+ /**
390
+ * Sliding-window length in seconds. Defaults to
391
+ * {@link DEFAULT_FRAME_WINDOW_SECONDS} (5).
392
+ */
393
+ frameWindowSeconds: z.number().int().positive().default(DEFAULT_FRAME_WINDOW_SECONDS),
394
+ });
395
+
396
+ /**
397
+ * Per-IP failed-`OPER` lockout tunables. After `maxFailures` credential
398
+ * failures from one source host within `failureWindowSeconds`, further
399
+ * `OPER` attempts from that host are rejected with `491 ERR_NOOPERHOST`
400
+ * before the credential store is consulted (online brute-force throttle).
401
+ */
402
+ export const OperThrottleConfigSchema = z.object({
403
+ /**
404
+ * Consecutive failed `OPER` attempts (per host, per window) that trip
405
+ * the lockout. Defaults to {@link DEFAULT_OPER_MAX_FAILURES} (3).
406
+ */
407
+ maxFailures: z.number().int().positive().default(DEFAULT_OPER_MAX_FAILURES),
408
+ /**
409
+ * Sliding window length in seconds. Failures older than the window no
410
+ * longer count. Defaults to {@link DEFAULT_OPER_FAILURE_WINDOW_SECONDS}
411
+ * (300).
412
+ */
413
+ failureWindowSeconds: z.number().int().positive().default(DEFAULT_OPER_FAILURE_WINDOW_SECONDS),
414
+ });
415
+
185
416
  /**
186
417
  * The full server-config schema. Optional fields default to deployment-
187
418
  * sensible values so a minimal `{ serverName, networkName }` config is
188
419
  * enough to boot; production deployments override the defaults.
420
+ *
421
+ * Composed as a plain object schema that the exported
422
+ * {@link ServerConfigSchema} wraps with cross-field refinements (the
423
+ * HostServ auto-approve allowlist boot guard).
189
424
  */
190
- export const ServerConfigSchema = z.object({
425
+ const ServerConfigSchemaObject = z.object({
191
426
  /** Server hostname shown in numerics (`:serverName 001 nick ...`). */
192
427
  serverName: z.string().min(1),
193
428
  /** Network name advertised in `005 NETWORK=...` and WHOIS replies. */
@@ -279,6 +514,15 @@ export const ServerConfigSchema = z.object({
279
514
  */
280
515
  operCreds: z.array(OperCredSchema).default([]),
281
516
 
517
+ /**
518
+ * Per-IP failed-`OPER` lockout tunables (online brute-force throttle).
519
+ * Omit entirely to inherit the documented defaults (3 failures / 300s).
520
+ */
521
+ oper: OperThrottleConfigSchema.default({
522
+ maxFailures: DEFAULT_OPER_MAX_FAILURES,
523
+ failureWindowSeconds: DEFAULT_OPER_FAILURE_WINDOW_SECONDS,
524
+ }),
525
+
282
526
  /**
283
527
  * SASL PLAIN seed credentials. The adapter ingests these into the
284
528
  * `ServicesStore` at boot via `registerNick` (idempotent — existing
@@ -336,6 +580,14 @@ export const ServerConfigSchema = z.object({
336
580
  */
337
581
  multilineMaxBytes: z.number().int().positive().default(DEFAULT_MULTILINE_MAX_BYTES),
338
582
 
583
+ /**
584
+ * Per-batch entry ceiling for IRCv3 `draft/multiline`, enforced
585
+ * incrementally on each accumulated inner line (defense-in-depth against
586
+ * a client that streams many tiny lines and never closes the batch).
587
+ * Defaults to {@link DEFAULT_MULTILINE_MAX_ENTRIES} (100).
588
+ */
589
+ multilineMaxEntries: z.number().int().positive().default(DEFAULT_MULTILINE_MAX_ENTRIES),
590
+
339
591
  /**
340
592
  * Per-connection MONITOR watchlist ceiling, advertised as `MONITOR=<n>`
341
593
  * in `005 RPL_ISUPPORT`. Omit to inherit the monitor reducer's built-in
@@ -366,10 +618,10 @@ export const ServerConfigSchema = z.object({
366
618
  nickEnforceGraceMs: z.number().int().nonnegative().optional(),
367
619
 
368
620
  /**
369
- * NickServ-specific tunables. Currently only the minimum length enforced
370
- * by `SET PASSWORD` is configurable; the maximum (256) is fixed to bound
371
- * the scrypt input budget. Omit entirely to inherit the documented
372
- * defaults.
621
+ * NickServ-specific tunables. The minimum length enforced by
622
+ * `SET PASSWORD` and the IDENTIFY freeze thresholds are configurable;
623
+ * the password maximum (256) is fixed to bound the scrypt input
624
+ * budget. Omit entirely to inherit the documented defaults.
373
625
  */
374
626
  nickServ: z
375
627
  .object({
@@ -380,20 +632,141 @@ export const ServerConfigSchema = z.object({
380
632
  * with `Password is too short (minimum <n> characters).`.
381
633
  */
382
634
  minPasswordLength: z.number().int().positive().optional(),
635
+ /**
636
+ * Failed-IDENTIFY threshold that freezes the account. After this
637
+ * many failed `IDENTIFY` attempts against one registered nick
638
+ * within {@link identifyFreezeSeconds}, further attempts are
639
+ * rejected with a freeze notice before scrypt runs. Defaults to
640
+ * {@link DEFAULT_MAX_IDENTIFY_FAILURES} (5) when unset.
641
+ */
642
+ maxIdentifyFailures: z.number().int().positive().optional(),
643
+ /**
644
+ * Sliding window (seconds) the failed-IDENTIFY threshold counts
645
+ * within. Failures older than the window stop counting, so the
646
+ * freeze lifts once they age out. Defaults to
647
+ * {@link DEFAULT_IDENTIFY_FREEZE_SECONDS} (300) when unset.
648
+ */
649
+ identifyFreezeSeconds: z.number().int().positive().optional(),
383
650
  })
384
651
  .optional(),
385
652
 
386
653
  /**
387
- * HostServ approval mode for `REQUEST <vhost>`. `true` (the default)
388
- * preserves the legacy auto-approve behaviour the requested vhost is
389
- * recorded against the account immediately (no oper review). `false`
390
- * switches HostServ into oper-queue mode: `REQUEST` records a pending
391
- * request that an oper must `APPROVE` (or `REJECT`) via HostServ. The
392
- * queue + the `APPROVE` / `REJECT` / `LIST` (oper-only) commands are
393
- * available in either mode, but only `false` routes `REQUEST` through
394
- * them.
654
+ * SASL brute-force hardening tunables. The SASL reducer disconnects a
655
+ * connection after `maxFailures` failed `AUTHENTICATE` attempts within
656
+ * `failureWindowSeconds` (a sliding window; a successful login resets
657
+ * the count). Omit entirely (or omit either knob) to inherit the
658
+ * documented defaults (3 failures / 60 s).
659
+ */
660
+ sasl: z
661
+ .object({
662
+ /**
663
+ * Failed `AUTHENTICATE` attempts (per connection, within the
664
+ * window) before the connection is disconnected with
665
+ * `ERROR :Closing link: too many failed SASL attempts`. Defaults to
666
+ * {@link DEFAULT_SASL_MAX_FAILURES} (3) when unset.
667
+ */
668
+ maxFailures: z.number().int().positive().optional(),
669
+ /**
670
+ * Sliding-window length (seconds) the failure tracker counts
671
+ * within. Failures older than the window no longer count. Defaults
672
+ * to {@link DEFAULT_SASL_FAILURE_WINDOW_SECONDS} (60) when unset.
673
+ */
674
+ failureWindowSeconds: z.number().int().positive().optional(),
675
+ /**
676
+ * Operator opt-in for the certificate-backed SASL EXTERNAL
677
+ * mechanism. Unset (the default) keeps EXTERNAL off: the mechanism
678
+ * is neither advertised in the `sasl` cap / `908 ERR_SASLMECHS`
679
+ * list nor accepted by `AUTHENTICATE EXTERNAL`, so deployments
680
+ * that have not wired edge mTLS (CF API Shield / AWS APIGW
681
+ * client certs) never see half-configured exchanges. Enabling
682
+ * requires all three of: this flag, a bound mTLS identity source,
683
+ * and a secure (TLS) connection — see the SASL reducer.
684
+ *
685
+ * The adapters surface this as the `EXTERNAL_ENABLED` env var
686
+ * ('true'/'1' to enable). Accounts opt into EXTERNAL per-binding
687
+ * (cert fingerprint / subject DN on the NickServ record), not via
688
+ * this flag.
689
+ */
690
+ externalEnabled: z.boolean().optional(),
691
+ /**
692
+ * Transport-level EXTERNAL non-support override (see
693
+ * `SaslConfig.externalUnsupportedMessage`): when set, the
694
+ * deployment's transport cannot surface a client-cert identity,
695
+ * so `AUTHENTICATE EXTERNAL` is rejected up front with a
696
+ * `904 ERR_SASLFAIL` carrying this message and the mechanism is
697
+ * never advertised. Takes precedence over `externalEnabled`.
698
+ */
699
+ externalUnsupportedMessage: z.string().min(1).optional(),
700
+ })
701
+ .optional(),
702
+
703
+ /**
704
+ * Adapter-boundary tunables (inbound frame-rate window). Omit entirely
705
+ * to inherit the documented defaults (50 frames / 5 s).
706
+ */
707
+ adapter: AdapterConfigSchema.default({
708
+ maxFramesPerWindow: DEFAULT_MAX_FRAMES_PER_WINDOW,
709
+ frameWindowSeconds: DEFAULT_FRAME_WINDOW_SECONDS,
710
+ }),
711
+
712
+ /**
713
+ * `draft/chathistory` tunables (client-supplied limit ceiling). Omit
714
+ * entirely to inherit the documented default (100).
715
+ */
716
+ chathistory: ChathistoryConfigSchema.default({
717
+ maxLimit: DEFAULT_MAX_CHATHISTORY_LIMIT,
718
+ }),
719
+
720
+ /**
721
+ * HostServ approval mode for `REQUEST <vhost>`. `false` (the default) is
722
+ * oper-queue mode: `REQUEST` records a pending request that an oper must
723
+ * `APPROVE` (or `REJECT`) via HostServ — this is the recommended (and
724
+ * long-term only) mode. `true` preserves the legacy auto-approve
725
+ * behaviour — the requested vhost is recorded against the account
726
+ * immediately (no oper review).
727
+ *
728
+ * Auto-approve is hard-constrained for security: a non-empty
729
+ * {@link hostservAutoApproveAllowlist} MUST accompany `true` or config
730
+ * parsing fails at boot, and every auto-approved vhost must still clear
731
+ * the built-in denylist (reserved labels + this server's own hostname).
732
+ *
733
+ * @deprecated Auto-approve is deprecated. Queue mode (`false`, the
734
+ * default) is the long-term path; a future release will remove the
735
+ * auto-approve arm entirely. Migrate deployments to oper review.
395
736
  */
396
737
  hostservAutoApproveVhosts: z.boolean().default(false),
738
+
739
+ /**
740
+ * Explicit allowlist constraining which vhosts HostServ may auto-approve
741
+ * when {@link hostservAutoApproveVhosts} is `true`. An entry is either an
742
+ * exact hostname (`users.example.com`) or a wildcard suffix
743
+ * (`*.users.example.com`, matching any depth beneath it but not the bare
744
+ * suffix). Matching is case-insensitive.
745
+ *
746
+ * Ignored in queue mode (opers vet requests themselves). Required and
747
+ * non-empty when auto-approve is enabled — parsing fails at boot
748
+ * otherwise, because an unconstrained allowlist would let any identified
749
+ * user self-assign arbitrary hostnames.
750
+ */
751
+ hostservAutoApproveAllowlist: z.array(z.string().min(1)).default([]),
752
+ });
753
+
754
+ /**
755
+ * Cross-field boot guard: auto-approve mode must declare a non-empty
756
+ * vhost allowlist. Applied at the schema level so every adapter's
757
+ * `parseServerConfig` boot path refuses the combination with a clear,
758
+ * field-named error instead of silently running an unconstrained
759
+ * auto-approver.
760
+ */
761
+ export const ServerConfigSchema = ServerConfigSchemaObject.superRefine((data, ctx) => {
762
+ if (data.hostservAutoApproveVhosts === true && data.hostservAutoApproveAllowlist.length === 0) {
763
+ ctx.addIssue({
764
+ code: z.ZodIssueCode.custom,
765
+ message:
766
+ 'Required and must be non-empty when hostservAutoApproveVhosts is true (auto-approve without an allowlist would let any identified user self-assign arbitrary vhosts); prefer queue mode (hostservAutoApproveVhosts: false)',
767
+ path: ['hostservAutoApproveAllowlist'],
768
+ });
769
+ }
397
770
  });
398
771
 
399
772
  /** Input type: what adapters supply (fields with defaults may be omitted). */
@@ -132,6 +132,24 @@ export interface BroadcastWallopsEffect {
132
132
  except?: ConnId;
133
133
  }
134
134
 
135
+ /**
136
+ * Oper-only notice fanout. Emitted by the OPER reducer's per-IP lockout
137
+ * gate (`:<server> NOTICE * :OPER lockout triggered for <host>`) so every
138
+ * online operator learns a brute-force attempt is being throttled.
139
+ *
140
+ * Like {@link BroadcastWallopsEffect} this is global cross-connection
141
+ * fanout (not channel-scoped), but the recipient gate is user mode `+o`
142
+ * rather than `+w`: the bound runtime's `broadcastOperNotice` method owns
143
+ * the enumeration (in-memory: scan of the connection map; CF: registry
144
+ * shard walk; AWS: `Connections` table scan) and filters on
145
+ * `userModes.oper`.
146
+ */
147
+ export interface BroadcastOperNoticeEffect {
148
+ tag: 'BroadcastOperNotice';
149
+ lines: RawLine[];
150
+ except?: ConnId;
151
+ }
152
+
135
153
  /**
136
154
  * NickServ nick-enforcement directive.
137
155
  *
@@ -176,6 +194,7 @@ export type Effect =
176
194
  | ApplyChannelDeltaEffect
177
195
  | SendToNickEffect
178
196
  | BroadcastWallopsEffect
197
+ | BroadcastOperNoticeEffect
179
198
  | EnforceNickEffect;
180
199
 
181
200
  /** Literal string tag of every {@link Effect} variant. */
@@ -245,6 +264,11 @@ export const Effect = {
245
264
  if (except !== undefined) e.except = except;
246
265
  return e;
247
266
  },
267
+ broadcastOperNotice(lines: RawLine[], except?: ConnId): Effect {
268
+ const e: BroadcastOperNoticeEffect = { tag: 'BroadcastOperNotice', lines };
269
+ if (except !== undefined) e.except = except;
270
+ return e;
271
+ },
248
272
  enforceNick(
249
273
  conn: ConnId,
250
274
  nick: Nick,
@@ -54,7 +54,8 @@ export interface FloodControlConfig {
54
54
  * When omitted, every command is charged the flat {@link MESSAGE_COST}
55
55
  * (`1`) — i.e. the original wrapper behaviour is preserved bit-for-bit.
56
56
  * Compose with {@link defaultCommandCost} to exempt control-plane
57
- * traffic (`PING`/`PONG`/`CAP`/`AUTHENTICATE`/`QUIT`).
57
+ * traffic (`PING`/`PONG`/`CAP`/`QUIT`; `AUTHENTICATE` is charged — see
58
+ * {@link EXEMPT_COMMANDS}).
58
59
  *
59
60
  * A cost of `0` neither decrements the bucket nor triggers the disconnect
60
61
  * path; the inner reducer is called as normal. Negative costs are rejected
@@ -66,18 +67,17 @@ export interface FloodControlConfig {
66
67
  /**
67
68
  * Control-plane commands that mainstream ircds (charybdis, hybrid, Unreal) exempt
68
69
  * from flood control. A client exchanging `PING`/`PONG` with the server or
69
- * negotiating `CAP` / `AUTHENTICATE` during SASL must not flood itself out
70
- * through no fault of its own.
70
+ * negotiating `CAP` during registration must not flood itself out through no
71
+ * fault of its own.
72
+ *
73
+ * `AUTHENTICATE` is deliberately NOT exempt: SASL attempts consume the
74
+ * normal bucket so an in-connection SASL brute force is rate-limited by
75
+ * the same token bucket as every other command. The SASL reducer layers a
76
+ * per-connection failed-attempt lockout on top (see `commands/sasl.ts`).
71
77
  *
72
78
  * Override the set by composing your own cost function on top of this one.
73
79
  */
74
- const EXEMPT_COMMANDS: ReadonlySet<string> = new Set([
75
- 'PING',
76
- 'PONG',
77
- 'CAP',
78
- 'AUTHENTICATE',
79
- 'QUIT',
80
- ]);
80
+ const EXEMPT_COMMANDS: ReadonlySet<string> = new Set(['PING', 'PONG', 'CAP', 'QUIT']);
81
81
 
82
82
  /**
83
83
  * Ready-made per-command cost: `0` for the exempt control-plane set, `1`
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Per-connection inbound frame-rate window for the adapter boundary.
3
+ *
4
+ * Client-side flood control (the token bucket in `flood-control.ts`)
5
+ * lives in the actor and only fires AFTER the frame has been parsed and
6
+ * dispatched — which on the serverless adapters means the frame has
7
+ * already cost a Durable Object storage write (Cloudflare) or a DynamoDB
8
+ * write (AWS). This module is the cheaper, earlier gate: a pure
9
+ * sliding-window admission decision the adapters consult BEFORE invoking
10
+ * the actor, so a hostile client spamming low-cost commands is cut off at
11
+ * the connection boundary instead of driving outsized storage cost.
12
+ *
13
+ * Semantics: a connection may deliver at most `maxFrames` frames inside
14
+ * any sliding `windowMs` window. Timestamps older than the window decay
15
+ * out lazily (they stop counting as soon as `now - t >= windowMs`), so a
16
+ * connection that pauses longer than the window starts fresh. The frame
17
+ * that would exceed the budget is rejected — the caller closes the
18
+ * connection with RFC 6455 close code 1008 (Policy Violation) after
19
+ * delivering the canonical `ERROR :Closing link (flooding)` line.
20
+ *
21
+ * Determinism invariant: the decision is a pure function of
22
+ * `(times, now, maxFrames, windowMs)`; the caller owns the timestamp
23
+ * list (in-memory on the CF Durable Object, persisted on the AWS
24
+ * Connections row) and the clock.
25
+ */
26
+
27
+ /**
28
+ * RFC 6455 close code used when the inbound frame window is exceeded
29
+ * (Policy Violation).
30
+ */
31
+ export const FRAME_FLOOD_CLOSE_CODE = 1008;
32
+
33
+ /**
34
+ * The `ERROR` line delivered to the client just before the flood close,
35
+ * matching the canonical ircd convention for connection-level flood
36
+ * kills.
37
+ */
38
+ export const FRAME_FLOOD_ERROR_LINE = 'ERROR :Closing link (flooding)';
39
+
40
+ /** Result of {@link admitFrameWindow}. */
41
+ export interface FrameWindowDecision {
42
+ /**
43
+ * `true` when the frame fits the window (the caller processes it);
44
+ * `false` when the window is full (the caller closes the connection).
45
+ */
46
+ readonly admitted: boolean;
47
+ /**
48
+ * The post-decision window state the caller should retain: the
49
+ * previous entries pruned of decayed timestamps, plus — when
50
+ * admitted — the current frame's `now` appended. Never mutated in
51
+ * place; a fresh array is returned on every call.
52
+ */
53
+ readonly times: number[];
54
+ }
55
+
56
+ /**
57
+ * Decides whether the frame arriving at `now` fits inside the
58
+ * `maxFrames`-per-`windowMs` sliding window.
59
+ *
60
+ * Entries with `t < now - windowMs` decay out first (an entry at exactly
61
+ * `now - windowMs` still counts — inclusive boundary, mirroring
62
+ * `AdmissionStats.recentAdmissions`). The decision then reduces to:
63
+ * if the pruned window already holds `maxFrames` entries, reject;
64
+ * otherwise admit and record `now`.
65
+ */
66
+ export function admitFrameWindow(
67
+ times: readonly number[],
68
+ now: number,
69
+ maxFrames: number,
70
+ windowMs: number,
71
+ ): FrameWindowDecision {
72
+ const cutoff = now - windowMs;
73
+ const kept: number[] = [];
74
+ for (const t of times) {
75
+ if (t >= cutoff) kept.push(t);
76
+ }
77
+ if (kept.length >= maxFrames) {
78
+ return { admitted: false, times: kept };
79
+ }
80
+ kept.push(now);
81
+ return { admitted: true, times: kept };
82
+ }
@@ -10,20 +10,28 @@ export * from './caps/index.js';
10
10
  export * from './commands/index.js';
11
11
  export * from './effects.js';
12
12
  export * from './ports.js';
13
+ export * from './certfp.js';
13
14
  export * from './credential-hashing.js';
14
15
  export * from './account-migration.js';
16
+ export * from './oper-hashing.js';
15
17
  export * from './flood-control.js';
18
+ export * from './frame-rate-limit.js';
16
19
  export * from './cloak.js';
17
20
  export * from './case-fold.js';
18
21
  export * from './ws-subprotocol.js';
19
22
  export * from './ws-framing.js';
20
23
  export * from './admission.js';
24
+ export * from './oper-lockout.js';
21
25
  export * from './config.js';
22
26
  export type {
23
27
  BuildCtxOptions,
28
+ ChathistoryConfig,
24
29
  CloakingConfig,
25
30
  Ctx,
26
31
  OperCred,
32
+ OperCredHashed,
33
+ OperCredPlaintext,
34
+ OperThrottleConfig,
27
35
  Reducer,
28
36
  ReducerResult,
29
37
  ServerConfig,