@seed-ship/mcp-ui-solid 2.2.10 → 2.3.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.
Files changed (41) hide show
  1. package/dist/components/ArtifactRenderer.cjs +4 -3
  2. package/dist/components/ArtifactRenderer.cjs.map +1 -1
  3. package/dist/components/ArtifactRenderer.js +4 -3
  4. package/dist/components/ArtifactRenderer.js.map +1 -1
  5. package/dist/components/CodeBlockRenderer.cjs +6 -1
  6. package/dist/components/CodeBlockRenderer.cjs.map +1 -1
  7. package/dist/components/CodeBlockRenderer.d.ts.map +1 -1
  8. package/dist/components/CodeBlockRenderer.js +6 -1
  9. package/dist/components/CodeBlockRenderer.js.map +1 -1
  10. package/dist/components/UIResourceRenderer.cjs +29 -27
  11. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  12. package/dist/components/UIResourceRenderer.js +30 -28
  13. package/dist/components/UIResourceRenderer.js.map +1 -1
  14. package/dist/index.cjs +2 -0
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -1
  20. package/dist/services/index.d.ts +1 -1
  21. package/dist/services/index.d.ts.map +1 -1
  22. package/dist/services/validation.cjs +176 -18
  23. package/dist/services/validation.cjs.map +1 -1
  24. package/dist/services/validation.d.ts +21 -0
  25. package/dist/services/validation.d.ts.map +1 -1
  26. package/dist/services/validation.js +176 -18
  27. package/dist/services/validation.js.map +1 -1
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/dist/types.d.cts +1 -1
  31. package/dist/types.d.ts +1 -1
  32. package/package.json +1 -1
  33. package/src/components/ArtifactRenderer.tsx +3 -3
  34. package/src/components/CodeBlockRenderer.tsx +7 -1
  35. package/src/components/UIResourceRenderer.tsx +2 -2
  36. package/src/index.ts +2 -0
  37. package/src/services/index.ts +2 -0
  38. package/src/services/validation.test.ts +158 -1
  39. package/src/services/validation.ts +221 -19
  40. package/src/types/index.ts +1 -1
  41. package/tsconfig.tsbuildinfo +1 -1
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, it, expect, vi } from 'vitest'
9
- import { validateComponent, validateChartComponent } from './validation'
9
+ import { validateComponent, validateChartComponent, getIframeSandbox } from './validation'
10
10
  import type { UIComponent, ComponentType } from '../types'
11
11
 
12
12
  /** Helper to create a minimal valid UIComponent for testing */
@@ -142,6 +142,108 @@ describe('validateComponent', () => {
142
142
  })
143
143
  })
144
144
 
145
+ describe('component-specific validation', () => {
146
+ it('rejects video without url', () => {
147
+ const result = validateComponent(makeComponent('video'))
148
+ expect(result.errors?.some((e) => e.code === 'INVALID_VIDEO')).toBe(true)
149
+ })
150
+
151
+ it('rejects carousel with empty items', () => {
152
+ const result = validateComponent(makeComponent('carousel', { items: [] }))
153
+ expect(result.errors?.some((e) => e.code === 'EMPTY_CAROUSEL')).toBe(true)
154
+ })
155
+
156
+ it('rejects image-gallery with empty images', () => {
157
+ const result = validateComponent(makeComponent('image-gallery', { images: [] }))
158
+ expect(result.errors?.some((e) => e.code === 'EMPTY_GALLERY')).toBe(true)
159
+ })
160
+
161
+ it('rejects form with empty fields', () => {
162
+ const result = validateComponent(makeComponent('form', { fields: [] }))
163
+ expect(result.errors?.some((e) => e.code === 'EMPTY_FORM')).toBe(true)
164
+ })
165
+
166
+ it('rejects action-group with empty actions', () => {
167
+ const result = validateComponent(makeComponent('action-group', { actions: [] }))
168
+ expect(result.errors?.some((e) => e.code === 'EMPTY_ACTION_GROUP')).toBe(true)
169
+ })
170
+
171
+ it('rejects code without code content', () => {
172
+ const result = validateComponent(makeComponent('code'))
173
+ expect(result.errors?.some((e) => e.code === 'INVALID_CODE')).toBe(true)
174
+ })
175
+
176
+ it('rejects map without center or markers', () => {
177
+ const result = validateComponent(makeComponent('map'))
178
+ expect(result.errors?.some((e) => e.code === 'INVALID_MAP')).toBe(true)
179
+ })
180
+
181
+ it('accepts map with markers but no center', () => {
182
+ const result = validateComponent(makeComponent('map', { markers: [{ position: [48, 2] }] }))
183
+ const mapError = result.errors?.find((e) => e.code === 'INVALID_MAP')
184
+ expect(mapError).toBeUndefined()
185
+ })
186
+
187
+ it('accepts modal with no params beyond type', () => {
188
+ const result = validateComponent(makeComponent('modal', { title: 'Test' }))
189
+ expect(result.valid).toBe(true)
190
+ })
191
+ })
192
+
193
+ describe('validateChartComponent — scatter/bubble/time-series', () => {
194
+ it('validates scatter chart without labels', () => {
195
+ const result = validateChartComponent({
196
+ type: 'scatter',
197
+ data: { datasets: [{ label: 'Test', data: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }] },
198
+ } as any)
199
+ expect(result.valid).toBe(true)
200
+ })
201
+
202
+ it('validates bubble chart without labels', () => {
203
+ const result = validateChartComponent({
204
+ type: 'bubble',
205
+ data: { datasets: [{ label: 'Test', data: [{ x: 1, y: 2, r: 5 }] }] },
206
+ } as any)
207
+ expect(result.valid).toBe(true)
208
+ })
209
+
210
+ it('validates line chart with time-series object data', () => {
211
+ const result = validateChartComponent({
212
+ type: 'line',
213
+ data: { datasets: [{ label: 'Prix', data: [{ x: '2024-01-01', y: 42 }, { x: '2024-02-01', y: 45 }] }] },
214
+ } as any)
215
+ expect(result.valid).toBe(true)
216
+ })
217
+
218
+ it('rejects scatter with number data instead of {x,y}', () => {
219
+ const result = validateChartComponent({
220
+ type: 'scatter',
221
+ data: { datasets: [{ label: 'Test', data: [1, 2, 3] }] },
222
+ } as any)
223
+ expect(result.valid).toBe(false)
224
+ expect(result.errors?.some((e) => e.code === 'INVALID_POINT_DATA')).toBe(true)
225
+ })
226
+
227
+ it('rejects bar chart without labels', () => {
228
+ const result = validateChartComponent({
229
+ type: 'bar',
230
+ data: { datasets: [{ label: 'Test', data: [1, 2, 3] }] },
231
+ } as any)
232
+ expect(result.valid).toBe(false)
233
+ expect(result.errors?.some((e) => e.code === 'MISSING_LABELS')).toBe(true)
234
+ })
235
+
236
+ it('accepts empty dataset without length mismatch', () => {
237
+ const result = validateChartComponent({
238
+ type: 'bar',
239
+ data: { labels: ['A', 'B'], datasets: [{ label: 'Test', data: [] }] },
240
+ } as any)
241
+ // Empty dataset should not trigger DATA_LENGTH_MISMATCH
242
+ const mismatch = result.errors?.find((e) => e.code === 'DATA_LENGTH_MISMATCH')
243
+ expect(mismatch).toBeUndefined()
244
+ })
245
+ })
246
+
145
247
  describe('validateChartComponent — H1 null guards', () => {
146
248
 
147
249
  it('rejects chart with undefined data', () => {
@@ -170,3 +272,58 @@ describe('validateChartComponent — H1 null guards', () => {
170
272
  expect(result.valid).toBe(true)
171
273
  })
172
274
  })
275
+
276
+ describe('getIframeSandbox — tiered sandbox', () => {
277
+ it('gives full sandbox to trusted domains (Google)', () => {
278
+ const sandbox = getIframeSandbox('https://docs.google.com/spreadsheets/d/123')
279
+ expect(sandbox).toContain('allow-same-origin')
280
+ expect(sandbox).toContain('allow-scripts')
281
+ expect(sandbox).toContain('allow-forms')
282
+ })
283
+
284
+ it('gives full sandbox to Deposium domains', () => {
285
+ const sandbox = getIframeSandbox('https://deposium.com/embed/123')
286
+ expect(sandbox).toContain('allow-same-origin')
287
+ })
288
+
289
+ it('gives full sandbox to payment domains (Stripe)', () => {
290
+ const sandbox = getIframeSandbox('https://checkout.stripe.com/c/pay_123')
291
+ expect(sandbox).toContain('allow-same-origin')
292
+ expect(sandbox).toContain('allow-forms')
293
+ })
294
+
295
+ it('gives full sandbox to Polar.sh', () => {
296
+ const sandbox = getIframeSandbox('https://polar.sh/checkout/123')
297
+ expect(sandbox).toContain('allow-same-origin')
298
+ })
299
+
300
+ it('gives restrictive sandbox to untrusted whitelisted domains (quickchart)', () => {
301
+ const sandbox = getIframeSandbox('https://quickchart.io/chart?c={}')
302
+ expect(sandbox).toContain('allow-scripts')
303
+ expect(sandbox).toContain('allow-popups')
304
+ expect(sandbox).not.toContain('allow-same-origin')
305
+ expect(sandbox).not.toContain('allow-forms')
306
+ })
307
+
308
+ it('gives restrictive sandbox to YouTube', () => {
309
+ const sandbox = getIframeSandbox('https://www.youtube.com/embed/abc123')
310
+ expect(sandbox).not.toContain('allow-same-origin')
311
+ })
312
+
313
+ it('gives restrictive sandbox to unknown domains', () => {
314
+ const sandbox = getIframeSandbox('https://evil.example.com/page')
315
+ expect(sandbox).not.toContain('allow-same-origin')
316
+ })
317
+
318
+ it('handles invalid URLs gracefully', () => {
319
+ const sandbox = getIframeSandbox('not-a-url')
320
+ expect(sandbox).toBe('allow-scripts allow-popups')
321
+ })
322
+
323
+ it('supports custom trusted domains', () => {
324
+ const sandbox = getIframeSandbox('https://my-internal-tool.corp.com/dash', {
325
+ customTrustedDomains: ['my-internal-tool.corp.com'],
326
+ })
327
+ expect(sandbox).toContain('allow-same-origin')
328
+ })
329
+ })
@@ -160,6 +160,67 @@ export const DEFAULT_IFRAME_DOMAINS = [
160
160
  'www.clinicaltrials.gov',
161
161
  'linear.app',
162
162
  'www.linear.app',
163
+
164
+ // Payment platforms (v2.2.12)
165
+ 'polar.sh',
166
+ 'www.polar.sh',
167
+ 'checkout.stripe.com',
168
+ 'js.stripe.com',
169
+ 'billing.stripe.com',
170
+ 'buy.stripe.com',
171
+ 'connect.stripe.com',
172
+ 'invoice.stripe.com',
173
+ ]
174
+
175
+ /**
176
+ * Trusted iframe domains that require allow-same-origin to function.
177
+ * These domains need access to their own cookies/storage for auth.
178
+ * All other whitelisted domains get a restrictive sandbox without allow-same-origin.
179
+ */
180
+ export const TRUSTED_IFRAME_DOMAINS = [
181
+ // Deposium (own domains)
182
+ 'deposium.com',
183
+ 'deposium.vip',
184
+ 'deposium.ai',
185
+ 'localhost',
186
+
187
+ // Google services (need auth cookies)
188
+ 'docs.google.com',
189
+ 'drive.google.com',
190
+ 'sheets.google.com',
191
+ 'slides.google.com',
192
+ 'maps.google.com',
193
+ 'datastudio.google.com',
194
+ 'lookerstudio.google.com',
195
+
196
+ // Productivity (need auth)
197
+ 'notion.so',
198
+ 'www.notion.so',
199
+ 'airtable.com',
200
+ 'figma.com',
201
+ 'www.figma.com',
202
+ 'miro.com',
203
+
204
+ // Payment (need auth + cookies for checkout)
205
+ 'polar.sh',
206
+ 'www.polar.sh',
207
+ 'checkout.stripe.com',
208
+ 'js.stripe.com',
209
+ 'billing.stripe.com',
210
+ 'buy.stripe.com',
211
+ 'connect.stripe.com',
212
+ 'invoice.stripe.com',
213
+
214
+ // Business tools (need auth)
215
+ 'app.hubspot.com',
216
+ 'share.hubspot.com',
217
+ 'app.powerbi.com',
218
+ 'linear.app',
219
+ 'www.linear.app',
220
+ 'calendly.com',
221
+ 'typeform.com',
222
+ 'cal.com',
223
+ 'canva.com',
163
224
  ]
164
225
 
165
226
  /**
@@ -244,13 +305,22 @@ export function validateChartComponent(
244
305
  if (!Array.isArray(params.data.datasets)) {
245
306
  return { valid: false, errors: [{ path: 'params.data.datasets', message: 'Missing or invalid datasets array', code: 'MISSING_DATASETS' }] }
246
307
  }
247
- if (!Array.isArray(params.data.labels)) {
248
- return { valid: false, errors: [{ path: 'params.data.labels', message: 'Missing or invalid labels array', code: 'MISSING_LABELS' }] }
308
+ // Detect point-based charts (scatter/bubble) or object data (time-series line)
309
+ const chartType = params.type || 'bar'
310
+ const firstDataPoint = params.data.datasets[0]?.data?.[0]
311
+ const hasObjectData = typeof firstDataPoint === 'object' && firstDataPoint !== null && 'x' in firstDataPoint
312
+ const isPointChart = chartType === 'scatter' || chartType === 'bubble' || hasObjectData
313
+
314
+ // Labels required only for categorical charts (not scatter/bubble/time-series)
315
+ if (!isPointChart) {
316
+ if (!Array.isArray(params.data.labels)) {
317
+ return { valid: false, errors: [{ path: 'params.data.labels', message: 'Missing or invalid labels array', code: 'MISSING_LABELS' }] }
318
+ }
249
319
  }
250
320
 
251
321
  // Validate data points count
252
322
  const totalDataPoints = params.data.datasets.reduce(
253
- (sum, dataset) => sum + dataset.data.length,
323
+ (sum, dataset) => sum + (Array.isArray(dataset.data) ? dataset.data.length : 0),
254
324
  0
255
325
  )
256
326
 
@@ -262,27 +332,41 @@ export function validateChartComponent(
262
332
  })
263
333
  }
264
334
 
265
- // Validate labels match dataset length
266
- const expectedLength = params.data.labels.length
267
- for (const [index, dataset] of params.data.datasets.entries()) {
268
- if (dataset.data.length !== expectedLength) {
269
- errors.push({
270
- path: `params.data.datasets[${index}]`,
271
- message: `Dataset length mismatch: expected ${expectedLength}, got ${dataset.data.length}`,
272
- code: 'DATA_LENGTH_MISMATCH',
273
- })
335
+ // Length mismatch check only for categorical charts, skip empty datasets
336
+ if (!isPointChart && Array.isArray(params.data.labels)) {
337
+ const expectedLength = params.data.labels.length
338
+ for (const [index, dataset] of params.data.datasets.entries()) {
339
+ if (Array.isArray(dataset.data) && dataset.data.length > 0 && dataset.data.length !== expectedLength) {
340
+ errors.push({
341
+ path: `params.data.datasets[${index}]`,
342
+ message: `Dataset length mismatch: expected ${expectedLength}, got ${dataset.data.length}`,
343
+ code: 'DATA_LENGTH_MISMATCH',
344
+ })
345
+ }
274
346
  }
275
347
  }
276
348
 
277
- // Validate numeric data
349
+ // Data type validation — numbers for categorical, {x,y} objects for point charts
278
350
  for (const [index, dataset] of params.data.datasets.entries()) {
351
+ if (!Array.isArray(dataset.data)) continue
279
352
  for (const [dataIndex, value] of dataset.data.entries()) {
280
- if (typeof value !== 'number' || !Number.isFinite(value)) {
281
- errors.push({
282
- path: `params.data.datasets[${index}].data[${dataIndex}]`,
283
- message: `Invalid data value: ${value} (must be finite number)`,
284
- code: 'INVALID_DATA_TYPE',
285
- })
353
+ if (isPointChart) {
354
+ const vObj = value as any
355
+ if (typeof value !== 'object' || value === null || vObj.x == null || typeof vObj.y !== 'number') {
356
+ errors.push({
357
+ path: `params.data.datasets[${index}].data[${dataIndex}]`,
358
+ message: `Invalid point data: expected {x, y} object`,
359
+ code: 'INVALID_POINT_DATA',
360
+ })
361
+ }
362
+ } else {
363
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
364
+ errors.push({
365
+ path: `params.data.datasets[${index}].data[${dataIndex}]`,
366
+ message: `Invalid data value: ${value} (must be finite number)`,
367
+ code: 'INVALID_DATA_TYPE',
368
+ })
369
+ }
286
370
  }
287
371
  }
288
372
  }
@@ -447,6 +531,45 @@ export function validateIframeDomain(
447
531
  }
448
532
  }
449
533
 
534
+ /**
535
+ * Get the appropriate sandbox attribute for an iframe URL.
536
+ *
537
+ * Trusted domains (Google, Deposium, payment, auth-requiring services) get
538
+ * `allow-same-origin` so they can access their own cookies/storage.
539
+ * All other whitelisted domains get a restrictive sandbox without it,
540
+ * preventing access to the parent page's localStorage/cookies.
541
+ *
542
+ * @param url - The iframe URL
543
+ * @param options - Optional custom trusted domains
544
+ * @returns sandbox attribute string
545
+ */
546
+ export function getIframeSandbox(
547
+ url: string,
548
+ options?: { customTrustedDomains?: string[] }
549
+ ): string {
550
+ const baseSandbox = 'allow-scripts allow-popups'
551
+
552
+ try {
553
+ const domain = new URL(url).hostname
554
+ let trustedList = TRUSTED_IFRAME_DOMAINS
555
+ if (options?.customTrustedDomains) {
556
+ trustedList = [...TRUSTED_IFRAME_DOMAINS, ...options.customTrustedDomains]
557
+ }
558
+
559
+ const isTrusted = trustedList.some(
560
+ (trusted) => domain === trusted || domain.endsWith(`.${trusted}`)
561
+ )
562
+
563
+ if (isTrusted) {
564
+ return `${baseSandbox} allow-same-origin allow-forms`
565
+ }
566
+ } catch {
567
+ // Invalid URL — use restrictive sandbox
568
+ }
569
+
570
+ return baseSandbox
571
+ }
572
+
450
573
  /**
451
574
  * Validate entire component
452
575
  *
@@ -582,6 +705,85 @@ export function validateComponent(
582
705
  break
583
706
  }
584
707
 
708
+ case 'video': {
709
+ const videoParams = component.params as any
710
+ if (!videoParams.url) {
711
+ errors.push({ path: 'params', message: 'Video component must have url', code: 'INVALID_VIDEO' })
712
+ } else {
713
+ // Reuse iframe domain validation for video URLs
714
+ const videoResult = validateIframeDomain(videoParams.url, {
715
+ policy: options?.iframePolicy,
716
+ customDomains: options?.customIframeDomains,
717
+ })
718
+ if (!videoResult.valid) {
719
+ errors.push(...(videoResult.errors || []))
720
+ }
721
+ }
722
+ break
723
+ }
724
+
725
+ case 'carousel': {
726
+ const carouselParams = component.params as any
727
+ if (!Array.isArray(carouselParams.items) || carouselParams.items.length === 0) {
728
+ errors.push({ path: 'params.items', message: 'Carousel must have non-empty items array', code: 'EMPTY_CAROUSEL' })
729
+ }
730
+ break
731
+ }
732
+
733
+ case 'image-gallery': {
734
+ const galleryParams = component.params as any
735
+ if (!Array.isArray(galleryParams.images) || galleryParams.images.length === 0) {
736
+ errors.push({ path: 'params.images', message: 'Gallery must have non-empty images array', code: 'EMPTY_GALLERY' })
737
+ }
738
+ break
739
+ }
740
+
741
+ case 'form': {
742
+ const formParams = component.params as any
743
+ if (!Array.isArray(formParams.fields) || formParams.fields.length === 0) {
744
+ errors.push({ path: 'params.fields', message: 'Form must have non-empty fields array', code: 'EMPTY_FORM' })
745
+ }
746
+ break
747
+ }
748
+
749
+ case 'action-group': {
750
+ const agParams = component.params as any
751
+ if (!Array.isArray(agParams.actions) || agParams.actions.length === 0) {
752
+ errors.push({ path: 'params.actions', message: 'Action group must have non-empty actions array', code: 'EMPTY_ACTION_GROUP' })
753
+ }
754
+ break
755
+ }
756
+
757
+ case 'code': {
758
+ const codeParams = component.params as any
759
+ if (!codeParams.code) {
760
+ errors.push({ path: 'params.code', message: 'Code component must have code content', code: 'INVALID_CODE' })
761
+ }
762
+ break
763
+ }
764
+
765
+ case 'map': {
766
+ // Map can auto-detect center from markers, so center is not strictly required
767
+ const mapParams = component.params as any
768
+ if (!mapParams.center && (!Array.isArray(mapParams.markers) || mapParams.markers.length === 0)) {
769
+ errors.push({ path: 'params', message: 'Map must have center or markers', code: 'INVALID_MAP' })
770
+ }
771
+ break
772
+ }
773
+
774
+ case 'modal': {
775
+ // Modal is valid with minimal params (title optional, content can be children)
776
+ break
777
+ }
778
+
779
+ case 'artifact': {
780
+ const artifactParams = component.params as any
781
+ if (!artifactParams.content) {
782
+ errors.push({ path: 'params.content', message: 'Artifact must have content', code: 'INVALID_ARTIFACT' })
783
+ }
784
+ break
785
+ }
786
+
585
787
  default:
586
788
  // Known types without specific validation pass through — renderer handles errors
587
789
  // Truly unknown types (e.g. typos in streamed JSON) are rejected
@@ -32,7 +32,7 @@ export type ComponentType =
32
32
  /**
33
33
  * Chart types (powered by Quickchart)
34
34
  */
35
- export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'scatter'
35
+ export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar' | 'scatter' | 'bubble' | 'polarArea'
36
36
 
37
37
  /**
38
38
  * Grid layout specification (12-column system)