@xemahq/ui-kernel 0.9.0 → 0.9.1

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 (238) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/biome-host/biome-registry.d.ts.map +1 -1
  6. package/dist/lib/biome-host/biome-registry.js +14 -9
  7. package/dist/lib/biome-host/biome-registry.js.map +1 -1
  8. package/dist/lib/contribution-registry/contribution-registry.d.ts +24 -0
  9. package/dist/lib/contribution-registry/contribution-registry.d.ts.map +1 -0
  10. package/dist/lib/contribution-registry/contribution-registry.js +67 -0
  11. package/dist/lib/contribution-registry/contribution-registry.js.map +1 -0
  12. package/dist/lib/contribution-registry/index.d.ts +3 -0
  13. package/dist/lib/contribution-registry/index.d.ts.map +1 -0
  14. package/dist/lib/contribution-registry/index.js +3 -0
  15. package/dist/lib/contribution-registry/index.js.map +1 -0
  16. package/dist/lib/contribution-registry/renderable-registry.d.ts +33 -0
  17. package/dist/lib/contribution-registry/renderable-registry.d.ts.map +1 -0
  18. package/dist/lib/contribution-registry/renderable-registry.js +51 -0
  19. package/dist/lib/contribution-registry/renderable-registry.js.map +1 -0
  20. package/dist/ui/capture/field-spec-to-zod.d.ts +1 -0
  21. package/dist/ui/capture/field-spec-to-zod.d.ts.map +1 -1
  22. package/dist/ui/capture/field-spec-to-zod.js +19 -0
  23. package/dist/ui/capture/field-spec-to-zod.js.map +1 -1
  24. package/dist/ui/capture/field-spec-validate.d.ts +4 -0
  25. package/dist/ui/capture/field-spec-validate.d.ts.map +1 -0
  26. package/dist/ui/capture/field-spec-validate.js +129 -0
  27. package/dist/ui/capture/field-spec-validate.js.map +1 -0
  28. package/dist/ui/capture/field-spec.d.ts +13 -1
  29. package/dist/ui/capture/field-spec.d.ts.map +1 -1
  30. package/dist/ui/capture/field-spec.js +25 -0
  31. package/dist/ui/capture/field-spec.js.map +1 -1
  32. package/dist/ui/capture/index.d.ts +4 -2
  33. package/dist/ui/capture/index.d.ts.map +1 -1
  34. package/dist/ui/capture/index.js +4 -2
  35. package/dist/ui/capture/index.js.map +1 -1
  36. package/dist/ui/capture/schema-field-input.d.ts.map +1 -1
  37. package/dist/ui/capture/schema-field-input.js +39 -12
  38. package/dist/ui/capture/schema-field-input.js.map +1 -1
  39. package/dist/ui/capture/submit-values.d.ts +6 -0
  40. package/dist/ui/capture/submit-values.d.ts.map +1 -0
  41. package/dist/ui/capture/submit-values.js +28 -0
  42. package/dist/ui/capture/submit-values.js.map +1 -0
  43. package/dist/ui/index.d.ts +1 -0
  44. package/dist/ui/index.d.ts.map +1 -1
  45. package/dist/ui/index.js +1 -0
  46. package/dist/ui/index.js.map +1 -1
  47. package/dist/ui/primitives/overflow-tabs.d.ts +5 -0
  48. package/dist/ui/primitives/overflow-tabs.d.ts.map +1 -1
  49. package/dist/ui/primitives/overflow-tabs.js +4 -4
  50. package/dist/ui/primitives/overflow-tabs.js.map +1 -1
  51. package/dist/ui/primitives/url-preview-chrome.d.ts +8 -0
  52. package/dist/ui/primitives/url-preview-chrome.d.ts.map +1 -0
  53. package/dist/ui/primitives/url-preview-chrome.js +40 -0
  54. package/dist/ui/primitives/url-preview-chrome.js.map +1 -0
  55. package/dist/vista/GenericCatalogueSurface.d.ts +10 -0
  56. package/dist/vista/GenericCatalogueSurface.d.ts.map +1 -0
  57. package/dist/vista/GenericCatalogueSurface.js +80 -0
  58. package/dist/vista/GenericCatalogueSurface.js.map +1 -0
  59. package/dist/vista/SurfaceFallbackCard.d.ts +9 -0
  60. package/dist/vista/SurfaceFallbackCard.d.ts.map +1 -0
  61. package/dist/vista/SurfaceFallbackCard.js +44 -0
  62. package/dist/vista/SurfaceFallbackCard.js.map +1 -0
  63. package/dist/vista/VistaPanel.d.ts +21 -0
  64. package/dist/vista/VistaPanel.d.ts.map +1 -0
  65. package/dist/vista/VistaPanel.js +31 -0
  66. package/dist/vista/VistaPanel.js.map +1 -0
  67. package/dist/vista/html/CustomUiFrame.d.ts +20 -0
  68. package/dist/vista/html/CustomUiFrame.d.ts.map +1 -0
  69. package/dist/vista/html/CustomUiFrame.js +69 -0
  70. package/dist/vista/html/CustomUiFrame.js.map +1 -0
  71. package/dist/vista/html/custom-ui-frame-bridge.d.ts +13 -0
  72. package/dist/vista/html/custom-ui-frame-bridge.d.ts.map +1 -0
  73. package/dist/vista/html/custom-ui-frame-bridge.js +30 -0
  74. package/dist/vista/html/custom-ui-frame-bridge.js.map +1 -0
  75. package/dist/vista/html/custom-ui-frame-contract.d.ts +29 -0
  76. package/dist/vista/html/custom-ui-frame-contract.d.ts.map +1 -0
  77. package/dist/vista/html/custom-ui-frame-contract.js +16 -0
  78. package/dist/vista/html/custom-ui-frame-contract.js.map +1 -0
  79. package/dist/vista/html/custom-ui-intents.d.ts +20 -0
  80. package/dist/vista/html/custom-ui-intents.d.ts.map +1 -0
  81. package/dist/vista/html/custom-ui-intents.js +57 -0
  82. package/dist/vista/html/custom-ui-intents.js.map +1 -0
  83. package/dist/vista/html/custom-ui-srcdoc.d.ts +17 -0
  84. package/dist/vista/html/custom-ui-srcdoc.d.ts.map +1 -0
  85. package/dist/vista/html/custom-ui-srcdoc.js +70 -0
  86. package/dist/vista/html/custom-ui-srcdoc.js.map +1 -0
  87. package/dist/vista/html/index.d.ts +6 -0
  88. package/dist/vista/html/index.d.ts.map +1 -0
  89. package/dist/vista/html/index.js +6 -0
  90. package/dist/vista/html/index.js.map +1 -0
  91. package/dist/vista/index.d.ts +18 -0
  92. package/dist/vista/index.d.ts.map +1 -0
  93. package/dist/vista/index.js +18 -0
  94. package/dist/vista/index.js.map +1 -0
  95. package/dist/vista/surface-catalogue.d.ts +5 -0
  96. package/dist/vista/surface-catalogue.d.ts.map +1 -0
  97. package/dist/vista/surface-catalogue.js +30 -0
  98. package/dist/vista/surface-catalogue.js.map +1 -0
  99. package/dist/vista/surface-icons.d.ts +7 -0
  100. package/dist/vista/surface-icons.d.ts.map +1 -0
  101. package/dist/vista/surface-icons.js +26 -0
  102. package/dist/vista/surface-icons.js.map +1 -0
  103. package/dist/vista/surface-registry.d.ts +7 -0
  104. package/dist/vista/surface-registry.d.ts.map +1 -0
  105. package/dist/vista/surface-registry.js +7 -0
  106. package/dist/vista/surface-registry.js.map +1 -0
  107. package/dist/vista/surfaces/ChartSurface.d.ts +4 -0
  108. package/dist/vista/surfaces/ChartSurface.d.ts.map +1 -0
  109. package/dist/vista/surfaces/ChartSurface.js +17 -0
  110. package/dist/vista/surfaces/ChartSurface.js.map +1 -0
  111. package/dist/vista/surfaces/FilesSurface.d.ts +15 -0
  112. package/dist/vista/surfaces/FilesSurface.d.ts.map +1 -0
  113. package/dist/vista/surfaces/FilesSurface.js +121 -0
  114. package/dist/vista/surfaces/FilesSurface.js.map +1 -0
  115. package/dist/vista/surfaces/FormSurface.d.ts +4 -0
  116. package/dist/vista/surfaces/FormSurface.d.ts.map +1 -0
  117. package/dist/vista/surfaces/FormSurface.js +97 -0
  118. package/dist/vista/surfaces/FormSurface.js.map +1 -0
  119. package/dist/vista/surfaces/HtmlSurface.d.ts +4 -0
  120. package/dist/vista/surfaces/HtmlSurface.d.ts.map +1 -0
  121. package/dist/vista/surfaces/HtmlSurface.js +11 -0
  122. package/dist/vista/surfaces/HtmlSurface.js.map +1 -0
  123. package/dist/vista/surfaces/TableSurface.d.ts +4 -0
  124. package/dist/vista/surfaces/TableSurface.d.ts.map +1 -0
  125. package/dist/vista/surfaces/TableSurface.js +42 -0
  126. package/dist/vista/surfaces/TableSurface.js.map +1 -0
  127. package/dist/vista/surfaces/UrlSurface.d.ts +4 -0
  128. package/dist/vista/surfaces/UrlSurface.d.ts.map +1 -0
  129. package/dist/vista/surfaces/UrlSurface.js +7 -0
  130. package/dist/vista/surfaces/UrlSurface.js.map +1 -0
  131. package/dist/vista/surfaces/blob-surfaces.d.ts +6 -0
  132. package/dist/vista/surfaces/blob-surfaces.d.ts.map +1 -0
  133. package/dist/vista/surfaces/blob-surfaces.js +38 -0
  134. package/dist/vista/surfaces/blob-surfaces.js.map +1 -0
  135. package/dist/vista/surfaces/elicitation-transport.d.ts +18 -0
  136. package/dist/vista/surfaces/elicitation-transport.d.ts.map +1 -0
  137. package/dist/vista/surfaces/elicitation-transport.js +100 -0
  138. package/dist/vista/surfaces/elicitation-transport.js.map +1 -0
  139. package/dist/vista/surfaces/index.d.ts +11 -0
  140. package/dist/vista/surfaces/index.d.ts.map +1 -0
  141. package/dist/vista/surfaces/index.js +89 -0
  142. package/dist/vista/surfaces/index.js.map +1 -0
  143. package/dist/vista/surfaces/text-surfaces.d.ts +8 -0
  144. package/dist/vista/surfaces/text-surfaces.d.ts.map +1 -0
  145. package/dist/vista/surfaces/text-surfaces.js +54 -0
  146. package/dist/vista/surfaces/text-surfaces.js.map +1 -0
  147. package/dist/vista/use-vista-tabs.d.ts +30 -0
  148. package/dist/vista/use-vista-tabs.d.ts.map +1 -0
  149. package/dist/vista/use-vista-tabs.js +77 -0
  150. package/dist/vista/use-vista-tabs.js.map +1 -0
  151. package/dist/vista/vista-code-renderer.d.ts +12 -0
  152. package/dist/vista/vista-code-renderer.d.ts.map +1 -0
  153. package/dist/vista/vista-code-renderer.js +10 -0
  154. package/dist/vista/vista-code-renderer.js.map +1 -0
  155. package/dist/vista/vista-file-source.d.ts +20 -0
  156. package/dist/vista/vista-file-source.d.ts.map +1 -0
  157. package/dist/vista/vista-file-source.js +69 -0
  158. package/dist/vista/vista-file-source.js.map +1 -0
  159. package/dist/vista/vista-form-host.d.ts +16 -0
  160. package/dist/vista/vista-form-host.d.ts.map +1 -0
  161. package/dist/vista/vista-form-host.js +10 -0
  162. package/dist/vista/vista-form-host.js.map +1 -0
  163. package/dist/vista/vista-model.d.ts +12 -0
  164. package/dist/vista/vista-model.d.ts.map +1 -0
  165. package/dist/vista/vista-model.js +72 -0
  166. package/dist/vista/vista-model.js.map +1 -0
  167. package/dist/vista/vista-open-context.d.ts +9 -0
  168. package/dist/vista/vista-open-context.d.ts.map +1 -0
  169. package/dist/vista/vista-open-context.js +10 -0
  170. package/dist/vista/vista-open-context.js.map +1 -0
  171. package/dist/vista/vista-payloads.d.ts +107 -0
  172. package/dist/vista/vista-payloads.d.ts.map +1 -0
  173. package/dist/vista/vista-payloads.js +305 -0
  174. package/dist/vista/vista-payloads.js.map +1 -0
  175. package/dist/vista/vista-tabs-manifest.d.ts +17 -0
  176. package/dist/vista/vista-tabs-manifest.d.ts.map +1 -0
  177. package/dist/vista/vista-tabs-manifest.js +60 -0
  178. package/dist/vista/vista-tabs-manifest.js.map +1 -0
  179. package/dist/vista/vista-tools.d.ts +41 -0
  180. package/dist/vista/vista-tools.d.ts.map +1 -0
  181. package/dist/vista/vista-tools.js +113 -0
  182. package/dist/vista/vista-tools.js.map +1 -0
  183. package/package.json +3 -3
  184. package/src/index.ts +5 -0
  185. package/src/lib/biome-host/biome-registry.ts +31 -11
  186. package/src/lib/contribution-registry/contribution-registry.spec.ts +141 -0
  187. package/src/lib/contribution-registry/contribution-registry.ts +202 -0
  188. package/src/lib/contribution-registry/index.ts +9 -0
  189. package/src/lib/contribution-registry/renderable-registry.ts +142 -0
  190. package/src/ui/capture/field-spec-to-zod.ts +22 -0
  191. package/src/ui/capture/field-spec-validate.ts +147 -0
  192. package/src/ui/capture/field-spec.ts +62 -0
  193. package/src/ui/capture/index.ts +9 -1
  194. package/src/ui/capture/schema-field-input.tsx +134 -14
  195. package/src/ui/capture/submit-values.test.ts +70 -0
  196. package/src/ui/capture/submit-values.ts +63 -0
  197. package/src/ui/index.ts +4 -0
  198. package/src/ui/primitives/overflow-tabs.tsx +24 -4
  199. package/src/ui/primitives/url-preview-chrome.tsx +102 -0
  200. package/src/vista/GenericCatalogueSurface.tsx +177 -0
  201. package/src/vista/SurfaceFallbackCard.tsx +109 -0
  202. package/src/vista/VistaPanel.tsx +155 -0
  203. package/src/vista/form-payload.spec.ts +93 -0
  204. package/src/vista/html/CustomUiFrame.spec.tsx +125 -0
  205. package/src/vista/html/CustomUiFrame.tsx +204 -0
  206. package/src/vista/html/custom-ui-frame-bridge.spec.ts +191 -0
  207. package/src/vista/html/custom-ui-frame-bridge.ts +92 -0
  208. package/src/vista/html/custom-ui-frame-contract.ts +88 -0
  209. package/src/vista/html/custom-ui-intents.spec.ts +133 -0
  210. package/src/vista/html/custom-ui-intents.ts +151 -0
  211. package/src/vista/html/custom-ui-srcdoc.spec.ts +151 -0
  212. package/src/vista/html/custom-ui-srcdoc.ts +193 -0
  213. package/src/vista/html/index.ts +13 -0
  214. package/src/vista/index.ts +48 -0
  215. package/src/vista/surface-catalogue.spec.ts +140 -0
  216. package/src/vista/surface-catalogue.ts +84 -0
  217. package/src/vista/surface-icons.ts +57 -0
  218. package/src/vista/surface-registry.ts +56 -0
  219. package/src/vista/surfaces/ChartSurface.tsx +34 -0
  220. package/src/vista/surfaces/FilesSurface.tsx +268 -0
  221. package/src/vista/surfaces/FormSurface.tsx +264 -0
  222. package/src/vista/surfaces/HtmlSurface.tsx +43 -0
  223. package/src/vista/surfaces/TableSurface.tsx +99 -0
  224. package/src/vista/surfaces/UrlSurface.tsx +40 -0
  225. package/src/vista/surfaces/blob-surfaces.tsx +129 -0
  226. package/src/vista/surfaces/elicitation-transport.spec.ts +187 -0
  227. package/src/vista/surfaces/elicitation-transport.ts +175 -0
  228. package/src/vista/surfaces/index.tsx +157 -0
  229. package/src/vista/surfaces/text-surfaces.tsx +119 -0
  230. package/src/vista/use-vista-tabs.ts +188 -0
  231. package/src/vista/vista-code-renderer.tsx +46 -0
  232. package/src/vista/vista-file-source.tsx +163 -0
  233. package/src/vista/vista-form-host.tsx +78 -0
  234. package/src/vista/vista-model.ts +130 -0
  235. package/src/vista/vista-open-context.tsx +42 -0
  236. package/src/vista/vista-payloads.ts +514 -0
  237. package/src/vista/vista-tabs-manifest.ts +123 -0
  238. package/src/vista/vista-tools.ts +220 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `VistaPanel` — the ONE tabbed render plane.
3
+ *
4
+ * It replaces the three hand-rolled tab strips the frontend carried (the thin
5
+ * session workspace, the Design-System-Builder preview, and App Forge's
6
+ * preview): tab chrome is {@link OverflowTabs} (auto-collapsing "More…" instead
7
+ * of a horizontal-scroll strip, active + pinned always visible), tab STATE is
8
+ * {@link useVistaTabs}, and the body is whatever the Vista surface registry
9
+ * resolves the active {@link VistaTab} to.
10
+ *
11
+ * The panel is presentation only: it never fetches, never authorises, and never
12
+ * decides what a kind means. Resolution follows the three-layer guarantee —
13
+ * a registered renderer wins, else the generic schema-driven renderer the
14
+ * catalogue sync installed, else {@link SurfaceFallbackCard} with a typed
15
+ * reason. It never throws and never blanks.
16
+ */
17
+ import { type ReactElement, type ReactNode } from 'react';
18
+
19
+ import { surfaceKindIcon } from './surface-icons';
20
+ import { vistaSurfaceRegistry } from './surface-registry';
21
+ import { SurfaceFallbackCard } from './SurfaceFallbackCard';
22
+ import { cn } from '../ui/cn';
23
+ import { OverflowTabs, type OverflowTab } from '../ui/primitives/overflow-tabs';
24
+
25
+ import type { VistaTab } from './vista-model';
26
+
27
+ export interface VistaPanelProps {
28
+ /** Every open tab, in display order (pinned first). */
29
+ readonly tabs: readonly VistaTab[];
30
+ /** The focused tab, or `null` when the panel holds no tabs. */
31
+ readonly activeTabId: string | null;
32
+ readonly onActivate: (tabId: string) => void;
33
+ /**
34
+ * Close handler. Omit for a panel whose tabs are all host-owned; pinned tabs
35
+ * never render a close affordance regardless.
36
+ */
37
+ readonly onClose?: (tabId: string) => void;
38
+ /** Optional action rendered at the right edge of the tab bar. */
39
+ readonly tabBarAction?: ReactNode;
40
+ /**
41
+ * Tab ids to mark with the strip's attention dot — "this tab changed while
42
+ * you were looking elsewhere". The panel does not decide what "changed"
43
+ * means; the owner does.
44
+ */
45
+ readonly indicatorTabIds?: readonly string[];
46
+ /** Rendered when there are no tabs at all. */
47
+ readonly emptyState?: ReactNode;
48
+ readonly className?: string;
49
+ }
50
+
51
+ export function VistaPanel({
52
+ tabs,
53
+ activeTabId,
54
+ onActivate,
55
+ onClose,
56
+ tabBarAction,
57
+ indicatorTabIds,
58
+ emptyState,
59
+ className,
60
+ }: VistaPanelProps): ReactElement {
61
+ const active = tabs.find((t) => t.tabId === activeTabId) ?? null;
62
+ return (
63
+ <div
64
+ className={cn(
65
+ 'flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden',
66
+ className,
67
+ )}
68
+ >
69
+ <VistaTabBar
70
+ tabs={tabs}
71
+ activeTabId={activeTabId}
72
+ onActivate={onActivate}
73
+ {...(onClose ? { onClose } : {})}
74
+ {...(tabBarAction === undefined ? {} : { tabBarAction })}
75
+ {...(indicatorTabIds === undefined ? {} : { indicatorTabIds })}
76
+ />
77
+ <div className="min-h-0 flex-1 overflow-hidden">
78
+ {active === null ? (
79
+ <>{emptyState ?? null}</>
80
+ ) : (
81
+ <VistaSurfaceBody tab={active} />
82
+ )}
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ export type VistaTabBarProps = Omit<
89
+ VistaPanelProps,
90
+ 'emptyState' | 'className'
91
+ > & { readonly className?: string };
92
+
93
+ /**
94
+ * The tab STRIP alone. Exported so a surface whose chrome interleaves other
95
+ * controls between the tabs and the body (the Design-System-Builder's browser
96
+ * chrome puts a URL + action row there) composes the SAME strip instead of
97
+ * hand-rolling a second one. `VistaPanel` is just this plus
98
+ * {@link VistaSurfaceBody}.
99
+ */
100
+ export function VistaTabBar({
101
+ tabs,
102
+ activeTabId,
103
+ onActivate,
104
+ onClose,
105
+ tabBarAction,
106
+ indicatorTabIds,
107
+ className,
108
+ }: VistaTabBarProps): ReactElement {
109
+ const overflowTabs: OverflowTab[] = tabs.map((tab) => {
110
+ const entry: {
111
+ value: string;
112
+ label: string;
113
+ pinned?: boolean;
114
+ indicator?: boolean;
115
+ icon?: OverflowTab['icon'];
116
+ } = { value: tab.tabId, label: tab.title };
117
+ if (tab.pinned === true) entry.pinned = true;
118
+ if (indicatorTabIds?.includes(tab.tabId) === true) entry.indicator = true;
119
+ const icon = surfaceKindIcon(tab.kind);
120
+ if (icon !== undefined) entry.icon = icon;
121
+ return entry;
122
+ });
123
+ return (
124
+ <div className={cn('flex shrink-0 items-end gap-2 px-2', className)}>
125
+ <div className="min-w-0 flex-1">
126
+ <OverflowTabs
127
+ tabs={overflowTabs}
128
+ value={activeTabId ?? ''}
129
+ onChange={onActivate}
130
+ {...(onClose ? { onClose } : {})}
131
+ />
132
+ </div>
133
+ {tabBarAction}
134
+ </div>
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Resolve ONE tab through the registry and draw it. Split out so the resolution
140
+ * decision (and therefore the fallback reason) is inspectable in isolation.
141
+ */
142
+ export function VistaSurfaceBody({
143
+ tab,
144
+ }: Readonly<{ tab: VistaTab }>): ReactElement {
145
+ const resolution = vistaSurfaceRegistry.resolve(tab);
146
+ if (resolution.matched) return resolution.rendered;
147
+ return (
148
+ <SurfaceFallbackCard
149
+ kind={resolution.kind}
150
+ reason={resolution.reason}
151
+ payload={tab.payload}
152
+ title={tab.title}
153
+ />
154
+ );
155
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Guards `validateFormPayload` — the structural half of "the value never
3
+ * reaches the agent".
4
+ *
5
+ * The validator is what makes the submit target un-aimable at the host's own
6
+ * origin (where the BFF / chat transport lives): only an ABSOLUTE `http(s)`
7
+ * URL is accepted, and a rejected payload draws the typed fallback card rather
8
+ * than a form.
9
+ */
10
+ import { describe, expect, it } from 'vitest';
11
+
12
+ import { CaptureFieldType } from '../ui/capture/field-spec';
13
+
14
+ import { validateFormPayload } from './vista-payloads';
15
+
16
+ const BASE = {
17
+ ref: 'ref-1',
18
+ purpose: 'Connect Acme so I can read your tickets.',
19
+ submitUrl: 'https://connector-gateway.example.test/credential-elicitations/ref-1/submit',
20
+ statusUrl: 'https://connector-gateway.example.test/credential-elicitations/ref-1',
21
+ fields: [
22
+ {
23
+ key: 'apiKey',
24
+ label: 'API key',
25
+ type: CaptureFieldType.String,
26
+ required: true,
27
+ isSecret: true,
28
+ transform: 'trim',
29
+ },
30
+ { key: 'baseUrl', label: 'Base URL', type: CaptureFieldType.Url, required: true, isSecret: false },
31
+ ],
32
+ };
33
+
34
+ describe('validateFormPayload', () => {
35
+ it('accepts a well-formed field-mode payload and preserves declared secrecy', () => {
36
+ const payload = validateFormPayload(BASE);
37
+ expect(payload).not.toBeNull();
38
+ expect(payload?.fields).toHaveLength(2);
39
+ expect(payload?.fields[0]?.isSecret).toBe(true);
40
+ expect(payload?.fields[1]?.isSecret).toBe(false);
41
+ expect(payload?.fields[0]?.transform).toBe('trim');
42
+ });
43
+
44
+ it('REJECTS a host-relative submit URL — it could only hit the app itself', () => {
45
+ expect(validateFormPayload({ ...BASE, submitUrl: '/credential-elicitations/ref-1/submit' })).toBeNull();
46
+ expect(validateFormPayload({ ...BASE, submitUrl: '//evil.example/x' })).toBeNull();
47
+ expect(validateFormPayload({ ...BASE, submitUrl: 'javascript:alert(1)' })).toBeNull();
48
+ expect(validateFormPayload({ ...BASE, statusUrl: '/credential-elicitations/ref-1' })).toBeNull();
49
+ });
50
+
51
+ it('accepts an EMPTY field schema — the service is the authority, not the client', () => {
52
+ const payload = validateFormPayload({ ...BASE, fields: [] });
53
+ expect(payload).not.toBeNull();
54
+ expect(payload?.fields).toEqual([]);
55
+ });
56
+
57
+ it('rejects a malformed field spec rather than half-rendering it', () => {
58
+ expect(validateFormPayload({ ...BASE, fields: [{ key: 'a', label: 'A', type: 'nope' }] })).toBeNull();
59
+ expect(validateFormPayload({ ...BASE, fields: [{ key: 'a', label: 'A' }] })).toBeNull();
60
+ expect(
61
+ validateFormPayload({
62
+ ...BASE,
63
+ fields: [{ key: 'a', label: 'A', type: CaptureFieldType.String, isSecret: 'yes' }],
64
+ }),
65
+ ).toBeNull();
66
+ expect(validateFormPayload({ ...BASE, fields: {} })).toBeNull();
67
+ });
68
+
69
+ it('accepts a launch (oauth2 / app_install) payload with an absolute URL', () => {
70
+ const payload = validateFormPayload({
71
+ ...BASE,
72
+ fields: [],
73
+ launch: { url: 'https://acme.example.test/oauth/authorize?x=1', label: 'Continue to Acme', hint: 'h' },
74
+ });
75
+ expect(payload?.launch).toEqual({
76
+ url: 'https://acme.example.test/oauth/authorize?x=1',
77
+ label: 'Continue to Acme',
78
+ hint: 'h',
79
+ });
80
+ });
81
+
82
+ it('rejects a launch with a relative URL or no label', () => {
83
+ expect(validateFormPayload({ ...BASE, launch: { url: '/authorize', label: 'Go' } })).toBeNull();
84
+ expect(
85
+ validateFormPayload({ ...BASE, launch: { url: 'https://acme.example.test/a', label: '' } }),
86
+ ).toBeNull();
87
+ });
88
+
89
+ it('rejects a missing ref', () => {
90
+ expect(validateFormPayload({ ...BASE, ref: '' })).toBeNull();
91
+ expect(validateFormPayload({ ...BASE, ref: undefined })).toBeNull();
92
+ });
93
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * CustomUiFrame — the executes-author-code security surface.
3
+ *
4
+ * Asserts the opaque-origin posture on the rendered iframe: sandbox is
5
+ * `allow-scripts` with NO `allow-same-origin`, the srcdoc pins the server SRI
6
+ * hash as the CSP script-src, and the chunk bytes are the inline module script.
7
+ * The static-document form is asserted too: same containment, `script-src
8
+ * 'none'`, so an authored `<script>` can never run.
9
+ *
10
+ * The final block MUTATION-tests the sandbox invariant IN ISOLATION: it proves
11
+ * the suite goes RED if `allow-same-origin` is ever added to the sandbox token
12
+ * (which would dissolve the origin boundary), independent of the CSP-hash
13
+ * mutation proof in `custom-ui-srcdoc.spec.ts`.
14
+ *
15
+ * Migrated from `app-forge-web` when the frame was promoted into the UI kernel;
16
+ * runs under the host-web vitest mirror (this package carries no vitest dep).
17
+ */
18
+ import { renderToStaticMarkup } from 'react-dom/server';
19
+ import { describe, expect, it, vi } from 'vitest';
20
+
21
+ import {
22
+ createNavigateIntentSet,
23
+ CustomUiIntentKind,
24
+ NO_CUSTOM_UI_INTENTS,
25
+ } from './custom-ui-intents';
26
+ import { CustomUiFrame } from './CustomUiFrame';
27
+
28
+ const INTEGRITY = 'sha384-AbC123def456Ghi789jklMnoPqr';
29
+ const COMPILED = 'export default function C(){return null}';
30
+ // The style import is a HOST concern (the kernel defaults to its own reset) —
31
+ // this test is about the security surface, so it pins a trivial stylesheet.
32
+ const STYLE_CSS = ':root{--x:1}';
33
+
34
+ function parseIframe(markup: string): Element {
35
+ const doc = new DOMParser().parseFromString(markup, 'text/html');
36
+ const iframe = doc.querySelector('iframe');
37
+ if (!iframe) throw new Error('no iframe rendered');
38
+ return iframe;
39
+ }
40
+
41
+ function renderFrame() {
42
+ return parseIframe(
43
+ renderToStaticMarkup(
44
+ <CustomUiFrame
45
+ content={{ compiledOutput: COMPILED, integrity: INTEGRITY }}
46
+ componentProps={{ records: [] }}
47
+ intents={createNavigateIntentSet({
48
+ [CustomUiIntentKind.Navigate]: vi.fn(),
49
+ })}
50
+ styleCss={STYLE_CSS}
51
+ title="Test custom view"
52
+ />,
53
+ ),
54
+ );
55
+ }
56
+
57
+ function renderDocumentFrame(document: string) {
58
+ return parseIframe(
59
+ renderToStaticMarkup(
60
+ <CustomUiFrame
61
+ content={{ document }}
62
+ intents={NO_CUSTOM_UI_INTENTS}
63
+ styleCss={STYLE_CSS}
64
+ title="Test static document"
65
+ />,
66
+ ),
67
+ );
68
+ }
69
+
70
+ describe('CustomUiFrame security posture', () => {
71
+ it('renders an opaque-origin sandbox: allow-scripts, NO allow-same-origin', () => {
72
+ const iframe = renderFrame();
73
+ expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
74
+ expect(iframe.getAttribute('sandbox')).not.toContain('allow-same-origin');
75
+ });
76
+
77
+ it('pins the server integrity as the CSP script-src in the srcdoc', () => {
78
+ const srcdoc = renderFrame().getAttribute('srcdoc') ?? '';
79
+ expect(srcdoc).toContain(`script-src '${INTEGRITY}'`);
80
+ expect(srcdoc).toContain(`default-src 'none'`);
81
+ });
82
+
83
+ it('inlines the chunk bytes as the one module script', () => {
84
+ const srcdoc = renderFrame().getAttribute('srcdoc') ?? '';
85
+ expect(srcdoc).toContain(`<script type="module">${COMPILED}</script>`);
86
+ expect(srcdoc).toContain('<div id="root"></div>');
87
+ });
88
+
89
+ // ── MUTATION TEST (sandbox invariant, isolated) ────────────────────────────
90
+ // Prove the assertion is load-bearing: if the component were changed to
91
+ // `sandbox="allow-scripts allow-same-origin"`, THIS exact assertion fails.
92
+ it('MUTATION: adding allow-same-origin to the sandbox token is caught', () => {
93
+ const sandbox = renderFrame().getAttribute('sandbox') ?? '';
94
+ // Exact-match pin: any extra token (e.g. allow-same-origin) breaks equality.
95
+ expect(sandbox).toBe('allow-scripts');
96
+ // And the explicit negative guard the mutation would trip.
97
+ const mutated = 'allow-scripts allow-same-origin';
98
+ expect(sandbox).not.toBe(mutated);
99
+ });
100
+ });
101
+
102
+ describe('CustomUiFrame static-document form', () => {
103
+ it("renders an untrusted document with script-src 'none' in the same sandbox", () => {
104
+ const iframe = renderDocumentFrame('<h1>Report</h1>');
105
+ expect(iframe.getAttribute('sandbox')).toBe('allow-scripts');
106
+ const srcdoc = iframe.getAttribute('srcdoc') ?? '';
107
+ expect(srcdoc).toContain(`script-src 'none'`);
108
+ expect(srcdoc).toContain(`default-src 'none'`);
109
+ expect(srcdoc).toContain('<h1>Report</h1>');
110
+ });
111
+
112
+ it('renders a visible notice (no iframe) when the document cannot be built safely', () => {
113
+ const markup = renderToStaticMarkup(
114
+ <CustomUiFrame
115
+ content={{ document: '<p>x</p></script><script>alert(1)</script>' }}
116
+ intents={NO_CUSTOM_UI_INTENTS}
117
+ styleCss={STYLE_CSS}
118
+ title="Unsafe document"
119
+ />,
120
+ );
121
+ expect(markup).toContain('role="alert"');
122
+ expect(markup).toContain('could not be prepared safely');
123
+ expect(markup).not.toContain('<iframe');
124
+ });
125
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * `CustomUiFrame` — the reusable, security-hardened renderer for untrusted UI.
3
+ * It runs author-/agent-authored content inside an OPAQUE-ORIGIN sandbox iframe
4
+ * and bridges it to the host over the frozen `postMessage` contract.
5
+ *
6
+ * Promoted from `app-forge-web` into the UI kernel (Canopy §8.3) so App Forge's
7
+ * runtime views and the Vista `html` surface share ONE hardened frame. The
8
+ * intent set is now supplied by the mounting surface
9
+ * ({@link CustomUiIntentSet}); everything below is carried over verbatim.
10
+ *
11
+ * Security posture (NON-NEGOTIABLE — this renders untrusted content):
12
+ * • sandbox="allow-scripts" and NO allow-same-origin → the frame runs in a
13
+ * unique opaque origin and cannot reach host DOM, cookies, localStorage or
14
+ * tokens.
15
+ * • srcdoc with a `<meta>` CSP that pins the chunk's server SRI hash as the
16
+ * sole script-src (or `'none'` for a static document) and sets default-src
17
+ * 'none' → no network, only the exact pinned bytes execute. See
18
+ * {@link buildCustomUiSrcDoc} / {@link buildUntrustedHtmlSrcDoc}.
19
+ * • Every inbound message is validated by {@link classifyFrameMessage}
20
+ * (source-window identity + protocol + closed type set) before the host
21
+ * acts. Intents are validated against a closed set and performed by the
22
+ * host — the frame never acts with the user's authority.
23
+ *
24
+ * The host pushes read-only props on `frameReady` (and re-pushes when they
25
+ * change), performs validated intents, and surfaces a render error the frame
26
+ * reports — never a blank frame.
27
+ */
28
+ import { AlertTriangle } from 'lucide-react';
29
+ import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
30
+
31
+ import { classifyFrameMessage } from './custom-ui-frame-bridge';
32
+ import {
33
+ hostPropsMessage,
34
+ type CustomUiComponentProps,
35
+ } from './custom-ui-frame-contract';
36
+ import { type CustomUiIntentSet } from './custom-ui-intents';
37
+ import {
38
+ CUSTOM_UI_FRAME_RESET_CSS,
39
+ buildCustomUiSrcDoc,
40
+ buildUntrustedHtmlSrcDoc,
41
+ } from './custom-ui-srcdoc';
42
+ import { cn } from '../../ui/cn';
43
+
44
+ /** What the frame renders. Exactly one of the two forms. */
45
+ export type CustomUiFrameContent =
46
+ | {
47
+ /** A server-compiled, SRI-pinned, self-mounting ESM chunk. */
48
+ readonly compiledOutput: string;
49
+ /** Server SRI/CSP token (`sha384-<base64>`) pinning the chunk bytes. */
50
+ readonly integrity: string;
51
+ }
52
+ | {
53
+ /** An untrusted HTML document. Rendered with `script-src 'none'`. */
54
+ readonly document: string;
55
+ };
56
+
57
+ export interface CustomUiFrameProps<TIntent> {
58
+ /** The content to render. */
59
+ readonly content: CustomUiFrameContent;
60
+ /** Read-only, serializable props handed to a compiled component. */
61
+ readonly componentProps?: CustomUiComponentProps;
62
+ /** The CLOSED intent set this frame may exercise against the host. */
63
+ readonly intents: CustomUiIntentSet<TIntent>;
64
+ /**
65
+ * CSS inlined into the frame (the frame cannot fetch a stylesheet — its CSP
66
+ * is `default-src 'none'`). Defaults to {@link CUSTOM_UI_FRAME_RESET_CSS};
67
+ * hosts prepend it to their own design-token CSS.
68
+ */
69
+ readonly styleCss?: string;
70
+ /** Accessible title for the iframe. */
71
+ readonly title: string;
72
+ readonly className?: string;
73
+ }
74
+
75
+ const EMPTY_PROPS: CustomUiComponentProps = {};
76
+
77
+ export function CustomUiFrame<TIntent>({
78
+ content,
79
+ componentProps = EMPTY_PROPS,
80
+ intents,
81
+ styleCss = CUSTOM_UI_FRAME_RESET_CSS,
82
+ title,
83
+ className,
84
+ }: CustomUiFrameProps<TIntent>): ReactElement {
85
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
86
+ const [frameError, setFrameError] = useState<string | null>(null);
87
+
88
+ // Latest props/intent set, read by the message listener without
89
+ // re-subscribing (the listener is attached once; a props re-render is handled
90
+ // by a dedicated effect below).
91
+ const propsRef = useRef(componentProps);
92
+ propsRef.current = componentProps;
93
+ const intentsRef = useRef(intents);
94
+ intentsRef.current = intents;
95
+ const readyRef = useRef(false);
96
+
97
+ // The srcdoc is a pure function of the content identity + inlined styles — it
98
+ // does NOT depend on props (props flow over postMessage, not by re-mounting
99
+ // the frame). Recomputing only on content change avoids reloading the frame
100
+ // on every prop update.
101
+ // The builders throw on content that would truncate the inline module (a
102
+ // `</script`-bearing body) — surface it as a visible error rather than
103
+ // white-screening. `null` srcDoc ⇒ render the notice, no iframe.
104
+ const isCompiled = 'compiledOutput' in content;
105
+ const compiledOutput = isCompiled ? content.compiledOutput : null;
106
+ const integrity = isCompiled ? content.integrity : null;
107
+ const document = isCompiled ? null : content.document;
108
+ const srcDoc = useMemo<string | null>(() => {
109
+ try {
110
+ return compiledOutput !== null && integrity !== null
111
+ ? buildCustomUiSrcDoc({ compiledOutput, integrity, styleCss })
112
+ : buildUntrustedHtmlSrcDoc({ document: document ?? '', styleCss });
113
+ } catch {
114
+ return null;
115
+ }
116
+ }, [compiledOutput, integrity, document, styleCss]);
117
+
118
+ // Fresh content is a fresh frame lifecycle — reset ready + error state.
119
+ useEffect(() => {
120
+ readyRef.current = false;
121
+ setFrameError(null);
122
+ }, [srcDoc]);
123
+
124
+ useEffect(() => {
125
+ function onMessage(event: MessageEvent): void {
126
+ const frameWindow = iframeRef.current?.contentWindow ?? null;
127
+ const classified = classifyFrameMessage(
128
+ event,
129
+ frameWindow,
130
+ intentsRef.current.parse,
131
+ );
132
+ switch (classified.kind) {
133
+ case 'ready':
134
+ readyRef.current = true;
135
+ setFrameError(null);
136
+ frameWindow?.postMessage(hostPropsMessage(propsRef.current), '*');
137
+ return;
138
+ case 'intent':
139
+ intentsRef.current.perform(classified.intent);
140
+ return;
141
+ case 'error':
142
+ setFrameError(classified.message);
143
+ return;
144
+ case 'ignored':
145
+ return;
146
+ }
147
+ }
148
+ window.addEventListener('message', onMessage);
149
+ return () => window.removeEventListener('message', onMessage);
150
+ }, []);
151
+
152
+ // Re-push props to an already-ready frame when they change (repeatable
153
+ // re-render, per the HostProps contract). Before ready, the frameReady
154
+ // handler above pushes the current props — so no update is lost.
155
+ useEffect(() => {
156
+ if (!readyRef.current) return;
157
+ iframeRef.current?.contentWindow?.postMessage(
158
+ hostPropsMessage(componentProps),
159
+ '*',
160
+ );
161
+ }, [componentProps]);
162
+
163
+ if (srcDoc === null) {
164
+ return (
165
+ <div className={className}>
166
+ <div
167
+ role="alert"
168
+ className="flex items-start gap-2 rounded border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive"
169
+ >
170
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
171
+ <span>
172
+ This custom view could not be prepared safely and was not rendered.
173
+ </span>
174
+ </div>
175
+ </div>
176
+ );
177
+ }
178
+
179
+ return (
180
+ <div className={cn('flex h-full min-h-0 flex-col', className)}>
181
+ {frameError !== null ? (
182
+ <div
183
+ role="alert"
184
+ className="mb-2 flex items-start gap-2 rounded border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive"
185
+ >
186
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
187
+ <span>This custom view failed to render: {frameError}</span>
188
+ </div>
189
+ ) : null}
190
+ <iframe
191
+ ref={iframeRef}
192
+ title={title}
193
+ // OPAQUE ORIGIN: allow-scripts ONLY. Adding allow-same-origin would
194
+ // dissolve the origin boundary and give author code access to host
195
+ // cookies/DOM/tokens — never add it.
196
+ sandbox="allow-scripts"
197
+ srcDoc={srcDoc}
198
+ className="min-h-[24rem] w-full flex-1 rounded border border-rule bg-paper"
199
+ />
200
+ </div>
201
+ );
202
+ }
203
+
204
+ export default CustomUiFrame;