serverless-ircd 0.9.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 (254) hide show
  1. package/.github/workflows/ci.yml +28 -0
  2. package/.github/workflows/deploy-aws.yml +156 -32
  3. package/.github/workflows/deploy-cf-tcp.yml +35 -9
  4. package/.github/workflows/deploy-cf.yml +40 -14
  5. package/CHANGELOG.md +594 -0
  6. package/README.md +286 -60
  7. package/apps/aws-stack/README.md +3 -5
  8. package/apps/aws-stack/bin/aws.ts +118 -9
  9. package/apps/aws-stack/cdk.json +0 -3
  10. package/apps/aws-stack/package.json +3 -4
  11. package/apps/aws-stack/src/aws-stack.ts +398 -67
  12. package/apps/aws-stack/src/static-site.ts +323 -0
  13. package/apps/aws-stack/tests/smoke-helpers.test.ts +1 -1
  14. package/apps/aws-stack/tests/stack.test.ts +714 -105
  15. package/apps/aws-stack/tests/static-site.test.ts +491 -0
  16. package/apps/aws-stack/tests/synth-no-bundle.test.ts +0 -1
  17. package/apps/cf-tcp-container/Dockerfile +37 -5
  18. package/apps/cf-tcp-container/package.json +7 -3
  19. package/apps/cf-tcp-container/src/config-loader.ts +113 -2
  20. package/apps/cf-tcp-container/src/container-server.ts +267 -87
  21. package/apps/cf-tcp-container/src/main.ts +22 -7
  22. package/apps/cf-tcp-container/src/proxy-protocol.ts +112 -0
  23. package/apps/cf-tcp-container/terraform/spectrum.tf +40 -11
  24. package/apps/cf-tcp-container/tests/config-loader.test.ts +170 -0
  25. package/apps/cf-tcp-container/tests/container-server-tls.test.ts +382 -0
  26. package/apps/cf-tcp-container/tests/container-server.test.ts +358 -31
  27. package/apps/cf-tcp-container/tests/dockerfile.test.ts +110 -0
  28. package/apps/cf-tcp-container/tests/proxy-protocol.test.ts +187 -0
  29. package/apps/cf-tcp-container/tests/spectrum-terraform.test.ts +135 -0
  30. package/apps/cf-tcp-container/tests/tls-e2e.test.ts +5 -1
  31. package/apps/cf-tcp-container/wrangler.toml +18 -14
  32. package/apps/cf-worker/package.json +3 -4
  33. package/apps/cf-worker/src/worker.ts +77 -5
  34. package/apps/cf-worker/tests/raw-modules.d.ts +11 -0
  35. package/apps/cf-worker/tests/smoke.test.ts +4 -0
  36. package/apps/cf-worker/tests/wrangler-config.test.ts +47 -0
  37. package/apps/cf-worker/tests/ws-admission.test.ts +112 -0
  38. package/apps/cf-worker/tests/ws-rate-limit.test.ts +133 -0
  39. package/apps/cf-worker/wrangler.test.toml +15 -1
  40. package/apps/cf-worker/wrangler.toml +95 -77
  41. package/apps/local-cli/package.json +1 -1
  42. package/apps/local-cli/src/config-loader.ts +14 -2
  43. package/apps/local-cli/src/line-scanner.ts +26 -0
  44. package/apps/local-cli/src/server.ts +44 -19
  45. package/apps/local-cli/tests/line-scanner.test.ts +64 -0
  46. package/apps/local-cli/tests/tcp.test.ts +29 -0
  47. package/apps/web/landing/favicon.ico +0 -0
  48. package/apps/web/landing/index.html +1 -0
  49. package/apps/web/package.json +2 -2
  50. package/apps/web/scripts/build.mjs +66 -4
  51. package/apps/web/src/build-env.ts +125 -4
  52. package/apps/web/src/config-schema.ts +20 -6
  53. package/apps/web/static/{config.staging.json → config.prod-aws.json} +3 -2
  54. package/apps/web/tests/build-env.test.ts +210 -9
  55. package/apps/web/tests/build-smoke.test.ts +2 -2
  56. package/apps/web/tests/config-schema.test.ts +149 -25
  57. package/docs/AWS-Deployment.md +793 -118
  58. package/docs/AWS-TCP-Deployment.md +57 -47
  59. package/docs/Chat-History.md +55 -0
  60. package/docs/Cloudflare-Deployment-Guide.md +95 -114
  61. package/docs/Cloudflare-TCP-Deployment.md +160 -101
  62. package/docs/Release-Process.md +27 -23
  63. package/docs/SASL-EXTERNAL.md +175 -0
  64. package/docs/Services.md +69 -22
  65. package/docs/WebClientGuide.md +35 -26
  66. package/package.json +7 -10
  67. package/packages/aws-adapter/package.json +1 -1
  68. package/packages/aws-adapter/src/admission.ts +28 -13
  69. package/packages/aws-adapter/src/aws-runtime.ts +30 -3
  70. package/packages/aws-adapter/src/cdk-table-defs.ts +39 -16
  71. package/packages/aws-adapter/src/config-loader.ts +153 -8
  72. package/packages/aws-adapter/src/dynamo-services-store.ts +19 -0
  73. package/packages/aws-adapter/src/handlers/connect.ts +73 -1
  74. package/packages/aws-adapter/src/handlers/default.ts +279 -123
  75. package/packages/aws-adapter/src/handlers/index.ts +98 -25
  76. package/packages/aws-adapter/src/handlers/nlb-stream.ts +135 -14
  77. package/packages/aws-adapter/src/index.ts +5 -7
  78. package/packages/aws-adapter/src/ip-admission.ts +79 -0
  79. package/packages/aws-adapter/src/origin-allowlist.ts +94 -0
  80. package/packages/aws-adapter/src/serialize.ts +23 -0
  81. package/packages/aws-adapter/src/tables.ts +11 -12
  82. package/packages/aws-adapter/tests/admission.test.ts +60 -2
  83. package/packages/aws-adapter/tests/aws-harness.ts +23 -2
  84. package/packages/aws-adapter/tests/aws-runtime.test.ts +64 -0
  85. package/packages/aws-adapter/tests/config-loader.test.ts +217 -0
  86. package/packages/aws-adapter/tests/connect.test.ts +323 -3
  87. package/packages/aws-adapter/tests/default-frame-limit.test.ts +231 -0
  88. package/packages/aws-adapter/tests/default-occ.test.ts +226 -0
  89. package/packages/aws-adapter/tests/dynamo-services-store-unit.test.ts +134 -1
  90. package/packages/aws-adapter/tests/handlers.test.ts +174 -12
  91. package/packages/aws-adapter/tests/migrate-accounts-to-services.test.ts +164 -0
  92. package/packages/aws-adapter/tests/nlb-secure.test.ts +362 -0
  93. package/packages/aws-adapter/tests/nlb-stream.test.ts +628 -9
  94. package/packages/aws-adapter/tests/origin-allowlist.test.ts +110 -0
  95. package/packages/aws-adapter/tests/ping-checker.test.ts +0 -1
  96. package/packages/aws-adapter/tests/stats.test.ts +0 -3
  97. package/packages/aws-adapter/tests/sweeper.test.ts +0 -1
  98. package/packages/aws-adapter/tests/tables.test.ts +1 -8
  99. package/packages/aws-adapter/tests/transactions.test.ts +0 -1
  100. package/packages/cf-adapter/package.json +1 -5
  101. package/packages/cf-adapter/src/cf-runtime.ts +100 -10
  102. package/packages/cf-adapter/src/channel-do.ts +13 -3
  103. package/packages/cf-adapter/src/config-loader.ts +133 -8
  104. package/packages/cf-adapter/src/connection-do.ts +406 -116
  105. package/packages/cf-adapter/src/counter-do.ts +142 -0
  106. package/packages/cf-adapter/src/d1-services-store.ts +105 -26
  107. package/packages/cf-adapter/src/env.ts +99 -10
  108. package/packages/cf-adapter/src/index.ts +17 -7
  109. package/packages/cf-adapter/src/rate-limit-do.ts +87 -0
  110. package/packages/cf-adapter/tests/cf-runtime.test.ts +205 -16
  111. package/packages/cf-adapter/tests/channel-do.test.ts +118 -1
  112. package/packages/cf-adapter/tests/config-loader.test.ts +159 -0
  113. package/packages/cf-adapter/tests/connection-do-counter.test.ts +165 -0
  114. package/packages/cf-adapter/tests/connection-do-coverage.test.ts +460 -0
  115. package/packages/cf-adapter/tests/connection-do-frame-limit.test.ts +177 -0
  116. package/packages/cf-adapter/tests/connection-do-pure.test.ts +164 -54
  117. package/packages/cf-adapter/tests/connection-do-sasl-d1.test.ts +62 -38
  118. package/packages/cf-adapter/tests/connection-do-ws-spec-contract.test.ts +7 -4
  119. package/packages/cf-adapter/tests/counter-do.test.ts +181 -0
  120. package/packages/cf-adapter/tests/d1-services-store.test.ts +245 -3
  121. package/packages/cf-adapter/tests/rate-limit-do.test.ts +160 -0
  122. package/packages/cf-adapter/tests/serialize.test.ts +25 -0
  123. package/packages/cf-adapter/tests/worker/main.ts +4 -0
  124. package/packages/cf-adapter/wrangler.test.toml +18 -1
  125. package/packages/in-memory-runtime/package.json +1 -1
  126. package/packages/in-memory-runtime/src/in-memory-runtime.ts +25 -0
  127. package/packages/in-memory-runtime/tests/in-memory-runtime.test.ts +74 -0
  128. package/packages/irc-core/package.json +1 -1
  129. package/packages/irc-core/src/account-migration.ts +140 -0
  130. package/packages/irc-core/src/caps/capabilities.ts +20 -10
  131. package/packages/irc-core/src/certfp.ts +178 -0
  132. package/packages/irc-core/src/commands/account-auth.ts +16 -19
  133. package/packages/irc-core/src/commands/cap.ts +10 -2
  134. package/packages/irc-core/src/commands/chanserv.ts +117 -14
  135. package/packages/irc-core/src/commands/chathistory.ts +13 -5
  136. package/packages/irc-core/src/commands/hostserv.ts +84 -8
  137. package/packages/irc-core/src/commands/index.ts +2 -1
  138. package/packages/irc-core/src/commands/invite.ts +1 -7
  139. package/packages/irc-core/src/commands/join.ts +1 -16
  140. package/packages/irc-core/src/commands/kick.ts +1 -8
  141. package/packages/irc-core/src/commands/list.ts +1 -8
  142. package/packages/irc-core/src/commands/memoserv.ts +1 -1
  143. package/packages/irc-core/src/commands/mode.ts +1 -8
  144. package/packages/irc-core/src/commands/multiline.ts +4 -10
  145. package/packages/irc-core/src/commands/names.ts +53 -13
  146. package/packages/irc-core/src/commands/nickserv.ts +161 -11
  147. package/packages/irc-core/src/commands/oper.ts +361 -8
  148. package/packages/irc-core/src/commands/part.ts +4 -10
  149. package/packages/irc-core/src/commands/privmsg.ts +8 -4
  150. package/packages/irc-core/src/commands/registration.ts +148 -4
  151. package/packages/irc-core/src/commands/sasl.ts +154 -46
  152. package/packages/irc-core/src/commands/topic.ts +10 -12
  153. package/packages/irc-core/src/commands/who.ts +1 -8
  154. package/packages/irc-core/src/config.ts +424 -25
  155. package/packages/irc-core/src/credential-hashing.ts +11 -54
  156. package/packages/irc-core/src/effects.ts +24 -0
  157. package/packages/irc-core/src/flood-control.ts +10 -10
  158. package/packages/irc-core/src/frame-rate-limit.ts +82 -0
  159. package/packages/irc-core/src/index.ts +9 -0
  160. package/packages/irc-core/src/oper-hashing.ts +43 -0
  161. package/packages/irc-core/src/oper-lockout.ts +87 -0
  162. package/packages/irc-core/src/ports.ts +529 -190
  163. package/packages/irc-core/src/protocol/bytes.ts +65 -0
  164. package/packages/irc-core/src/protocol/channel-name.ts +37 -0
  165. package/packages/irc-core/src/protocol/index.ts +12 -1
  166. package/packages/irc-core/src/protocol/outbound.ts +43 -10
  167. package/packages/irc-core/src/protocol/parser.ts +79 -10
  168. package/packages/irc-core/src/state/connection.ts +13 -0
  169. package/packages/irc-core/src/types.ts +266 -23
  170. package/packages/irc-core/src/ws-framing.ts +5 -4
  171. package/packages/irc-core/tests/account-migration.test.ts +133 -0
  172. package/packages/irc-core/tests/bytes.test.ts +89 -0
  173. package/packages/irc-core/tests/certfp.test.ts +117 -0
  174. package/packages/irc-core/tests/commands/cap.test.ts +76 -2
  175. package/packages/irc-core/tests/commands/chanserv.test.ts +166 -0
  176. package/packages/irc-core/tests/commands/chathistory.test.ts +140 -0
  177. package/packages/irc-core/tests/commands/hostserv.test.ts +316 -0
  178. package/packages/irc-core/tests/commands/join.test.ts +78 -1
  179. package/packages/irc-core/tests/commands/markread.test.ts +54 -0
  180. package/packages/irc-core/tests/commands/memoserv.test.ts +19 -0
  181. package/packages/irc-core/tests/commands/names.test.ts +193 -0
  182. package/packages/irc-core/tests/commands/nickserv.test.ts +419 -3
  183. package/packages/irc-core/tests/commands/oper.test.ts +574 -1
  184. package/packages/irc-core/tests/commands/privmsg.test.ts +16 -0
  185. package/packages/irc-core/tests/commands/registration.test.ts +602 -133
  186. package/packages/irc-core/tests/commands/sasl.test.ts +742 -172
  187. package/packages/irc-core/tests/commands/topic.test.ts +137 -2
  188. package/packages/irc-core/tests/commands/unified-account.test.ts +104 -84
  189. package/packages/irc-core/tests/config.test.ts +534 -2
  190. package/packages/irc-core/tests/credential-hashing.test.ts +0 -78
  191. package/packages/irc-core/tests/effects.test.ts +14 -0
  192. package/packages/irc-core/tests/flood-control.test.ts +29 -1
  193. package/packages/irc-core/tests/frame-rate-limit.test.ts +98 -0
  194. package/packages/irc-core/tests/message-store.test.ts +5 -0
  195. package/packages/irc-core/tests/oper-hashing.test.ts +60 -0
  196. package/packages/irc-core/tests/oper-lockout.test.ts +74 -0
  197. package/packages/irc-core/tests/outbound.test.ts +148 -0
  198. package/packages/irc-core/tests/parser.test.ts +287 -5
  199. package/packages/irc-core/tests/persistent-services-store.test.ts +212 -12
  200. package/packages/irc-core/tests/ports.test.ts +170 -7
  201. package/packages/irc-core/tests/services-store.test.ts +567 -1
  202. package/packages/irc-core/tests/ws-framing.test.ts +45 -0
  203. package/packages/irc-core/vitest.config.ts +6 -1
  204. package/packages/irc-server/package.json +1 -1
  205. package/packages/irc-server/src/actor.ts +123 -22
  206. package/packages/irc-server/src/dispatch.ts +1 -0
  207. package/packages/irc-server/src/index.ts +7 -0
  208. package/packages/irc-server/src/redact.ts +159 -0
  209. package/packages/irc-server/src/runtime.ts +14 -0
  210. package/packages/irc-server/src/transport.ts +28 -1
  211. package/packages/irc-server/tests/actor.test.ts +563 -54
  212. package/packages/irc-server/tests/dispatch.test.ts +31 -0
  213. package/packages/irc-server/tests/redact.test.ts +198 -0
  214. package/packages/irc-server/tests/runtime.test.ts +2 -0
  215. package/packages/irc-server/tests/transport.test.ts +66 -0
  216. package/packages/irc-test-support/package.json +1 -1
  217. package/packages/irc-test-support/src/in-memory-harness.ts +4 -0
  218. package/pnpm-workspace.yaml +1 -0
  219. package/scripts/__tests__/deploy-web-aws.test.ts +491 -0
  220. package/scripts/deploy-web-aws.mjs +290 -0
  221. package/scripts/package.json +23 -0
  222. package/scripts/tsconfig.test.json +12 -0
  223. package/scripts/vitest.config.ts +19 -0
  224. package/tools/ci-hardening/package.json +2 -2
  225. package/tools/ci-hardening/src/cf-deploy-cli.ts +3 -0
  226. package/tools/ci-hardening/src/cf-deploy.ts +118 -0
  227. package/tools/ci-hardening/src/deploy-hostname.ts +118 -0
  228. package/tools/ci-hardening/src/env-var-drift.ts +192 -0
  229. package/tools/ci-hardening/src/hostname-guard.ts +11 -0
  230. package/tools/ci-hardening/src/index.ts +19 -0
  231. package/tools/ci-hardening/src/validate.ts +57 -0
  232. package/tools/ci-hardening/tests/__wrangler_missing__.toml +2 -0
  233. package/tools/ci-hardening/tests/__wrangler_placeholder__.toml +3 -0
  234. package/tools/ci-hardening/tests/__wrangler_real__.toml +3 -0
  235. package/tools/ci-hardening/tests/cf-deploy.test.ts +200 -0
  236. package/tools/ci-hardening/tests/deploy-aws-oidc.test.ts +96 -0
  237. package/tools/ci-hardening/tests/deploy-hostname.test.ts +348 -0
  238. package/tools/ci-hardening/tests/env-var-drift.test.ts +284 -0
  239. package/tools/ci-hardening/tests/validate.test.ts +42 -0
  240. package/tools/ci-hardening/vitest.config.ts +5 -1
  241. package/tools/hash-oper-cred.ts +85 -0
  242. package/tools/load-test/package.json +1 -1
  243. package/tools/migrate-accounts-to-services.ts +270 -0
  244. package/tools/package.json +2 -1
  245. package/tools/seed-aws-accounts.ts +35 -10
  246. package/tools/seed-cf-accounts.ts +42 -9
  247. package/tools/tcp-ws-forwarder/package.json +1 -1
  248. package/packages/aws-adapter/src/account-store.ts +0 -121
  249. package/packages/aws-adapter/src/dynamo-account-store.ts +0 -95
  250. package/packages/aws-adapter/tests/account-store-dynamo.test.ts +0 -223
  251. package/packages/aws-adapter/tests/account-store.test.ts +0 -276
  252. package/packages/cf-adapter/src/d1-account-store.ts +0 -198
  253. package/packages/cf-adapter/tests/d1-account-store.test.ts +0 -274
  254. package/packages/irc-core/tests/account-store.test.ts +0 -131
@@ -18,24 +18,125 @@
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
36
137
  * the canonical account name recorded on the connection (for `extended-join`
37
- * / `account-tag`). Both fields are non-empty strings; adapters seed an
38
- * {@link InMemoryAccountStore} from this list at boot.
138
+ * / `account-tag`). Both fields are non-empty strings; adapters seed the
139
+ * `InMemoryServicesStore` from this list at boot.
39
140
  */
40
141
  export const SaslAccountSchema = z.object({
41
142
  username: z.string().min(1),
@@ -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
@@ -173,12 +290,139 @@ export const DEFAULT_CREATED_TEXT: string = BUILD_DATE;
173
290
  */
174
291
  export const DEFAULT_NICK_ENFORCE_GRACE_MS = 30_000;
175
292
 
293
+ /**
294
+ * Default minimum password length enforced by NickServ `SET PASSWORD`.
295
+ * Mirrors common IRC services (Atheme/Anope) and modern guidance (NIST
296
+ * SP 800-63B). Overridable per deployment via
297
+ * `ServerConfig.nickServ.minPasswordLength`. A new password shorter than
298
+ * the floor is rejected before hashing.
299
+ */
300
+ export const DEFAULT_MIN_PASSWORD_LENGTH = 8;
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
+
176
416
  /**
177
417
  * The full server-config schema. Optional fields default to deployment-
178
418
  * sensible values so a minimal `{ serverName, networkName }` config is
179
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).
180
424
  */
181
- export const ServerConfigSchema = z.object({
425
+ const ServerConfigSchemaObject = z.object({
182
426
  /** Server hostname shown in numerics (`:serverName 001 nick ...`). */
183
427
  serverName: z.string().min(1),
184
428
  /** Network name advertised in `005 NETWORK=...` and WHOIS replies. */
@@ -271,13 +515,21 @@ export const ServerConfigSchema = z.object({
271
515
  operCreds: z.array(OperCredSchema).default([]),
272
516
 
273
517
  /**
274
- * SASL PLAIN account credentials. The adapter seeds an
275
- * `InMemoryAccountStore` from this list at boot so `AUTHENTICATE PLAIN`
276
- * succeeds end-to-end for the listed accounts. Empty array (the default)
277
- * disables SASL account verification: `ctx.accounts` is then omitted and
278
- * a client completing `AUTHENTICATE PLAIN` receives `904 ERR_SASLFAIL`.
279
- * A persistent variant (DynamoDB `Accounts`, D1) is a documented
280
- * follow-up; swapping it in means replacing the construction call.
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
+
526
+ /**
527
+ * SASL PLAIN seed credentials. The adapter ingests these into the
528
+ * `ServicesStore` at boot via `registerNick` (idempotent — existing
529
+ * registrations win). `AUTHENTICATE PLAIN`, `PASS <nick>:<password>`,
530
+ * and NickServ `IDENTIFY` all verify against the resulting
531
+ * `nickserv_accounts` rows. Empty array (the default) leaves the seed
532
+ * empty; accounts can still be created at runtime via NickServ REGISTER.
281
533
  */
282
534
  saslAccounts: z.array(SaslAccountSchema).default([]),
283
535
 
@@ -328,6 +580,14 @@ export const ServerConfigSchema = z.object({
328
580
  */
329
581
  multilineMaxBytes: z.number().int().positive().default(DEFAULT_MULTILINE_MAX_BYTES),
330
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
+
331
591
  /**
332
592
  * Per-connection MONITOR watchlist ceiling, advertised as `MONITOR=<n>`
333
593
  * in `005 RPL_ISUPPORT`. Omit to inherit the monitor reducer's built-in
@@ -358,16 +618,155 @@ export const ServerConfigSchema = z.object({
358
618
  nickEnforceGraceMs: z.number().int().nonnegative().optional(),
359
619
 
360
620
  /**
361
- * HostServ approval mode for `REQUEST <vhost>`. `true` (the default)
362
- * preserves the legacy auto-approve behaviour the requested vhost is
363
- * recorded against the account immediately (no oper review). `false`
364
- * switches HostServ into oper-queue mode: `REQUEST` records a pending
365
- * request that an oper must `APPROVE` (or `REJECT`) via HostServ. The
366
- * queue + the `APPROVE` / `REJECT` / `LIST` (oper-only) commands are
367
- * available in either mode, but only `false` routes `REQUEST` through
368
- * them.
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.
625
+ */
626
+ nickServ: z
627
+ .object({
628
+ /**
629
+ * Minimum length a new password must meet to be accepted by
630
+ * `SET PASSWORD`. Defaults to {@link DEFAULT_MIN_PASSWORD_LENGTH}
631
+ * (8) when unset. A shorter new password is rejected before hashing
632
+ * with `Password is too short (minimum <n> characters).`.
633
+ */
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(),
650
+ })
651
+ .optional(),
652
+
653
+ /**
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.
369
736
  */
370
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
+ }
371
770
  });
372
771
 
373
772
  /** Input type: what adapters supply (fields with defaults may be omitted). */
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Shared SASL credential hashing primitives.
3
3
  *
4
- * Both the AWS adapter (`DynamoAccountStore`) and the CF adapter
5
- * (`D1AccountStore`) store SASL PLAIN credentials as **scrypt hashes**.
6
- * These helpers live in `irc-core` so both adapters share one hashing
7
- * implementation the AWS path previously owned the only copy.
4
+ * Both the AWS adapter (`DynamoServicesStore`) and the CF adapter
5
+ * (`D1ServicesStore`) store NickServ/SASL credentials as **scrypt hashes**
6
+ * via the unified `ServicesStore`. These helpers live in `irc-core` so both
7
+ * adapters share one hashing implementation.
8
8
  *
9
9
  * Uses `node:crypto` (`scryptSync`), which is available on Node (AWS Lambda)
10
10
  * natively and on Cloudflare Workers via the `nodejs_compat` flag. The public
@@ -14,16 +14,16 @@
14
14
  */
15
15
 
16
16
  import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
17
- import type { AccountStore, SaslPayload, SaslResult } from './ports.js';
18
17
 
19
18
  const SCRYPT_KEY_LEN = 64;
20
19
  const SALT_LEN = 16;
21
20
 
22
21
  /**
23
- * A single SASL PLAIN credential stored as a scrypt hash.
22
+ * A single credential stored as a scrypt hash.
24
23
  *
25
- * Every field is a persistence-backend attribute — this object IS the table
26
- * row (DynamoDB `Accounts` item / D1 `accounts` row, keyed by `account`).
24
+ * Every field is a persistence-backend attribute — this object IS the
25
+ * credential portion of a `nickserv_accounts` row (DynamoDB `Services`
26
+ * item / D1 `nickserv_accounts` row, keyed by the folded nick).
27
27
  * `algorithm` is retained so future migrations to Argon2/bcrypt can be
28
28
  * detected and handled gracefully by {@link verifyHashedPassword}.
29
29
  */
@@ -36,8 +36,9 @@ export interface HashedAccountCredential {
36
36
 
37
37
  /**
38
38
  * Hashes a plaintext password into a {@link HashedAccountCredential}
39
- * suitable for writing to the accounts backend (`putAccountCredential` /
40
- * `seed-cf-accounts`).
39
+ * suitable for writing to the services backend
40
+ * (`ServicesStore.registerNick` stores the result on the registered-nick
41
+ * row).
41
42
  *
42
43
  * Uses `scryptSync` (memory-hard, GPU-resistant) with a random salt.
43
44
  * Pass `{ salt }` for deterministic test scenarios; pass `{ keyLen }` to
@@ -78,47 +79,3 @@ export function verifyHashedPassword(password: string, entry: HashedAccountCrede
78
79
  const computed = scryptSync(password, salt, expected.length);
79
80
  return computed.length === expected.length && timingSafeEqual(computed, expected);
80
81
  }
81
-
82
- /**
83
- * Synchronous {@link AccountStore} backed by pre-loaded hashed credentials.
84
- *
85
- * Construct with entries produced by {@link hashAccountCredential} (for unit
86
- * tests / seeding) or pre-loaded from a persistence backend at boot. Both
87
- * adapters use this class:
88
- *
89
- * - AWS: `loadDynamoAccountStore` scans the `Accounts` table at Lambda
90
- * cold start and constructs this store; `DynamoAccountStore` is a
91
- * re-export alias for back-compat.
92
- * - CF: `loadD1AccountStore` queries the D1 `accounts` table at
93
- * `ConnectionDO` construction (mirroring the two-phase load pattern).
94
- *
95
- * The class has zero backend coupling — it works equally well with entries
96
- * from any source. The `AccountStore.verify` port stays synchronous, so
97
- * the async pre-load MUST complete before the first frame dispatches.
98
- */
99
- export class HashedAccountStore implements AccountStore {
100
- private readonly entries: ReadonlyMap<string, HashedAccountCredential>;
101
-
102
- constructor(entries: ReadonlyArray<HashedAccountCredential>) {
103
- this.entries = new Map(entries.map((e) => [e.account, e]));
104
- }
105
-
106
- verify(mech: string, payload: SaslPayload): SaslResult {
107
- if (mech.toUpperCase() !== 'PLAIN' || payload.kind !== 'PLAIN') {
108
- return { ok: false, reason: `unsupported mechanism: ${mech}` };
109
- }
110
- const entry = this.entries.get(payload.username);
111
- if (entry === undefined) {
112
- return { ok: false, reason: 'invalid credentials' };
113
- }
114
- if (!verifyHashedPassword(payload.password, entry)) {
115
- return { ok: false, reason: 'invalid credentials' };
116
- }
117
- return { ok: true, account: payload.username };
118
- }
119
-
120
- /** Number of loaded accounts (diagnostics / logging). */
121
- get size(): number {
122
- return this.entries.size;
123
- }
124
- }
@@ -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`