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
@@ -8,8 +8,8 @@
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
- AccountStore,
13
13
  AwayStore,
14
14
  Clock,
15
15
  IdFactory,
@@ -53,16 +53,163 @@ export interface StsConfig {
53
53
  }
54
54
 
55
55
  /**
56
- * A single IRC operator credential. The {@link operReducer} consults the
57
- * configured list to authenticate `OPER <name> <password>`. Both fields are
58
- * compared verbatim; adapters are responsible for any hashing at the auth
59
- * 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.
60
63
  */
61
- export interface OperCred {
64
+ export interface OperCredPlaintext {
62
65
  readonly user: string;
63
66
  readonly password: string;
64
67
  }
65
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
+
117
+ /**
118
+ * NickServ-specific tunables as seen by reducers. Mirrors the
119
+ * `nickServ` sub-schema of {@link ServerConfigSchema}; the NickServ
120
+ * `SET PASSWORD` reducer reads `minPasswordLength` (falling back to
121
+ * {@link DEFAULT_MIN_PASSWORD_LENGTH} when unset).
122
+ *
123
+ * Optional fields are widened to `T | undefined` to mirror Zod's
124
+ * `.optional()` output under `exactOptionalPropertyTypes: true`.
125
+ */
126
+ export interface NickServConfig {
127
+ /**
128
+ * Minimum length a new password must meet to be accepted by
129
+ * `SET PASSWORD`. Omitted → the reducer falls back to
130
+ * {@link DEFAULT_MIN_PASSWORD_LENGTH} (8).
131
+ */
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;
211
+ }
212
+
66
213
  /**
67
214
  * Per-deployment server configuration. Adapters load this from a KV store
68
215
  * (CF) or Secrets Manager / SSM (AWS); the core only reads it.
@@ -114,6 +261,17 @@ export interface ServerConfig {
114
261
  * here.
115
262
  */
116
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;
117
275
  maxChannelsPerUser: number;
118
276
  maxTargetsPerCommand: number;
119
277
  maxListEntries: number;
@@ -166,6 +324,18 @@ export interface ServerConfig {
166
324
  * literals in tests may omit it.
167
325
  */
168
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;
169
339
  /**
170
340
  * Per-connection MONITOR watchlist ceiling, advertised as `MONITOR=<n>`
171
341
  * in `005 RPL_ISUPPORT`. Omitted → the reducer falls back to
@@ -210,18 +380,71 @@ export interface ServerConfig {
210
380
  */
211
381
  readonly nickEnforceGraceMs?: number | undefined;
212
382
  /**
213
- * HostServ approval mode for `REQUEST <vhost>`. `true` (the parsed
214
- * default) preserves the legacy auto-approve behaviour the vhost is
215
- * recorded against the account immediately, no oper review. `false`
216
- * switches HostServ into oper-queue mode: `REQUEST` queues a pending
217
- * request that an oper must `APPROVE` (or `REJECT`); `LIST` enumerates
218
- * the pending queue. See `docs/Services.md` §6.
383
+ * NickServ-specific tunables consulted by the NickServ reducer. Currently
384
+ * only the minimum length enforced by `SET PASSWORD` is configurable
385
+ * (`minPasswordLength`); the maximum (256) is fixed to bound the scrypt
386
+ * input budget.
387
+ *
388
+ * Typed `NickServConfig | undefined` to mirror Zod's optional output
389
+ * shape under `exactOptionalPropertyTypes: true` so inline
390
+ * `ServerConfig` literals in tests may omit it (the reducer falls back
391
+ * to {@link DEFAULT_MIN_PASSWORD_LENGTH} when `minPasswordLength` is
392
+ * unset).
393
+ */
394
+ readonly nickServ?: NickServConfig | undefined;
395
+ /**
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`.
219
428
  *
220
429
  * Typed `boolean | undefined` so inline `ServerConfig` literals in tests
221
- * may omit it (the reducer treats `undefined` as "auto-approve on",
222
- * matching the parsed default).
430
+ * may omit it (the reducer treats `undefined` as queue mode, matching
431
+ * the parsed default).
223
432
  */
224
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;
225
448
  }
226
449
 
227
450
  /** Per-invocation context handed to every reducer. */
@@ -235,8 +458,6 @@ export interface Ctx {
235
458
  readonly ids: IdFactory;
236
459
  /** Pre-loaded MOTD source for the `MOTD` reducer. */
237
460
  readonly motd: MotdProvider;
238
- /** SASL account verification source (absent when no accounts are configured). */
239
- readonly accounts?: AccountStore;
240
461
  /**
241
462
  * mTLS client-cert identity source for SASL EXTERNAL (absent when mTLS
242
463
  * is not configured). When present, `AUTHENTICATE EXTERNAL` resolves the
@@ -280,13 +501,30 @@ export interface Ctx {
280
501
  * marker via {@link ServicesStore.setLastReadMarker}, SASL identify seeds
281
502
  * the connection's `lastReadMarkers` from
282
503
  * {@link ServicesStore.listReadMarkers} so a reconnect restores the user's
283
- * read position, and SASL PLAIN falls back to this store for nicks
284
- * registered via NickServ. Omitted (→ `ctx.services` undefined) preserves
285
- * the no-services behaviour: `PRIVMSG NickServ :…` is not intercepted,
286
- * read-marker persistence stays session-only, and SASL PLAIN consults
287
- * only {@link Ctx.accounts}.
504
+ * read position, SASL PLAIN consults `services.verifyNick`, SASL EXTERNAL
505
+ * consults `services.verifyCertFP`, and PASS-based account auth
506
+ * (`PASS <nick>:<password>`) consults `services.verifyNick`. Omitted (→
507
+ * `ctx.services` undefined) preserves the no-services behaviour:
508
+ * `PRIVMSG NickServ :…` is not intercepted, read-marker persistence stays
509
+ * session-only, and every SASL/PASS credential verify fails with
510
+ * `904` / silent no-op.
288
511
  */
289
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;
290
528
  /** The connection invoking the command. Reducers mutate this freely. */
291
529
  readonly connection: ConnectionState;
292
530
  /** Convenience: `connection.id`. */
@@ -323,12 +561,17 @@ export interface BuildCtxOptions {
323
561
  clock: Clock;
324
562
  ids: IdFactory;
325
563
  motd: MotdProvider;
326
- accounts?: AccountStore;
327
564
  mtlsIdentity?: MtlsIdentityProvider;
328
565
  messages?: MessageStore;
329
566
  history?: NickHistoryStore;
330
567
  away?: AwayStore;
331
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;
332
575
  connection: ConnectionState;
333
576
  /**
334
577
  * IRCv3 `labeled-response` label extracted from the inbound `+label` tag.
@@ -349,12 +592,12 @@ export function buildCtx(opts: BuildCtxOptions): Ctx {
349
592
  motd: opts.motd,
350
593
  connection: opts.connection,
351
594
  connId: opts.connection.id,
352
- ...(opts.accounts !== undefined ? { accounts: opts.accounts } : {}),
353
595
  ...(opts.mtlsIdentity !== undefined ? { mtlsIdentity: opts.mtlsIdentity } : {}),
354
596
  ...(opts.messages !== undefined ? { messages: opts.messages } : {}),
355
597
  ...(opts.history !== undefined ? { history: opts.history } : {}),
356
598
  ...(opts.away !== undefined ? { away: opts.away } : {}),
357
599
  ...(opts.services !== undefined ? { services: opts.services } : {}),
600
+ ...(opts.operFailures !== undefined ? { operFailures: opts.operFailures } : {}),
358
601
  ...(opts.label !== undefined ? { label: opts.label } : {}),
359
602
  };
360
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,133 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ type LegacyAccountRow,
4
+ buildMigrationSql,
5
+ buildServicesPutItem,
6
+ parseSaslAccountsForMigration,
7
+ } from '../src/account-migration';
8
+
9
+ describe('parseSaslAccountsForMigration', () => {
10
+ it('returns an empty array for undefined', () => {
11
+ expect(parseSaslAccountsForMigration(undefined)).toEqual([]);
12
+ });
13
+
14
+ it('returns an empty array for an empty string', () => {
15
+ expect(parseSaslAccountsForMigration('')).toEqual([]);
16
+ });
17
+
18
+ it('parses a single username:password entry', () => {
19
+ expect(parseSaslAccountsForMigration('alice:s3cret')).toEqual([
20
+ { username: 'alice', password: 's3cret' },
21
+ ]);
22
+ });
23
+
24
+ it('skips blank lines and malformed entries', () => {
25
+ expect(
26
+ parseSaslAccountsForMigration('alice:s3cret\n\nnocolon\n:emptyuser\nemptypass:\n \n'),
27
+ ).toEqual([{ username: 'alice', password: 's3cret' }]);
28
+ });
29
+ });
30
+
31
+ describe('buildMigrationSql', () => {
32
+ const legacyRow = (account: string, salt = 'salt', hash = 'hash'): LegacyAccountRow => ({
33
+ account,
34
+ algorithm: 'scrypt',
35
+ salt,
36
+ hash,
37
+ });
38
+
39
+ it('creates a nickserv_accounts row with cert_subjects "[]" for a legacy account', () => {
40
+ const stmts = buildMigrationSql([legacyRow('alice')], undefined);
41
+ expect(stmts[0]).toContain('CREATE TABLE IF NOT EXISTS nickserv_accounts');
42
+ expect(stmts[1]).toContain('INSERT OR IGNORE');
43
+ expect(stmts[1]).toContain("'alice'");
44
+ expect(stmts[1]).toContain("'[]'");
45
+ });
46
+
47
+ it('uses INSERT OR IGNORE (never REPLACE)', () => {
48
+ const stmts = buildMigrationSql([legacyRow('alice')], undefined);
49
+ expect(stmts[1]).toContain('INSERT OR IGNORE');
50
+ expect(stmts[1]).not.toContain('INSERT OR REPLACE');
51
+ });
52
+
53
+ it('is idempotent (same input → same output)', () => {
54
+ const stmts1 = buildMigrationSql([legacyRow('alice')], undefined);
55
+ const stmts2 = buildMigrationSql([legacyRow('alice')], undefined);
56
+ expect(stmts1).toEqual(stmts2);
57
+ });
58
+
59
+ it('carries SASL_ACCOUNTS env-seed entries not in the legacy table', () => {
60
+ const stmts = buildMigrationSql([legacyRow('alice')], 'bob:bobspw\nalice:alicespw');
61
+ expect(stmts).toHaveLength(3);
62
+ expect(stmts[1]).toContain("'alice'");
63
+ expect(stmts[1]).toContain("'salt'");
64
+ expect(stmts[2]).toContain("'bob'");
65
+ expect(stmts[2]).not.toContain("'bobspw'");
66
+ });
67
+
68
+ it('does not re-hash legacy rows', () => {
69
+ const stmts = buildMigrationSql([legacyRow('alice', 'mysalt', 'myhash')], undefined);
70
+ expect(stmts[1]).toContain("'mysalt'");
71
+ expect(stmts[1]).toContain("'myhash'");
72
+ });
73
+
74
+ it('produces only CREATE TABLE when both inputs are empty', () => {
75
+ const stmts = buildMigrationSql([], undefined);
76
+ expect(stmts).toHaveLength(1);
77
+ });
78
+
79
+ it('escapes single quotes in usernames', () => {
80
+ const stmts = buildMigrationSql([legacyRow("O'Brien")], undefined);
81
+ expect(stmts[1]).toContain("'O''Brien'");
82
+ });
83
+ });
84
+
85
+ describe('buildServicesPutItem', () => {
86
+ it('targets the NICK:<fold> partition', () => {
87
+ const input = buildServicesPutItem(
88
+ { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
89
+ 'StagingServices',
90
+ );
91
+ expect(input.TableName).toBe('StagingServices');
92
+ expect(input.Item.pk).toBe('NICK:alice');
93
+ expect(input.Item.sk).toBe('#');
94
+ expect(input.Item.type).toBe('nick');
95
+ });
96
+
97
+ it('preserves the legacy scrypt credential fields', () => {
98
+ const input = buildServicesPutItem(
99
+ { account: 'alice', algorithm: 'scrypt', salt: 'mysalt', hash: 'myhash' },
100
+ 'Services',
101
+ );
102
+ expect(input.Item.algorithm).toBe('scrypt');
103
+ expect(input.Item.salt).toBe('mysalt');
104
+ expect(input.Item.hash).toBe('myhash');
105
+ });
106
+
107
+ it('defaults certSubjects to an empty array', () => {
108
+ const input = buildServicesPutItem(
109
+ { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
110
+ 'Services',
111
+ );
112
+ expect(input.Item.certSubjects).toEqual([]);
113
+ });
114
+
115
+ it('folds the nick case-insensitively', () => {
116
+ const input = buildServicesPutItem(
117
+ { account: 'Alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
118
+ 'Services',
119
+ );
120
+ expect(input.Item.pk).toBe('NICK:alice');
121
+ expect(input.Item.nick).toBe('Alice');
122
+ });
123
+
124
+ it('stamps default email / enforce / createdAt', () => {
125
+ const input = buildServicesPutItem(
126
+ { account: 'alice', algorithm: 'scrypt', salt: 's', hash: 'h' },
127
+ 'Services',
128
+ );
129
+ expect(input.Item.email).toBe('');
130
+ expect(input.Item.enforce).toBe('none');
131
+ expect(input.Item.createdAt).toBe(0);
132
+ });
133
+ });
@@ -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
+ });