@seed-ship/mcp-ui-solid 1.0.29 → 1.0.31
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/dist/components/GenerativeUIErrorBoundary.cjs.map +1 -0
- package/dist/components/GenerativeUIErrorBoundary.js.map +1 -0
- package/dist/components/StreamingUIRenderer.cjs.map +1 -0
- package/dist/components/StreamingUIRenderer.js.map +1 -0
- package/dist/{mcp-ui-solid/src/components → components}/UIResourceRenderer.cjs +102 -97
- package/dist/components/UIResourceRenderer.cjs.map +1 -0
- package/dist/components/UIResourceRenderer.d.ts +0 -11
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/{mcp-ui-solid/src/components → components}/UIResourceRenderer.js +102 -97
- package/dist/components/UIResourceRenderer.js.map +1 -0
- package/dist/components.cjs +3 -3
- package/dist/components.d.ts +12 -0
- package/dist/components.js +3 -3
- package/dist/hooks/useStreamingUI.cjs.map +1 -0
- package/dist/hooks/useStreamingUI.js.map +1 -0
- package/dist/hooks.cjs +1 -1
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.js +1 -1
- package/dist/index.cjs +6 -6
- package/dist/index.js +6 -6
- package/dist/node_modules/.pnpm/dompurify@3.3.0/node_modules/dompurify/dist/purify.es.cjs +1006 -0
- package/dist/node_modules/.pnpm/dompurify@3.3.0/node_modules/dompurify/dist/purify.es.cjs.map +1 -0
- package/dist/node_modules/.pnpm/dompurify@3.3.0/node_modules/dompurify/dist/purify.es.js +1007 -0
- package/dist/node_modules/.pnpm/dompurify@3.3.0/node_modules/dompurify/dist/purify.es.js.map +1 -0
- package/dist/services/component-registry.cjs.map +1 -0
- package/dist/services/component-registry.js.map +1 -0
- package/dist/services/validation.cjs.map +1 -0
- package/dist/services/validation.js.map +1 -0
- package/dist/types.d.ts +265 -0
- package/dist/utils/logger.cjs.map +1 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/validation.cjs +1 -1
- package/dist/validation.js +1 -1
- package/package.json +20 -23
- package/src/components/ActionRenderer.tsx +33 -0
- package/src/components/ArtifactRenderer.tsx +54 -0
- package/src/components/CarouselRenderer.tsx +77 -0
- package/src/components/FooterRenderer.tsx +66 -0
- package/src/components/GenerativeUIErrorBoundary.tsx +259 -0
- package/src/components/StreamingUIRenderer.tsx +327 -0
- package/src/components/UIResourceRenderer.tsx +573 -0
- package/src/components/index.ts +14 -0
- package/src/hooks/index.ts +14 -0
- package/src/hooks/useStreamingUI.ts +447 -0
- package/src/index.test.ts +36 -0
- package/src/index.ts +70 -0
- package/src/services/component-registry.ts +378 -0
- package/src/services/index.ts +9 -0
- package/src/services/validation.ts +472 -0
- package/src/types/index.ts +320 -0
- package/src/types-export.ts +31 -0
- package/src/utils/logger.ts +74 -0
- package/src/validation.ts +38 -0
- package/src/vite-env.d.ts +11 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +52 -0
- package/vite.config.ts.timestamp-1763266929437-a71eed80b91318.mjs +45 -0
- package/vitest.config.ts +10 -0
- package/dist/mcp-ui-solid/src/components/GenerativeUIErrorBoundary.cjs.map +0 -1
- package/dist/mcp-ui-solid/src/components/GenerativeUIErrorBoundary.js.map +0 -1
- package/dist/mcp-ui-solid/src/components/StreamingUIRenderer.cjs.map +0 -1
- package/dist/mcp-ui-solid/src/components/StreamingUIRenderer.js.map +0 -1
- package/dist/mcp-ui-solid/src/components/UIResourceRenderer.cjs.map +0 -1
- package/dist/mcp-ui-solid/src/components/UIResourceRenderer.js.map +0 -1
- package/dist/mcp-ui-solid/src/hooks/useStreamingUI.cjs.map +0 -1
- package/dist/mcp-ui-solid/src/hooks/useStreamingUI.js.map +0 -1
- package/dist/mcp-ui-solid/src/services/component-registry.cjs.map +0 -1
- package/dist/mcp-ui-solid/src/services/component-registry.js.map +0 -1
- package/dist/mcp-ui-solid/src/services/validation.cjs.map +0 -1
- package/dist/mcp-ui-solid/src/services/validation.js.map +0 -1
- package/dist/mcp-ui-solid/src/utils/logger.cjs.map +0 -1
- package/dist/mcp-ui-solid/src/utils/logger.js.map +0 -1
- /package/dist/{mcp-ui-solid/src/components → components}/GenerativeUIErrorBoundary.cjs +0 -0
- /package/dist/{mcp-ui-solid/src/components → components}/GenerativeUIErrorBoundary.js +0 -0
- /package/dist/{mcp-ui-solid/src/components → components}/StreamingUIRenderer.cjs +0 -0
- /package/dist/{mcp-ui-solid/src/components → components}/StreamingUIRenderer.js +0 -0
- /package/dist/{mcp-ui-solid/src/hooks → hooks}/useStreamingUI.cjs +0 -0
- /package/dist/{mcp-ui-solid/src/hooks → hooks}/useStreamingUI.js +0 -0
- /package/dist/{mcp-ui-solid/src/services → services}/component-registry.cjs +0 -0
- /package/dist/{mcp-ui-solid/src/services → services}/component-registry.js +0 -0
- /package/dist/{mcp-ui-solid/src/services → services}/validation.cjs +0 -0
- /package/dist/{mcp-ui-solid/src/services → services}/validation.js +0 -0
- /package/dist/{mcp-ui-solid/src/utils → utils}/logger.cjs +0 -0
- /package/dist/{mcp-ui-solid/src/utils → utils}/logger.js +0 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component Validation Service
|
|
3
|
+
* Phase 0: Resource Limits & Schema Validation
|
|
4
|
+
*
|
|
5
|
+
* Validates LLM-generated components against:
|
|
6
|
+
* - JSON schema
|
|
7
|
+
* - Resource limits (data points, payload size, grid bounds)
|
|
8
|
+
* - Security constraints (domain whitelist, XSS prevention)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
UIComponent,
|
|
13
|
+
UILayout,
|
|
14
|
+
ValidationResult,
|
|
15
|
+
ResourceLimits,
|
|
16
|
+
ChartComponentParams,
|
|
17
|
+
TableComponentParams,
|
|
18
|
+
} from '../types'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default resource limits (configurable via env)
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_RESOURCE_LIMITS: ResourceLimits = {
|
|
24
|
+
maxDataPoints: 1000,
|
|
25
|
+
maxTableRows: 100,
|
|
26
|
+
maxPayloadSize: 50 * 1024, // 50KB
|
|
27
|
+
renderTimeout: 5000, // 5 seconds
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Allowed iframe domains (whitelist)
|
|
32
|
+
* Must match CSP frame-src directive
|
|
33
|
+
*/
|
|
34
|
+
const ALLOWED_IFRAME_DOMAINS = [
|
|
35
|
+
'quickchart.io',
|
|
36
|
+
'www.quickchart.io',
|
|
37
|
+
'deposium.com',
|
|
38
|
+
'deposium.vip',
|
|
39
|
+
'localhost',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate grid position bounds (1-12 columns)
|
|
44
|
+
*/
|
|
45
|
+
export function validateGridPosition(position: UIComponent['position']): ValidationResult {
|
|
46
|
+
const errors: ValidationResult['errors'] = []
|
|
47
|
+
|
|
48
|
+
// ✅ PHASE 3 FIX: Defensive check for undefined position
|
|
49
|
+
if (!position) {
|
|
50
|
+
return {
|
|
51
|
+
valid: false,
|
|
52
|
+
errors: [
|
|
53
|
+
{
|
|
54
|
+
path: 'position',
|
|
55
|
+
message: 'Position is required',
|
|
56
|
+
code: 'MISSING_POSITION',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (position.colStart < 1 || position.colStart > 12) {
|
|
63
|
+
errors.push({
|
|
64
|
+
path: 'position.colStart',
|
|
65
|
+
message: 'Column start must be between 1 and 12',
|
|
66
|
+
code: 'INVALID_GRID_COL_START',
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (position.colSpan < 1 || position.colSpan > 12) {
|
|
71
|
+
errors.push({
|
|
72
|
+
path: 'position.colSpan',
|
|
73
|
+
message: 'Column span must be between 1 and 12',
|
|
74
|
+
code: 'INVALID_GRID_COL_SPAN',
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (position.colStart + position.colSpan - 1 > 12) {
|
|
79
|
+
errors.push({
|
|
80
|
+
path: 'position',
|
|
81
|
+
message: 'Column start + span exceeds grid width (12)',
|
|
82
|
+
code: 'GRID_OVERFLOW',
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (position.rowStart !== undefined && position.rowStart < 1) {
|
|
87
|
+
errors.push({
|
|
88
|
+
path: 'position.rowStart',
|
|
89
|
+
message: 'Row start must be >= 1',
|
|
90
|
+
code: 'INVALID_GRID_ROW_START',
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (position.rowSpan !== undefined && position.rowSpan < 1) {
|
|
95
|
+
errors.push({
|
|
96
|
+
path: 'position.rowSpan',
|
|
97
|
+
message: 'Row span must be >= 1',
|
|
98
|
+
code: 'INVALID_GRID_ROW_SPAN',
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
valid: errors.length === 0,
|
|
104
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate chart component against resource limits
|
|
110
|
+
*/
|
|
111
|
+
export function validateChartComponent(
|
|
112
|
+
params: ChartComponentParams,
|
|
113
|
+
limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
|
|
114
|
+
): ValidationResult {
|
|
115
|
+
const errors: ValidationResult['errors'] = []
|
|
116
|
+
|
|
117
|
+
// Validate data points count
|
|
118
|
+
const totalDataPoints = params.data.datasets.reduce(
|
|
119
|
+
(sum, dataset) => sum + dataset.data.length,
|
|
120
|
+
0
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if (totalDataPoints > limits.maxDataPoints) {
|
|
124
|
+
errors.push({
|
|
125
|
+
path: 'params.data',
|
|
126
|
+
message: `Chart exceeds max data points: ${totalDataPoints} > ${limits.maxDataPoints}`,
|
|
127
|
+
code: 'RESOURCE_LIMIT_EXCEEDED',
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Validate labels match dataset length
|
|
132
|
+
const expectedLength = params.data.labels.length
|
|
133
|
+
for (const [index, dataset] of params.data.datasets.entries()) {
|
|
134
|
+
if (dataset.data.length !== expectedLength) {
|
|
135
|
+
errors.push({
|
|
136
|
+
path: `params.data.datasets[${index}]`,
|
|
137
|
+
message: `Dataset length mismatch: expected ${expectedLength}, got ${dataset.data.length}`,
|
|
138
|
+
code: 'DATA_LENGTH_MISMATCH',
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Validate numeric data
|
|
144
|
+
for (const [index, dataset] of params.data.datasets.entries()) {
|
|
145
|
+
for (const [dataIndex, value] of dataset.data.entries()) {
|
|
146
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
147
|
+
errors.push({
|
|
148
|
+
path: `params.data.datasets[${index}].data[${dataIndex}]`,
|
|
149
|
+
message: `Invalid data value: ${value} (must be finite number)`,
|
|
150
|
+
code: 'INVALID_DATA_TYPE',
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
valid: errors.length === 0,
|
|
158
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Validate table component against resource limits
|
|
164
|
+
*/
|
|
165
|
+
export function validateTableComponent(
|
|
166
|
+
params: TableComponentParams,
|
|
167
|
+
limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
|
|
168
|
+
): ValidationResult {
|
|
169
|
+
const errors: ValidationResult['errors'] = []
|
|
170
|
+
|
|
171
|
+
// Validate row count
|
|
172
|
+
if (params.rows.length > limits.maxTableRows) {
|
|
173
|
+
errors.push({
|
|
174
|
+
path: 'params.rows',
|
|
175
|
+
message: `Table exceeds max rows: ${params.rows.length} > ${limits.maxTableRows}`,
|
|
176
|
+
code: 'RESOURCE_LIMIT_EXCEEDED',
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Validate columns
|
|
181
|
+
if (params.columns.length === 0) {
|
|
182
|
+
errors.push({
|
|
183
|
+
path: 'params.columns',
|
|
184
|
+
message: 'Table must have at least one column',
|
|
185
|
+
code: 'EMPTY_COLUMNS',
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Validate column keys are unique
|
|
190
|
+
const columnKeys = new Set<string>()
|
|
191
|
+
for (const [index, column] of params.columns.entries()) {
|
|
192
|
+
if (columnKeys.has(column.key)) {
|
|
193
|
+
errors.push({
|
|
194
|
+
path: `params.columns[${index}]`,
|
|
195
|
+
message: `Duplicate column key: ${column.key}`,
|
|
196
|
+
code: 'DUPLICATE_COLUMN_KEY',
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
columnKeys.add(column.key)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Validate rows have valid data for defined columns
|
|
203
|
+
for (const [rowIndex, row] of params.rows.entries()) {
|
|
204
|
+
for (const column of params.columns) {
|
|
205
|
+
if (!(column.key in row)) {
|
|
206
|
+
errors.push({
|
|
207
|
+
path: `params.rows[${rowIndex}]`,
|
|
208
|
+
message: `Missing column key: ${column.key}`,
|
|
209
|
+
code: 'MISSING_COLUMN_DATA',
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
valid: errors.length === 0,
|
|
217
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Validate payload size
|
|
223
|
+
*/
|
|
224
|
+
export function validatePayloadSize(
|
|
225
|
+
component: UIComponent,
|
|
226
|
+
limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
|
|
227
|
+
): ValidationResult {
|
|
228
|
+
const payloadSize = JSON.stringify(component).length
|
|
229
|
+
|
|
230
|
+
if (payloadSize > limits.maxPayloadSize) {
|
|
231
|
+
return {
|
|
232
|
+
valid: false,
|
|
233
|
+
errors: [
|
|
234
|
+
{
|
|
235
|
+
path: 'component',
|
|
236
|
+
message: `Payload size exceeds limit: ${payloadSize} > ${limits.maxPayloadSize} bytes`,
|
|
237
|
+
code: 'PAYLOAD_TOO_LARGE',
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { valid: true }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Sanitize string to prevent XSS
|
|
248
|
+
* Basic implementation - DOMPurify used at render time
|
|
249
|
+
*/
|
|
250
|
+
export function sanitizeString(input: string): string {
|
|
251
|
+
return input
|
|
252
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
253
|
+
.replace(/on\w+="[^"]*"/gi, '')
|
|
254
|
+
.replace(/javascript:/gi, '')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Validate iframe domain against whitelist
|
|
259
|
+
*/
|
|
260
|
+
export function validateIframeDomain(url: string): ValidationResult {
|
|
261
|
+
try {
|
|
262
|
+
const parsedUrl = new URL(url)
|
|
263
|
+
const domain = parsedUrl.hostname
|
|
264
|
+
|
|
265
|
+
const isAllowed = ALLOWED_IFRAME_DOMAINS.some(
|
|
266
|
+
(allowed) => domain === allowed || domain.endsWith(`.${allowed}`) || allowed === 'localhost'
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if (!isAllowed) {
|
|
270
|
+
return {
|
|
271
|
+
valid: false,
|
|
272
|
+
errors: [
|
|
273
|
+
{
|
|
274
|
+
path: 'url',
|
|
275
|
+
message: `Domain not whitelisted: ${domain}`,
|
|
276
|
+
code: 'DOMAIN_NOT_WHITELISTED',
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { valid: true }
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return {
|
|
285
|
+
valid: false,
|
|
286
|
+
errors: [
|
|
287
|
+
{
|
|
288
|
+
path: 'url',
|
|
289
|
+
message: 'Invalid URL format',
|
|
290
|
+
code: 'INVALID_URL',
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Validate entire component
|
|
299
|
+
*/
|
|
300
|
+
export function validateComponent(
|
|
301
|
+
component: UIComponent,
|
|
302
|
+
limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
|
|
303
|
+
): ValidationResult {
|
|
304
|
+
const errors: ValidationResult['errors'] = []
|
|
305
|
+
|
|
306
|
+
// Validate grid position
|
|
307
|
+
const gridResult = validateGridPosition(component.position)
|
|
308
|
+
if (!gridResult.valid) {
|
|
309
|
+
errors.push(...(gridResult.errors || []))
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Validate payload size
|
|
313
|
+
const sizeResult = validatePayloadSize(component, limits)
|
|
314
|
+
if (!sizeResult.valid) {
|
|
315
|
+
errors.push(...(sizeResult.errors || []))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Type-specific validation
|
|
319
|
+
switch (component.type) {
|
|
320
|
+
case 'chart':
|
|
321
|
+
const chartResult = validateChartComponent(component.params as ChartComponentParams, limits)
|
|
322
|
+
if (!chartResult.valid) {
|
|
323
|
+
errors.push(...(chartResult.errors || []))
|
|
324
|
+
}
|
|
325
|
+
break
|
|
326
|
+
|
|
327
|
+
case 'table':
|
|
328
|
+
const tableResult = validateTableComponent(component.params as TableComponentParams, limits)
|
|
329
|
+
if (!tableResult.valid) {
|
|
330
|
+
errors.push(...(tableResult.errors || []))
|
|
331
|
+
}
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
case 'metric':
|
|
335
|
+
// Basic validation for metrics
|
|
336
|
+
const metricParams = component.params as any
|
|
337
|
+
if (!metricParams.title || !metricParams.value) {
|
|
338
|
+
errors.push({
|
|
339
|
+
path: 'params',
|
|
340
|
+
message: 'Metric must have title and value',
|
|
341
|
+
code: 'INVALID_METRIC',
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
break
|
|
345
|
+
|
|
346
|
+
case 'text':
|
|
347
|
+
// Basic validation for text
|
|
348
|
+
const textParams = component.params as any
|
|
349
|
+
if (!textParams.content) {
|
|
350
|
+
errors.push({
|
|
351
|
+
path: 'params',
|
|
352
|
+
message: 'Text component must have content',
|
|
353
|
+
code: 'INVALID_TEXT',
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
break
|
|
357
|
+
|
|
358
|
+
case 'iframe':
|
|
359
|
+
// Basic validation for iframe
|
|
360
|
+
const iframeParams = component.params as any
|
|
361
|
+
if (!iframeParams.url) {
|
|
362
|
+
errors.push({
|
|
363
|
+
path: 'params',
|
|
364
|
+
message: 'Iframe component must have url',
|
|
365
|
+
code: 'INVALID_IFRAME',
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
break
|
|
369
|
+
|
|
370
|
+
case 'image':
|
|
371
|
+
// Basic validation for image
|
|
372
|
+
const imageParams = component.params as any
|
|
373
|
+
if (!imageParams.url) {
|
|
374
|
+
errors.push({
|
|
375
|
+
path: 'params',
|
|
376
|
+
message: 'Image component must have url',
|
|
377
|
+
code: 'INVALID_IMAGE',
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
break
|
|
381
|
+
|
|
382
|
+
case 'link':
|
|
383
|
+
// Basic validation for link
|
|
384
|
+
const linkParams = component.params as any
|
|
385
|
+
if (!linkParams.url) {
|
|
386
|
+
errors.push({
|
|
387
|
+
path: 'params',
|
|
388
|
+
message: 'Link component must have url',
|
|
389
|
+
code: 'INVALID_LINK',
|
|
390
|
+
})
|
|
391
|
+
}
|
|
392
|
+
break
|
|
393
|
+
|
|
394
|
+
case 'action':
|
|
395
|
+
// Basic validation for action
|
|
396
|
+
const actionParams = component.params as any
|
|
397
|
+
if (!actionParams.label) {
|
|
398
|
+
errors.push({
|
|
399
|
+
path: 'params',
|
|
400
|
+
message: 'Action component must have label',
|
|
401
|
+
code: 'INVALID_ACTION',
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
break
|
|
405
|
+
|
|
406
|
+
default:
|
|
407
|
+
errors.push({
|
|
408
|
+
path: 'type',
|
|
409
|
+
message: `Unknown component type: ${component.type}`,
|
|
410
|
+
code: 'UNKNOWN_COMPONENT_TYPE',
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
valid: errors.length === 0,
|
|
416
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Validate entire layout
|
|
422
|
+
*/
|
|
423
|
+
export function validateLayout(
|
|
424
|
+
layout: UILayout,
|
|
425
|
+
limits: ResourceLimits = DEFAULT_RESOURCE_LIMITS
|
|
426
|
+
): ValidationResult {
|
|
427
|
+
const errors: ValidationResult['errors'] = []
|
|
428
|
+
|
|
429
|
+
// Validate component count
|
|
430
|
+
if (layout.components.length === 0) {
|
|
431
|
+
errors.push({
|
|
432
|
+
path: 'components',
|
|
433
|
+
message: 'Layout must have at least one component',
|
|
434
|
+
code: 'EMPTY_LAYOUT',
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (layout.components.length > 12) {
|
|
439
|
+
errors.push({
|
|
440
|
+
path: 'components',
|
|
441
|
+
message: `Layout exceeds max components: ${layout.components.length} > 12`,
|
|
442
|
+
code: 'TOO_MANY_COMPONENTS',
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Validate each component
|
|
447
|
+
for (const [index, component] of layout.components.entries()) {
|
|
448
|
+
const result = validateComponent(component, limits)
|
|
449
|
+
if (!result.valid) {
|
|
450
|
+
errors.push(
|
|
451
|
+
...(result.errors?.map((error) => ({
|
|
452
|
+
...error,
|
|
453
|
+
path: `components[${index}].${error.path}`,
|
|
454
|
+
})) || [])
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Validate grid configuration
|
|
460
|
+
if (layout.grid.columns !== 12) {
|
|
461
|
+
errors.push({
|
|
462
|
+
path: 'grid.columns',
|
|
463
|
+
message: 'Grid must have 12 columns (Bootstrap-like)',
|
|
464
|
+
code: 'INVALID_GRID_COLUMNS',
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
valid: errors.length === 0,
|
|
470
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
471
|
+
}
|
|
472
|
+
}
|