@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.
- package/README.md +1 -1
- package/build/executor.js +271 -17
- package/build/executor.js.map +1 -1
- package/build/failure-attribution.d.ts +55 -0
- package/build/failure-attribution.js +326 -0
- package/build/failure-attribution.js.map +1 -0
- package/build/image-dimensions.d.ts +5 -0
- package/build/image-dimensions.js +203 -0
- package/build/image-dimensions.js.map +1 -0
- package/build/kb-search.js +16 -1
- package/build/kb-search.js.map +1 -1
- package/build/namespaced.d.ts +5 -0
- package/build/namespaced.js +199 -11
- package/build/namespaced.js.map +1 -1
- package/build/operator.d.ts +14 -0
- package/build/operator.js +128 -19
- package/build/operator.js.map +1 -1
- package/build/prompts.js +10 -3
- package/build/prompts.js.map +1 -1
- package/build/runtime.d.ts +15 -2
- package/build/runtime.js +186 -20
- package/build/runtime.js.map +1 -1
- package/build/whats-new.d.ts +24 -0
- package/build/whats-new.js +150 -0
- package/build/whats-new.js.map +1 -0
- package/data/convert-kb-legacy-map.json +24956 -9527
- package/data/convert-kb-manifest.json +26 -27
- package/data/convert-kb.json +44625 -64753
- package/data/kb-vectors/0/embedding.index.3.proxima +0 -0
- package/data/kb-vectors/0/scalar.0.ipc +0 -0
- package/data/kb-vectors/corpus.json +2 -2
- package/data/kb-vectors/del.0 +0 -0
- package/data/kb-vectors/idmap.0/000008.sst +0 -0
- package/data/kb-vectors/idmap.0/IDENTITY +1 -1
- package/data/kb-vectors/idmap.0/MANIFEST-000005 +0 -0
- package/data/kb-vectors/manifest.3 +0 -0
- package/package.json +1 -1
- 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** –
|
|
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
|
-
|
|
18
|
-
|
|
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:
|
|
265
|
-
structuredContent:
|
|
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
|
|
698
|
+
const payload = isPlainObject(response.data) && isPlainObject(response.data.data)
|
|
512
699
|
? response.data.data
|
|
513
|
-
:
|
|
514
|
-
|
|
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
|
|
520
|
-
if (
|
|
521
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
813
|
+
if (!isAlphaNumeric && code !== 43 && code !== 47 && code !== 95 && code !== 45) {
|
|
560
814
|
return false;
|
|
561
815
|
}
|
|
562
816
|
}
|