@seed-ship/mcp-ui-solid 6.8.1 → 6.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/dist/components/ChartJSRenderer.cjs +27 -13
- package/dist/components/ChartJSRenderer.cjs.map +1 -1
- package/dist/components/ChartJSRenderer.d.ts.map +1 -1
- package/dist/components/ChartJSRenderer.js +28 -14
- package/dist/components/ChartJSRenderer.js.map +1 -1
- package/dist/components/DegradedFallback.cjs +73 -0
- package/dist/components/DegradedFallback.cjs.map +1 -0
- package/dist/components/DegradedFallback.d.ts +37 -0
- package/dist/components/DegradedFallback.d.ts.map +1 -0
- package/dist/components/DegradedFallback.js +73 -0
- package/dist/components/DegradedFallback.js.map +1 -0
- package/dist/components/GraphRenderer.cjs +30 -15
- package/dist/components/GraphRenderer.cjs.map +1 -1
- package/dist/components/GraphRenderer.d.ts.map +1 -1
- package/dist/components/GraphRenderer.js +31 -16
- package/dist/components/GraphRenderer.js.map +1 -1
- package/dist/components/MapRenderer.cjs +128 -107
- package/dist/components/MapRenderer.cjs.map +1 -1
- package/dist/components/MapRenderer.d.ts.map +1 -1
- package/dist/components/MapRenderer.js +129 -108
- package/dist/components/MapRenderer.js.map +1 -1
- package/dist/index.cjs +4 -4
- package/dist/index.js +1 -1
- package/dist/services/validation.cjs +43 -9
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +43 -9
- package/dist/services/validation.js.map +1 -1
- package/dist/utils/degraded-projections.cjs +87 -0
- package/dist/utils/degraded-projections.cjs.map +1 -0
- package/dist/utils/degraded-projections.d.ts +64 -0
- package/dist/utils/degraded-projections.d.ts.map +1 -0
- package/dist/utils/degraded-projections.js +87 -0
- package/dist/utils/degraded-projections.js.map +1 -0
- package/package.json +1 -1
- package/src/components/ChartJSRenderer.tsx +94 -85
- package/src/components/DegradedFallback.test.tsx +61 -0
- package/src/components/DegradedFallback.tsx +93 -0
- package/src/components/GraphRenderer.tsx +26 -4
- package/src/components/MapRenderer.tsx +446 -392
- package/src/services/validation.test.ts +298 -232
- package/src/services/validation.ts +210 -136
- package/src/utils/degraded-projections.test.ts +113 -0
- package/src/utils/degraded-projections.ts +149 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Security constraints (domain whitelist, XSS prevention)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { ZodIssue, ZodSchema } from 'zod'
|
|
11
|
+
import type { ZodIssue, ZodSchema } from 'zod';
|
|
12
12
|
import {
|
|
13
13
|
MetricComponentParamsSchema,
|
|
14
14
|
TextComponentParamsSchema,
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
FormComponentParamsSchema,
|
|
28
28
|
// v6.0.0 — graph primitive (peer @antv/g6 ^5)
|
|
29
29
|
GraphComponentParamsSchema,
|
|
30
|
-
} from '@seed-ship/mcp-ui-spec'
|
|
30
|
+
} from '@seed-ship/mcp-ui-spec';
|
|
31
31
|
import type {
|
|
32
32
|
UIComponent,
|
|
33
33
|
UILayout,
|
|
@@ -39,19 +39,35 @@ import type {
|
|
|
39
39
|
IframePolicy,
|
|
40
40
|
ValidationOptions,
|
|
41
41
|
ComponentType,
|
|
42
|
-
} from '../types'
|
|
42
|
+
} from '../types';
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* All known ComponentType values — used to distinguish known-but-unvalidated
|
|
46
46
|
* types (pass through) from truly unknown strings (reject).
|
|
47
47
|
*/
|
|
48
48
|
const KNOWN_COMPONENT_TYPES: Set<string> = new Set<ComponentType>([
|
|
49
|
-
'chart',
|
|
50
|
-
'
|
|
51
|
-
'
|
|
49
|
+
'chart',
|
|
50
|
+
'table',
|
|
51
|
+
'metric',
|
|
52
|
+
'text',
|
|
53
|
+
'grid',
|
|
54
|
+
'iframe',
|
|
55
|
+
'image',
|
|
56
|
+
'link',
|
|
57
|
+
'action',
|
|
58
|
+
'footer',
|
|
59
|
+
'carousel',
|
|
60
|
+
'artifact',
|
|
61
|
+
'form',
|
|
62
|
+
'modal',
|
|
63
|
+
'action-group',
|
|
64
|
+
'image-gallery',
|
|
65
|
+
'video',
|
|
66
|
+
'code',
|
|
67
|
+
'map',
|
|
52
68
|
// v6.0.0
|
|
53
69
|
'graph',
|
|
54
|
-
])
|
|
70
|
+
]);
|
|
55
71
|
|
|
56
72
|
/**
|
|
57
73
|
* Spec-driven validation dispatch table (B.1 — v5.5.0, expanded in v5.6.0).
|
|
@@ -95,7 +111,7 @@ const SPEC_VALIDATORS: Partial<Record<ComponentType, { schema: ZodSchema; legacy
|
|
|
95
111
|
// because LLM payloads sometimes ship edges to nodes added later.
|
|
96
112
|
// Unresolved refs are gracefully ignored by G6 v5.)
|
|
97
113
|
graph: { schema: GraphComponentParamsSchema, legacyCode: 'INVALID_GRAPH' },
|
|
98
|
-
}
|
|
114
|
+
};
|
|
99
115
|
|
|
100
116
|
/**
|
|
101
117
|
* Map a Zod issue list to the legacy `ValidationError[]` shape.
|
|
@@ -113,7 +129,7 @@ function mapZodIssuesToErrors(
|
|
|
113
129
|
path: issue.path.length > 0 ? `params.${issue.path.join('.')}` : 'params',
|
|
114
130
|
message: issue.message,
|
|
115
131
|
code: legacyCode,
|
|
116
|
-
}))
|
|
132
|
+
}));
|
|
117
133
|
}
|
|
118
134
|
|
|
119
135
|
/**
|
|
@@ -133,7 +149,7 @@ export const DEFAULT_RESOURCE_LIMITS: ResourceLimits = {
|
|
|
133
149
|
// default ceiling moved.
|
|
134
150
|
maxPayloadSize: 512 * 1024, // 512KB
|
|
135
151
|
renderTimeout: 5000, // 5 seconds
|
|
136
|
-
}
|
|
152
|
+
};
|
|
137
153
|
|
|
138
154
|
/**
|
|
139
155
|
* Default allowed iframe domains (whitelist)
|
|
@@ -264,7 +280,7 @@ export const DEFAULT_IFRAME_DOMAINS = [
|
|
|
264
280
|
'buy.stripe.com',
|
|
265
281
|
'connect.stripe.com',
|
|
266
282
|
'invoice.stripe.com',
|
|
267
|
-
]
|
|
283
|
+
];
|
|
268
284
|
|
|
269
285
|
/**
|
|
270
286
|
* Trusted iframe domains that require allow-same-origin to function.
|
|
@@ -315,13 +331,13 @@ export const TRUSTED_IFRAME_DOMAINS = [
|
|
|
315
331
|
'typeform.com',
|
|
316
332
|
'cal.com',
|
|
317
333
|
'canva.com',
|
|
318
|
-
]
|
|
334
|
+
];
|
|
319
335
|
|
|
320
336
|
/**
|
|
321
337
|
* Validate grid position bounds (1-12 columns)
|
|
322
338
|
*/
|
|
323
339
|
export function validateGridPosition(position: UIComponent['position']): ValidationResult {
|
|
324
|
-
const errors: ValidationResult['errors'] = []
|
|
340
|
+
const errors: ValidationResult['errors'] = [];
|
|
325
341
|
|
|
326
342
|
// ✅ PHASE 3 FIX: Defensive check for undefined position
|
|
327
343
|
if (!position) {
|
|
@@ -334,7 +350,7 @@ export function validateGridPosition(position: UIComponent['position']): Validat
|
|
|
334
350
|
code: 'MISSING_POSITION',
|
|
335
351
|
},
|
|
336
352
|
],
|
|
337
|
-
}
|
|
353
|
+
};
|
|
338
354
|
}
|
|
339
355
|
|
|
340
356
|
if (position.colStart < 1 || position.colStart > 12) {
|
|
@@ -342,7 +358,7 @@ export function validateGridPosition(position: UIComponent['position']): Validat
|
|
|
342
358
|
path: 'position.colStart',
|
|
343
359
|
message: 'Column start must be between 1 and 12',
|
|
344
360
|
code: 'INVALID_GRID_COL_START',
|
|
345
|
-
})
|
|
361
|
+
});
|
|
346
362
|
}
|
|
347
363
|
|
|
348
364
|
if (position.colSpan < 1 || position.colSpan > 12) {
|
|
@@ -350,7 +366,7 @@ export function validateGridPosition(position: UIComponent['position']): Validat
|
|
|
350
366
|
path: 'position.colSpan',
|
|
351
367
|
message: 'Column span must be between 1 and 12',
|
|
352
368
|
code: 'INVALID_GRID_COL_SPAN',
|
|
353
|
-
})
|
|
369
|
+
});
|
|
354
370
|
}
|
|
355
371
|
|
|
356
372
|
if (position.colStart + position.colSpan - 1 > 12) {
|
|
@@ -358,7 +374,7 @@ export function validateGridPosition(position: UIComponent['position']): Validat
|
|
|
358
374
|
path: 'position',
|
|
359
375
|
message: 'Column start + span exceeds grid width (12)',
|
|
360
376
|
code: 'GRID_OVERFLOW',
|
|
361
|
-
})
|
|
377
|
+
});
|
|
362
378
|
}
|
|
363
379
|
|
|
364
380
|
if (position.rowStart !== undefined && position.rowStart < 1) {
|
|
@@ -366,7 +382,7 @@ export function validateGridPosition(position: UIComponent['position']): Validat
|
|
|
366
382
|
path: 'position.rowStart',
|
|
367
383
|
message: 'Row start must be >= 1',
|
|
368
384
|
code: 'INVALID_GRID_ROW_START',
|
|
369
|
-
})
|
|
385
|
+
});
|
|
370
386
|
}
|
|
371
387
|
|
|
372
388
|
if (position.rowSpan !== undefined && position.rowSpan < 1) {
|
|
@@ -374,13 +390,13 @@ export function validateGridPosition(position: UIComponent['position']): Validat
|
|
|
374
390
|
path: 'position.rowSpan',
|
|
375
391
|
message: 'Row span must be >= 1',
|
|
376
392
|
code: 'INVALID_GRID_ROW_SPAN',
|
|
377
|
-
})
|
|
393
|
+
});
|
|
378
394
|
}
|
|
379
395
|
|
|
380
396
|
return {
|
|
381
397
|
valid: errors.length === 0,
|
|
382
398
|
errors: errors.length > 0 ? errors : undefined,
|
|
383
|
-
}
|
|
399
|
+
};
|
|
384
400
|
}
|
|
385
401
|
|
|
386
402
|
/**
|
|
@@ -390,25 +406,47 @@ export function validateChartComponent(
|
|
|
390
406
|
params: ChartComponentParams,
|
|
391
407
|
limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
|
|
392
408
|
): ValidationResult {
|
|
393
|
-
const errors: ValidationResult['errors'] = []
|
|
409
|
+
const errors: ValidationResult['errors'] = [];
|
|
394
410
|
|
|
395
411
|
// Guard: params.data must exist with labels + datasets
|
|
396
412
|
if (!params?.data) {
|
|
397
|
-
return {
|
|
413
|
+
return {
|
|
414
|
+
valid: false,
|
|
415
|
+
errors: [{ path: 'params.data', message: 'Missing chart data object', code: 'MISSING_DATA' }],
|
|
416
|
+
};
|
|
398
417
|
}
|
|
399
418
|
if (!Array.isArray(params.data.datasets)) {
|
|
400
|
-
return {
|
|
419
|
+
return {
|
|
420
|
+
valid: false,
|
|
421
|
+
errors: [
|
|
422
|
+
{
|
|
423
|
+
path: 'params.data.datasets',
|
|
424
|
+
message: 'Missing or invalid datasets array',
|
|
425
|
+
code: 'MISSING_DATASETS',
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
};
|
|
401
429
|
}
|
|
402
430
|
// Detect point-based charts (scatter/bubble) or object data (time-series line)
|
|
403
|
-
const chartType = params.type || 'bar'
|
|
404
|
-
const firstDataPoint = params.data.datasets[0]?.data?.[0]
|
|
405
|
-
const hasObjectData =
|
|
406
|
-
|
|
431
|
+
const chartType = params.type || 'bar';
|
|
432
|
+
const firstDataPoint = params.data.datasets[0]?.data?.[0];
|
|
433
|
+
const hasObjectData =
|
|
434
|
+
typeof firstDataPoint === 'object' && firstDataPoint !== null && 'x' in firstDataPoint;
|
|
435
|
+
const isPointChart = chartType === 'scatter' || chartType === 'bubble' || hasObjectData;
|
|
407
436
|
|
|
408
437
|
// Labels required only for categorical charts (not scatter/bubble/time-series)
|
|
409
438
|
if (!isPointChart) {
|
|
410
439
|
if (!Array.isArray(params.data.labels)) {
|
|
411
|
-
return {
|
|
440
|
+
return {
|
|
441
|
+
valid: false,
|
|
442
|
+
errors: [
|
|
443
|
+
{
|
|
444
|
+
path: 'params.data.labels',
|
|
445
|
+
message: 'Missing or invalid labels array',
|
|
446
|
+
code: 'MISSING_LABELS',
|
|
447
|
+
},
|
|
448
|
+
],
|
|
449
|
+
};
|
|
412
450
|
}
|
|
413
451
|
}
|
|
414
452
|
|
|
@@ -416,42 +454,51 @@ export function validateChartComponent(
|
|
|
416
454
|
const totalDataPoints = params.data.datasets.reduce(
|
|
417
455
|
(sum, dataset) => sum + (Array.isArray(dataset.data) ? dataset.data.length : 0),
|
|
418
456
|
0
|
|
419
|
-
)
|
|
457
|
+
);
|
|
420
458
|
|
|
421
459
|
if (totalDataPoints > limits.maxDataPoints) {
|
|
422
460
|
errors.push({
|
|
423
461
|
path: 'params.data',
|
|
424
462
|
message: `Chart exceeds max data points: ${totalDataPoints} > ${limits.maxDataPoints}`,
|
|
425
463
|
code: 'RESOURCE_LIMIT_EXCEEDED',
|
|
426
|
-
})
|
|
464
|
+
});
|
|
427
465
|
}
|
|
428
466
|
|
|
429
467
|
// Length mismatch check — only for categorical charts, skip empty datasets
|
|
430
468
|
if (!isPointChart && Array.isArray(params.data.labels)) {
|
|
431
|
-
const expectedLength = params.data.labels.length
|
|
469
|
+
const expectedLength = params.data.labels.length;
|
|
432
470
|
for (const [index, dataset] of params.data.datasets.entries()) {
|
|
433
|
-
if (
|
|
471
|
+
if (
|
|
472
|
+
Array.isArray(dataset.data) &&
|
|
473
|
+
dataset.data.length > 0 &&
|
|
474
|
+
dataset.data.length !== expectedLength
|
|
475
|
+
) {
|
|
434
476
|
errors.push({
|
|
435
477
|
path: `params.data.datasets[${index}]`,
|
|
436
478
|
message: `Dataset length mismatch: expected ${expectedLength}, got ${dataset.data.length}`,
|
|
437
479
|
code: 'DATA_LENGTH_MISMATCH',
|
|
438
|
-
})
|
|
480
|
+
});
|
|
439
481
|
}
|
|
440
482
|
}
|
|
441
483
|
}
|
|
442
484
|
|
|
443
485
|
// Data type validation — numbers for categorical, {x,y} objects for point charts
|
|
444
486
|
for (const [index, dataset] of params.data.datasets.entries()) {
|
|
445
|
-
if (!Array.isArray(dataset.data)) continue
|
|
487
|
+
if (!Array.isArray(dataset.data)) continue;
|
|
446
488
|
for (const [dataIndex, value] of dataset.data.entries()) {
|
|
447
489
|
if (isPointChart) {
|
|
448
|
-
const vObj = value as any
|
|
449
|
-
if (
|
|
490
|
+
const vObj = value as any;
|
|
491
|
+
if (
|
|
492
|
+
typeof value !== 'object' ||
|
|
493
|
+
value === null ||
|
|
494
|
+
vObj.x == null ||
|
|
495
|
+
typeof vObj.y !== 'number'
|
|
496
|
+
) {
|
|
450
497
|
errors.push({
|
|
451
498
|
path: `params.data.datasets[${index}].data[${dataIndex}]`,
|
|
452
499
|
message: `Invalid point data: expected {x, y} object`,
|
|
453
500
|
code: 'INVALID_POINT_DATA',
|
|
454
|
-
})
|
|
501
|
+
});
|
|
455
502
|
}
|
|
456
503
|
} else {
|
|
457
504
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
@@ -459,7 +506,7 @@ export function validateChartComponent(
|
|
|
459
506
|
path: `params.data.datasets[${index}].data[${dataIndex}]`,
|
|
460
507
|
message: `Invalid data value: ${value} (must be finite number)`,
|
|
461
508
|
code: 'INVALID_DATA_TYPE',
|
|
462
|
-
})
|
|
509
|
+
});
|
|
463
510
|
}
|
|
464
511
|
}
|
|
465
512
|
}
|
|
@@ -468,7 +515,7 @@ export function validateChartComponent(
|
|
|
468
515
|
return {
|
|
469
516
|
valid: errors.length === 0,
|
|
470
517
|
errors: errors.length > 0 ? errors : undefined,
|
|
471
|
-
}
|
|
518
|
+
};
|
|
472
519
|
}
|
|
473
520
|
|
|
474
521
|
/**
|
|
@@ -478,7 +525,7 @@ export function validateTableComponent(
|
|
|
478
525
|
params: TableComponentParams,
|
|
479
526
|
limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
|
|
480
527
|
): ValidationResult {
|
|
481
|
-
const errors: ValidationResult['errors'] = []
|
|
528
|
+
const errors: ValidationResult['errors'] = [];
|
|
482
529
|
|
|
483
530
|
// Validate row count
|
|
484
531
|
if (params.rows.length > limits.maxTableRows) {
|
|
@@ -486,7 +533,7 @@ export function validateTableComponent(
|
|
|
486
533
|
path: 'params.rows',
|
|
487
534
|
message: `Table exceeds max rows: ${params.rows.length} > ${limits.maxTableRows}`,
|
|
488
535
|
code: 'RESOURCE_LIMIT_EXCEEDED',
|
|
489
|
-
})
|
|
536
|
+
});
|
|
490
537
|
}
|
|
491
538
|
|
|
492
539
|
// Validate columns
|
|
@@ -495,20 +542,20 @@ export function validateTableComponent(
|
|
|
495
542
|
path: 'params.columns',
|
|
496
543
|
message: 'Table must have at least one column',
|
|
497
544
|
code: 'EMPTY_COLUMNS',
|
|
498
|
-
})
|
|
545
|
+
});
|
|
499
546
|
}
|
|
500
547
|
|
|
501
548
|
// Validate column keys are unique
|
|
502
|
-
const columnKeys = new Set<string>()
|
|
549
|
+
const columnKeys = new Set<string>();
|
|
503
550
|
for (const [index, column] of params.columns.entries()) {
|
|
504
551
|
if (columnKeys.has(column.key)) {
|
|
505
552
|
errors.push({
|
|
506
553
|
path: `params.columns[${index}]`,
|
|
507
554
|
message: `Duplicate column key: ${column.key}`,
|
|
508
555
|
code: 'DUPLICATE_COLUMN_KEY',
|
|
509
|
-
})
|
|
556
|
+
});
|
|
510
557
|
}
|
|
511
|
-
columnKeys.add(column.key)
|
|
558
|
+
columnKeys.add(column.key);
|
|
512
559
|
}
|
|
513
560
|
|
|
514
561
|
// Validate rows have valid data for defined columns
|
|
@@ -519,7 +566,7 @@ export function validateTableComponent(
|
|
|
519
566
|
path: `params.rows[${rowIndex}]`,
|
|
520
567
|
message: `Missing column key: ${column.key}`,
|
|
521
568
|
code: 'MISSING_COLUMN_DATA',
|
|
522
|
-
})
|
|
569
|
+
});
|
|
523
570
|
}
|
|
524
571
|
}
|
|
525
572
|
}
|
|
@@ -527,7 +574,7 @@ export function validateTableComponent(
|
|
|
527
574
|
return {
|
|
528
575
|
valid: errors.length === 0,
|
|
529
576
|
errors: errors.length > 0 ? errors : undefined,
|
|
530
|
-
}
|
|
577
|
+
};
|
|
531
578
|
}
|
|
532
579
|
|
|
533
580
|
/**
|
|
@@ -537,7 +584,7 @@ export function validatePayloadSize(
|
|
|
537
584
|
component: UIComponent,
|
|
538
585
|
limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
|
|
539
586
|
): ValidationResult {
|
|
540
|
-
const payloadSize = JSON.stringify(component).length
|
|
587
|
+
const payloadSize = JSON.stringify(component).length;
|
|
541
588
|
|
|
542
589
|
if (payloadSize > limits.maxPayloadSize) {
|
|
543
590
|
return {
|
|
@@ -549,10 +596,10 @@ export function validatePayloadSize(
|
|
|
549
596
|
code: 'PAYLOAD_TOO_LARGE',
|
|
550
597
|
},
|
|
551
598
|
],
|
|
552
|
-
}
|
|
599
|
+
};
|
|
553
600
|
}
|
|
554
601
|
|
|
555
|
-
return { valid: true }
|
|
602
|
+
return { valid: true };
|
|
556
603
|
}
|
|
557
604
|
|
|
558
605
|
/**
|
|
@@ -563,7 +610,7 @@ export function sanitizeString(input: string): string {
|
|
|
563
610
|
return input
|
|
564
611
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
565
612
|
.replace(/on\w+="[^"]*"/gi, '')
|
|
566
|
-
.replace(/javascript:/gi, '')
|
|
613
|
+
.replace(/javascript:/gi, '');
|
|
567
614
|
}
|
|
568
615
|
|
|
569
616
|
/**
|
|
@@ -580,17 +627,17 @@ export function validateIframeDomain(
|
|
|
580
627
|
): ValidationResult {
|
|
581
628
|
// If allow-all, skip validation
|
|
582
629
|
if (options?.policy === 'allow-all') {
|
|
583
|
-
return { valid: true }
|
|
630
|
+
return { valid: true };
|
|
584
631
|
}
|
|
585
632
|
|
|
586
633
|
try {
|
|
587
|
-
const parsedUrl = new URL(url)
|
|
588
|
-
const domain = parsedUrl.hostname
|
|
634
|
+
const parsedUrl = new URL(url);
|
|
635
|
+
const domain = parsedUrl.hostname;
|
|
589
636
|
|
|
590
637
|
// Build effective whitelist
|
|
591
|
-
let effectiveWhitelist = DEFAULT_IFRAME_DOMAINS
|
|
638
|
+
let effectiveWhitelist = DEFAULT_IFRAME_DOMAINS;
|
|
592
639
|
if (options?.policy === 'extend' && options.customDomains) {
|
|
593
|
-
effectiveWhitelist = [...DEFAULT_IFRAME_DOMAINS, ...options.customDomains]
|
|
640
|
+
effectiveWhitelist = [...DEFAULT_IFRAME_DOMAINS, ...options.customDomains];
|
|
594
641
|
}
|
|
595
642
|
|
|
596
643
|
// SECURITY (v5.5.1) — pre-fix bug: predicate was `allowed === 'localhost'`
|
|
@@ -598,12 +645,13 @@ export function validateIframeDomain(
|
|
|
598
645
|
// 'localhost' (an entry from DEFAULT_IFRAME_DOMAINS), making the entire
|
|
599
646
|
// domain whitelist inoperative. Fixed: only the URL's actual hostname
|
|
600
647
|
// being 'localhost' (or a 127.0.0.x loopback) bypasses the whitelist.
|
|
601
|
-
const isLoopback = domain === 'localhost' || /^127(\.\d{1,3}){3}$/.test(domain)
|
|
648
|
+
const isLoopback = domain === 'localhost' || /^127(\.\d{1,3}){3}$/.test(domain);
|
|
602
649
|
const isAllowed =
|
|
603
650
|
isLoopback ||
|
|
604
651
|
effectiveWhitelist.some(
|
|
605
|
-
(allowed) =>
|
|
606
|
-
|
|
652
|
+
(allowed) =>
|
|
653
|
+
allowed !== 'localhost' && (domain === allowed || domain.endsWith(`.${allowed}`))
|
|
654
|
+
);
|
|
607
655
|
|
|
608
656
|
if (!isAllowed) {
|
|
609
657
|
return {
|
|
@@ -615,10 +663,10 @@ export function validateIframeDomain(
|
|
|
615
663
|
code: 'DOMAIN_NOT_WHITELISTED',
|
|
616
664
|
},
|
|
617
665
|
],
|
|
618
|
-
}
|
|
666
|
+
};
|
|
619
667
|
}
|
|
620
668
|
|
|
621
|
-
return { valid: true }
|
|
669
|
+
return { valid: true };
|
|
622
670
|
} catch (error) {
|
|
623
671
|
return {
|
|
624
672
|
valid: false,
|
|
@@ -629,7 +677,7 @@ export function validateIframeDomain(
|
|
|
629
677
|
code: 'INVALID_URL',
|
|
630
678
|
},
|
|
631
679
|
],
|
|
632
|
-
}
|
|
680
|
+
};
|
|
633
681
|
}
|
|
634
682
|
}
|
|
635
683
|
|
|
@@ -649,27 +697,27 @@ export function getIframeSandbox(
|
|
|
649
697
|
url: string,
|
|
650
698
|
options?: { customTrustedDomains?: string[] }
|
|
651
699
|
): string {
|
|
652
|
-
const baseSandbox = 'allow-scripts allow-popups'
|
|
700
|
+
const baseSandbox = 'allow-scripts allow-popups';
|
|
653
701
|
|
|
654
702
|
try {
|
|
655
|
-
const domain = new URL(url).hostname
|
|
656
|
-
let trustedList = TRUSTED_IFRAME_DOMAINS
|
|
703
|
+
const domain = new URL(url).hostname;
|
|
704
|
+
let trustedList = TRUSTED_IFRAME_DOMAINS;
|
|
657
705
|
if (options?.customTrustedDomains) {
|
|
658
|
-
trustedList = [...TRUSTED_IFRAME_DOMAINS, ...options.customTrustedDomains]
|
|
706
|
+
trustedList = [...TRUSTED_IFRAME_DOMAINS, ...options.customTrustedDomains];
|
|
659
707
|
}
|
|
660
708
|
|
|
661
709
|
const isTrusted = trustedList.some(
|
|
662
710
|
(trusted) => domain === trusted || domain.endsWith(`.${trusted}`)
|
|
663
|
-
)
|
|
711
|
+
);
|
|
664
712
|
|
|
665
713
|
if (isTrusted) {
|
|
666
|
-
return `${baseSandbox} allow-same-origin allow-forms
|
|
714
|
+
return `${baseSandbox} allow-same-origin allow-forms`;
|
|
667
715
|
}
|
|
668
716
|
} catch {
|
|
669
717
|
// Invalid URL — use restrictive sandbox
|
|
670
718
|
}
|
|
671
719
|
|
|
672
|
-
return baseSandbox
|
|
720
|
+
return baseSandbox;
|
|
673
721
|
}
|
|
674
722
|
|
|
675
723
|
/**
|
|
@@ -682,24 +730,27 @@ export function validateComponent(
|
|
|
682
730
|
component: UIComponent,
|
|
683
731
|
options?: ValidationOptions
|
|
684
732
|
): ValidationResult {
|
|
685
|
-
const limits = options?.limits ?? DEFAULT_RESOURCE_LIMITS
|
|
686
|
-
const errors: ValidationResult['errors'] = []
|
|
733
|
+
const limits = options?.limits ?? DEFAULT_RESOURCE_LIMITS;
|
|
734
|
+
const errors: ValidationResult['errors'] = [];
|
|
687
735
|
|
|
688
736
|
// Guard: params must exist
|
|
689
737
|
if (!component.params) {
|
|
690
|
-
return {
|
|
738
|
+
return {
|
|
739
|
+
valid: false,
|
|
740
|
+
errors: [{ path: 'params', message: 'Missing component params', code: 'MISSING_PARAMS' }],
|
|
741
|
+
};
|
|
691
742
|
}
|
|
692
743
|
|
|
693
744
|
// Validate grid position
|
|
694
|
-
const gridResult = validateGridPosition(component.position)
|
|
745
|
+
const gridResult = validateGridPosition(component.position);
|
|
695
746
|
if (!gridResult.valid) {
|
|
696
|
-
errors.push(...(gridResult.errors || []))
|
|
747
|
+
errors.push(...(gridResult.errors || []));
|
|
697
748
|
}
|
|
698
749
|
|
|
699
750
|
// Validate payload size
|
|
700
|
-
const sizeResult = validatePayloadSize(component, limits)
|
|
751
|
+
const sizeResult = validatePayloadSize(component, limits);
|
|
701
752
|
if (!sizeResult.valid) {
|
|
702
|
-
errors.push(...(sizeResult.errors || []))
|
|
753
|
+
errors.push(...(sizeResult.errors || []));
|
|
703
754
|
}
|
|
704
755
|
|
|
705
756
|
// Type-specific validation (B.1 — v5.5.0, expanded v5.6.0).
|
|
@@ -708,38 +759,55 @@ export function validateComponent(
|
|
|
708
759
|
// SPEC_VALIDATORS. The 3 remaining types stay imperative because they
|
|
709
760
|
// need cross-field consistency, resource limits, or have nothing to validate
|
|
710
761
|
// (see SPEC_VALIDATORS docstring).
|
|
711
|
-
const specValidator = SPEC_VALIDATORS[component.type]
|
|
762
|
+
const specValidator = SPEC_VALIDATORS[component.type];
|
|
712
763
|
if (specValidator) {
|
|
713
|
-
const result = specValidator.schema.safeParse(component.params)
|
|
764
|
+
const result = specValidator.schema.safeParse(component.params);
|
|
714
765
|
if (!result.success) {
|
|
715
|
-
errors.push(...mapZodIssuesToErrors(result.error.issues, specValidator.legacyCode))
|
|
766
|
+
errors.push(...mapZodIssuesToErrors(result.error.issues, specValidator.legacyCode));
|
|
716
767
|
}
|
|
717
768
|
// Post-spec chained checks. Skipped when the shape parse failed to avoid
|
|
718
769
|
// cascading errors on already-broken payloads.
|
|
719
770
|
if (result.success) {
|
|
720
771
|
// Iframe + video: domain whitelist
|
|
721
772
|
if (component.type === 'iframe' || component.type === 'video') {
|
|
722
|
-
const url = (component.params as { url?: string })?.url
|
|
773
|
+
const url = (component.params as { url?: string })?.url;
|
|
723
774
|
if (typeof url === 'string') {
|
|
724
775
|
const domainResult = validateIframeDomain(url, {
|
|
725
776
|
policy: options?.iframePolicy,
|
|
726
777
|
customDomains: options?.customIframeDomains,
|
|
727
|
-
})
|
|
778
|
+
});
|
|
728
779
|
if (!domainResult.valid) {
|
|
729
|
-
errors.push(...(domainResult.errors || []))
|
|
780
|
+
errors.push(...(domainResult.errors || []));
|
|
730
781
|
}
|
|
731
782
|
}
|
|
732
783
|
}
|
|
733
|
-
// Map (v5.6.0):
|
|
734
|
-
//
|
|
784
|
+
// Map (v5.6.0; widened v6.8.2): a map must carry at least one
|
|
785
|
+
// visualizable thing. Originally only center/markers counted, but since
|
|
786
|
+
// spec@5.2.0 a `type:'map'` may render purely from `geojson`, named
|
|
787
|
+
// `layers`, or a `pmtiles` source (e.g. a Cadastre choropleth with no
|
|
788
|
+
// markers and an auto-fit viewport). Rejecting those as INVALID_MAP
|
|
789
|
+
// blocked valid maps the renderer draws. Accept ANY of:
|
|
790
|
+
// center | markers | geojson | layers | pmtiles.
|
|
735
791
|
if (component.type === 'map') {
|
|
736
|
-
const mapParams = component.params as {
|
|
737
|
-
|
|
792
|
+
const mapParams = component.params as {
|
|
793
|
+
center?: unknown;
|
|
794
|
+
markers?: unknown[];
|
|
795
|
+
geojson?: unknown;
|
|
796
|
+
layers?: unknown[];
|
|
797
|
+
pmtiles?: unknown;
|
|
798
|
+
};
|
|
799
|
+
const hasContent =
|
|
800
|
+
mapParams.center != null ||
|
|
801
|
+
(Array.isArray(mapParams.markers) && mapParams.markers.length > 0) ||
|
|
802
|
+
mapParams.geojson != null ||
|
|
803
|
+
(Array.isArray(mapParams.layers) && mapParams.layers.length > 0) ||
|
|
804
|
+
mapParams.pmtiles != null;
|
|
805
|
+
if (!hasContent) {
|
|
738
806
|
errors.push({
|
|
739
807
|
path: 'params',
|
|
740
|
-
message: 'Map must have center or
|
|
808
|
+
message: 'Map must have center, markers, geojson, layers, or pmtiles',
|
|
741
809
|
code: 'INVALID_MAP',
|
|
742
|
-
})
|
|
810
|
+
});
|
|
743
811
|
}
|
|
744
812
|
}
|
|
745
813
|
}
|
|
@@ -747,24 +815,30 @@ export function validateComponent(
|
|
|
747
815
|
// Imperative path for chart/table/modal/grid/footer/composite.
|
|
748
816
|
switch (component.type) {
|
|
749
817
|
case 'chart': {
|
|
750
|
-
const chartResult = validateChartComponent(
|
|
818
|
+
const chartResult = validateChartComponent(
|
|
819
|
+
component.params as ChartComponentParams,
|
|
820
|
+
limits
|
|
821
|
+
);
|
|
751
822
|
if (!chartResult.valid) {
|
|
752
|
-
errors.push(...(chartResult.errors || []))
|
|
823
|
+
errors.push(...(chartResult.errors || []));
|
|
753
824
|
}
|
|
754
|
-
break
|
|
825
|
+
break;
|
|
755
826
|
}
|
|
756
827
|
|
|
757
828
|
case 'table': {
|
|
758
|
-
const tableResult = validateTableComponent(
|
|
829
|
+
const tableResult = validateTableComponent(
|
|
830
|
+
component.params as TableComponentParams,
|
|
831
|
+
limits
|
|
832
|
+
);
|
|
759
833
|
if (!tableResult.valid) {
|
|
760
|
-
errors.push(...(tableResult.errors || []))
|
|
834
|
+
errors.push(...(tableResult.errors || []));
|
|
761
835
|
}
|
|
762
|
-
break
|
|
836
|
+
break;
|
|
763
837
|
}
|
|
764
838
|
|
|
765
839
|
case 'modal':
|
|
766
840
|
// Modal is valid with minimal params (title optional, content can be children).
|
|
767
|
-
break
|
|
841
|
+
break;
|
|
768
842
|
|
|
769
843
|
default:
|
|
770
844
|
// Known types without specific validation pass through — renderer handles errors.
|
|
@@ -774,16 +848,16 @@ export function validateComponent(
|
|
|
774
848
|
path: 'type',
|
|
775
849
|
message: `Unknown component type: ${component.type}`,
|
|
776
850
|
code: 'UNKNOWN_COMPONENT_TYPE',
|
|
777
|
-
})
|
|
851
|
+
});
|
|
778
852
|
}
|
|
779
|
-
break
|
|
853
|
+
break;
|
|
780
854
|
}
|
|
781
855
|
}
|
|
782
856
|
|
|
783
857
|
return {
|
|
784
858
|
valid: errors.length === 0,
|
|
785
859
|
errors: errors.length > 0 ? errors : undefined,
|
|
786
|
-
}
|
|
860
|
+
};
|
|
787
861
|
}
|
|
788
862
|
|
|
789
863
|
/**
|
|
@@ -792,11 +866,8 @@ export function validateComponent(
|
|
|
792
866
|
* @param layout - The layout to validate
|
|
793
867
|
* @param options - Optional validation options (limits, iframePolicy, customIframeDomains)
|
|
794
868
|
*/
|
|
795
|
-
export function validateLayout(
|
|
796
|
-
|
|
797
|
-
options?: ValidationOptions
|
|
798
|
-
): ValidationResult {
|
|
799
|
-
const errors: ValidationResult['errors'] = []
|
|
869
|
+
export function validateLayout(layout: UILayout, options?: ValidationOptions): ValidationResult {
|
|
870
|
+
const errors: ValidationResult['errors'] = [];
|
|
800
871
|
|
|
801
872
|
// Validate component count
|
|
802
873
|
if (layout.components.length === 0) {
|
|
@@ -804,7 +875,7 @@ export function validateLayout(
|
|
|
804
875
|
path: 'components',
|
|
805
876
|
message: 'Layout must have at least one component',
|
|
806
877
|
code: 'EMPTY_LAYOUT',
|
|
807
|
-
})
|
|
878
|
+
});
|
|
808
879
|
}
|
|
809
880
|
|
|
810
881
|
if (layout.components.length > 12) {
|
|
@@ -812,19 +883,19 @@ export function validateLayout(
|
|
|
812
883
|
path: 'components',
|
|
813
884
|
message: `Layout exceeds max components: ${layout.components.length} > 12`,
|
|
814
885
|
code: 'TOO_MANY_COMPONENTS',
|
|
815
|
-
})
|
|
886
|
+
});
|
|
816
887
|
}
|
|
817
888
|
|
|
818
889
|
// Validate each component
|
|
819
890
|
for (const [index, component] of layout.components.entries()) {
|
|
820
|
-
const result = validateComponent(component, options)
|
|
891
|
+
const result = validateComponent(component, options);
|
|
821
892
|
if (!result.valid) {
|
|
822
893
|
errors.push(
|
|
823
894
|
...(result.errors?.map((error) => ({
|
|
824
895
|
...error,
|
|
825
896
|
path: `components[${index}].${error.path}`,
|
|
826
897
|
})) || [])
|
|
827
|
-
)
|
|
898
|
+
);
|
|
828
899
|
}
|
|
829
900
|
}
|
|
830
901
|
|
|
@@ -834,13 +905,13 @@ export function validateLayout(
|
|
|
834
905
|
path: 'grid.columns',
|
|
835
906
|
message: 'Grid must have 12 columns (Bootstrap-like)',
|
|
836
907
|
code: 'INVALID_GRID_COLUMNS',
|
|
837
|
-
})
|
|
908
|
+
});
|
|
838
909
|
}
|
|
839
910
|
|
|
840
911
|
return {
|
|
841
912
|
valid: errors.length === 0,
|
|
842
913
|
errors: errors.length > 0 ? errors : undefined,
|
|
843
|
-
}
|
|
914
|
+
};
|
|
844
915
|
}
|
|
845
916
|
|
|
846
917
|
/**
|
|
@@ -853,16 +924,16 @@ export function validateFieldValue(
|
|
|
853
924
|
// Required check
|
|
854
925
|
if (field.required) {
|
|
855
926
|
if (value === undefined || value === null || value === '') {
|
|
856
|
-
return { valid: false, error: `${field.label || field.name} is required` }
|
|
927
|
+
return { valid: false, error: `${field.label || field.name} is required` };
|
|
857
928
|
}
|
|
858
929
|
if (field.type === 'checkbox' && value !== true) {
|
|
859
|
-
return { valid: false, error: `${field.label || field.name} must be checked` }
|
|
930
|
+
return { valid: false, error: `${field.label || field.name} must be checked` };
|
|
860
931
|
}
|
|
861
932
|
}
|
|
862
933
|
|
|
863
934
|
// Skip further validation if value is empty and not required
|
|
864
935
|
if (value === undefined || value === null || value === '') {
|
|
865
|
-
return { valid: true }
|
|
936
|
+
return { valid: true };
|
|
866
937
|
}
|
|
867
938
|
|
|
868
939
|
// Type-specific validation
|
|
@@ -871,68 +942,71 @@ export function validateFieldValue(
|
|
|
871
942
|
case 'textarea':
|
|
872
943
|
case 'password':
|
|
873
944
|
if (field.minLength && String(value).length < field.minLength) {
|
|
874
|
-
return { valid: false, error: `Minimum ${field.minLength} characters required` }
|
|
945
|
+
return { valid: false, error: `Minimum ${field.minLength} characters required` };
|
|
875
946
|
}
|
|
876
947
|
if (field.maxLength && String(value).length > field.maxLength) {
|
|
877
|
-
return { valid: false, error: `Maximum ${field.maxLength} characters allowed` }
|
|
948
|
+
return { valid: false, error: `Maximum ${field.maxLength} characters allowed` };
|
|
878
949
|
}
|
|
879
950
|
if (field.pattern && !new RegExp(field.pattern).test(String(value))) {
|
|
880
|
-
return { valid: false, error: 'Invalid format' }
|
|
951
|
+
return { valid: false, error: 'Invalid format' };
|
|
881
952
|
}
|
|
882
|
-
break
|
|
953
|
+
break;
|
|
883
954
|
|
|
884
955
|
case 'email':
|
|
885
956
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value))) {
|
|
886
|
-
return { valid: false, error: 'Invalid email address' }
|
|
957
|
+
return { valid: false, error: 'Invalid email address' };
|
|
887
958
|
}
|
|
888
|
-
break
|
|
959
|
+
break;
|
|
889
960
|
|
|
890
961
|
case 'number': {
|
|
891
|
-
const numValue = Number(value)
|
|
962
|
+
const numValue = Number(value);
|
|
892
963
|
if (isNaN(numValue)) {
|
|
893
|
-
return { valid: false, error: 'Must be a valid number' }
|
|
964
|
+
return { valid: false, error: 'Must be a valid number' };
|
|
894
965
|
}
|
|
895
966
|
if (field.min !== undefined && numValue < field.min) {
|
|
896
|
-
return { valid: false, error: `Minimum value is ${field.min}` }
|
|
967
|
+
return { valid: false, error: `Minimum value is ${field.min}` };
|
|
897
968
|
}
|
|
898
969
|
if (field.max !== undefined && numValue > field.max) {
|
|
899
|
-
return { valid: false, error: `Maximum value is ${field.max}` }
|
|
970
|
+
return { valid: false, error: `Maximum value is ${field.max}` };
|
|
900
971
|
}
|
|
901
|
-
break
|
|
972
|
+
break;
|
|
902
973
|
}
|
|
903
974
|
|
|
904
975
|
case 'date':
|
|
905
976
|
if (field.minDate && value < field.minDate) {
|
|
906
|
-
return { valid: false, error: `Date must be after ${field.minDate}` }
|
|
977
|
+
return { valid: false, error: `Date must be after ${field.minDate}` };
|
|
907
978
|
}
|
|
908
979
|
if (field.maxDate && value > field.maxDate) {
|
|
909
|
-
return { valid: false, error: `Date must be before ${field.maxDate}` }
|
|
980
|
+
return { valid: false, error: `Date must be before ${field.maxDate}` };
|
|
910
981
|
}
|
|
911
|
-
break
|
|
982
|
+
break;
|
|
912
983
|
|
|
913
984
|
case 'select':
|
|
914
985
|
case 'radio':
|
|
915
986
|
// Validate that value is one of the options
|
|
916
987
|
if (field.options && field.options.length > 0) {
|
|
917
|
-
const validValues = field.options.map((opt) => opt.value)
|
|
988
|
+
const validValues = field.options.map((opt) => opt.value);
|
|
918
989
|
if (!validValues.includes(String(value))) {
|
|
919
|
-
return { valid: false, error: 'Please select a valid option' }
|
|
990
|
+
return { valid: false, error: 'Please select a valid option' };
|
|
920
991
|
}
|
|
921
992
|
}
|
|
922
|
-
break
|
|
993
|
+
break;
|
|
923
994
|
}
|
|
924
995
|
|
|
925
996
|
// valueFormat validation (v4.3.0) — runs after type-specific checks
|
|
926
997
|
if (field.valueFormat && value !== undefined && value !== null && value !== '') {
|
|
927
|
-
const vals = Array.isArray(value) ? value : [String(value)]
|
|
998
|
+
const vals = Array.isArray(value) ? value : [String(value)];
|
|
928
999
|
for (const v of vals) {
|
|
929
1000
|
if (!new RegExp(field.valueFormat).test(v)) {
|
|
930
|
-
return {
|
|
1001
|
+
return {
|
|
1002
|
+
valid: false,
|
|
1003
|
+
error: field.valueFormatHint || `Invalid format (expected: ${field.valueFormat})`,
|
|
1004
|
+
};
|
|
931
1005
|
}
|
|
932
1006
|
}
|
|
933
1007
|
}
|
|
934
1008
|
|
|
935
|
-
return { valid: true }
|
|
1009
|
+
return { valid: true };
|
|
936
1010
|
}
|
|
937
1011
|
|
|
938
1012
|
/**
|
|
@@ -942,17 +1016,17 @@ export function validateFormData(
|
|
|
942
1016
|
data: Record<string, any>,
|
|
943
1017
|
fields: FormFieldParams[]
|
|
944
1018
|
): { valid: boolean; errors: Record<string, string> } {
|
|
945
|
-
const errors: Record<string, string> = {}
|
|
1019
|
+
const errors: Record<string, string> = {};
|
|
946
1020
|
|
|
947
1021
|
for (const field of fields) {
|
|
948
|
-
const result = validateFieldValue(data[field.name], field)
|
|
1022
|
+
const result = validateFieldValue(data[field.name], field);
|
|
949
1023
|
if (!result.valid && result.error) {
|
|
950
|
-
errors[field.name] = result.error
|
|
1024
|
+
errors[field.name] = result.error;
|
|
951
1025
|
}
|
|
952
1026
|
}
|
|
953
1027
|
|
|
954
1028
|
return {
|
|
955
1029
|
valid: Object.keys(errors).length === 0,
|
|
956
1030
|
errors,
|
|
957
|
-
}
|
|
1031
|
+
};
|
|
958
1032
|
}
|