@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/biome-host/biome-registry.d.ts.map +1 -1
- package/dist/lib/biome-host/biome-registry.js +14 -9
- package/dist/lib/biome-host/biome-registry.js.map +1 -1
- package/dist/lib/contribution-registry/contribution-registry.d.ts +24 -0
- package/dist/lib/contribution-registry/contribution-registry.d.ts.map +1 -0
- package/dist/lib/contribution-registry/contribution-registry.js +67 -0
- package/dist/lib/contribution-registry/contribution-registry.js.map +1 -0
- package/dist/lib/contribution-registry/index.d.ts +3 -0
- package/dist/lib/contribution-registry/index.d.ts.map +1 -0
- package/dist/lib/contribution-registry/index.js +3 -0
- package/dist/lib/contribution-registry/index.js.map +1 -0
- package/dist/lib/contribution-registry/renderable-registry.d.ts +33 -0
- package/dist/lib/contribution-registry/renderable-registry.d.ts.map +1 -0
- package/dist/lib/contribution-registry/renderable-registry.js +51 -0
- package/dist/lib/contribution-registry/renderable-registry.js.map +1 -0
- package/dist/ui/capture/field-spec-to-zod.d.ts +1 -0
- package/dist/ui/capture/field-spec-to-zod.d.ts.map +1 -1
- package/dist/ui/capture/field-spec-to-zod.js +19 -0
- package/dist/ui/capture/field-spec-to-zod.js.map +1 -1
- package/dist/ui/capture/field-spec-validate.d.ts +4 -0
- package/dist/ui/capture/field-spec-validate.d.ts.map +1 -0
- package/dist/ui/capture/field-spec-validate.js +129 -0
- package/dist/ui/capture/field-spec-validate.js.map +1 -0
- package/dist/ui/capture/field-spec.d.ts +13 -1
- package/dist/ui/capture/field-spec.d.ts.map +1 -1
- package/dist/ui/capture/field-spec.js +25 -0
- package/dist/ui/capture/field-spec.js.map +1 -1
- package/dist/ui/capture/index.d.ts +4 -2
- package/dist/ui/capture/index.d.ts.map +1 -1
- package/dist/ui/capture/index.js +4 -2
- package/dist/ui/capture/index.js.map +1 -1
- package/dist/ui/capture/schema-field-input.d.ts.map +1 -1
- package/dist/ui/capture/schema-field-input.js +39 -12
- package/dist/ui/capture/schema-field-input.js.map +1 -1
- package/dist/ui/capture/submit-values.d.ts +6 -0
- package/dist/ui/capture/submit-values.d.ts.map +1 -0
- package/dist/ui/capture/submit-values.js +28 -0
- package/dist/ui/capture/submit-values.js.map +1 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/primitives/overflow-tabs.d.ts +5 -0
- package/dist/ui/primitives/overflow-tabs.d.ts.map +1 -1
- package/dist/ui/primitives/overflow-tabs.js +4 -4
- package/dist/ui/primitives/overflow-tabs.js.map +1 -1
- package/dist/ui/primitives/url-preview-chrome.d.ts +8 -0
- package/dist/ui/primitives/url-preview-chrome.d.ts.map +1 -0
- package/dist/ui/primitives/url-preview-chrome.js +40 -0
- package/dist/ui/primitives/url-preview-chrome.js.map +1 -0
- package/dist/vista/GenericCatalogueSurface.d.ts +10 -0
- package/dist/vista/GenericCatalogueSurface.d.ts.map +1 -0
- package/dist/vista/GenericCatalogueSurface.js +80 -0
- package/dist/vista/GenericCatalogueSurface.js.map +1 -0
- package/dist/vista/SurfaceFallbackCard.d.ts +9 -0
- package/dist/vista/SurfaceFallbackCard.d.ts.map +1 -0
- package/dist/vista/SurfaceFallbackCard.js +44 -0
- package/dist/vista/SurfaceFallbackCard.js.map +1 -0
- package/dist/vista/VistaPanel.d.ts +21 -0
- package/dist/vista/VistaPanel.d.ts.map +1 -0
- package/dist/vista/VistaPanel.js +31 -0
- package/dist/vista/VistaPanel.js.map +1 -0
- package/dist/vista/html/CustomUiFrame.d.ts +20 -0
- package/dist/vista/html/CustomUiFrame.d.ts.map +1 -0
- package/dist/vista/html/CustomUiFrame.js +69 -0
- package/dist/vista/html/CustomUiFrame.js.map +1 -0
- package/dist/vista/html/custom-ui-frame-bridge.d.ts +13 -0
- package/dist/vista/html/custom-ui-frame-bridge.d.ts.map +1 -0
- package/dist/vista/html/custom-ui-frame-bridge.js +30 -0
- package/dist/vista/html/custom-ui-frame-bridge.js.map +1 -0
- package/dist/vista/html/custom-ui-frame-contract.d.ts +29 -0
- package/dist/vista/html/custom-ui-frame-contract.d.ts.map +1 -0
- package/dist/vista/html/custom-ui-frame-contract.js +16 -0
- package/dist/vista/html/custom-ui-frame-contract.js.map +1 -0
- package/dist/vista/html/custom-ui-intents.d.ts +20 -0
- package/dist/vista/html/custom-ui-intents.d.ts.map +1 -0
- package/dist/vista/html/custom-ui-intents.js +57 -0
- package/dist/vista/html/custom-ui-intents.js.map +1 -0
- package/dist/vista/html/custom-ui-srcdoc.d.ts +17 -0
- package/dist/vista/html/custom-ui-srcdoc.d.ts.map +1 -0
- package/dist/vista/html/custom-ui-srcdoc.js +70 -0
- package/dist/vista/html/custom-ui-srcdoc.js.map +1 -0
- package/dist/vista/html/index.d.ts +6 -0
- package/dist/vista/html/index.d.ts.map +1 -0
- package/dist/vista/html/index.js +6 -0
- package/dist/vista/html/index.js.map +1 -0
- package/dist/vista/index.d.ts +18 -0
- package/dist/vista/index.d.ts.map +1 -0
- package/dist/vista/index.js +18 -0
- package/dist/vista/index.js.map +1 -0
- package/dist/vista/surface-catalogue.d.ts +5 -0
- package/dist/vista/surface-catalogue.d.ts.map +1 -0
- package/dist/vista/surface-catalogue.js +30 -0
- package/dist/vista/surface-catalogue.js.map +1 -0
- package/dist/vista/surface-icons.d.ts +7 -0
- package/dist/vista/surface-icons.d.ts.map +1 -0
- package/dist/vista/surface-icons.js +26 -0
- package/dist/vista/surface-icons.js.map +1 -0
- package/dist/vista/surface-registry.d.ts +7 -0
- package/dist/vista/surface-registry.d.ts.map +1 -0
- package/dist/vista/surface-registry.js +7 -0
- package/dist/vista/surface-registry.js.map +1 -0
- package/dist/vista/surfaces/ChartSurface.d.ts +4 -0
- package/dist/vista/surfaces/ChartSurface.d.ts.map +1 -0
- package/dist/vista/surfaces/ChartSurface.js +17 -0
- package/dist/vista/surfaces/ChartSurface.js.map +1 -0
- package/dist/vista/surfaces/FilesSurface.d.ts +15 -0
- package/dist/vista/surfaces/FilesSurface.d.ts.map +1 -0
- package/dist/vista/surfaces/FilesSurface.js +121 -0
- package/dist/vista/surfaces/FilesSurface.js.map +1 -0
- package/dist/vista/surfaces/FormSurface.d.ts +4 -0
- package/dist/vista/surfaces/FormSurface.d.ts.map +1 -0
- package/dist/vista/surfaces/FormSurface.js +97 -0
- package/dist/vista/surfaces/FormSurface.js.map +1 -0
- package/dist/vista/surfaces/HtmlSurface.d.ts +4 -0
- package/dist/vista/surfaces/HtmlSurface.d.ts.map +1 -0
- package/dist/vista/surfaces/HtmlSurface.js +11 -0
- package/dist/vista/surfaces/HtmlSurface.js.map +1 -0
- package/dist/vista/surfaces/TableSurface.d.ts +4 -0
- package/dist/vista/surfaces/TableSurface.d.ts.map +1 -0
- package/dist/vista/surfaces/TableSurface.js +42 -0
- package/dist/vista/surfaces/TableSurface.js.map +1 -0
- package/dist/vista/surfaces/UrlSurface.d.ts +4 -0
- package/dist/vista/surfaces/UrlSurface.d.ts.map +1 -0
- package/dist/vista/surfaces/UrlSurface.js +7 -0
- package/dist/vista/surfaces/UrlSurface.js.map +1 -0
- package/dist/vista/surfaces/blob-surfaces.d.ts +6 -0
- package/dist/vista/surfaces/blob-surfaces.d.ts.map +1 -0
- package/dist/vista/surfaces/blob-surfaces.js +38 -0
- package/dist/vista/surfaces/blob-surfaces.js.map +1 -0
- package/dist/vista/surfaces/elicitation-transport.d.ts +18 -0
- package/dist/vista/surfaces/elicitation-transport.d.ts.map +1 -0
- package/dist/vista/surfaces/elicitation-transport.js +100 -0
- package/dist/vista/surfaces/elicitation-transport.js.map +1 -0
- package/dist/vista/surfaces/index.d.ts +11 -0
- package/dist/vista/surfaces/index.d.ts.map +1 -0
- package/dist/vista/surfaces/index.js +89 -0
- package/dist/vista/surfaces/index.js.map +1 -0
- package/dist/vista/surfaces/text-surfaces.d.ts +8 -0
- package/dist/vista/surfaces/text-surfaces.d.ts.map +1 -0
- package/dist/vista/surfaces/text-surfaces.js +54 -0
- package/dist/vista/surfaces/text-surfaces.js.map +1 -0
- package/dist/vista/use-vista-tabs.d.ts +30 -0
- package/dist/vista/use-vista-tabs.d.ts.map +1 -0
- package/dist/vista/use-vista-tabs.js +77 -0
- package/dist/vista/use-vista-tabs.js.map +1 -0
- package/dist/vista/vista-code-renderer.d.ts +12 -0
- package/dist/vista/vista-code-renderer.d.ts.map +1 -0
- package/dist/vista/vista-code-renderer.js +10 -0
- package/dist/vista/vista-code-renderer.js.map +1 -0
- package/dist/vista/vista-file-source.d.ts +20 -0
- package/dist/vista/vista-file-source.d.ts.map +1 -0
- package/dist/vista/vista-file-source.js +69 -0
- package/dist/vista/vista-file-source.js.map +1 -0
- package/dist/vista/vista-form-host.d.ts +16 -0
- package/dist/vista/vista-form-host.d.ts.map +1 -0
- package/dist/vista/vista-form-host.js +10 -0
- package/dist/vista/vista-form-host.js.map +1 -0
- package/dist/vista/vista-model.d.ts +12 -0
- package/dist/vista/vista-model.d.ts.map +1 -0
- package/dist/vista/vista-model.js +72 -0
- package/dist/vista/vista-model.js.map +1 -0
- package/dist/vista/vista-open-context.d.ts +9 -0
- package/dist/vista/vista-open-context.d.ts.map +1 -0
- package/dist/vista/vista-open-context.js +10 -0
- package/dist/vista/vista-open-context.js.map +1 -0
- package/dist/vista/vista-payloads.d.ts +107 -0
- package/dist/vista/vista-payloads.d.ts.map +1 -0
- package/dist/vista/vista-payloads.js +305 -0
- package/dist/vista/vista-payloads.js.map +1 -0
- package/dist/vista/vista-tabs-manifest.d.ts +17 -0
- package/dist/vista/vista-tabs-manifest.d.ts.map +1 -0
- package/dist/vista/vista-tabs-manifest.js +60 -0
- package/dist/vista/vista-tabs-manifest.js.map +1 -0
- package/dist/vista/vista-tools.d.ts +41 -0
- package/dist/vista/vista-tools.d.ts.map +1 -0
- package/dist/vista/vista-tools.js +113 -0
- package/dist/vista/vista-tools.js.map +1 -0
- package/package.json +3 -3
- package/src/index.ts +5 -0
- package/src/lib/biome-host/biome-registry.ts +31 -11
- package/src/lib/contribution-registry/contribution-registry.spec.ts +141 -0
- package/src/lib/contribution-registry/contribution-registry.ts +202 -0
- package/src/lib/contribution-registry/index.ts +9 -0
- package/src/lib/contribution-registry/renderable-registry.ts +142 -0
- package/src/ui/capture/field-spec-to-zod.ts +22 -0
- package/src/ui/capture/field-spec-validate.ts +147 -0
- package/src/ui/capture/field-spec.ts +62 -0
- package/src/ui/capture/index.ts +9 -1
- package/src/ui/capture/schema-field-input.tsx +134 -14
- package/src/ui/capture/submit-values.test.ts +70 -0
- package/src/ui/capture/submit-values.ts +63 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/primitives/overflow-tabs.tsx +24 -4
- package/src/ui/primitives/url-preview-chrome.tsx +102 -0
- package/src/vista/GenericCatalogueSurface.tsx +177 -0
- package/src/vista/SurfaceFallbackCard.tsx +109 -0
- package/src/vista/VistaPanel.tsx +155 -0
- package/src/vista/form-payload.spec.ts +93 -0
- package/src/vista/html/CustomUiFrame.spec.tsx +125 -0
- package/src/vista/html/CustomUiFrame.tsx +204 -0
- package/src/vista/html/custom-ui-frame-bridge.spec.ts +191 -0
- package/src/vista/html/custom-ui-frame-bridge.ts +92 -0
- package/src/vista/html/custom-ui-frame-contract.ts +88 -0
- package/src/vista/html/custom-ui-intents.spec.ts +133 -0
- package/src/vista/html/custom-ui-intents.ts +151 -0
- package/src/vista/html/custom-ui-srcdoc.spec.ts +151 -0
- package/src/vista/html/custom-ui-srcdoc.ts +193 -0
- package/src/vista/html/index.ts +13 -0
- package/src/vista/index.ts +48 -0
- package/src/vista/surface-catalogue.spec.ts +140 -0
- package/src/vista/surface-catalogue.ts +84 -0
- package/src/vista/surface-icons.ts +57 -0
- package/src/vista/surface-registry.ts +56 -0
- package/src/vista/surfaces/ChartSurface.tsx +34 -0
- package/src/vista/surfaces/FilesSurface.tsx +268 -0
- package/src/vista/surfaces/FormSurface.tsx +264 -0
- package/src/vista/surfaces/HtmlSurface.tsx +43 -0
- package/src/vista/surfaces/TableSurface.tsx +99 -0
- package/src/vista/surfaces/UrlSurface.tsx +40 -0
- package/src/vista/surfaces/blob-surfaces.tsx +129 -0
- package/src/vista/surfaces/elicitation-transport.spec.ts +187 -0
- package/src/vista/surfaces/elicitation-transport.ts +175 -0
- package/src/vista/surfaces/index.tsx +157 -0
- package/src/vista/surfaces/text-surfaces.tsx +119 -0
- package/src/vista/use-vista-tabs.ts +188 -0
- package/src/vista/vista-code-renderer.tsx +46 -0
- package/src/vista/vista-file-source.tsx +163 -0
- package/src/vista/vista-form-host.tsx +78 -0
- package/src/vista/vista-model.ts +130 -0
- package/src/vista/vista-open-context.tsx +42 -0
- package/src/vista/vista-payloads.ts +514 -0
- package/src/vista/vista-tabs-manifest.ts +123 -0
- package/src/vista/vista-tools.ts +220 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom-UI srcdoc / CSP builder — the security invariant lives HERE.
|
|
3
|
+
*
|
|
4
|
+
* These assertions pin the strict-CSP posture that makes running author- and
|
|
5
|
+
* agent-authored content safe, on BOTH forms the frame renders: the compiled,
|
|
6
|
+
* SRI-pinned chunk (`script-src '<hash>'`) and the untrusted static document
|
|
7
|
+
* (`script-src 'none'`). The mutation blocks prove the invariants IN ISOLATION:
|
|
8
|
+
* the suite goes RED if the CSP `script-src` hash is dropped or loosened (e.g.
|
|
9
|
+
* to `'unsafe-inline'` / a wildcard), or if the static-document policy ever
|
|
10
|
+
* grew a script source.
|
|
11
|
+
*
|
|
12
|
+
* Migrated verbatim from `app-forge-web` when the frame was promoted into the
|
|
13
|
+
* UI kernel; runs under the host-web vitest mirror (this package carries no
|
|
14
|
+
* vitest dep).
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
buildCustomUiFrameCsp,
|
|
20
|
+
buildCustomUiSrcDoc,
|
|
21
|
+
buildUntrustedHtmlFrameCsp,
|
|
22
|
+
buildUntrustedHtmlSrcDoc,
|
|
23
|
+
CustomUiSrcDocError,
|
|
24
|
+
} from './custom-ui-srcdoc';
|
|
25
|
+
|
|
26
|
+
const INTEGRITY = 'sha384-AbC123+/def456Ghi789jklMnoPqr==';
|
|
27
|
+
const COMPILED = 'export default function C(){return null}';
|
|
28
|
+
|
|
29
|
+
describe('buildCustomUiFrameCsp', () => {
|
|
30
|
+
it('emits the exact airtight directive string, pinning the server hash', () => {
|
|
31
|
+
expect(buildCustomUiFrameCsp(INTEGRITY)).toBe(
|
|
32
|
+
`default-src 'none'; script-src '${INTEGRITY}'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'`,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('script-src carries the server integrity token verbatim', () => {
|
|
37
|
+
expect(buildCustomUiFrameCsp(INTEGRITY)).toContain(
|
|
38
|
+
`script-src '${INTEGRITY}'`,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('never grants scripts unsafe-inline or a wildcard', () => {
|
|
43
|
+
const csp = buildCustomUiFrameCsp(INTEGRITY);
|
|
44
|
+
expect(csp).not.toContain("script-src 'unsafe-inline'");
|
|
45
|
+
expect(csp).not.toContain('script-src *');
|
|
46
|
+
expect(csp).toContain(`default-src 'none'`);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('buildCustomUiSrcDoc', () => {
|
|
51
|
+
const srcDoc = buildCustomUiSrcDoc({
|
|
52
|
+
compiledOutput: COMPILED,
|
|
53
|
+
integrity: INTEGRITY,
|
|
54
|
+
styleCss: ':root{--x:1}',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('embeds the pinned CSP as a meta http-equiv', () => {
|
|
58
|
+
expect(srcDoc).toContain(
|
|
59
|
+
`<meta http-equiv="Content-Security-Policy" content="${buildCustomUiFrameCsp(
|
|
60
|
+
INTEGRITY,
|
|
61
|
+
)}" />`,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('inlines the chunk verbatim as the one module script (hash must match)', () => {
|
|
66
|
+
expect(srcDoc).toContain(`<script type="module">${COMPILED}</script>`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('carries the #root mount point the self-mount wrapper renders into', () => {
|
|
70
|
+
expect(srcDoc).toContain('<div id="root"></div>');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('inlines the design-system style (style-src unsafe-inline)', () => {
|
|
74
|
+
expect(srcDoc).toContain('<style>:root{--x:1}</style>');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('refuses a chunk containing a literal </script (would truncate the module)', () => {
|
|
78
|
+
expect(() =>
|
|
79
|
+
buildCustomUiSrcDoc({
|
|
80
|
+
compiledOutput: 'const x = "a</script><script>alert(1)</script>";',
|
|
81
|
+
integrity: INTEGRITY,
|
|
82
|
+
styleCss: '',
|
|
83
|
+
}),
|
|
84
|
+
).toThrow(CustomUiSrcDocError);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── MUTATION TEST (security invariant, isolated) ───────────────────────────
|
|
88
|
+
// A loosened policy that drops the hash pin must FAIL these assertions. This
|
|
89
|
+
// proves the guard is load-bearing: remove/loosen the script-src hash and the
|
|
90
|
+
// suite goes red.
|
|
91
|
+
it('MUTATION: dropping the script-src hash makes the CSP invalid — caught', () => {
|
|
92
|
+
const loosened = `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'`;
|
|
93
|
+
// The real builder must NOT equal the loosened (hash-dropped) policy…
|
|
94
|
+
expect(buildCustomUiFrameCsp(INTEGRITY)).not.toBe(loosened);
|
|
95
|
+
// …and the real policy MUST pin the exact hash the loosened one dropped.
|
|
96
|
+
expect(buildCustomUiFrameCsp(INTEGRITY)).toContain(
|
|
97
|
+
`script-src '${INTEGRITY}'`,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('buildUntrustedHtmlFrameCsp', () => {
|
|
103
|
+
it('emits the same airtight policy with script-src none', () => {
|
|
104
|
+
expect(buildUntrustedHtmlFrameCsp()).toBe(
|
|
105
|
+
`default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'`,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('buildUntrustedHtmlSrcDoc', () => {
|
|
111
|
+
const DOCUMENT = '<h1>Report</h1><p>Static, agent-authored markup.</p>';
|
|
112
|
+
const srcDoc = buildUntrustedHtmlSrcDoc({
|
|
113
|
+
document: DOCUMENT,
|
|
114
|
+
styleCss: ':root{--x:1}',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("pins script-src 'none' — an authored <script> can NEVER execute", () => {
|
|
118
|
+
expect(srcDoc).toContain(
|
|
119
|
+
`<meta http-equiv="Content-Security-Policy" content="${buildUntrustedHtmlFrameCsp()}" />`,
|
|
120
|
+
);
|
|
121
|
+
expect(srcDoc).toContain(`script-src 'none'`);
|
|
122
|
+
// No script source of any kind may be granted on the static path.
|
|
123
|
+
expect(srcDoc).not.toContain("script-src 'unsafe-inline'");
|
|
124
|
+
expect(srcDoc).not.toContain('script-src *');
|
|
125
|
+
expect(srcDoc).not.toContain('script-src sha384-');
|
|
126
|
+
expect(srcDoc).toContain(`default-src 'none'`);
|
|
127
|
+
expect(srcDoc).toContain(`base-uri 'none'`);
|
|
128
|
+
expect(srcDoc).toContain(`form-action 'none'`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('emits the document verbatim with the inlined styles', () => {
|
|
132
|
+
expect(srcDoc).toContain('<style>:root{--x:1}</style>');
|
|
133
|
+
expect(srcDoc).toContain(DOCUMENT);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('refuses a document containing a literal </script (fail-closed)', () => {
|
|
137
|
+
expect(() =>
|
|
138
|
+
buildUntrustedHtmlSrcDoc({
|
|
139
|
+
document: '<p>ok</p></script><script>alert(1)</script>',
|
|
140
|
+
styleCss: '',
|
|
141
|
+
}),
|
|
142
|
+
).toThrow(CustomUiSrcDocError);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ── MUTATION TEST (static-document invariant, isolated) ────────────────────
|
|
146
|
+
it('MUTATION: granting the static document any script source is caught', () => {
|
|
147
|
+
const loosened = `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'`;
|
|
148
|
+
expect(buildUntrustedHtmlFrameCsp()).not.toBe(loosened);
|
|
149
|
+
expect(buildUntrustedHtmlFrameCsp()).toContain(`script-src 'none'`);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the `srcdoc` document for a sandbox frame, and the strict
|
|
3
|
+
* Content-Security-Policy that makes rendering untrusted content safe.
|
|
4
|
+
*
|
|
5
|
+
* A sandboxed, opaque-origin frame cannot receive an HTTP CSP header from us
|
|
6
|
+
* (there is no HTTP response — the bytes come from `srcdoc`), so the policy is
|
|
7
|
+
* embedded as a `<meta http-equiv="Content-Security-Policy">`. The policy is
|
|
8
|
+
* airtight by construction:
|
|
9
|
+
*
|
|
10
|
+
* default-src 'none' — no network of ANY kind (no fetch, no our-origin,
|
|
11
|
+
* no img/font/frame). The frame cannot phone home.
|
|
12
|
+
* script-src 'sha384-…' — ONLY the one chunk whose SRI hash the SERVER
|
|
13
|
+
* computed may execute. No 'unsafe-inline', no
|
|
14
|
+
* scheme-source, no wildcard — a hash source pins
|
|
15
|
+
* the exact bytes. The chunk is inlined as one
|
|
16
|
+
* <script type="module"> whose body IS those exact
|
|
17
|
+
* bytes, so its computed hash matches the token.
|
|
18
|
+
* For a STATIC document (no compiled chunk) the
|
|
19
|
+
* source is `'none'`: script never runs at all.
|
|
20
|
+
* style-src 'unsafe-inline'— allows the inlined design-system <style> and any
|
|
21
|
+
* inline styles components set. Styles are not a
|
|
22
|
+
* script vector under default-src 'none'.
|
|
23
|
+
* base-uri 'none' — the frame cannot repoint relative URLs.
|
|
24
|
+
* form-action 'none' — the frame cannot submit a form anywhere.
|
|
25
|
+
*
|
|
26
|
+
* The chunk document also carries `<div id="root">` — the mount point the
|
|
27
|
+
* broker's self-mount wrapper renders into (it throws if the element is
|
|
28
|
+
* missing).
|
|
29
|
+
*
|
|
30
|
+
* Promoted from `app-forge-web` into the UI kernel (Canopy §8.3). The policy
|
|
31
|
+
* string and the `</script` fail-closed guard are carried over VERBATIM.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** A minimal reset so a frame document has sane base geometry. */
|
|
35
|
+
export const CUSTOM_UI_FRAME_RESET_CSS = `
|
|
36
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
37
|
+
html, body { margin: 0; padding: 0; }
|
|
38
|
+
body {
|
|
39
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
40
|
+
color: hsl(var(--foreground, 228 14% 11%));
|
|
41
|
+
background: transparent;
|
|
42
|
+
}
|
|
43
|
+
#root { min-height: 100%; }
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The frame CSP over an already-quoted `script-src` source expression. Private:
|
|
48
|
+
* the only two source expressions this plane ever emits are a pinned SRI hash
|
|
49
|
+
* and `'none'`, exposed through the two named builders below. There is no seam
|
|
50
|
+
* for an arbitrary caller-supplied source.
|
|
51
|
+
*/
|
|
52
|
+
function buildFrameCsp(scriptSource: string): string {
|
|
53
|
+
return [
|
|
54
|
+
`default-src 'none'`,
|
|
55
|
+
`script-src ${scriptSource}`,
|
|
56
|
+
`style-src 'unsafe-inline'`,
|
|
57
|
+
`base-uri 'none'`,
|
|
58
|
+
`form-action 'none'`,
|
|
59
|
+
].join('; ');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The exact CSP directive string for a COMPILED custom-UI frame. `integrity` is
|
|
64
|
+
* the server-issued Subresource-Integrity / CSP token (`sha384-<base64>`)
|
|
65
|
+
* pinning the compiled chunk's bytes; it is embedded verbatim as the sole
|
|
66
|
+
* `script-src` source.
|
|
67
|
+
*/
|
|
68
|
+
export function buildCustomUiFrameCsp(integrity: string): string {
|
|
69
|
+
return buildFrameCsp(`'${integrity}'`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The CSP for an UNTRUSTED static document: identical to the compiled-frame
|
|
74
|
+
* policy but with `script-src 'none'` — no script may execute at all.
|
|
75
|
+
*/
|
|
76
|
+
export function buildUntrustedHtmlFrameCsp(): string {
|
|
77
|
+
return buildFrameCsp(`'none'`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Inputs for {@link buildCustomUiSrcDoc}. */
|
|
81
|
+
export interface CustomUiSrcDocInput {
|
|
82
|
+
/** The compiled, self-mounting ESM chunk (verbatim script body). */
|
|
83
|
+
readonly compiledOutput: string;
|
|
84
|
+
/** Server SRI/CSP token (`sha384-<base64>`) pinning `compiledOutput`. */
|
|
85
|
+
readonly integrity: string;
|
|
86
|
+
/**
|
|
87
|
+
* Host design-system CSS inlined into the frame so components render styled
|
|
88
|
+
* with zero network. Placed in a `<style>` (allowed by
|
|
89
|
+
* `style-src 'unsafe-inline'`).
|
|
90
|
+
*/
|
|
91
|
+
readonly styleCss: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Thrown when a document cannot be assembled SAFELY. The caller renders a
|
|
96
|
+
* visible notice — never a silently-truncated or unpinned frame.
|
|
97
|
+
*/
|
|
98
|
+
export class CustomUiSrcDocError extends Error {}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reject content that would truncate the inline module. The broker minifies +
|
|
102
|
+
* esbuild escapes `</script>` inside string literals to `<\/script>`, so a
|
|
103
|
+
* well-formed chunk never contains a literal `</script`. We do NOT rely on that
|
|
104
|
+
* implicitly — a chunk that DID contain it would let the HTML parser truncate
|
|
105
|
+
* the module early (a mis-render, not an XSS: the CSP + opaque origin still
|
|
106
|
+
* contain it). We assert it and throw so the caller surfaces a visible error
|
|
107
|
+
* instead of shipping a silently-truncated frame.
|
|
108
|
+
*/
|
|
109
|
+
function assertNoScriptTerminator(source: string, what: string): void {
|
|
110
|
+
if (/<\/script/i.test(source)) {
|
|
111
|
+
throw new CustomUiSrcDocError(
|
|
112
|
+
`${what} contains a literal "</script" — it would truncate the inline ` +
|
|
113
|
+
'module. The producer must emit escaped bytes; refusing to build a ' +
|
|
114
|
+
'mis-rendered frame.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Assemble the full `srcdoc` HTML for a COMPILED custom-UI frame: the pinned
|
|
121
|
+
* CSP, the inlined design-system styles, the `#root` mount point, and the
|
|
122
|
+
* SRI-matched ESM chunk as a single inline module script.
|
|
123
|
+
*
|
|
124
|
+
* The chunk bytes are emitted verbatim (NOT HTML-escaped): the CSP hash was
|
|
125
|
+
* computed over exactly these bytes, so any transformation would break the
|
|
126
|
+
* match and the browser would refuse to execute the script — a fail-closed
|
|
127
|
+
* property we rely on rather than sanitizing.
|
|
128
|
+
*/
|
|
129
|
+
export function buildCustomUiSrcDoc({
|
|
130
|
+
compiledOutput,
|
|
131
|
+
integrity,
|
|
132
|
+
styleCss,
|
|
133
|
+
}: CustomUiSrcDocInput): string {
|
|
134
|
+
assertNoScriptTerminator(compiledOutput, 'compiled custom-UI chunk');
|
|
135
|
+
const csp = buildCustomUiFrameCsp(integrity);
|
|
136
|
+
return [
|
|
137
|
+
`<!doctype html>`,
|
|
138
|
+
`<html>`,
|
|
139
|
+
`<head>`,
|
|
140
|
+
`<meta charset="utf-8" />`,
|
|
141
|
+
`<meta http-equiv="Content-Security-Policy" content="${csp}" />`,
|
|
142
|
+
`<style>${styleCss}</style>`,
|
|
143
|
+
`</head>`,
|
|
144
|
+
`<body>`,
|
|
145
|
+
`<div id="root"></div>`,
|
|
146
|
+
`<script type="module">${compiledOutput}</script>`,
|
|
147
|
+
`</body>`,
|
|
148
|
+
`</html>`,
|
|
149
|
+
].join('\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Inputs for {@link buildUntrustedHtmlSrcDoc}. */
|
|
153
|
+
export interface UntrustedHtmlSrcDocInput {
|
|
154
|
+
/** The untrusted HTML document/fragment, emitted verbatim into `<body>`. */
|
|
155
|
+
readonly document: string;
|
|
156
|
+
/** Styles inlined ahead of the document. */
|
|
157
|
+
readonly styleCss: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Assemble the `srcdoc` for an UNTRUSTED, agent-authored HTML document — the
|
|
162
|
+
* Vista `html` surface's static form.
|
|
163
|
+
*
|
|
164
|
+
* Identical containment to the compiled path (opaque origin, `default-src
|
|
165
|
+
* 'none'`, `base-uri`/`form-action` `'none'`) with `script-src 'none'`: the
|
|
166
|
+
* markup and styles render, and any `<script>` the author included NEVER
|
|
167
|
+
* executes. There is no "trusted document" mode and no way to opt into script
|
|
168
|
+
* without a server-issued SRI token.
|
|
169
|
+
*
|
|
170
|
+
* A document containing a literal `</script` is REJECTED for the same
|
|
171
|
+
* truncation reason as a chunk: a partially-parsed document is a mis-render,
|
|
172
|
+
* and we surface it rather than ship it.
|
|
173
|
+
*/
|
|
174
|
+
export function buildUntrustedHtmlSrcDoc({
|
|
175
|
+
document,
|
|
176
|
+
styleCss,
|
|
177
|
+
}: UntrustedHtmlSrcDocInput): string {
|
|
178
|
+
assertNoScriptTerminator(document, 'untrusted HTML document');
|
|
179
|
+
const csp = buildUntrustedHtmlFrameCsp();
|
|
180
|
+
return [
|
|
181
|
+
`<!doctype html>`,
|
|
182
|
+
`<html>`,
|
|
183
|
+
`<head>`,
|
|
184
|
+
`<meta charset="utf-8" />`,
|
|
185
|
+
`<meta http-equiv="Content-Security-Policy" content="${csp}" />`,
|
|
186
|
+
`<style>${styleCss}</style>`,
|
|
187
|
+
`</head>`,
|
|
188
|
+
`<body>`,
|
|
189
|
+
document,
|
|
190
|
+
`</body>`,
|
|
191
|
+
`</html>`,
|
|
192
|
+
].join('\n');
|
|
193
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hardened untrusted-UI frame — ONE implementation for every plane that
|
|
3
|
+
* renders author- or agent-authored content (App Forge runtime views, the Vista
|
|
4
|
+
* `html` surface).
|
|
5
|
+
*
|
|
6
|
+
* Promoted from `app-forge-web` (Canopy §8.3). The security properties are
|
|
7
|
+
* load-bearing, not stylistic — see the module docs for each.
|
|
8
|
+
*/
|
|
9
|
+
export * from './custom-ui-frame-contract';
|
|
10
|
+
export * from './custom-ui-intents';
|
|
11
|
+
export * from './custom-ui-srcdoc';
|
|
12
|
+
export * from './custom-ui-frame-bridge';
|
|
13
|
+
export * from './CustomUiFrame';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vista — the agent-driven, biome-extensible render plane (Canopy Plane ④).
|
|
3
|
+
*
|
|
4
|
+
* What this subpath ships:
|
|
5
|
+
* • the surface REGISTRY (built on the shared contribution-registry core),
|
|
6
|
+
* the catalogue sync that lights up biome-contributed kinds with a generic
|
|
7
|
+
* schema-driven renderer, and the safe fallback card — the three-layer
|
|
8
|
+
* guarantee: a biome's own renderer wins, else a generic schema-driven
|
|
9
|
+
* renderer, else a fallback card with a typed reason. Never a throw, never
|
|
10
|
+
* a blank;
|
|
11
|
+
* • the TAB SHELL — `useVistaTabs` (state) + `VistaPanel` (chrome, built on
|
|
12
|
+
* `OverflowTabs`). ONE implementation; the thin session workspace and the
|
|
13
|
+
* Design-System-Builder preview both compose it;
|
|
14
|
+
* • the FIRST-PARTY renderers the kernel owns (see `./surfaces`), registered
|
|
15
|
+
* as a side effect of importing this module;
|
|
16
|
+
* • the hardened untrusted-UI frame promoted from App Forge (`./html`);
|
|
17
|
+
* • the `.tabs.json` manifest parser lifted out of the DSB page;
|
|
18
|
+
* • the agent-facing `vista_*` tool contract + frontend dispatcher.
|
|
19
|
+
*
|
|
20
|
+
* Host-coupled first-party kinds (`markdown`, `chart`, `web`, `biome-route`,
|
|
21
|
+
* `object`, `graph`, `app`) are registered by the HOST — see `./surfaces` for
|
|
22
|
+
* the partition and why it is drawn there.
|
|
23
|
+
*
|
|
24
|
+
* The `form` kind (secure elicitation) is kernel-owned but needs the SUBJECT's
|
|
25
|
+
* bearer token and an outcome sink: the host supplies both through
|
|
26
|
+
* `VistaFormHostProvider` (`./vista-form-host`), the same shape of seam as
|
|
27
|
+
* `VistaFileSourceProvider`. No provider ⇒ the surface refuses to submit,
|
|
28
|
+
* visibly.
|
|
29
|
+
*/
|
|
30
|
+
export * from './surface-registry';
|
|
31
|
+
export * from './surface-catalogue';
|
|
32
|
+
export * from './surface-icons';
|
|
33
|
+
export * from './GenericCatalogueSurface';
|
|
34
|
+
export * from './SurfaceFallbackCard';
|
|
35
|
+
export * from './vista-model';
|
|
36
|
+
export * from './vista-payloads';
|
|
37
|
+
export * from './vista-tabs-manifest';
|
|
38
|
+
export * from './vista-file-source';
|
|
39
|
+
export * from './vista-form-host';
|
|
40
|
+
export * from './vista-code-renderer';
|
|
41
|
+
export * from './vista-open-context';
|
|
42
|
+
export * from './use-vista-tabs';
|
|
43
|
+
export * from './VistaPanel';
|
|
44
|
+
export * from './vista-tools';
|
|
45
|
+
export * from './html';
|
|
46
|
+
// Imported for its REGISTRATION side effect as well as its exports: pulling in
|
|
47
|
+
// `@xemahq/ui-kernel/vista` wires the kernel-owned first-party renderers.
|
|
48
|
+
export * from './surfaces';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guards the Vista three-layer zero-maintenance guarantee:
|
|
3
|
+
* 1. a biome's OWN renderer always wins;
|
|
4
|
+
* 2. a biome-contributed kind with no renderer gets the GENERIC
|
|
5
|
+
* schema-driven renderer derived from its catalogue `payloadSchema`;
|
|
6
|
+
* 3. anything still unknown resolves to a typed fallback reason — never a
|
|
7
|
+
* throw, never a blank.
|
|
8
|
+
*
|
|
9
|
+
* Runs under the host-web vitest mirror (this package carries no vitest dep).
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
SurfaceKind,
|
|
13
|
+
SurfaceKindSource,
|
|
14
|
+
type SurfaceKindCatalogueEntry,
|
|
15
|
+
} from '@xemahq/kernel-contracts/surface';
|
|
16
|
+
import { createElement } from 'react';
|
|
17
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
18
|
+
|
|
19
|
+
import { deriveGenericSurfaceFields } from './GenericCatalogueSurface';
|
|
20
|
+
import {
|
|
21
|
+
__resetSurfaceCatalogueSyncForTests,
|
|
22
|
+
syncSurfaceCatalogue,
|
|
23
|
+
} from './surface-catalogue';
|
|
24
|
+
import { vistaSurfaceRegistry } from './surface-registry';
|
|
25
|
+
import { ContributionFallbackReason } from '../lib/contribution-registry';
|
|
26
|
+
|
|
27
|
+
function entry(
|
|
28
|
+
overrides: Partial<SurfaceKindCatalogueEntry> = {},
|
|
29
|
+
): SurfaceKindCatalogueEntry {
|
|
30
|
+
return {
|
|
31
|
+
kind: 'acme:invoice',
|
|
32
|
+
version: 1,
|
|
33
|
+
displayName: 'Invoice',
|
|
34
|
+
summary: 'An invoice preview.',
|
|
35
|
+
payloadSchema: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
number: { type: 'string', title: 'Invoice #' },
|
|
39
|
+
total: { type: 'number' },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
source: SurfaceKindSource.Biome,
|
|
43
|
+
biomeId: 'acme',
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vistaSurfaceRegistry.__resetOpenForTests();
|
|
50
|
+
__resetSurfaceCatalogueSyncForTests();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('Vista surface registry', () => {
|
|
54
|
+
it('reserves every first-party SurfaceKind against biome shadowing', () => {
|
|
55
|
+
for (const kind of Object.values(SurfaceKind)) {
|
|
56
|
+
expect(vistaSurfaceRegistry.isReserved(kind)).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
expect(() =>
|
|
59
|
+
vistaSurfaceRegistry.register(SurfaceKind.Markdown, {
|
|
60
|
+
version: 1,
|
|
61
|
+
validate: (p) => p,
|
|
62
|
+
render: () => createElement('div'),
|
|
63
|
+
}),
|
|
64
|
+
).toThrow(/reserved/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('falls back with a typed reason for an unknown kind', () => {
|
|
68
|
+
const resolution = vistaSurfaceRegistry.resolve({
|
|
69
|
+
kind: 'acme:nope',
|
|
70
|
+
version: 1,
|
|
71
|
+
payload: {},
|
|
72
|
+
});
|
|
73
|
+
expect(resolution).toMatchObject({
|
|
74
|
+
matched: false,
|
|
75
|
+
reason: ContributionFallbackReason.UnknownKind,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('syncSurfaceCatalogue', () => {
|
|
81
|
+
it('registers a generic renderer only where the biome shipped none', async () => {
|
|
82
|
+
const own = {
|
|
83
|
+
version: 1,
|
|
84
|
+
validate: (p: unknown) => p,
|
|
85
|
+
render: () => createElement('div', null, 'own'),
|
|
86
|
+
};
|
|
87
|
+
vistaSurfaceRegistry.register('acme:invoice', own);
|
|
88
|
+
await syncSurfaceCatalogue(() =>
|
|
89
|
+
Promise.resolve([
|
|
90
|
+
entry(),
|
|
91
|
+
entry({ kind: 'acme:ledger', displayName: 'Ledger' }),
|
|
92
|
+
]),
|
|
93
|
+
);
|
|
94
|
+
// The biome's own renderer survived; the un-rendered kind got the generic.
|
|
95
|
+
expect(vistaSurfaceRegistry.get('acme:invoice')).toBe(own);
|
|
96
|
+
expect(vistaSurfaceRegistry.get('acme:ledger')).toBeDefined();
|
|
97
|
+
const resolution = vistaSurfaceRegistry.resolve({
|
|
98
|
+
kind: 'acme:ledger',
|
|
99
|
+
version: 1,
|
|
100
|
+
payload: { number: 'INV-1' },
|
|
101
|
+
});
|
|
102
|
+
expect(resolution.matched).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('skips first-party entries', async () => {
|
|
106
|
+
await syncSurfaceCatalogue(() =>
|
|
107
|
+
Promise.resolve([
|
|
108
|
+
entry({
|
|
109
|
+
kind: SurfaceKind.Markdown,
|
|
110
|
+
source: SurfaceKindSource.FirstParty,
|
|
111
|
+
}),
|
|
112
|
+
]),
|
|
113
|
+
);
|
|
114
|
+
expect(vistaSurfaceRegistry.listOpen()).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('leaves the registry untouched and retryable when the fetch fails', async () => {
|
|
118
|
+
await syncSurfaceCatalogue(() => Promise.reject(new Error('offline')));
|
|
119
|
+
expect(vistaSurfaceRegistry.listOpen()).toEqual([]);
|
|
120
|
+
await syncSurfaceCatalogue(() => Promise.resolve([entry()]));
|
|
121
|
+
expect(vistaSurfaceRegistry.listOpen()).toEqual(['acme:invoice']);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('deriveGenericSurfaceFields', () => {
|
|
126
|
+
it('orders schema-declared keys first, then undeclared payload keys', () => {
|
|
127
|
+
const fields = deriveGenericSurfaceFields(entry().payloadSchema, {
|
|
128
|
+
total: 12,
|
|
129
|
+
extra: true,
|
|
130
|
+
});
|
|
131
|
+
expect(fields.map((f) => f.key)).toEqual(['number', 'total', 'extra']);
|
|
132
|
+
expect(fields[0]?.label).toBe('Invoice #');
|
|
133
|
+
expect(fields[1]?.label).toBe('total');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('degrades to plain payload entries when the schema declares no properties', () => {
|
|
137
|
+
const fields = deriveGenericSurfaceFields({ type: 'object' }, { a: 1, b: 2 });
|
|
138
|
+
expect(fields.map((f) => f.key)).toEqual(['a', 'b']);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vista surface-kind catalogue sync.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the shipped ChatWidget catalogue sync: read the surface-kind
|
|
5
|
+
* catalogue (first-party + every biome-contributed kind, each with a payload
|
|
6
|
+
* JSON Schema) and register any BIOME-contributed kind that does NOT yet have a
|
|
7
|
+
* renderer into the OPEN registry, using the generic schema-driven renderer.
|
|
8
|
+
*
|
|
9
|
+
* Why a generic renderer — the three-layer zero-maintenance guarantee:
|
|
10
|
+
* 1. A biome that ships a frontend biome registers its OWN renderer via
|
|
11
|
+
* `vistaSurfaceRegistry.register('<biomeId>:<kind>', …)` from
|
|
12
|
+
* `FrontendBiome.init()`. That ALWAYS wins: this sync only registers where
|
|
13
|
+
* `has(kind)` is false.
|
|
14
|
+
* 2. A biome that contributed the kind on the BACKEND but has not (yet)
|
|
15
|
+
* shipped a frontend renderer still gets a structured, KNOWN rendering
|
|
16
|
+
* derived from its catalogue `payloadSchema`.
|
|
17
|
+
* 3. Anything still unknown draws `SurfaceFallbackCard` with a typed reason.
|
|
18
|
+
*
|
|
19
|
+
* First-party kinds are skipped — they are registered statically by the
|
|
20
|
+
* first-party surface renderers. The fetch is BEST-EFFORT: any failure leaves
|
|
21
|
+
* the registry EXACTLY as the static builtins left it (layer 3 still handles
|
|
22
|
+
* every unknown kind) and clears the one-shot guard so a later call retries.
|
|
23
|
+
* Idempotent — safe to call on every session load.
|
|
24
|
+
*
|
|
25
|
+
* The catalogue TRANSPORT is injected rather than imported: `@xemahq/ui-kernel`
|
|
26
|
+
* is host-framework-agnostic and carries no generated API client, so the host
|
|
27
|
+
* passes a fetcher bound to its own client (exactly as the ChatWidget sync
|
|
28
|
+
* calls its Orval `widgetCatalogueControllerList`).
|
|
29
|
+
*/
|
|
30
|
+
import {
|
|
31
|
+
SurfaceKindSource,
|
|
32
|
+
type SurfaceKindCatalogueEntry,
|
|
33
|
+
} from '@xemahq/kernel-contracts/surface';
|
|
34
|
+
|
|
35
|
+
import { makeGenericSurfaceRenderer } from './GenericCatalogueSurface';
|
|
36
|
+
import { vistaSurfaceRegistry } from './surface-registry';
|
|
37
|
+
|
|
38
|
+
/** Host-supplied transport for the surface-kind catalogue. */
|
|
39
|
+
export type SurfaceCatalogueFetcher = () => Promise<
|
|
40
|
+
readonly SurfaceKindCatalogueEntry[]
|
|
41
|
+
>;
|
|
42
|
+
|
|
43
|
+
let synced = false;
|
|
44
|
+
|
|
45
|
+
/** Reset the one-shot guard — tests only. */
|
|
46
|
+
export function __resetSurfaceCatalogueSyncForTests(): void {
|
|
47
|
+
synced = false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Fetch the surface-kind catalogue and register biome-contributed kinds into
|
|
52
|
+
* the open registry with a generic schema-driven renderer. Runs at most once
|
|
53
|
+
* per app load. Never throws.
|
|
54
|
+
*/
|
|
55
|
+
export async function syncSurfaceCatalogue(
|
|
56
|
+
fetchCatalogue: SurfaceCatalogueFetcher,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
if (synced) return;
|
|
59
|
+
synced = true;
|
|
60
|
+
try {
|
|
61
|
+
const entries = await fetchCatalogue();
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
// Only biome-contributed kinds need registering here; first-party kinds
|
|
64
|
+
// are already registered by the static builtins.
|
|
65
|
+
if (entry.source !== SurfaceKindSource.Biome) continue;
|
|
66
|
+
// Never shadow a biome's own (or an already-registered) renderer.
|
|
67
|
+
if (vistaSurfaceRegistry.has(entry.kind)) continue;
|
|
68
|
+
vistaSurfaceRegistry.register(entry.kind, {
|
|
69
|
+
version: entry.version,
|
|
70
|
+
validate: (payload) =>
|
|
71
|
+
payload !== null && typeof payload === 'object' ? payload : null,
|
|
72
|
+
render: makeGenericSurfaceRenderer(
|
|
73
|
+
entry.displayName,
|
|
74
|
+
entry.payloadSchema,
|
|
75
|
+
),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Best-effort: leave the registry exactly as the static builtins left it.
|
|
80
|
+
// The safe fallback card still renders any kind we did not learn about.
|
|
81
|
+
// Clearing the guard lets a later call retry.
|
|
82
|
+
synced = false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab glyph per surface kind. Presentation only — a kind with no glyph renders
|
|
3
|
+
* a label-only tab, which is a complete rendering, not a degraded one.
|
|
4
|
+
*
|
|
5
|
+
* The map is EXHAUSTIVE over {@link SurfaceKind} (a `Record` over the enum), so
|
|
6
|
+
* adding a first-party kind is a compile error here until it gets a glyph.
|
|
7
|
+
* Biome-contributed (`<biomeId>:<kind>`) tabs are label-only by design: the
|
|
8
|
+
* kernel does not ship an icon vocabulary biomes must map into.
|
|
9
|
+
*/
|
|
10
|
+
import { SurfaceKind } from '@xemahq/kernel-contracts/surface';
|
|
11
|
+
import {
|
|
12
|
+
AppWindow,
|
|
13
|
+
BarChart3,
|
|
14
|
+
Braces,
|
|
15
|
+
FileCode,
|
|
16
|
+
FileText,
|
|
17
|
+
FileType2,
|
|
18
|
+
Film,
|
|
19
|
+
Folder,
|
|
20
|
+
Globe,
|
|
21
|
+
Image,
|
|
22
|
+
LayoutGrid,
|
|
23
|
+
Share2,
|
|
24
|
+
SquarePen,
|
|
25
|
+
Table2,
|
|
26
|
+
TextCursorInput,
|
|
27
|
+
} from 'lucide-react';
|
|
28
|
+
|
|
29
|
+
import type { ComponentType } from 'react';
|
|
30
|
+
|
|
31
|
+
type Glyph = ComponentType<{ className?: string }>;
|
|
32
|
+
|
|
33
|
+
const SURFACE_KIND_ICON: Record<SurfaceKind, Glyph> = {
|
|
34
|
+
[SurfaceKind.Url]: Globe,
|
|
35
|
+
[SurfaceKind.Web]: AppWindow,
|
|
36
|
+
[SurfaceKind.Html]: LayoutGrid,
|
|
37
|
+
[SurfaceKind.Markdown]: FileText,
|
|
38
|
+
[SurfaceKind.Code]: FileCode,
|
|
39
|
+
[SurfaceKind.Diff]: SquarePen,
|
|
40
|
+
[SurfaceKind.Json]: Braces,
|
|
41
|
+
[SurfaceKind.Table]: Table2,
|
|
42
|
+
[SurfaceKind.Chart]: BarChart3,
|
|
43
|
+
[SurfaceKind.Pdf]: FileType2,
|
|
44
|
+
[SurfaceKind.Image]: Image,
|
|
45
|
+
[SurfaceKind.Media]: Film,
|
|
46
|
+
[SurfaceKind.Object]: LayoutGrid,
|
|
47
|
+
[SurfaceKind.BiomeRoute]: AppWindow,
|
|
48
|
+
[SurfaceKind.Form]: TextCursorInput,
|
|
49
|
+
[SurfaceKind.App]: AppWindow,
|
|
50
|
+
[SurfaceKind.Files]: Folder,
|
|
51
|
+
[SurfaceKind.Graph]: Share2,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Glyph for a surface kind, or `undefined` for a biome-contributed kind. */
|
|
55
|
+
export function surfaceKindIcon(kind: string): Glyph | undefined {
|
|
56
|
+
return SURFACE_KIND_ICON[kind as SurfaceKind];
|
|
57
|
+
}
|