@convertcom/mcp-server 0.0.1-beta.17 → 0.0.1-beta.18

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 (38) hide show
  1. package/README.md +1 -1
  2. package/build/executor.js +271 -17
  3. package/build/executor.js.map +1 -1
  4. package/build/failure-attribution.d.ts +55 -0
  5. package/build/failure-attribution.js +326 -0
  6. package/build/failure-attribution.js.map +1 -0
  7. package/build/image-dimensions.d.ts +5 -0
  8. package/build/image-dimensions.js +203 -0
  9. package/build/image-dimensions.js.map +1 -0
  10. package/build/kb-search.js +16 -1
  11. package/build/kb-search.js.map +1 -1
  12. package/build/namespaced.d.ts +5 -0
  13. package/build/namespaced.js +199 -11
  14. package/build/namespaced.js.map +1 -1
  15. package/build/operator.d.ts +14 -0
  16. package/build/operator.js +128 -19
  17. package/build/operator.js.map +1 -1
  18. package/build/prompts.js +10 -3
  19. package/build/prompts.js.map +1 -1
  20. package/build/runtime.d.ts +15 -2
  21. package/build/runtime.js +186 -20
  22. package/build/runtime.js.map +1 -1
  23. package/build/whats-new.d.ts +24 -0
  24. package/build/whats-new.js +150 -0
  25. package/build/whats-new.js.map +1 -0
  26. package/data/convert-kb-legacy-map.json +24956 -9527
  27. package/data/convert-kb-manifest.json +26 -27
  28. package/data/convert-kb.json +44625 -64753
  29. package/data/kb-vectors/0/embedding.index.3.proxima +0 -0
  30. package/data/kb-vectors/0/scalar.0.ipc +0 -0
  31. package/data/kb-vectors/corpus.json +2 -2
  32. package/data/kb-vectors/del.0 +0 -0
  33. package/data/kb-vectors/idmap.0/000008.sst +0 -0
  34. package/data/kb-vectors/idmap.0/IDENTITY +1 -1
  35. package/data/kb-vectors/idmap.0/MANIFEST-000005 +0 -0
  36. package/data/kb-vectors/manifest.3 +0 -0
  37. package/package.json +1 -1
  38. package/server.json +2 -2
package/README.md CHANGED
@@ -20,7 +20,7 @@ MCP (Model Context Protocol) is an open standard that allows AI models to intera
20
20
  **Key Benefits:**
21
21
  - 🔌 **Zero-config integration** – Works with Claude Desktop, Cursor, and any MCP-compatible client
22
22
  - 🔍 **Full API access** – Comprehensive coverage of Convert's [REST API v2](https://api.convert.com/doc/v2/)
23
- - 📚 **Built-in knowledge** – 9,738 indexed chunks across 1,157 Convert documentation URLs
23
+ - 📚 **Built-in knowledge** – 7,992 indexed chunks across 1,245 Convert documentation URLs
24
24
  - 🛡️ **Configurable permissions** – Three access levels from reporting-only to full read/write
25
25
  - 🧩 **Namespaced tools** – Clean interface with 15 generated API namespaces plus one computed `operator` namespace
26
26
  - 🧠 **MCP prompts** – 20 curated Convert experience workflows exposed through `prompts/list` and `prompts/get`
package/build/executor.js CHANGED
@@ -7,6 +7,8 @@ import { precompiledToolValidators } from './generated-tool-validators.js';
7
7
  import { BAYESIAN_ONLY_STATS_FIELDS, FREQUENTIST_ONLY_STATS_FIELDS, isPlainObject, isStatsEngineSchemaTool, normalizeSchema, } from './schema-normalizer.js';
8
8
  import { isReadOnlyDelegate } from './tool-safety.js';
9
9
  import { redactSensitiveValues, scrubDiagnosticText } from './redaction.js';
10
+ import { imageDimensionsFromBase64 } from './image-dimensions.js';
11
+ import { classifyExecutorFailure, failureAttribution, inputFailure, permissionFailure, validationLabels, } from './failure-attribution.js';
10
12
  const MAX_API_ERROR_DETAIL_LEN = 2000;
11
13
  const MAX_SCHEMA_ERRORS_TO_SHOW = 8;
12
14
  const MAX_SCHEMA_ERRORS_TO_ANALYZE = 1000;
@@ -14,11 +16,21 @@ const MAX_STRUCTURED_SCHEMA_ISSUES = 25;
14
16
  const MAX_STRUCTURED_SCHEMA_ISSUES_BYTES = 64 * 1024;
15
17
  const MAX_ENUM_VALUES_TO_SHOW = 20;
16
18
  const MAX_ENUM_VALUE_LENGTH = 80;
17
- const MEDIA_RESPONSE_TOOLS = new Set([
18
- 'getexperiencevariationscreenshot',
19
+ // Heatmap images are explicitly requested, but their output must also fit a
20
+ // conservative visual/model budget. These are policy ceilings, not token-cost
21
+ // guarantees. Keep the existing separate screenshot behavior.
22
+ const MAX_HEATMAP_RESPONSE_BYTES = 1024 * 1024;
23
+ const MAX_HEATMAP_DIMENSION = 2048;
24
+ const MAX_HEATMAP_PIXELS = 1024 * 1024;
25
+ const MAX_HEATMAP_FALLBACK_MODEL_BYTES = 64 * 1024;
26
+ const HEATMAP_RESPONSE_TOOLS = new Set([
19
27
  'getexperienceheatmapbackground',
20
28
  'getexperienceheatmapoverlay',
21
29
  ]);
30
+ const MEDIA_RESPONSE_TOOLS = new Set([
31
+ 'getexperiencevariationscreenshot',
32
+ ...HEATMAP_RESPONSE_TOOLS,
33
+ ]);
22
34
  const EXPLICIT_CREDENTIAL_RESPONSE_TOOLS = new Set([
23
35
  'getapikeyslist',
24
36
  'getsdkkeyslist',
@@ -87,6 +99,7 @@ export async function executeValidatedToolByName(toolName, toolArgs, executionCo
87
99
  async function executeApiTool(toolName, definition, toolArgs, executionContext, argumentsAlreadyValidated = false) {
88
100
  let uploadScreenshotReadbackUrl = null;
89
101
  let authContext;
102
+ let dispatchState = 'not_dispatched';
90
103
  // This is an authorization boundary, not input validation. Keep it on the
91
104
  // common execution path so the namespace-only validated seam cannot bypass
92
105
  // the staff browse restriction.
@@ -95,10 +108,12 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
95
108
  error: 'ReadOnlyImpersonation',
96
109
  tool: toolName,
97
110
  message: 'Staff impersonation is browse-only. Remove as_user and operate as yourself for any write or delete action.',
111
+ failure: permissionFailure('ReadOnlyImpersonation', 'executor'),
98
112
  };
99
113
  return {
100
114
  isError: true,
101
115
  content: [{ type: 'text', text: JSON.stringify(payload) }],
116
+ structuredContent: payload,
102
117
  };
103
118
  }
104
119
  try {
@@ -106,6 +121,7 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
106
121
  if (!argumentsAlreadyValidated) {
107
122
  const validation = validateToolArgs(toolName, definition.inputSchema, argsToValidate);
108
123
  if (!validation.ok) {
124
+ const validationAttribution = validationLabels(validation.errors);
109
125
  return {
110
126
  isError: true,
111
127
  content: [{ type: 'text', text: validation.message }],
@@ -115,6 +131,7 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
115
131
  issues: validation.errors,
116
132
  issue_count: validation.totalErrors,
117
133
  issues_truncated: validation.errorsTruncated,
134
+ failure: inputFailure('InvalidArguments', 'executor', validationAttribution.rule, validationAttribution.path),
118
135
  },
119
136
  };
120
137
  }
@@ -190,6 +207,7 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
190
207
  error: 'MissingConvertCredentials',
191
208
  message,
192
209
  hint: executionContext.missingCredentialsHint,
210
+ failure: permissionFailure('MissingConvertCredentials', 'executor'),
193
211
  },
194
212
  };
195
213
  }
@@ -208,12 +226,61 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
208
226
  if (toolName === 'uploadexperiencevariationscreenshot') {
209
227
  uploadScreenshotReadbackUrl = fullUrl;
210
228
  }
229
+ dispatchState = 'unknown';
211
230
  const response = await executeApiRequest(config, executionContext);
231
+ dispatchState = 'dispatched';
212
232
  const embeddedError = embeddedApiError(response.data);
213
233
  if (embeddedError) {
214
234
  throw new ConvertApiTransportError(`API Error: Status ${embeddedError.status}`, embeddedError.status, response.data);
215
235
  }
216
236
  const media = mediaResponse(toolName, response);
237
+ if (media && 'problem' in media) {
238
+ if (!HEATMAP_RESPONSE_TOOLS.has(toolName)) {
239
+ return {
240
+ isError: true,
241
+ content: [{ type: 'text', text: 'The upstream image response had unsupported base64 encoding or image signature. No image data was returned.' }],
242
+ structuredContent: {
243
+ error: 'InvalidMediaResponse',
244
+ status: 502,
245
+ failure: failureAttribution({
246
+ category: 'upstream', source: 'executor', stage: 'upstream_response',
247
+ dispatchState: 'dispatched', code: 'InvalidMediaResponse', upstreamStatus: response.status,
248
+ }),
249
+ },
250
+ };
251
+ }
252
+ const oversized = media.problem === 'budget_exceeded';
253
+ const error = oversized ? 'ImageBudgetExceeded' : 'InvalidMediaResponse';
254
+ return {
255
+ isError: true,
256
+ content: [{ type: 'text', text: oversized
257
+ ? 'The requested heatmap image exceeds the MCP visual output budget. No image data was returned. Open the original heatmap in Convert UI for full-resolution inspection.'
258
+ : 'The upstream image has an unsupported, malformed, truncated, or animated format. No image data was returned. Open the original heatmap in Convert UI for inspection.' }],
259
+ structuredContent: {
260
+ error,
261
+ status: oversized ? 413 : 502,
262
+ upstream_status: response.status,
263
+ ...(media.mimeType && { mime_type: media.mimeType }),
264
+ ...(media.sizeBytes !== undefined && { size_bytes: media.sizeBytes }),
265
+ ...(media.width !== undefined && { width: media.width }),
266
+ ...(media.height !== undefined && { height: media.height }),
267
+ ...(oversized && { limits: {
268
+ max_size_bytes: MAX_HEATMAP_RESPONSE_BYTES,
269
+ max_dimension: MAX_HEATMAP_DIMENSION,
270
+ max_pixels: MAX_HEATMAP_PIXELS,
271
+ } }),
272
+ next_step: 'Open the original heatmap in Convert UI. MCP does not crop or resize heatmap images.',
273
+ failure: failureAttribution({
274
+ category: 'upstream',
275
+ source: 'executor',
276
+ stage: 'upstream_response',
277
+ dispatchState: 'dispatched',
278
+ code: error,
279
+ upstreamStatus: response.status,
280
+ }),
281
+ },
282
+ };
283
+ }
217
284
  if (media) {
218
285
  const metadata = {
219
286
  status: response.status,
@@ -222,7 +289,9 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
222
289
  mime_type: media.mimeType,
223
290
  encoding: 'base64',
224
291
  size_bytes: media.sizeBytes,
292
+ ...(media.width !== undefined && { width: media.width, height: media.height }),
225
293
  },
294
+ ...media.context,
226
295
  binary_omitted_from_structured_content: true,
227
296
  },
228
297
  };
@@ -237,6 +306,11 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
237
306
  structuredContent: metadata,
238
307
  };
239
308
  }
309
+ if (HEATMAP_RESPONSE_TOOLS.has(toolName)) {
310
+ const fallbackProblem = inspectHeatmapFallback(response.data);
311
+ if (fallbackProblem)
312
+ return heatmapFallbackRefusal(fallbackProblem, response.status);
313
+ }
240
314
  let responseText = '';
241
315
  const contentType = response.headers['content-type']?.toLowerCase() || '';
242
316
  const isJsonResponse = contentType.includes('application/json') && typeof response.data === 'object' && response.data !== null;
@@ -260,11 +334,19 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
260
334
  else {
261
335
  responseText = `(Status: ${response.status} - No body content)`;
262
336
  }
337
+ const textResponse = `API Response (Status: ${response.status}):\n${responseText}`;
338
+ const structuredResponse = isJsonResponse
339
+ ? { status: response.status, data: responseData }
340
+ : { status: response.status };
341
+ if (HEATMAP_RESPONSE_TOOLS.has(toolName)
342
+ && new TextEncoder().encode(textResponse).length
343
+ + new TextEncoder().encode(JSON.stringify(structuredResponse)).length
344
+ > MAX_HEATMAP_FALLBACK_MODEL_BYTES) {
345
+ return heatmapFallbackRefusal('too_large', response.status);
346
+ }
263
347
  return {
264
- content: [{ type: 'text', text: `API Response (Status: ${response.status}):\n${responseText}` }],
265
- structuredContent: isJsonResponse
266
- ? { status: response.status, data: responseData }
267
- : { status: response.status },
348
+ content: [{ type: 'text', text: textResponse }],
349
+ structuredContent: structuredResponse,
268
350
  };
269
351
  }
270
352
  catch (error) {
@@ -302,6 +384,14 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
302
384
  code: 'ApprovalRequired',
303
385
  status: 422,
304
386
  approval_required: approvalRequired,
387
+ failure: failureAttribution({
388
+ category: 'permission',
389
+ source: 'executor',
390
+ stage: 'upstream_response',
391
+ dispatchState: 'dispatched',
392
+ code: 'ApprovalRequired',
393
+ upstreamStatus: 422,
394
+ }),
305
395
  },
306
396
  };
307
397
  }
@@ -319,6 +409,8 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
319
409
  else {
320
410
  errorMessage = `Unexpected error: ${String(error)}`;
321
411
  }
412
+ errorStatus ??= statusFromUploadError(error);
413
+ const failure = classifyExecutorFailure(error, errorStatus, dispatchState);
322
414
  errorMessage = scrubDiagnosticText(errorMessage);
323
415
  console.error(JSON.stringify({
324
416
  component: 'executor',
@@ -333,6 +425,7 @@ async function executeApiTool(toolName, definition, toolArgs, executionContext,
333
425
  structuredContent: {
334
426
  error: errorMessage,
335
427
  ...(typeof errorStatus === 'number' ? { status: errorStatus } : {}),
428
+ failure,
336
429
  },
337
430
  };
338
431
  }
@@ -505,24 +598,175 @@ function embeddedApiError(data) {
505
598
  || isPlainObject(data.errors);
506
599
  return hasErrorSignal ? { status: code } : null;
507
600
  }
601
+ function looksLikeEncodedMedia(value, key) {
602
+ if (/^data:[^,]{1,100};base64,/i.test(value)
603
+ || /^(?:iVBORw0KGgo|\/9j\/|R0lGOD|UklGR)/.test(value))
604
+ return true;
605
+ return Boolean(key && /^(?:image|base64|data_?url|binary|blob)$/i.test(key)
606
+ && value.length >= 16 && /^[A-Za-z0-9+/_-]+={0,2}$/.test(value));
607
+ }
608
+ // Preflight the non-image heatmap fallback without serializing an unbounded
609
+ // upstream object. The exact combined text + structuredContent size is checked
610
+ // after this traversal, before either copy is returned to a model client.
611
+ function inspectHeatmapFallback(value) {
612
+ const encoder = new TextEncoder();
613
+ const seen = new WeakSet();
614
+ const stack = [{ value }];
615
+ let bytes = 0;
616
+ let nodes = 0;
617
+ while (stack.length > 0) {
618
+ if (++nodes > 20_000)
619
+ return 'too_large';
620
+ const current = stack.pop();
621
+ const item = current.value;
622
+ if (typeof item === 'string') {
623
+ if (item.length > MAX_HEATMAP_FALLBACK_MODEL_BYTES)
624
+ return 'too_large';
625
+ if (looksLikeEncodedMedia(item, current.key))
626
+ return 'encoded_media';
627
+ bytes += encoder.encode(JSON.stringify(item)).length;
628
+ }
629
+ else if (item === null || typeof item === 'number' || typeof item === 'boolean') {
630
+ bytes += String(item).length;
631
+ }
632
+ else if (typeof item === 'undefined') {
633
+ bytes += 4;
634
+ }
635
+ else if (typeof item === 'object') {
636
+ if (ArrayBuffer.isView(item) || item instanceof ArrayBuffer)
637
+ return 'encoded_media';
638
+ if (seen.has(item))
639
+ return 'too_large';
640
+ seen.add(item);
641
+ if (Array.isArray(item)) {
642
+ if (item.length > 20_000)
643
+ return 'too_large';
644
+ bytes += item.length + 2;
645
+ for (const entry of item)
646
+ stack.push({ value: entry });
647
+ }
648
+ else if (isPlainObject(item)) {
649
+ bytes += 2;
650
+ for (const key in item) {
651
+ if (!Object.hasOwn(item, key))
652
+ continue;
653
+ if (++nodes > 20_000)
654
+ return 'too_large';
655
+ if (key.length > MAX_HEATMAP_FALLBACK_MODEL_BYTES)
656
+ return 'too_large';
657
+ bytes += encoder.encode(JSON.stringify(key)).length + 2;
658
+ if (bytes > MAX_HEATMAP_FALLBACK_MODEL_BYTES)
659
+ return 'too_large';
660
+ stack.push({ value: item[key], key });
661
+ }
662
+ }
663
+ else
664
+ return 'encoded_media';
665
+ }
666
+ else
667
+ return 'encoded_media';
668
+ if (bytes > MAX_HEATMAP_FALLBACK_MODEL_BYTES)
669
+ return 'too_large';
670
+ }
671
+ return null;
672
+ }
673
+ function heatmapFallbackRefusal(problem, upstreamStatus) {
674
+ const oversized = problem === 'too_large';
675
+ const error = oversized ? 'MediaResponseTooLarge' : 'UnexpectedMediaPayload';
676
+ const message = oversized
677
+ ? 'The heatmap response exceeds the MCP structured output budget. No response data was returned.'
678
+ : 'The heatmap response contains unexpected encoded media. No response data was returned.';
679
+ return {
680
+ isError: true,
681
+ content: [{ type: 'text', text: `${message} Open the original heatmap in Convert UI for inspection.` }],
682
+ structuredContent: {
683
+ error,
684
+ status: oversized ? 413 : 502,
685
+ upstream_status: upstreamStatus,
686
+ limit_combined_model_bytes: MAX_HEATMAP_FALLBACK_MODEL_BYTES,
687
+ next_step: 'Open the original heatmap in Convert UI. MCP does not crop or resize heatmap images.',
688
+ failure: failureAttribution({
689
+ category: 'upstream', source: 'executor', stage: 'upstream_response',
690
+ dispatchState: 'dispatched', code: error, upstreamStatus,
691
+ }),
692
+ },
693
+ };
694
+ }
508
695
  function mediaResponse(toolName, response) {
509
696
  if (!MEDIA_RESPONSE_TOOLS.has(toolName))
510
697
  return null;
511
- const candidate = isPlainObject(response.data) && typeof response.data.data === 'string'
698
+ const payload = isPlainObject(response.data) && isPlainObject(response.data.data)
512
699
  ? response.data.data
513
- : (typeof response.data === 'string' ? response.data : null);
514
- if (!candidate)
700
+ : null;
701
+ const heatmapField = toolName === 'getexperienceheatmapbackground'
702
+ ? 'background'
703
+ : toolName === 'getexperienceheatmapoverlay' ? 'overlay' : null;
704
+ const nestedCandidate = payload && heatmapField ? payload[heatmapField] : null;
705
+ const heatmapCandidate = nestedCandidate == null || typeof nestedCandidate === 'string'
706
+ ? nestedCandidate
707
+ : isPlainObject(nestedCandidate)
708
+ ? ('image' in nestedCandidate
709
+ ? (typeof nestedCandidate.image === 'string' ? nestedCandidate.image : 'invalid')
710
+ : ('url' in nestedCandidate ? 'invalid' : null))
711
+ : Array.isArray(nestedCandidate)
712
+ ? (nestedCandidate.length === 1 && isPlainObject(nestedCandidate[0]) && typeof nestedCandidate[0].image === 'string'
713
+ ? nestedCandidate[0].image
714
+ : (nestedCandidate.some((item) => isPlainObject(item) && 'image' in item) ? 'invalid' : null))
715
+ : 'invalid';
716
+ if (heatmapCandidate === 'invalid')
717
+ return { problem: 'invalid' };
718
+ const candidate = typeof heatmapCandidate === 'string'
719
+ ? heatmapCandidate
720
+ : isPlainObject(response.data) && typeof response.data.data === 'string'
721
+ ? response.data.data
722
+ : (typeof response.data === 'string' ? response.data : null);
723
+ if (candidate === null)
515
724
  return null;
725
+ if (!candidate.trim())
726
+ return { problem: 'invalid' };
727
+ if (heatmapField && candidate.length > Math.ceil(MAX_HEATMAP_RESPONSE_BYTES * 4 / 3) + 64) {
728
+ return { problem: 'budget_exceeded' };
729
+ }
516
730
  const parsedDataUrl = parseImageDataUrl(candidate);
517
731
  const data = parsedDataUrl?.data ?? candidate.trim();
518
732
  const headerMimeType = imageMimeTypeFromHeader(response.headers['content-type']);
519
- const mimeType = parsedDataUrl?.mimeType ?? imageMimeTypeFromMagic(data) ?? headerMimeType;
520
- if (!mimeType || !isBase64Payload(data))
521
- return null;
733
+ const sizeBytes = base64DecodedSize(data);
734
+ if (heatmapField && sizeBytes > MAX_HEATMAP_RESPONSE_BYTES) {
735
+ return { problem: 'budget_exceeded', sizeBytes };
736
+ }
737
+ const signatureMimeType = imageMimeTypeFromMagic(data);
738
+ const mimeType = signatureMimeType;
739
+ if (!mimeType || !isBase64Payload(data)
740
+ || (parsedDataUrl && parsedDataUrl.mimeType !== mimeType)
741
+ || (heatmapField && headerMimeType && headerMimeType !== mimeType))
742
+ return { problem: 'invalid' };
743
+ const dimensions = heatmapField ? imageDimensionsFromBase64(data, mimeType) : null;
744
+ if (heatmapField && !dimensions)
745
+ return { problem: 'invalid', mimeType, sizeBytes };
746
+ if (dimensions && (dimensions.width > MAX_HEATMAP_DIMENSION
747
+ || dimensions.height > MAX_HEATMAP_DIMENSION
748
+ || dimensions.width * dimensions.height > MAX_HEATMAP_PIXELS)) {
749
+ return { problem: 'budget_exceeded', mimeType, sizeBytes, ...dimensions };
750
+ }
751
+ const heatmapId = payload?.heatmap_id;
752
+ const safeHeatmapId = typeof heatmapId === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(heatmapId)
753
+ ? heatmapId
754
+ : typeof heatmapId === 'number' && Number.isSafeInteger(heatmapId) && heatmapId > 0
755
+ ? String(heatmapId)
756
+ : undefined;
757
+ const context = payload && heatmapField
758
+ ? {
759
+ ...(safeHeatmapId && { heatmap_id: safeHeatmapId }),
760
+ ...((payload.device === 'desktop' || payload.device === 'tablet' || payload.device === 'mobile') && { device: payload.device }),
761
+ ...((payload.interaction === 'click' || payload.interaction === 'scroll' || payload.interaction === 'move') && { interaction: payload.interaction }),
762
+ }
763
+ : undefined;
522
764
  return {
523
765
  data,
524
766
  mimeType,
525
- sizeBytes: base64DecodedSize(data),
767
+ sizeBytes,
768
+ ...(dimensions && dimensions),
769
+ context,
526
770
  };
527
771
  }
528
772
  function parseImageDataUrl(value) {
@@ -544,19 +788,29 @@ function imageMimeTypeFromMagic(data) {
544
788
  return 'image/jpeg';
545
789
  if (data.startsWith('R0lGOD'))
546
790
  return 'image/gif';
547
- if (data.startsWith('UklGR'))
548
- return 'image/webp';
791
+ if (data.startsWith('UklGR')) {
792
+ try {
793
+ const prefix = atob(data.slice(0, 16).replace(/-/g, '+').replace(/_/g, '/'));
794
+ if (prefix.startsWith('RIFF') && prefix.slice(8, 12) === 'WEBP')
795
+ return 'image/webp';
796
+ }
797
+ catch { /* An invalid base64 prefix is not an image signature. */ }
798
+ }
549
799
  return null;
550
800
  }
551
801
  function isBase64Payload(value) {
552
802
  if (value.length < 8 || value.length % 4 === 1)
553
803
  return false;
554
- for (let index = 0; index < value.length; index += 1) {
804
+ const paddingIndex = value.indexOf('=');
805
+ if (paddingIndex >= 0 && (value.length % 4 !== 0 || !/^={1,2}$/.test(value.slice(paddingIndex))))
806
+ return false;
807
+ const payloadEnd = paddingIndex >= 0 ? paddingIndex : value.length;
808
+ for (let index = 0; index < payloadEnd; index += 1) {
555
809
  const code = value.charCodeAt(index);
556
810
  const isAlphaNumeric = (code >= 48 && code <= 57)
557
811
  || (code >= 65 && code <= 90)
558
812
  || (code >= 97 && code <= 122);
559
- if (!isAlphaNumeric && code !== 43 && code !== 47 && code !== 95 && code !== 45 && code !== 61) {
813
+ if (!isAlphaNumeric && code !== 43 && code !== 47 && code !== 95 && code !== 45) {
560
814
  return false;
561
815
  }
562
816
  }