@cyanheads/mcp-ts-core 0.13.6 → 0.13.8

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 (267) hide show
  1. package/AGENTS.md +5 -5
  2. package/CLAUDE.md +5 -5
  3. package/README.md +57 -52
  4. package/biome.json +1 -1
  5. package/changelog/0.13.x/0.13.7.md +77 -0
  6. package/changelog/0.13.x/0.13.8.md +101 -0
  7. package/config/tsconfig.base.json +2 -2
  8. package/dist/config/index.d.ts +9 -0
  9. package/dist/config/index.d.ts.map +1 -1
  10. package/dist/config/index.js +61 -11
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/core/app.d.ts.map +1 -1
  13. package/dist/core/app.js +35 -6
  14. package/dist/core/app.js.map +1 -1
  15. package/dist/core/context.d.ts +9 -1
  16. package/dist/core/context.d.ts.map +1 -1
  17. package/dist/core/context.js +17 -16
  18. package/dist/core/context.js.map +1 -1
  19. package/dist/core/worker.d.ts +2 -0
  20. package/dist/core/worker.d.ts.map +1 -1
  21. package/dist/core/worker.js +9 -1
  22. package/dist/core/worker.js.map +1 -1
  23. package/dist/linter/rules/enrichment-rules.d.ts +3 -2
  24. package/dist/linter/rules/enrichment-rules.d.ts.map +1 -1
  25. package/dist/linter/rules/enrichment-rules.js +9 -2
  26. package/dist/linter/rules/enrichment-rules.js.map +1 -1
  27. package/dist/linter/rules/handler-body-rules.d.ts.map +1 -1
  28. package/dist/linter/rules/handler-body-rules.js +10 -4
  29. package/dist/linter/rules/handler-body-rules.js.map +1 -1
  30. package/dist/linter/rules/schema-rules.d.ts +5 -0
  31. package/dist/linter/rules/schema-rules.d.ts.map +1 -1
  32. package/dist/linter/rules/schema-rules.js +44 -17
  33. package/dist/linter/rules/schema-rules.js.map +1 -1
  34. package/dist/mcp-server/handlerContext.d.ts +6 -0
  35. package/dist/mcp-server/handlerContext.d.ts.map +1 -1
  36. package/dist/mcp-server/handlerContext.js +3 -0
  37. package/dist/mcp-server/handlerContext.js.map +1 -1
  38. package/dist/mcp-server/outputContract.d.ts +33 -0
  39. package/dist/mcp-server/outputContract.d.ts.map +1 -0
  40. package/dist/mcp-server/outputContract.js +43 -0
  41. package/dist/mcp-server/outputContract.js.map +1 -0
  42. package/dist/mcp-server/prompts/prompt-registration.d.ts.map +1 -1
  43. package/dist/mcp-server/prompts/prompt-registration.js +6 -3
  44. package/dist/mcp-server/prompts/prompt-registration.js.map +1 -1
  45. package/dist/mcp-server/resources/utils/resourceHandlerFactory.d.ts.map +1 -1
  46. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js +10 -2
  47. package/dist/mcp-server/resources/utils/resourceHandlerFactory.js.map +1 -1
  48. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts +16 -5
  49. package/dist/mcp-server/tools/utils/toolHandlerFactory.d.ts.map +1 -1
  50. package/dist/mcp-server/tools/utils/toolHandlerFactory.js +70 -14
  51. package/dist/mcp-server/tools/utils/toolHandlerFactory.js.map +1 -1
  52. package/dist/mcp-server/transports/auth/lib/authUtils.js +4 -1
  53. package/dist/mcp-server/transports/auth/lib/authUtils.js.map +1 -1
  54. package/dist/mcp-server/transports/auth/strategies/jwtStrategy.d.ts.map +1 -1
  55. package/dist/mcp-server/transports/auth/strategies/jwtStrategy.js +1 -1
  56. package/dist/mcp-server/transports/auth/strategies/jwtStrategy.js.map +1 -1
  57. package/dist/mcp-server/transports/auth/strategies/oauthStrategy.d.ts.map +1 -1
  58. package/dist/mcp-server/transports/auth/strategies/oauthStrategy.js +2 -5
  59. package/dist/mcp-server/transports/auth/strategies/oauthStrategy.js.map +1 -1
  60. package/dist/mcp-server/transports/http/httpErrorHandler.d.ts.map +1 -1
  61. package/dist/mcp-server/transports/http/httpErrorHandler.js +15 -5
  62. package/dist/mcp-server/transports/http/httpErrorHandler.js.map +1 -1
  63. package/dist/mcp-server/transports/http/sessionStore.d.ts.map +1 -1
  64. package/dist/mcp-server/transports/http/sessionStore.js +2 -2
  65. package/dist/mcp-server/transports/http/sessionStore.js.map +1 -1
  66. package/dist/services/canvas/core/CanvasRegistry.js +1 -1
  67. package/dist/services/canvas/core/CanvasRegistry.js.map +1 -1
  68. package/dist/services/canvas/core/DataCanvas.d.ts.map +1 -1
  69. package/dist/services/canvas/core/DataCanvas.js +7 -5
  70. package/dist/services/canvas/core/DataCanvas.js.map +1 -1
  71. package/dist/services/canvas/core/canvasFactory.d.ts.map +1 -1
  72. package/dist/services/canvas/core/canvasFactory.js +2 -2
  73. package/dist/services/canvas/core/canvasFactory.js.map +1 -1
  74. package/dist/services/canvas/providers/duckdb/DuckdbProvider.d.ts.map +1 -1
  75. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js +25 -16
  76. package/dist/services/canvas/providers/duckdb/DuckdbProvider.js.map +1 -1
  77. package/dist/services/llm/providers/openrouter.provider.js +1 -1
  78. package/dist/services/llm/providers/openrouter.provider.js.map +1 -1
  79. package/dist/services/speech/providers/elevenlabs.provider.js +3 -3
  80. package/dist/services/speech/providers/elevenlabs.provider.js.map +1 -1
  81. package/dist/services/speech/providers/whisper.provider.d.ts.map +1 -1
  82. package/dist/services/speech/providers/whisper.provider.js +5 -5
  83. package/dist/services/speech/providers/whisper.provider.js.map +1 -1
  84. package/dist/storage/core/IStorageProvider.d.ts +5 -2
  85. package/dist/storage/core/IStorageProvider.d.ts.map +1 -1
  86. package/dist/storage/core/StorageService.d.ts.map +1 -1
  87. package/dist/storage/core/StorageService.js +3 -6
  88. package/dist/storage/core/StorageService.js.map +1 -1
  89. package/dist/storage/core/providerHelpers.d.ts +29 -8
  90. package/dist/storage/core/providerHelpers.d.ts.map +1 -1
  91. package/dist/storage/core/providerHelpers.js +49 -11
  92. package/dist/storage/core/providerHelpers.js.map +1 -1
  93. package/dist/storage/core/storageFactory.d.ts.map +1 -1
  94. package/dist/storage/core/storageFactory.js +12 -15
  95. package/dist/storage/core/storageFactory.js.map +1 -1
  96. package/dist/storage/core/storageValidation.d.ts +13 -13
  97. package/dist/storage/core/storageValidation.d.ts.map +1 -1
  98. package/dist/storage/core/storageValidation.js +49 -125
  99. package/dist/storage/core/storageValidation.js.map +1 -1
  100. package/dist/storage/providers/cloudflare/d1Provider.d.ts.map +1 -1
  101. package/dist/storage/providers/cloudflare/d1Provider.js +9 -7
  102. package/dist/storage/providers/cloudflare/d1Provider.js.map +1 -1
  103. package/dist/storage/providers/cloudflare/kvProvider.d.ts +2 -0
  104. package/dist/storage/providers/cloudflare/kvProvider.d.ts.map +1 -1
  105. package/dist/storage/providers/cloudflare/kvProvider.js +12 -10
  106. package/dist/storage/providers/cloudflare/kvProvider.js.map +1 -1
  107. package/dist/storage/providers/cloudflare/r2Provider.d.ts.map +1 -1
  108. package/dist/storage/providers/cloudflare/r2Provider.js +11 -8
  109. package/dist/storage/providers/cloudflare/r2Provider.js.map +1 -1
  110. package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts +1 -0
  111. package/dist/storage/providers/fileSystem/fileSystemProvider.d.ts.map +1 -1
  112. package/dist/storage/providers/fileSystem/fileSystemProvider.js +14 -12
  113. package/dist/storage/providers/fileSystem/fileSystemProvider.js.map +1 -1
  114. package/dist/storage/providers/inMemory/inMemoryProvider.d.ts +6 -1
  115. package/dist/storage/providers/inMemory/inMemoryProvider.d.ts.map +1 -1
  116. package/dist/storage/providers/inMemory/inMemoryProvider.js +15 -10
  117. package/dist/storage/providers/inMemory/inMemoryProvider.js.map +1 -1
  118. package/dist/storage/providers/supabase/supabaseProvider.d.ts.map +1 -1
  119. package/dist/storage/providers/supabase/supabaseProvider.js +5 -1
  120. package/dist/storage/providers/supabase/supabaseProvider.js.map +1 -1
  121. package/dist/testing/fuzz.d.ts.map +1 -1
  122. package/dist/testing/fuzz.js +7 -1
  123. package/dist/testing/fuzz.js.map +1 -1
  124. package/dist/testing/index.d.ts +21 -6
  125. package/dist/testing/index.d.ts.map +1 -1
  126. package/dist/testing/index.js +57 -10
  127. package/dist/testing/index.js.map +1 -1
  128. package/dist/types-global/errors.d.ts +7 -4
  129. package/dist/types-global/errors.d.ts.map +1 -1
  130. package/dist/types-global/errors.js.map +1 -1
  131. package/dist/utils/formatting/codeSpan.d.ts +27 -0
  132. package/dist/utils/formatting/codeSpan.d.ts.map +1 -0
  133. package/dist/utils/formatting/codeSpan.js +42 -0
  134. package/dist/utils/formatting/codeSpan.js.map +1 -0
  135. package/dist/utils/formatting/diffFormatter.d.ts.map +1 -1
  136. package/dist/utils/formatting/diffFormatter.js +7 -15
  137. package/dist/utils/formatting/diffFormatter.js.map +1 -1
  138. package/dist/utils/formatting/markdownBuilder.d.ts +12 -5
  139. package/dist/utils/formatting/markdownBuilder.d.ts.map +1 -1
  140. package/dist/utils/formatting/markdownBuilder.js +14 -2
  141. package/dist/utils/formatting/markdownBuilder.js.map +1 -1
  142. package/dist/utils/formatting/partialResult.d.ts +28 -2
  143. package/dist/utils/formatting/partialResult.d.ts.map +1 -1
  144. package/dist/utils/formatting/partialResult.js +46 -2
  145. package/dist/utils/formatting/partialResult.js.map +1 -1
  146. package/dist/utils/formatting/tableFormatter.d.ts.map +1 -1
  147. package/dist/utils/formatting/tableFormatter.js +5 -9
  148. package/dist/utils/formatting/tableFormatter.js.map +1 -1
  149. package/dist/utils/formatting/treeFormatter.d.ts.map +1 -1
  150. package/dist/utils/formatting/treeFormatter.js +5 -9
  151. package/dist/utils/formatting/treeFormatter.js.map +1 -1
  152. package/dist/utils/internal/error-handler/errorHandler.d.ts +21 -8
  153. package/dist/utils/internal/error-handler/errorHandler.d.ts.map +1 -1
  154. package/dist/utils/internal/error-handler/errorHandler.js +70 -38
  155. package/dist/utils/internal/error-handler/errorHandler.js.map +1 -1
  156. package/dist/utils/internal/error-handler/mappings.d.ts +18 -1
  157. package/dist/utils/internal/error-handler/mappings.d.ts.map +1 -1
  158. package/dist/utils/internal/error-handler/mappings.js +23 -1
  159. package/dist/utils/internal/error-handler/mappings.js.map +1 -1
  160. package/dist/utils/internal/error-handler/types.d.ts +2 -0
  161. package/dist/utils/internal/error-handler/types.d.ts.map +1 -1
  162. package/dist/utils/internal/logger.d.ts +75 -3
  163. package/dist/utils/internal/logger.d.ts.map +1 -1
  164. package/dist/utils/internal/logger.js +181 -52
  165. package/dist/utils/internal/logger.js.map +1 -1
  166. package/dist/utils/internal/performance.d.ts +16 -1
  167. package/dist/utils/internal/performance.d.ts.map +1 -1
  168. package/dist/utils/internal/performance.js +59 -20
  169. package/dist/utils/internal/performance.js.map +1 -1
  170. package/dist/utils/network/fetchWithTimeout.d.ts +11 -5
  171. package/dist/utils/network/fetchWithTimeout.d.ts.map +1 -1
  172. package/dist/utils/network/fetchWithTimeout.js +50 -23
  173. package/dist/utils/network/fetchWithTimeout.js.map +1 -1
  174. package/dist/utils/network/retry.d.ts +16 -8
  175. package/dist/utils/network/retry.d.ts.map +1 -1
  176. package/dist/utils/network/retry.js +19 -8
  177. package/dist/utils/network/retry.js.map +1 -1
  178. package/dist/utils/overflow/outlineOnOverflow.d.ts +18 -2
  179. package/dist/utils/overflow/outlineOnOverflow.d.ts.map +1 -1
  180. package/dist/utils/overflow/outlineOnOverflow.js +28 -3
  181. package/dist/utils/overflow/outlineOnOverflow.js.map +1 -1
  182. package/dist/utils/pagination/pagination.d.ts +3 -1
  183. package/dist/utils/pagination/pagination.d.ts.map +1 -1
  184. package/dist/utils/pagination/pagination.js +10 -2
  185. package/dist/utils/pagination/pagination.js.map +1 -1
  186. package/dist/utils/parsing/csvParser.d.ts.map +1 -1
  187. package/dist/utils/parsing/csvParser.js +4 -2
  188. package/dist/utils/parsing/csvParser.js.map +1 -1
  189. package/dist/utils/parsing/htmlExtractor.js +1 -1
  190. package/dist/utils/parsing/htmlExtractor.js.map +1 -1
  191. package/dist/utils/parsing/jsonParser.d.ts.map +1 -1
  192. package/dist/utils/parsing/jsonParser.js +3 -1
  193. package/dist/utils/parsing/jsonParser.js.map +1 -1
  194. package/dist/utils/parsing/xmlParser.d.ts.map +1 -1
  195. package/dist/utils/parsing/xmlParser.js +3 -1
  196. package/dist/utils/parsing/xmlParser.js.map +1 -1
  197. package/dist/utils/parsing/yamlParser.d.ts.map +1 -1
  198. package/dist/utils/parsing/yamlParser.js +3 -1
  199. package/dist/utils/parsing/yamlParser.js.map +1 -1
  200. package/dist/utils/security/idGenerator.d.ts.map +1 -1
  201. package/dist/utils/security/idGenerator.js +20 -4
  202. package/dist/utils/security/idGenerator.js.map +1 -1
  203. package/dist/utils/security/sanitization.d.ts +46 -15
  204. package/dist/utils/security/sanitization.d.ts.map +1 -1
  205. package/dist/utils/security/sanitization.js +203 -96
  206. package/dist/utils/security/sanitization.js.map +1 -1
  207. package/dist/utils/telemetry/attributes.d.ts +16 -1
  208. package/dist/utils/telemetry/attributes.d.ts.map +1 -1
  209. package/dist/utils/telemetry/attributes.js +16 -1
  210. package/dist/utils/telemetry/attributes.js.map +1 -1
  211. package/dist/utils/telemetry/instrumentation.d.ts +13 -3
  212. package/dist/utils/telemetry/instrumentation.d.ts.map +1 -1
  213. package/dist/utils/telemetry/instrumentation.js +104 -17
  214. package/dist/utils/telemetry/instrumentation.js.map +1 -1
  215. package/framework-skills/add-app-tool/SKILL.md +12 -18
  216. package/framework-skills/add-prompt/SKILL.md +3 -1
  217. package/framework-skills/add-provider/SKILL.md +14 -4
  218. package/framework-skills/add-resource/SKILL.md +3 -3
  219. package/framework-skills/add-tool/SKILL.md +22 -7
  220. package/framework-skills/api-auth/SKILL.md +3 -1
  221. package/framework-skills/api-canvas/SKILL.md +4 -4
  222. package/framework-skills/api-config/SKILL.md +9 -5
  223. package/framework-skills/api-context/SKILL.md +10 -7
  224. package/framework-skills/api-errors/SKILL.md +20 -11
  225. package/framework-skills/api-linter/SKILL.md +14 -10
  226. package/framework-skills/api-telemetry/SKILL.md +38 -13
  227. package/framework-skills/api-testing/SKILL.md +25 -15
  228. package/framework-skills/api-utils/SKILL.md +10 -10
  229. package/framework-skills/api-utils/references/formatting.md +1 -1
  230. package/framework-skills/api-utils/references/parsing.md +2 -2
  231. package/framework-skills/api-utils/references/security.md +13 -10
  232. package/framework-skills/code-simplifier/SKILL.md +31 -18
  233. package/framework-skills/design-mcp-server/SKILL.md +62 -35
  234. package/framework-skills/git-wrapup/SKILL.md +19 -10
  235. package/framework-skills/maintenance/SKILL.md +3 -3
  236. package/framework-skills/orchestrations/SKILL.md +1 -1
  237. package/framework-skills/orchestrations/workflows/greenfield-build.md +15 -8
  238. package/framework-skills/polish-docs-meta/SKILL.md +2 -2
  239. package/framework-skills/polish-docs-meta/references/package-meta.md +1 -1
  240. package/framework-skills/polish-docs-meta/references/readme.md +4 -3
  241. package/framework-skills/release-and-publish/SKILL.md +7 -5
  242. package/framework-skills/release-pr-review/SKILL.md +18 -1
  243. package/framework-skills/report-issue-framework/SKILL.md +2 -2
  244. package/framework-skills/report-issue-local/SKILL.md +3 -3
  245. package/framework-skills/security-pass/SKILL.md +11 -3
  246. package/framework-skills/techniques/SKILL.md +1 -1
  247. package/framework-skills/techniques/references/outline-on-overflow.md +12 -7
  248. package/framework-skills/tool-defs-analysis/SKILL.md +3 -3
  249. package/package.json +30 -36
  250. package/scripts/check-skill-versions.ts +103 -22
  251. package/scripts/devcheck.ts +4 -3
  252. package/scripts/lint-packaging.ts +38 -1
  253. package/templates/.env.example +7 -1
  254. package/templates/.github/ISSUE_TEMPLATE/feature_request.yml +1 -1
  255. package/templates/AGENTS.md +2 -2
  256. package/templates/CLAUDE.md +2 -2
  257. package/templates/Dockerfile +30 -10
  258. package/templates/package.json +4 -3
  259. package/templates/src/mcp-server/prompts/definitions/echo.prompt.ts +2 -4
  260. package/templates/src/mcp-server/resources/definitions/echo-app-ui.app-resource.ts +51 -14
  261. package/templates/src/mcp-server/resources/definitions/echo.resource.ts +1 -1
  262. package/templates/src/mcp-server/tools/definitions/echo-app.app-tool.ts +2 -3
  263. package/templates/src/mcp-server/tools/definitions/echo.tool.ts +1 -1
  264. package/dist/utils/telemetry/index.d.ts +0 -12
  265. package/dist/utils/telemetry/index.d.ts.map +0 -1
  266. package/dist/utils/telemetry/index.js +0 -12
  267. package/dist/utils/telemetry/index.js.map +0 -1
@@ -4,9 +4,9 @@
4
4
  * and numbers, as well as redacting sensitive fields from data intended for logging.
5
5
  *
6
6
  * Several methods (`sanitizeHtml`, `sanitizeString`, `sanitizeUrl`, `sanitizeNumber`) are
7
- * **async** because they lazy-load optional peer dependencies (`sanitize-html`, `validator`)
8
- * on first use. If a required peer dependency is not installed, these methods throw a
9
- * `McpError` with `JsonRpcErrorCode.ConfigurationError`.
7
+ * **async**. HTML handling lazy-loads the optional `sanitize-html` peer on first use and throws
8
+ * a `McpError` with `JsonRpcErrorCode.ConfigurationError` when it is not installed; URL and
9
+ * number validation are built in.
10
10
  *
11
11
  * Path sanitization (`sanitizePath`) is synchronous but only available in Node.js environments.
12
12
  *
@@ -208,22 +208,21 @@ export declare class Sanitization {
208
208
  /**
209
209
  * Sanitizes a string according to its intended usage context.
210
210
  *
211
- * This method is **async** because it lazy-loads `sanitize-html` and/or `validator`
212
- * depending on the requested context.
211
+ * This method is **async** because the HTML-stripping contexts lazy-load `sanitize-html`.
213
212
  *
214
213
  * | `context` | Behavior |
215
214
  * |----------------|----------|
216
215
  * | `'text'` | Strips all HTML tags and attributes (default). |
217
216
  * | `'html'` | Runs full HTML sanitization via `sanitizeHtml` (respects `allowedTags`/`allowedAttributes`). |
218
217
  * | `'attribute'` | Strips all tags and attributes — safe for use inside an HTML attribute value. |
219
- * | `'url'` | Validates the URL with `validator.isURL` (http/https only); returns `''` if invalid. |
218
+ * | `'url'` | Validates the URL as `sanitizeUrl` does (http/https only); returns `''` if invalid. |
220
219
  * | `'javascript'` | **Disallowed.** Always throws `McpError`. |
221
220
  *
222
221
  * @param input - The string to sanitize. Returns `''` immediately if falsy.
223
222
  * @param options - Context and optional allowlist overrides.
224
223
  * @returns Promise resolving to the sanitized string, or `''` for invalid URLs.
225
224
  * @throws {McpError} With `ValidationError` if `context` is `'javascript'`.
226
- * @throws {McpError} With `ConfigurationError` if a required peer dep is not installed.
225
+ * @throws {McpError} With `ConfigurationError` if `sanitize-html` is needed and not installed.
227
226
  * @example
228
227
  * ```ts
229
228
  * await sanitization.sanitizeString('<b>hello</b>', { context: 'text' });
@@ -240,17 +239,19 @@ export declare class Sanitization {
240
239
  /**
241
240
  * Validates and sanitizes a URL string.
242
241
  *
243
- * This method is **async** because it lazy-loads the `validator` peer dependency on first call.
242
+ * Async for API stability; validation is built in and needs no peer dependency.
244
243
  *
245
- * Validation requires a protocol and host. Even if a protocol appears in `allowedProtocols`,
246
- * the pseudo-protocols `javascript:`, `data:`, and `vbscript:` are always rejected.
244
+ * Validation requires a protocol and host. Hosts may be domains, single-label names such as
245
+ * `localhost`, IPv4 dotted quads, or bracketed IPv6 literals. Whitespace, `<`, and `>` are
246
+ * rejected anywhere, as are URLs over 2084 characters. Even if a protocol appears in
247
+ * `allowedProtocols`, the pseudo-protocols `javascript:`, `data:`, and `vbscript:` are always
248
+ * rejected.
247
249
  *
248
250
  * @param input - The URL string to sanitize. Leading/trailing whitespace is trimmed.
249
251
  * @param allowedProtocols - URL schemes that are permitted. Defaults to `['http', 'https']`.
250
252
  * @returns Promise resolving to the trimmed, validated URL string.
251
253
  * @throws {McpError} With `ValidationError` if the URL is invalid, uses a disallowed protocol,
252
254
  * or uses a blocked pseudo-protocol (`javascript:`, `data:`, `vbscript:`).
253
- * @throws {McpError} With `ConfigurationError` if `validator` is not installed.
254
255
  * @example
255
256
  * ```ts
256
257
  * await sanitization.sanitizeUrl('https://example.com/path');
@@ -324,10 +325,10 @@ export declare class Sanitization {
324
325
  /**
325
326
  * Validates a numeric input and optionally clamps it to a range.
326
327
  *
327
- * This method is **async** because string inputs are validated using the `validator` peer
328
- * dependency, which is lazy-loaded on first call. Numeric inputs bypass the lazy load.
328
+ * Async for API stability; validation is built in and needs no peer dependency.
329
329
  *
330
- * - String inputs: trimmed and checked with `validator.isNumeric`, then parsed with `parseFloat`.
330
+ * - String inputs: trimmed, required to be a plain decimal (optional sign and fraction, no
331
+ * exponent or separators), then parsed with `parseFloat`.
331
332
  * - Number inputs: used directly.
332
333
  * - `NaN` and `Infinity` are always rejected.
333
334
  * - If `min` or `max` are provided, the value is silently clamped (a debug log is emitted).
@@ -337,7 +338,6 @@ export declare class Sanitization {
337
338
  * @param max - Inclusive upper bound. If the value is above this, it is clamped to `max`.
338
339
  * @returns Promise resolving to the validated (and potentially clamped) number.
339
340
  * @throws {McpError} With `ValidationError` if the input is not numeric, is `NaN`, or is `Infinity`.
340
- * @throws {McpError} With `ConfigurationError` if `validator` is not installed (string input only).
341
341
  * @example
342
342
  * ```ts
343
343
  * await sanitization.sanitizeNumber('42.5');
@@ -378,6 +378,37 @@ export declare class Sanitization {
378
378
  * ```
379
379
  */
380
380
  sanitizeForLogging(input: unknown): unknown;
381
+ /**
382
+ * Redacts, serializes, and caps a value for a log field: {@link sanitizeForLogging},
383
+ * then `JSON.stringify`, then truncation to at most `maxBytes` UTF-8 bytes.
384
+ *
385
+ * Redaction runs first, so a cut can never keep the prefix of a secret the
386
+ * redactor would have replaced. The cut lands on a character boundary — never
387
+ * inside a multi-byte sequence or between the halves of a surrogate pair — so
388
+ * the result is always well-formed. It is a prefix of the whole serialization,
389
+ * so a truncated payload is no longer valid JSON; `truncated` says so.
390
+ *
391
+ * Returned as a string rather than an object because the logger drops values
392
+ * nested past its sanitize depth, which would silently strip the deep parts of
393
+ * a payload. A value `JSON.stringify` rejects (a `bigint`) yields
394
+ * `'[Log Serialization Failed]'` rather than throwing into the caller's path.
395
+ *
396
+ * Key-name redaction only: a secret inside a free-form string value (a query,
397
+ * a message) is serialized as-is.
398
+ *
399
+ * @param value - The value to serialize.
400
+ * @param maxBytes - Maximum UTF-8 byte length of `text`.
401
+ * @returns `text`, the serialized and possibly truncated payload, and `truncated`.
402
+ * @example
403
+ * ```ts
404
+ * sanitization.serializeForLogging({ query: 'q', apiKey: 'sk-1' }, 16_384);
405
+ * // => { text: '{"query":"q","apiKey":"[REDACTED]"}', truncated: false }
406
+ * ```
407
+ */
408
+ serializeForLogging(value: unknown, maxBytes: number): {
409
+ text: string;
410
+ truncated: boolean;
411
+ };
381
412
  /**
382
413
  * Recursively redacts sensitive fields in an object or array in place.
383
414
  * @param obj - The object or array to redact.
@@ -1 +1 @@
1
- {"version":3,"file":"sanitization.d.ts","sourceRoot":"","sources":["../../../src/utils/security/sanitization.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sanitization.d.ts","sourceRoot":"","sources":["../../../src/utils/security/sanitization.ts"],"names":[],"mappings":"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"}
@@ -11,13 +11,52 @@ async function loadSanitizeHtml() {
11
11
  })).default;
12
12
  return _sanitizeHtmlFn;
13
13
  }
14
- let _validator;
15
- async function loadValidator() {
16
- _validator ??= (await import('validator').catch(() => {
17
- throw configurationError('Install "validator" to use input validation: bun add validator');
18
- })).default;
19
- return _validator;
14
+ const URL_MAX_LENGTH = 2084;
15
+ const URL_FORBIDDEN_CHARS = /[\s<>]/;
16
+ const URL_AUTHORITY = /^[a-z][a-z0-9+.-]*:(?:\/\/)?([^/?#]*)/i;
17
+ const URL_HOST_LABEL = String.raw `[\p{L}\p{N}_](?:[\p{L}\p{M}\p{N}_-]*[\p{L}\p{M}\p{N}_])?`;
18
+ const URL_HOST = new RegExp(String.raw `^(?:${URL_HOST_LABEL}(?:\.${URL_HOST_LABEL})*\.?|\[[0-9a-f:.]+\])$`, 'iu');
19
+ const IPV4_HOST = /^\d+\.\d+\.\d+\.\d+$/;
20
+ /**
21
+ * Validates `input` as an absolute URL with a host, using a scheme from `protocols`.
22
+ *
23
+ * Rejects whitespace, `<`, or `>` anywhere, inputs over 2084 characters, and hosts outside
24
+ * letters, digits, `_`, and non-edge `-` labels (or a bracketed IPv6 literal). The host is
25
+ * checked as written and must match the host the WHATWG parser resolves, so inputs that read
26
+ * as one host but fetch another (a backslash before `@`, an obfuscated IPv4 form such as
27
+ * `2130706433` or `127.1`) are refused. Single-label hosts such as `localhost` are accepted.
28
+ */
29
+ function isAllowedUrl(input, protocols) {
30
+ if (!input || input.length > URL_MAX_LENGTH || URL_FORBIDDEN_CHARS.test(input))
31
+ return false;
32
+ const url = URL.parse(input);
33
+ if (!url || !protocols.some((protocol) => `${protocol.toLowerCase()}:` === url.protocol)) {
34
+ return false;
35
+ }
36
+ const authority = URL_AUTHORITY.exec(input)?.[1] ?? '';
37
+ const host = authority.slice(authority.lastIndexOf('@') + 1).replace(/:\d*$/, '');
38
+ if (!URL_HOST.test(host))
39
+ return false;
40
+ // The host as written must be the host the parser resolved — `http://evil.com\@good.com`
41
+ // reads as good.com but parses (and fetches) as evil.com.
42
+ if (URL.parse(`${url.protocol}//${host}`)?.hostname !== url.hostname)
43
+ return false;
44
+ return !IPV4_HOST.test(url.hostname) || url.hostname === host;
20
45
  }
46
+ /** A plain decimal: optional sign, optional fraction, no exponent or separators. */
47
+ const NUMERIC_STRING = /^[+-]?([0-9]*\.)?[0-9]+$/;
48
+ /** `data.reason` + recovery shared by every `sanitizeNumber` rejection. */
49
+ const INVALID_NUMBER = {
50
+ reason: 'invalid_number',
51
+ recovery: {
52
+ hint: 'Provide a finite decimal number, such as 42 or -3.5, without an exponent or thousands separators.',
53
+ },
54
+ };
55
+ const PATH_REJECTION_HINTS = {
56
+ invalid_path: 'Provide a non-empty path string that contains no null bytes.',
57
+ path_traversal: 'Provide a relative path that stays inside the permitted directory, with no `..` segments climbing out of it.',
58
+ absolute_path_disallowed: 'Provide a relative path instead of an absolute one.',
59
+ };
21
60
  // Dynamically import 'path' only in Node.js environments.
22
61
  // Top-level await ensures the module is loaded before any sanitizePath call.
23
62
  let pathModule;
@@ -253,22 +292,21 @@ export class Sanitization {
253
292
  /**
254
293
  * Sanitizes a string according to its intended usage context.
255
294
  *
256
- * This method is **async** because it lazy-loads `sanitize-html` and/or `validator`
257
- * depending on the requested context.
295
+ * This method is **async** because the HTML-stripping contexts lazy-load `sanitize-html`.
258
296
  *
259
297
  * | `context` | Behavior |
260
298
  * |----------------|----------|
261
299
  * | `'text'` | Strips all HTML tags and attributes (default). |
262
300
  * | `'html'` | Runs full HTML sanitization via `sanitizeHtml` (respects `allowedTags`/`allowedAttributes`). |
263
301
  * | `'attribute'` | Strips all tags and attributes — safe for use inside an HTML attribute value. |
264
- * | `'url'` | Validates the URL with `validator.isURL` (http/https only); returns `''` if invalid. |
302
+ * | `'url'` | Validates the URL as `sanitizeUrl` does (http/https only); returns `''` if invalid. |
265
303
  * | `'javascript'` | **Disallowed.** Always throws `McpError`. |
266
304
  *
267
305
  * @param input - The string to sanitize. Returns `''` immediately if falsy.
268
306
  * @param options - Context and optional allowlist overrides.
269
307
  * @returns Promise resolving to the sanitized string, or `''` for invalid URLs.
270
308
  * @throws {McpError} With `ValidationError` if `context` is `'javascript'`.
271
- * @throws {McpError} With `ConfigurationError` if a required peer dep is not installed.
309
+ * @throws {McpError} With `ConfigurationError` if `sanitize-html` is needed and not installed.
272
310
  * @example
273
311
  * ```ts
274
312
  * await sanitization.sanitizeString('<b>hello</b>', { context: 'text' });
@@ -301,26 +339,21 @@ export class Sanitization {
301
339
  return sanitizeHtmlFn(input, { allowedTags: [], allowedAttributes: {} });
302
340
  }
303
341
  case 'url': {
304
- const v = await loadValidator();
305
- if (!v.isURL(input, {
306
- protocols: ['http', 'https'],
307
- require_protocol: true,
308
- require_host: true,
309
- })) {
342
+ if (!isAllowedUrl(input, ['http', 'https'])) {
310
343
  logger.warning('Potentially invalid URL detected during string sanitization (context: url)', requestContextService.createRequestContext({
311
344
  operation: 'Sanitization.sanitizeString.urlWarning',
312
345
  additionalContext: { invalidUrlAttempt: input },
313
346
  }));
314
347
  return '';
315
348
  }
316
- return v.trim(input);
349
+ return input.trim();
317
350
  }
318
351
  case 'javascript':
319
352
  logger.error('Attempted JavaScript sanitization via sanitizeString, which is disallowed.', requestContextService.createRequestContext({
320
353
  operation: 'Sanitization.sanitizeString.jsAttempt',
321
354
  additionalContext: { inputSnippet: input.substring(0, 50) },
322
355
  }));
323
- throw validationError('JavaScript sanitization is not supported through sanitizeString due to security risks.');
356
+ throw validationError('JavaScript sanitization is not supported through sanitizeString due to security risks.', { reason: 'unsupported_sanitize_context' });
324
357
  default: {
325
358
  const sanitizeHtmlFn = await loadSanitizeHtml();
326
359
  return sanitizeHtmlFn(input, { allowedTags: [], allowedAttributes: {} });
@@ -330,17 +363,19 @@ export class Sanitization {
330
363
  /**
331
364
  * Validates and sanitizes a URL string.
332
365
  *
333
- * This method is **async** because it lazy-loads the `validator` peer dependency on first call.
366
+ * Async for API stability; validation is built in and needs no peer dependency.
334
367
  *
335
- * Validation requires a protocol and host. Even if a protocol appears in `allowedProtocols`,
336
- * the pseudo-protocols `javascript:`, `data:`, and `vbscript:` are always rejected.
368
+ * Validation requires a protocol and host. Hosts may be domains, single-label names such as
369
+ * `localhost`, IPv4 dotted quads, or bracketed IPv6 literals. Whitespace, `<`, and `>` are
370
+ * rejected anywhere, as are URLs over 2084 characters. Even if a protocol appears in
371
+ * `allowedProtocols`, the pseudo-protocols `javascript:`, `data:`, and `vbscript:` are always
372
+ * rejected.
337
373
  *
338
374
  * @param input - The URL string to sanitize. Leading/trailing whitespace is trimmed.
339
375
  * @param allowedProtocols - URL schemes that are permitted. Defaults to `['http', 'https']`.
340
376
  * @returns Promise resolving to the trimmed, validated URL string.
341
377
  * @throws {McpError} With `ValidationError` if the URL is invalid, uses a disallowed protocol,
342
378
  * or uses a blocked pseudo-protocol (`javascript:`, `data:`, `vbscript:`).
343
- * @throws {McpError} With `ConfigurationError` if `validator` is not installed.
344
379
  * @example
345
380
  * ```ts
346
381
  * await sanitization.sanitizeUrl('https://example.com/path');
@@ -353,28 +388,31 @@ export class Sanitization {
353
388
  * // throws McpError (ValidationError)
354
389
  * ```
355
390
  */
356
- async sanitizeUrl(input, allowedProtocols = ['http', 'https']) {
357
- try {
358
- const v = await loadValidator();
359
- const trimmedInput = input.trim();
360
- if (!v.isURL(trimmedInput, {
361
- protocols: allowedProtocols,
362
- require_protocol: true,
363
- require_host: true,
364
- })) {
365
- throw new Error('Invalid URL format or protocol not in allowed list.');
391
+ sanitizeUrl(input, allowedProtocols = ['http', 'https']) {
392
+ return Promise.try(() => {
393
+ try {
394
+ const trimmedInput = input.trim();
395
+ if (!isAllowedUrl(trimmedInput, allowedProtocols)) {
396
+ throw new Error('Invalid URL format or protocol not in allowed list.');
397
+ }
398
+ const lowercasedInput = trimmedInput.toLowerCase();
399
+ if (lowercasedInput.startsWith('javascript:') ||
400
+ lowercasedInput.startsWith('data:') ||
401
+ lowercasedInput.startsWith('vbscript:')) {
402
+ throw new Error('Disallowed pseudo-protocol (javascript:, data:, or vbscript:) in URL.');
403
+ }
404
+ return trimmedInput;
366
405
  }
367
- const lowercasedInput = trimmedInput.toLowerCase();
368
- if (lowercasedInput.startsWith('javascript:') ||
369
- lowercasedInput.startsWith('data:') ||
370
- lowercasedInput.startsWith('vbscript:')) {
371
- throw new Error('Disallowed pseudo-protocol (javascript:, data:, or vbscript:) in URL.');
406
+ catch (error) {
407
+ throw validationError(error instanceof Error ? error.message : 'Invalid or unsafe URL provided.', {
408
+ input,
409
+ reason: 'invalid_url',
410
+ recovery: {
411
+ hint: `Provide an absolute URL with a host and no whitespace or angle brackets, using one of these schemes: ${allowedProtocols.join(', ')}.`,
412
+ },
413
+ });
372
414
  }
373
- return trimmedInput;
374
- }
375
- catch (error) {
376
- throw validationError(error instanceof Error ? error.message : 'Invalid or unsafe URL provided.', { input });
377
- }
415
+ });
378
416
  }
379
417
  /**
380
418
  * Sanitizes a file path, preventing path traversal attacks and normalizing format.
@@ -418,11 +456,16 @@ export class Sanitization {
418
456
  ...(resolvedRootDir && { rootDir: resolvedRootDir }),
419
457
  };
420
458
  let wasAbsoluteInitially = false;
459
+ const rejectPath = (message, reason) => validationError(message, {
460
+ input: originalInput,
461
+ reason,
462
+ recovery: { hint: PATH_REJECTION_HINTS[reason] },
463
+ });
421
464
  try {
422
465
  if (!input || typeof input !== 'string')
423
- throw new Error('Invalid path input: must be a non-empty string.');
466
+ throw rejectPath('Invalid path input: must be a non-empty string.', 'invalid_path');
424
467
  if (input.includes('\0'))
425
- throw new Error('Path contains null byte, which is disallowed.');
468
+ throw rejectPath('Path contains null byte, which is disallowed.', 'invalid_path');
426
469
  let normalized = path.normalize(input);
427
470
  wasAbsoluteInitially = path.isAbsolute(normalized);
428
471
  if (effectiveOptions.toPosix) {
@@ -432,18 +475,18 @@ export class Sanitization {
432
475
  if (resolvedRootDir) {
433
476
  const fullPath = path.resolve(resolvedRootDir, normalized);
434
477
  if (!fullPath.startsWith(resolvedRootDir + path.sep) && fullPath !== resolvedRootDir) {
435
- throw new Error('Path traversal detected: attempts to escape the defined root directory.');
478
+ throw rejectPath('Path traversal detected: attempts to escape the defined root directory.', 'path_traversal');
436
479
  }
437
480
  finalSanitizedPath = path.relative(resolvedRootDir, fullPath);
438
481
  finalSanitizedPath = finalSanitizedPath === '' ? '.' : finalSanitizedPath;
439
482
  if (path.isAbsolute(finalSanitizedPath) && !effectiveOptions.allowAbsolute) {
440
- throw new Error('Path resolved to absolute outside root when absolute paths are disallowed.');
483
+ throw rejectPath('Path resolved to absolute outside root when absolute paths are disallowed.', 'path_traversal');
441
484
  }
442
485
  }
443
486
  else {
444
487
  if (path.isAbsolute(normalized)) {
445
488
  if (!effectiveOptions.allowAbsolute) {
446
- throw new Error('Absolute paths are disallowed by current options.');
489
+ throw rejectPath('Absolute paths are disallowed by current options.', 'absolute_path_disallowed');
447
490
  }
448
491
  else {
449
492
  finalSanitizedPath = normalized;
@@ -454,7 +497,7 @@ export class Sanitization {
454
497
  const currentWorkingDir = path.resolve('.');
455
498
  if (!resolvedAgainstCwd.startsWith(currentWorkingDir + path.sep) &&
456
499
  resolvedAgainstCwd !== currentWorkingDir) {
457
- throw new Error('Relative path traversal detected (escapes current working directory context).');
500
+ throw rejectPath('Relative path traversal detected (escapes current working directory context).', 'path_traversal');
458
501
  }
459
502
  finalSanitizedPath = normalized;
460
503
  }
@@ -478,7 +521,9 @@ export class Sanitization {
478
521
  errorMessage: error instanceof Error ? error.message : String(error),
479
522
  },
480
523
  }));
481
- throw validationError(error instanceof Error ? error.message : 'Invalid or unsafe path provided.', { input: originalInput });
524
+ if (error instanceof McpError)
525
+ throw error;
526
+ throw rejectPath(error instanceof Error ? error.message : 'Invalid or unsafe path provided.', 'invalid_path');
482
527
  }
483
528
  }
484
529
  /**
@@ -525,6 +570,8 @@ export class Sanitization {
525
570
  throw validationError(`JSON string exceeds maximum allowed size of ${maxSize} bytes.`, {
526
571
  actualSize: computeBytes(input),
527
572
  maxSize,
573
+ reason: 'json_too_large',
574
+ recovery: { hint: `Send a JSON payload of at most ${maxSize} bytes.` },
528
575
  });
529
576
  }
530
577
  return JSON.parse(input);
@@ -534,16 +581,18 @@ export class Sanitization {
534
581
  throw error;
535
582
  throw validationError(error instanceof Error ? error.message : 'Invalid JSON format.', {
536
583
  inputPreview: input.length > 100 ? `${input.substring(0, 100)}...` : input,
584
+ reason: 'invalid_json',
585
+ recovery: { hint: 'Send the value as a well-formed JSON string.' },
537
586
  });
538
587
  }
539
588
  }
540
589
  /**
541
590
  * Validates a numeric input and optionally clamps it to a range.
542
591
  *
543
- * This method is **async** because string inputs are validated using the `validator` peer
544
- * dependency, which is lazy-loaded on first call. Numeric inputs bypass the lazy load.
592
+ * Async for API stability; validation is built in and needs no peer dependency.
545
593
  *
546
- * - String inputs: trimmed and checked with `validator.isNumeric`, then parsed with `parseFloat`.
594
+ * - String inputs: trimmed, required to be a plain decimal (optional sign and fraction, no
595
+ * exponent or separators), then parsed with `parseFloat`.
547
596
  * - Number inputs: used directly.
548
597
  * - `NaN` and `Infinity` are always rejected.
549
598
  * - If `min` or `max` are provided, the value is silently clamped (a debug log is emitted).
@@ -553,7 +602,6 @@ export class Sanitization {
553
602
  * @param max - Inclusive upper bound. If the value is above this, it is clamped to `max`.
554
603
  * @returns Promise resolving to the validated (and potentially clamped) number.
555
604
  * @throws {McpError} With `ValidationError` if the input is not numeric, is `NaN`, or is `Infinity`.
556
- * @throws {McpError} With `ConfigurationError` if `validator` is not installed (string input only).
557
605
  * @example
558
606
  * ```ts
559
607
  * await sanitization.sanitizeNumber('42.5');
@@ -566,50 +614,58 @@ export class Sanitization {
566
614
  * // throws McpError (ValidationError)
567
615
  * ```
568
616
  */
569
- async sanitizeNumber(input, min, max) {
570
- let value;
571
- if (typeof input === 'string') {
572
- const v = await loadValidator();
573
- const trimmedInput = input.trim();
574
- if (trimmedInput === '' || !v.isNumeric(trimmedInput)) {
575
- throw validationError('Invalid number format: input is empty or not numeric.', { input });
617
+ sanitizeNumber(input, min, max) {
618
+ return Promise.try(() => {
619
+ let value;
620
+ if (typeof input === 'string') {
621
+ const trimmedInput = input.trim();
622
+ if (!NUMERIC_STRING.test(trimmedInput)) {
623
+ throw validationError('Invalid number format: input is empty or not numeric.', {
624
+ input,
625
+ ...INVALID_NUMBER,
626
+ });
627
+ }
628
+ value = parseFloat(trimmedInput);
576
629
  }
577
- value = parseFloat(trimmedInput);
578
- }
579
- else if (typeof input === 'number') {
580
- value = input;
581
- }
582
- else {
583
- throw validationError('Invalid input type: expected number or string.', {
584
- input: String(input),
585
- });
586
- }
587
- if (Number.isNaN(value) || !Number.isFinite(value)) {
588
- throw validationError('Invalid number value (NaN or Infinity).', { input });
589
- }
590
- let clamped = false;
591
- const originalValueForLog = value;
592
- if (min !== undefined && value < min) {
593
- value = min;
594
- clamped = true;
595
- }
596
- if (max !== undefined && value > max) {
597
- value = max;
598
- clamped = true;
599
- }
600
- if (clamped) {
601
- logger.debug('Number clamped to range.', requestContextService.createRequestContext({
602
- operation: 'Sanitization.sanitizeNumber.clamped',
603
- additionalContext: {
604
- originalInput: String(input),
605
- parsedValue: originalValueForLog,
606
- minValue: min,
607
- maxValue: max,
608
- clampedValue: value,
609
- },
610
- }));
611
- }
612
- return value;
630
+ else if (typeof input === 'number') {
631
+ value = input;
632
+ }
633
+ else {
634
+ throw validationError('Invalid input type: expected number or string.', {
635
+ input: String(input),
636
+ ...INVALID_NUMBER,
637
+ });
638
+ }
639
+ if (Number.isNaN(value) || !Number.isFinite(value)) {
640
+ throw validationError('Invalid number value (NaN or Infinity).', {
641
+ input,
642
+ ...INVALID_NUMBER,
643
+ });
644
+ }
645
+ let clamped = false;
646
+ const originalValueForLog = value;
647
+ if (min !== undefined && value < min) {
648
+ value = min;
649
+ clamped = true;
650
+ }
651
+ if (max !== undefined && value > max) {
652
+ value = max;
653
+ clamped = true;
654
+ }
655
+ if (clamped) {
656
+ logger.debug('Number clamped to range.', requestContextService.createRequestContext({
657
+ operation: 'Sanitization.sanitizeNumber.clamped',
658
+ additionalContext: {
659
+ originalInput: String(input),
660
+ parsedValue: originalValueForLog,
661
+ minValue: min,
662
+ maxValue: max,
663
+ clampedValue: value,
664
+ },
665
+ }));
666
+ }
667
+ return value;
668
+ });
613
669
  }
614
670
  /**
615
671
  * Produces a log-safe deep clone of `input` with sensitive field values replaced by `'[REDACTED]'`.
@@ -655,6 +711,57 @@ export class Sanitization {
655
711
  return '[Log Sanitization Failed]';
656
712
  }
657
713
  }
714
+ /**
715
+ * Redacts, serializes, and caps a value for a log field: {@link sanitizeForLogging},
716
+ * then `JSON.stringify`, then truncation to at most `maxBytes` UTF-8 bytes.
717
+ *
718
+ * Redaction runs first, so a cut can never keep the prefix of a secret the
719
+ * redactor would have replaced. The cut lands on a character boundary — never
720
+ * inside a multi-byte sequence or between the halves of a surrogate pair — so
721
+ * the result is always well-formed. It is a prefix of the whole serialization,
722
+ * so a truncated payload is no longer valid JSON; `truncated` says so.
723
+ *
724
+ * Returned as a string rather than an object because the logger drops values
725
+ * nested past its sanitize depth, which would silently strip the deep parts of
726
+ * a payload. A value `JSON.stringify` rejects (a `bigint`) yields
727
+ * `'[Log Serialization Failed]'` rather than throwing into the caller's path.
728
+ *
729
+ * Key-name redaction only: a secret inside a free-form string value (a query,
730
+ * a message) is serialized as-is.
731
+ *
732
+ * @param value - The value to serialize.
733
+ * @param maxBytes - Maximum UTF-8 byte length of `text`.
734
+ * @returns `text`, the serialized and possibly truncated payload, and `truncated`.
735
+ * @example
736
+ * ```ts
737
+ * sanitization.serializeForLogging({ query: 'q', apiKey: 'sk-1' }, 16_384);
738
+ * // => { text: '{"query":"q","apiKey":"[REDACTED]"}', truncated: false }
739
+ * ```
740
+ */
741
+ serializeForLogging(value, maxBytes) {
742
+ let json;
743
+ try {
744
+ json = JSON.stringify(this.sanitizeForLogging(value)) ?? 'undefined';
745
+ }
746
+ catch {
747
+ return { text: '[Log Serialization Failed]', truncated: false };
748
+ }
749
+ // Counted by hand rather than with `TextEncoder.encodeInto`, whose fill of a
750
+ // short buffer differs by runtime (Node stops up to two bytes early). A
751
+ // high surrogate starts a 4-byte pair and is sliced only with its partner;
752
+ // `JSON.stringify` escapes lone surrogates, so none reach here.
753
+ let bytes = 0;
754
+ for (let i = 0; i < json.length; i++) {
755
+ const unit = json.charCodeAt(i);
756
+ const width = unit < 0x80 ? 1 : unit < 0x800 ? 2 : unit >= 0xd800 && unit < 0xdc00 ? 4 : 3;
757
+ if (bytes + width > maxBytes)
758
+ return { text: json.slice(0, i), truncated: true };
759
+ bytes += width;
760
+ if (width === 4)
761
+ i++;
762
+ }
763
+ return { text: json, truncated: false };
764
+ }
658
765
  /**
659
766
  * Recursively redacts sensitive fields in an object or array in place.
660
767
  * @param obj - The object or array to redact.