@seed-ship/mcp-ui-solid 5.5.0 → 5.6.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 +62 -0
- package/dist/components/FormRenderer.cjs +13 -2
- package/dist/components/FormRenderer.cjs.map +1 -1
- package/dist/components/FormRenderer.d.ts.map +1 -1
- package/dist/components/FormRenderer.js +13 -2
- package/dist/components/FormRenderer.js.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.cjs +11 -0
- package/dist/components/GenerativeUIErrorBoundary.cjs.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.d.ts.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.js +11 -0
- package/dist/components/GenerativeUIErrorBoundary.js.map +1 -1
- package/dist/components/StreamingUIRenderer.cjs +49 -3
- package/dist/components/StreamingUIRenderer.cjs.map +1 -1
- package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
- package/dist/components/StreamingUIRenderer.js +51 -5
- package/dist/components/StreamingUIRenderer.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +62 -3
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +64 -5
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/context/MCPUITelemetryContext.cjs +25 -0
- package/dist/context/MCPUITelemetryContext.cjs.map +1 -0
- package/dist/context/MCPUITelemetryContext.d.ts +36 -0
- package/dist/context/MCPUITelemetryContext.d.ts.map +1 -0
- package/dist/context/MCPUITelemetryContext.js +25 -0
- package/dist/context/MCPUITelemetryContext.js.map +1 -0
- package/dist/index.cjs +6 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +16 -5
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +16 -5
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
- package/dist/services/telemetry.cjs +56 -0
- package/dist/services/telemetry.cjs.map +1 -0
- package/dist/services/telemetry.d.ts +87 -0
- package/dist/services/telemetry.d.ts.map +1 -0
- package/dist/services/telemetry.js +56 -0
- package/dist/services/telemetry.js.map +1 -0
- package/dist/services/validation.cjs +28 -26
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +29 -27
- package/dist/services/validation.js.map +1 -1
- package/package.json +2 -2
- package/src/components/FormRenderer.tsx +14 -0
- package/src/components/GenerativeUIErrorBoundary.tsx +17 -1
- package/src/components/StreamingUIRenderer.tsx +55 -3
- package/src/components/UIResourceRenderer.tsx +79 -3
- package/src/context/MCPUITelemetryContext.test.tsx +119 -0
- package/src/context/MCPUITelemetryContext.tsx +71 -0
- package/src/index.ts +15 -0
- package/src/services/telemetry.test.ts +134 -0
- package/src/services/telemetry.ts +149 -0
- package/src/services/validation.test.ts +53 -3
- package/src/services/validation.ts +54 -44
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI telemetry sink (B.5 — v5.6.0)
|
|
3
|
+
*
|
|
4
|
+
* Minimal OpenTelemetry-like Provider that lets a consumer (e.g. deposium
|
|
5
|
+
* `/admin/ui-telemetry`) collect lifecycle + error + action events from
|
|
6
|
+
* mcp-ui components without imposing any API change on apps that don't use
|
|
7
|
+
* it. Spec'd in `MCP-UI-AUDIT-2026-04-26.md` §M.6.
|
|
8
|
+
*
|
|
9
|
+
* Three hard rules:
|
|
10
|
+
* 1. Provider is OPTIONAL. When absent, `useTelemetry()` returns `null`
|
|
11
|
+
* and dispatch sites no-op. Existing apps see zero behavior change.
|
|
12
|
+
* 2. Sink is FAIL-OPEN. A `sink()` throw or rejected promise is caught
|
|
13
|
+
* silently — telemetry never crashes the renderer.
|
|
14
|
+
* 3. Events carry NO payload data. Only meta (type + id + counts + timing)
|
|
15
|
+
* to avoid PII / data leaks in centralized logs.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ComponentType } from '../types'
|
|
19
|
+
|
|
20
|
+
interface TelemetryEventBase {
|
|
21
|
+
/** Component instance id (from `UIComponent.id` or auto-generated). */
|
|
22
|
+
id: string
|
|
23
|
+
/** ComponentType, e.g. 'chart' / 'metric' / 'iframe'. */
|
|
24
|
+
componentType: ComponentType
|
|
25
|
+
/** Wall-clock timestamp (ms epoch) for cross-stack correlation. */
|
|
26
|
+
ts: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Discriminated union of all telemetry events emitted by mcp-ui.
|
|
31
|
+
* See §M.6.2 for field semantics + privacy rules.
|
|
32
|
+
*/
|
|
33
|
+
export type TelemetryEvent =
|
|
34
|
+
| ({ type: 'component:mounted' } & TelemetryEventBase)
|
|
35
|
+
| ({ type: 'component:rendered'; durationMs: number } & TelemetryEventBase)
|
|
36
|
+
| ({ type: 'component:unmounted' } & TelemetryEventBase)
|
|
37
|
+
| ({
|
|
38
|
+
type: 'validation:failed'
|
|
39
|
+
errorCount: number
|
|
40
|
+
firstErrorCode: string | null
|
|
41
|
+
} & TelemetryEventBase)
|
|
42
|
+
| ({ type: 'render:error'; errorMessage: string } & TelemetryEventBase)
|
|
43
|
+
| ({ type: 'action:dispatched'; actionName: string } & TelemetryEventBase)
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Consumer-supplied sink. Receives a batch of events (always an array,
|
|
47
|
+
* even for `bufferMs: 0` — single-element array in that case). Errors
|
|
48
|
+
* and rejected promises are caught silently by the dispatcher.
|
|
49
|
+
*/
|
|
50
|
+
export interface TelemetrySink {
|
|
51
|
+
(events: TelemetryEvent[]): void | Promise<void>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TelemetryOptions {
|
|
55
|
+
/** Per-event base sampling rate, 0..1, default 1.0 (all events). */
|
|
56
|
+
sampleRate?: number
|
|
57
|
+
/** Buffer events and flush after N ms (default 100). 0 = no buffer. */
|
|
58
|
+
bufferMs?: number
|
|
59
|
+
/** Max buffered events before forced flush (default 50). */
|
|
60
|
+
bufferMax?: number
|
|
61
|
+
/** Per-event-type override on sampling (high-volume types can be lower). */
|
|
62
|
+
sampleByType?: Partial<Record<TelemetryEvent['type'], number>>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Dispatcher returned by `createTelemetryDispatcher`. Used internally by
|
|
67
|
+
* the Provider, exposed only so tests can drive it without React/Solid.
|
|
68
|
+
*/
|
|
69
|
+
export interface TelemetryDispatcher {
|
|
70
|
+
/**
|
|
71
|
+
* Push an event. Sampling + buffering applied transparently. Never throws.
|
|
72
|
+
*/
|
|
73
|
+
dispatch(event: TelemetryEvent): void
|
|
74
|
+
/**
|
|
75
|
+
* Force-flush the buffer. Useful on tab-hidden / unload, or for tests.
|
|
76
|
+
* Never throws.
|
|
77
|
+
*/
|
|
78
|
+
flush(): void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_BUFFER_MS = 100
|
|
82
|
+
const DEFAULT_BUFFER_MAX = 50
|
|
83
|
+
|
|
84
|
+
function shouldSample(
|
|
85
|
+
eventType: TelemetryEvent['type'],
|
|
86
|
+
options: TelemetryOptions | undefined
|
|
87
|
+
): boolean {
|
|
88
|
+
const perTypeRate = options?.sampleByType?.[eventType]
|
|
89
|
+
const rate = perTypeRate !== undefined ? perTypeRate : (options?.sampleRate ?? 1.0)
|
|
90
|
+
if (rate >= 1) return true
|
|
91
|
+
if (rate <= 0) return false
|
|
92
|
+
return Math.random() < rate
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create a telemetry dispatcher. Pure function, no Solid context — exists
|
|
97
|
+
* separately from the Provider so it can be unit-tested in isolation.
|
|
98
|
+
*/
|
|
99
|
+
export function createTelemetryDispatcher(
|
|
100
|
+
sink: TelemetrySink,
|
|
101
|
+
options?: TelemetryOptions
|
|
102
|
+
): TelemetryDispatcher {
|
|
103
|
+
const buffer: TelemetryEvent[] = []
|
|
104
|
+
const bufferMs = options?.bufferMs ?? DEFAULT_BUFFER_MS
|
|
105
|
+
const bufferMax = options?.bufferMax ?? DEFAULT_BUFFER_MAX
|
|
106
|
+
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
|
107
|
+
|
|
108
|
+
function deliver(batch: TelemetryEvent[]): void {
|
|
109
|
+
try {
|
|
110
|
+
const result = sink(batch)
|
|
111
|
+
// Promise rejections are silenced too (fail-open, §M.6.1).
|
|
112
|
+
if (result && typeof (result as Promise<void>).then === 'function') {
|
|
113
|
+
;(result as Promise<void>).catch(() => {
|
|
114
|
+
/* silent */
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* silent */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function flush(): void {
|
|
123
|
+
if (flushTimer !== undefined) {
|
|
124
|
+
clearTimeout(flushTimer)
|
|
125
|
+
flushTimer = undefined
|
|
126
|
+
}
|
|
127
|
+
if (buffer.length === 0) return
|
|
128
|
+
const batch = buffer.splice(0, buffer.length)
|
|
129
|
+
deliver(batch)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function dispatch(event: TelemetryEvent): void {
|
|
133
|
+
if (!shouldSample(event.type, options)) return
|
|
134
|
+
buffer.push(event)
|
|
135
|
+
if (buffer.length >= bufferMax) {
|
|
136
|
+
flush()
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (bufferMs <= 0) {
|
|
140
|
+
flush()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (flushTimer === undefined) {
|
|
144
|
+
flushTimer = setTimeout(flush, bufferMs)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { dispatch, flush }
|
|
149
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect, vi } from 'vitest'
|
|
9
|
-
import { validateComponent, validateChartComponent, getIframeSandbox } from './validation'
|
|
9
|
+
import { validateComponent, validateChartComponent, getIframeSandbox, validateIframeDomain } from './validation'
|
|
10
10
|
import type { UIComponent, ComponentType } from '../types'
|
|
11
11
|
|
|
12
12
|
/** Helper to create a minimal valid UIComponent for testing */
|
|
@@ -22,13 +22,14 @@ function makeComponent(type: ComponentType, params: Record<string, any> = {}): U
|
|
|
22
22
|
/** Types that have explicit validation cases in validateComponent */
|
|
23
23
|
const VALIDATED_TYPES: ComponentType[] = [
|
|
24
24
|
'chart', 'table', 'metric', 'text', 'iframe', 'image', 'link', 'action',
|
|
25
|
+
'artifact',
|
|
25
26
|
]
|
|
26
27
|
|
|
27
28
|
/** Types that hit the default case (no specific validation) */
|
|
28
29
|
const PASSTHROUGH_TYPES: ComponentType[] = [
|
|
29
30
|
'code', 'map', 'form', 'modal', 'action-group',
|
|
30
31
|
'image-gallery', 'video', 'grid', 'carousel',
|
|
31
|
-
'
|
|
32
|
+
'footer',
|
|
32
33
|
]
|
|
33
34
|
|
|
34
35
|
describe('validateComponent', () => {
|
|
@@ -47,7 +48,7 @@ describe('validateComponent', () => {
|
|
|
47
48
|
describe('validated types still work', () => {
|
|
48
49
|
it('validates a valid chart component', () => {
|
|
49
50
|
const component = makeComponent('chart', {
|
|
50
|
-
|
|
51
|
+
type: 'bar',
|
|
51
52
|
data: { labels: ['A'], datasets: [{ data: [1] }] },
|
|
52
53
|
})
|
|
53
54
|
const result = validateComponent(component)
|
|
@@ -327,3 +328,52 @@ describe('getIframeSandbox — tiered sandbox', () => {
|
|
|
327
328
|
expect(sandbox).toContain('allow-same-origin')
|
|
328
329
|
})
|
|
329
330
|
})
|
|
331
|
+
|
|
332
|
+
describe('validateIframeDomain — security regression (v5.5.1)', () => {
|
|
333
|
+
// Pre-v5.5.1 bug: the predicate was
|
|
334
|
+
// `domain === allowed || domain.endsWith(`.${allowed}`) || allowed === 'localhost'`
|
|
335
|
+
// The third clause `allowed === 'localhost'` was checking the WHITELIST
|
|
336
|
+
// ENTRY (not the domain) — once 'localhost' appeared in DEFAULT_IFRAME_DOMAINS,
|
|
337
|
+
// every URL was accepted. These tests lock the fixed behavior in place.
|
|
338
|
+
|
|
339
|
+
it('REJECTS a non-whitelisted external domain (this used to silently pass)', () => {
|
|
340
|
+
const result = validateIframeDomain('https://evil.example.com/x')
|
|
341
|
+
expect(result.valid).toBe(false)
|
|
342
|
+
expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('REJECTS a typo-squat that is NOT a subdomain of any whitelisted entry', () => {
|
|
346
|
+
// youtube-evil.com is not youtube.com nor a subdomain of it
|
|
347
|
+
const result = validateIframeDomain('https://youtube-evil.com/embed/x')
|
|
348
|
+
expect(result.valid).toBe(false)
|
|
349
|
+
expect(result.errors?.[0]?.code).toBe('DOMAIN_NOT_WHITELISTED')
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('still accepts a whitelisted domain (quickchart.io)', () => {
|
|
353
|
+
expect(validateIframeDomain('https://quickchart.io/chart?c={}').valid).toBe(true)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('still accepts subdomains of whitelisted entries (player.vimeo.com)', () => {
|
|
357
|
+
expect(validateIframeDomain('https://player.vimeo.com/video/123').valid).toBe(true)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('still accepts localhost (dev convenience)', () => {
|
|
361
|
+
expect(validateIframeDomain('http://localhost:3000/x').valid).toBe(true)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('still accepts 127.0.0.1 (loopback equivalent of localhost)', () => {
|
|
365
|
+
expect(validateIframeDomain('http://127.0.0.1:8080/x').valid).toBe(true)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('respects allow-all policy bypass', () => {
|
|
369
|
+
expect(validateIframeDomain('https://anything.com', { policy: 'allow-all' }).valid).toBe(true)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('extend policy adds custom domains', () => {
|
|
373
|
+
const result = validateIframeDomain('https://my-internal-tool.corp.com/x', {
|
|
374
|
+
policy: 'extend',
|
|
375
|
+
customDomains: ['my-internal-tool.corp.com'],
|
|
376
|
+
})
|
|
377
|
+
expect(result.valid).toBe(true)
|
|
378
|
+
})
|
|
379
|
+
})
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
ImageGalleryParamsSchema,
|
|
23
23
|
ActionGroupParamsSchema,
|
|
24
24
|
CodeComponentParamsSchema,
|
|
25
|
+
// v5.6.0 — added after spec@5.0.2 relaxations (deposium audit §M)
|
|
26
|
+
MapComponentParamsSchema,
|
|
27
|
+
FormComponentParamsSchema,
|
|
25
28
|
} from '@seed-ship/mcp-ui-spec'
|
|
26
29
|
import type {
|
|
27
30
|
UIComponent,
|
|
@@ -47,7 +50,7 @@ const KNOWN_COMPONENT_TYPES: Set<string> = new Set<ComponentType>([
|
|
|
47
50
|
])
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
|
-
* Spec-driven validation dispatch table (B.1 — v5.5.0).
|
|
53
|
+
* Spec-driven validation dispatch table (B.1 — v5.5.0, expanded in v5.6.0).
|
|
51
54
|
*
|
|
52
55
|
* For each ComponentType where we delegate shape validation to a Zod schema
|
|
53
56
|
* from `@seed-ship/mcp-ui-spec`, this table maps:
|
|
@@ -56,13 +59,13 @@ const KNOWN_COMPONENT_TYPES: Set<string> = new Set<ComponentType>([
|
|
|
56
59
|
* pre-v5.5.0 `errors[].code` API contract — see MCP-UI-AUDIT-2026-04-26.md
|
|
57
60
|
* §I.3.a + §J.1)
|
|
58
61
|
*
|
|
62
|
+
* **v5.6.0** : `map` and `form` joined the dispatch after spec@5.0.2 relaxed
|
|
63
|
+
* their schemas (LatLngPoint union for map.center, regex relax for
|
|
64
|
+
* field.name) per deposium audit §L answers. Closed B.1 to **14/17 types**.
|
|
65
|
+
*
|
|
59
66
|
* Types deliberately omitted (kept on the imperative path):
|
|
60
67
|
* - `chart`, `table` — have rich imperative validators with their own
|
|
61
68
|
* codes (MISSING_DATA, DATA_LENGTH_MISMATCH, RESOURCE_LIMIT_EXCEEDED, …)
|
|
62
|
-
* - `form` — spec FormFieldSchema has strict regex on field
|
|
63
|
-
* names that could reject LLM-generated payloads. Conservative.
|
|
64
|
-
* - `map` — spec center is `tuple([number, number])`; production
|
|
65
|
-
* payloads use `{lat, lng}` objects. Avoid backward-compat regression.
|
|
66
69
|
* - `modal` — all params are optional; nothing to enforce.
|
|
67
70
|
* - `grid`, `footer`, `composite` — pass-through, validated elsewhere.
|
|
68
71
|
*/
|
|
@@ -79,6 +82,9 @@ const SPEC_VALIDATORS: Partial<Record<ComponentType, { schema: ZodSchema; legacy
|
|
|
79
82
|
'action-group': { schema: ActionGroupParamsSchema, legacyCode: 'EMPTY_ACTION_GROUP' },
|
|
80
83
|
code: { schema: CodeComponentParamsSchema, legacyCode: 'INVALID_CODE' },
|
|
81
84
|
artifact: { schema: ArtifactComponentParamsSchema, legacyCode: 'INVALID_ARTIFACT' },
|
|
85
|
+
// v5.6.0 additions
|
|
86
|
+
form: { schema: FormComponentParamsSchema, legacyCode: 'EMPTY_FORM' },
|
|
87
|
+
map: { schema: MapComponentParamsSchema, legacyCode: 'INVALID_MAP' },
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/**
|
|
@@ -568,9 +574,17 @@ export function validateIframeDomain(
|
|
|
568
574
|
effectiveWhitelist = [...DEFAULT_IFRAME_DOMAINS, ...options.customDomains]
|
|
569
575
|
}
|
|
570
576
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
)
|
|
577
|
+
// SECURITY (v5.5.1) — pre-fix bug: predicate was `allowed === 'localhost'`
|
|
578
|
+
// which trivially returned true for every URL once the whitelist contained
|
|
579
|
+
// 'localhost' (an entry from DEFAULT_IFRAME_DOMAINS), making the entire
|
|
580
|
+
// domain whitelist inoperative. Fixed: only the URL's actual hostname
|
|
581
|
+
// being 'localhost' (or a 127.0.0.x loopback) bypasses the whitelist.
|
|
582
|
+
const isLoopback = domain === 'localhost' || /^127(\.\d{1,3}){3}$/.test(domain)
|
|
583
|
+
const isAllowed =
|
|
584
|
+
isLoopback ||
|
|
585
|
+
effectiveWhitelist.some(
|
|
586
|
+
(allowed) => allowed !== 'localhost' && (domain === allowed || domain.endsWith(`.${allowed}`))
|
|
587
|
+
)
|
|
574
588
|
|
|
575
589
|
if (!isAllowed) {
|
|
576
590
|
return {
|
|
@@ -669,35 +683,49 @@ export function validateComponent(
|
|
|
669
683
|
errors.push(...(sizeResult.errors || []))
|
|
670
684
|
}
|
|
671
685
|
|
|
672
|
-
// Type-specific validation (B.1 — v5.5.0).
|
|
686
|
+
// Type-specific validation (B.1 — v5.5.0, expanded v5.6.0).
|
|
673
687
|
//
|
|
674
|
-
//
|
|
675
|
-
// SPEC_VALIDATORS. The
|
|
676
|
-
// need cross-field consistency, resource limits, or
|
|
677
|
-
//
|
|
688
|
+
// 14 types delegate shape validation to Zod schemas in `mcp-ui-spec` via
|
|
689
|
+
// SPEC_VALIDATORS. The 3 remaining types stay imperative because they
|
|
690
|
+
// need cross-field consistency, resource limits, or have nothing to validate
|
|
691
|
+
// (see SPEC_VALIDATORS docstring).
|
|
678
692
|
const specValidator = SPEC_VALIDATORS[component.type]
|
|
679
693
|
if (specValidator) {
|
|
680
694
|
const result = specValidator.schema.safeParse(component.params)
|
|
681
695
|
if (!result.success) {
|
|
682
696
|
errors.push(...mapZodIssuesToErrors(result.error.issues, specValidator.legacyCode))
|
|
683
697
|
}
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
698
|
+
// Post-spec chained checks. Skipped when the shape parse failed to avoid
|
|
699
|
+
// cascading errors on already-broken payloads.
|
|
700
|
+
if (result.success) {
|
|
701
|
+
// Iframe + video: domain whitelist
|
|
702
|
+
if (component.type === 'iframe' || component.type === 'video') {
|
|
703
|
+
const url = (component.params as { url?: string })?.url
|
|
704
|
+
if (typeof url === 'string') {
|
|
705
|
+
const domainResult = validateIframeDomain(url, {
|
|
706
|
+
policy: options?.iframePolicy,
|
|
707
|
+
customDomains: options?.customIframeDomains,
|
|
708
|
+
})
|
|
709
|
+
if (!domainResult.valid) {
|
|
710
|
+
errors.push(...(domainResult.errors || []))
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// Map (v5.6.0): center OR markers required. Spec has both .optional()
|
|
715
|
+
// since auto-center from markers is supported, but we need ONE of them.
|
|
716
|
+
if (component.type === 'map') {
|
|
717
|
+
const mapParams = component.params as { center?: unknown; markers?: unknown[] }
|
|
718
|
+
if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
|
|
719
|
+
errors.push({
|
|
720
|
+
path: 'params',
|
|
721
|
+
message: 'Map must have center or markers',
|
|
722
|
+
code: 'INVALID_MAP',
|
|
723
|
+
})
|
|
696
724
|
}
|
|
697
725
|
}
|
|
698
726
|
}
|
|
699
727
|
} else {
|
|
700
|
-
// Imperative path for chart/table/
|
|
728
|
+
// Imperative path for chart/table/modal/grid/footer/composite.
|
|
701
729
|
switch (component.type) {
|
|
702
730
|
case 'chart': {
|
|
703
731
|
const chartResult = validateChartComponent(component.params as ChartComponentParams, limits)
|
|
@@ -715,24 +743,6 @@ export function validateComponent(
|
|
|
715
743
|
break
|
|
716
744
|
}
|
|
717
745
|
|
|
718
|
-
case 'form': {
|
|
719
|
-
const formParams = component.params as { fields?: unknown[] }
|
|
720
|
-
if (!Array.isArray(formParams.fields) || formParams.fields.length === 0) {
|
|
721
|
-
errors.push({ path: 'params.fields', message: 'Form must have non-empty fields array', code: 'EMPTY_FORM' })
|
|
722
|
-
}
|
|
723
|
-
break
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
case 'map': {
|
|
727
|
-
// Map can auto-detect center from markers, so center is not strictly required.
|
|
728
|
-
// Spec MapComponentParamsSchema would be too strict (tuple-only center) — kept imperative.
|
|
729
|
-
const mapParams = component.params as { center?: unknown; markers?: unknown[] }
|
|
730
|
-
if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
|
|
731
|
-
errors.push({ path: 'params', message: 'Map must have center or markers', code: 'INVALID_MAP' })
|
|
732
|
-
}
|
|
733
|
-
break
|
|
734
|
-
}
|
|
735
|
-
|
|
736
746
|
case 'modal':
|
|
737
747
|
// Modal is valid with minimal params (title optional, content can be children).
|
|
738
748
|
break
|