@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,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host-side inbound-message classifier — the security gate for every
|
|
3
|
+
* message the host acts on. Proves it REJECTS a wrong-source message, a
|
|
4
|
+
* wrong-protocol message, an unknown type, and a well-formed envelope carrying
|
|
5
|
+
* an unknown intent; and that it correctly classifies frameReady / a valid
|
|
6
|
+
* intent / a render error. Also proves frameReady is answered with a hostProps
|
|
7
|
+
* message (via the paired builder), and that a frame mounted with the EMPTY
|
|
8
|
+
* intent set can never get an intent performed.
|
|
9
|
+
*
|
|
10
|
+
* Migrated from `app-forge-web` when the frame was promoted into the UI kernel
|
|
11
|
+
* (the intent parser is now an argument instead of a hard-wired import — every
|
|
12
|
+
* gate is otherwise unchanged); runs under the host-web vitest mirror.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
|
|
16
|
+
import { classifyFrameMessage } from './custom-ui-frame-bridge';
|
|
17
|
+
import {
|
|
18
|
+
CUSTOM_UI_FRAME_PROTOCOL,
|
|
19
|
+
CustomUiFrameMessageType,
|
|
20
|
+
hostPropsMessage,
|
|
21
|
+
} from './custom-ui-frame-contract';
|
|
22
|
+
import {
|
|
23
|
+
CustomUiIntentKind,
|
|
24
|
+
NO_CUSTOM_UI_INTENTS,
|
|
25
|
+
parseCustomUiIntent,
|
|
26
|
+
} from './custom-ui-intents';
|
|
27
|
+
|
|
28
|
+
// A stand-in for the iframe's contentWindow — identity is all the gate checks.
|
|
29
|
+
const FRAME_WINDOW = { name: 'frame' } as unknown as Window;
|
|
30
|
+
const OTHER_WINDOW = { name: 'other' } as unknown as Window;
|
|
31
|
+
|
|
32
|
+
function evt(
|
|
33
|
+
data: unknown,
|
|
34
|
+
source: Window | null,
|
|
35
|
+
): Pick<MessageEvent, 'source' | 'data'> {
|
|
36
|
+
return { data, source };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('classifyFrameMessage', () => {
|
|
40
|
+
it('classifies a valid frameReady', () => {
|
|
41
|
+
const result = classifyFrameMessage(
|
|
42
|
+
evt(
|
|
43
|
+
{
|
|
44
|
+
type: CustomUiFrameMessageType.FrameReady,
|
|
45
|
+
protocol: CUSTOM_UI_FRAME_PROTOCOL,
|
|
46
|
+
},
|
|
47
|
+
FRAME_WINDOW,
|
|
48
|
+
),
|
|
49
|
+
FRAME_WINDOW,
|
|
50
|
+
parseCustomUiIntent,
|
|
51
|
+
);
|
|
52
|
+
expect(result).toEqual({ kind: 'ready' });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('classifies a valid frameIntent into a typed intent', () => {
|
|
56
|
+
const result = classifyFrameMessage(
|
|
57
|
+
evt(
|
|
58
|
+
{
|
|
59
|
+
type: CustomUiFrameMessageType.FrameIntent,
|
|
60
|
+
protocol: CUSTOM_UI_FRAME_PROTOCOL,
|
|
61
|
+
intent: { kind: 'navigate', href: '/apps/x/page' },
|
|
62
|
+
},
|
|
63
|
+
FRAME_WINDOW,
|
|
64
|
+
),
|
|
65
|
+
FRAME_WINDOW,
|
|
66
|
+
parseCustomUiIntent,
|
|
67
|
+
);
|
|
68
|
+
expect(result).toEqual({
|
|
69
|
+
kind: 'intent',
|
|
70
|
+
intent: { kind: CustomUiIntentKind.Navigate, href: '/apps/x/page' },
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('classifies a frameError, surfacing the message', () => {
|
|
75
|
+
const result = classifyFrameMessage(
|
|
76
|
+
evt(
|
|
77
|
+
{
|
|
78
|
+
type: CustomUiFrameMessageType.FrameError,
|
|
79
|
+
protocol: CUSTOM_UI_FRAME_PROTOCOL,
|
|
80
|
+
message: 'boom',
|
|
81
|
+
},
|
|
82
|
+
FRAME_WINDOW,
|
|
83
|
+
),
|
|
84
|
+
FRAME_WINDOW,
|
|
85
|
+
parseCustomUiIntent,
|
|
86
|
+
);
|
|
87
|
+
expect(result).toEqual({ kind: 'error', message: 'boom' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('REJECTS a message from the wrong source window', () => {
|
|
91
|
+
const result = classifyFrameMessage(
|
|
92
|
+
evt(
|
|
93
|
+
{
|
|
94
|
+
type: CustomUiFrameMessageType.FrameReady,
|
|
95
|
+
protocol: CUSTOM_UI_FRAME_PROTOCOL,
|
|
96
|
+
},
|
|
97
|
+
OTHER_WINDOW,
|
|
98
|
+
),
|
|
99
|
+
FRAME_WINDOW,
|
|
100
|
+
parseCustomUiIntent,
|
|
101
|
+
);
|
|
102
|
+
expect(result).toEqual({ kind: 'ignored' });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('REJECTS a message with the wrong protocol', () => {
|
|
106
|
+
const result = classifyFrameMessage(
|
|
107
|
+
evt(
|
|
108
|
+
{ type: CustomUiFrameMessageType.FrameReady, protocol: 999 },
|
|
109
|
+
FRAME_WINDOW,
|
|
110
|
+
),
|
|
111
|
+
FRAME_WINDOW,
|
|
112
|
+
parseCustomUiIntent,
|
|
113
|
+
);
|
|
114
|
+
expect(result).toEqual({ kind: 'ignored' });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('REJECTS an unknown message type', () => {
|
|
118
|
+
const result = classifyFrameMessage(
|
|
119
|
+
evt(
|
|
120
|
+
{
|
|
121
|
+
type: 'xema.customui.somethingElse',
|
|
122
|
+
protocol: CUSTOM_UI_FRAME_PROTOCOL,
|
|
123
|
+
},
|
|
124
|
+
FRAME_WINDOW,
|
|
125
|
+
),
|
|
126
|
+
FRAME_WINDOW,
|
|
127
|
+
parseCustomUiIntent,
|
|
128
|
+
);
|
|
129
|
+
expect(result).toEqual({ kind: 'ignored' });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('REJECTS a well-formed envelope carrying an unknown intent', () => {
|
|
133
|
+
const result = classifyFrameMessage(
|
|
134
|
+
evt(
|
|
135
|
+
{
|
|
136
|
+
type: CustomUiFrameMessageType.FrameIntent,
|
|
137
|
+
protocol: CUSTOM_UI_FRAME_PROTOCOL,
|
|
138
|
+
intent: { kind: 'wipe-db' },
|
|
139
|
+
},
|
|
140
|
+
FRAME_WINDOW,
|
|
141
|
+
),
|
|
142
|
+
FRAME_WINDOW,
|
|
143
|
+
parseCustomUiIntent,
|
|
144
|
+
);
|
|
145
|
+
expect(result).toEqual({ kind: 'ignored' });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('REJECTS when no frame window is mounted yet', () => {
|
|
149
|
+
const result = classifyFrameMessage(
|
|
150
|
+
evt(
|
|
151
|
+
{
|
|
152
|
+
type: CustomUiFrameMessageType.FrameReady,
|
|
153
|
+
protocol: CUSTOM_UI_FRAME_PROTOCOL,
|
|
154
|
+
},
|
|
155
|
+
FRAME_WINDOW,
|
|
156
|
+
),
|
|
157
|
+
null,
|
|
158
|
+
parseCustomUiIntent,
|
|
159
|
+
);
|
|
160
|
+
expect(result).toEqual({ kind: 'ignored' });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// The intent set is now supplied by the mounting surface — a frame mounted
|
|
164
|
+
// with the EMPTY set has no authority, so even a perfectly-formed navigate
|
|
165
|
+
// envelope is ignored.
|
|
166
|
+
it('REJECTS every intent when mounted with the EMPTY intent set', () => {
|
|
167
|
+
const result = classifyFrameMessage(
|
|
168
|
+
evt(
|
|
169
|
+
{
|
|
170
|
+
type: CustomUiFrameMessageType.FrameIntent,
|
|
171
|
+
protocol: CUSTOM_UI_FRAME_PROTOCOL,
|
|
172
|
+
intent: { kind: 'navigate', href: '/apps/x/page' },
|
|
173
|
+
},
|
|
174
|
+
FRAME_WINDOW,
|
|
175
|
+
),
|
|
176
|
+
FRAME_WINDOW,
|
|
177
|
+
NO_CUSTOM_UI_INTENTS.parse,
|
|
178
|
+
);
|
|
179
|
+
expect(result).toEqual({ kind: 'ignored' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('answers frameReady with a hostProps message (paired builder)', () => {
|
|
183
|
+
const props = { records: [{ id: 'r1' }] };
|
|
184
|
+
const message = hostPropsMessage(props);
|
|
185
|
+
expect(message).toEqual({
|
|
186
|
+
type: CustomUiFrameMessageType.HostProps,
|
|
187
|
+
protocol: CUSTOM_UI_FRAME_PROTOCOL,
|
|
188
|
+
props,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host-side inbound-message classifier for the sandbox-frame bridge.
|
|
3
|
+
*
|
|
4
|
+
* Every `message` event the host window receives while a frame is mounted
|
|
5
|
+
* passes through {@link classifyFrameMessage}. It enforces the security gate
|
|
6
|
+
* BEFORE the host acts on anything:
|
|
7
|
+
*
|
|
8
|
+
* 1. SOURCE IDENTITY — `event.source` must be the frame's own
|
|
9
|
+
* `contentWindow`. We compare the window identity, NOT `event.origin`: an
|
|
10
|
+
* opaque-origin sandbox frame (no `allow-same-origin`) reports its origin
|
|
11
|
+
* as the string `"null"`, so origin is useless as an allow-check. Window
|
|
12
|
+
* identity is unforgeable across the boundary.
|
|
13
|
+
* 2. PROTOCOL — `event.data.protocol` must equal the pinned protocol version.
|
|
14
|
+
* 3. TYPE — `event.data.type` must be a member of the closed
|
|
15
|
+
* {@link CustomUiFrameMessageType} set.
|
|
16
|
+
*
|
|
17
|
+
* Anything failing any gate is classified `ignored` and the host does nothing.
|
|
18
|
+
* A valid message is narrowed to a typed, discriminated result the caller acts
|
|
19
|
+
* on. The intent payload is parsed against the mounting surface's CLOSED intent
|
|
20
|
+
* set here too, so a frame that posts a well-formed envelope but an
|
|
21
|
+
* unknown/invalid intent is still `ignored` — never performed.
|
|
22
|
+
*
|
|
23
|
+
* Promoted from `app-forge-web` into the UI kernel (Canopy §8.3). The only
|
|
24
|
+
* change is that the intent set is a parameter instead of a hard-wired import;
|
|
25
|
+
* every gate is carried over verbatim.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
CUSTOM_UI_FRAME_PROTOCOL,
|
|
29
|
+
CustomUiFrameMessageType,
|
|
30
|
+
} from './custom-ui-frame-contract';
|
|
31
|
+
|
|
32
|
+
/** The typed outcome of classifying one inbound frame message. */
|
|
33
|
+
export type ClassifiedFrameMessage<TIntent> =
|
|
34
|
+
| { readonly kind: 'ready' }
|
|
35
|
+
| { readonly kind: 'intent'; readonly intent: TIntent }
|
|
36
|
+
| { readonly kind: 'error'; readonly message: string }
|
|
37
|
+
| { readonly kind: 'ignored' };
|
|
38
|
+
|
|
39
|
+
const IGNORED: ClassifiedFrameMessage<never> = { kind: 'ignored' };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Classify an inbound `message` event from a sandbox frame. `frameWindow` is the
|
|
43
|
+
* mounted iframe's `contentWindow` (may be `null` before mount); `parseIntent`
|
|
44
|
+
* is the mounting surface's closed-set parser (returns `null` to reject).
|
|
45
|
+
* Returns a typed result; every rejection path returns `ignored` (fail-safe —
|
|
46
|
+
* the host performs nothing on a message it cannot fully validate).
|
|
47
|
+
*/
|
|
48
|
+
export function classifyFrameMessage<TIntent>(
|
|
49
|
+
event: Pick<MessageEvent, 'source' | 'data'>,
|
|
50
|
+
frameWindow: Window | null,
|
|
51
|
+
parseIntent: (raw: unknown) => TIntent | null,
|
|
52
|
+
): ClassifiedFrameMessage<TIntent> {
|
|
53
|
+
// Gate 1: source identity. Reject if we have no frame yet or the message did
|
|
54
|
+
// not come from THIS frame's window.
|
|
55
|
+
if (frameWindow === null || event.source !== frameWindow) return IGNORED;
|
|
56
|
+
|
|
57
|
+
const data = event.data;
|
|
58
|
+
if (data === null || typeof data !== 'object') return IGNORED;
|
|
59
|
+
const envelope = data as {
|
|
60
|
+
protocol?: unknown;
|
|
61
|
+
type?: unknown;
|
|
62
|
+
intent?: unknown;
|
|
63
|
+
message?: unknown;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Gate 2: protocol version.
|
|
67
|
+
if (envelope.protocol !== CUSTOM_UI_FRAME_PROTOCOL) return IGNORED;
|
|
68
|
+
|
|
69
|
+
// Gate 3: closed message-type set.
|
|
70
|
+
switch (envelope.type) {
|
|
71
|
+
case CustomUiFrameMessageType.FrameReady:
|
|
72
|
+
return { kind: 'ready' };
|
|
73
|
+
case CustomUiFrameMessageType.FrameIntent: {
|
|
74
|
+
const intent = parseIntent(envelope.intent);
|
|
75
|
+
// A valid envelope carrying an unknown/invalid intent is rejected, not
|
|
76
|
+
// performed.
|
|
77
|
+
return intent === null ? IGNORED : { kind: 'intent', intent };
|
|
78
|
+
}
|
|
79
|
+
case CustomUiFrameMessageType.FrameError:
|
|
80
|
+
return {
|
|
81
|
+
kind: 'error',
|
|
82
|
+
message:
|
|
83
|
+
typeof envelope.message === 'string'
|
|
84
|
+
? envelope.message
|
|
85
|
+
: 'The custom view failed to render.',
|
|
86
|
+
};
|
|
87
|
+
default:
|
|
88
|
+
// Includes HostProps (host→frame only, never inbound) and any unknown
|
|
89
|
+
// string — ignored.
|
|
90
|
+
return IGNORED;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frozen custom-UI frame wire contract — the HOST side of the ONLY channel
|
|
3
|
+
* across the sandbox-frame boundary.
|
|
4
|
+
*
|
|
5
|
+
* Promoted from `app-forge-web` into the UI kernel (Canopy §8.3) so every plane
|
|
6
|
+
* that renders untrusted, author-authored UI — App Forge's runtime views AND
|
|
7
|
+
* the Vista `html` surface — speaks ONE protocol through ONE hardened frame.
|
|
8
|
+
*
|
|
9
|
+
* A compiled custom-UI chunk (self-mounting ESM, running inside a strict-CSP,
|
|
10
|
+
* opaque-origin iframe) speaks exactly these four message `type`s at exactly
|
|
11
|
+
* this protocol version. The chunk's self-mount wrapper is generated by the
|
|
12
|
+
* app-function-broker from its OWN copy of these values
|
|
13
|
+
* (`repos/xema-cultivars/biomes/platform/app-function-broker/api/src/compile/ui/custom-ui-frame-contract.ts`),
|
|
14
|
+
* so the enum members and protocol number here MUST stay byte-for-byte
|
|
15
|
+
* identical to that source of truth. Changing a value is a coordinated,
|
|
16
|
+
* breaking wire change: bump the protocol on BOTH sides in lockstep.
|
|
17
|
+
*
|
|
18
|
+
* The chunk never touches host state directly — it announces readiness, the
|
|
19
|
+
* host pushes props, the component asks the host to perform intents, and a
|
|
20
|
+
* render throw is reported back. The host validates every inbound message
|
|
21
|
+
* (source identity + protocol + closed `type` set) before acting.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The frozen protocol version stamped on every frame message. */
|
|
25
|
+
export const CUSTOM_UI_FRAME_PROTOCOL = 1 as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The closed set of message `type`s exchanged across the frame boundary. The
|
|
29
|
+
* string values are the wire contract and must never diverge from the broker's.
|
|
30
|
+
*
|
|
31
|
+
* - {@link FrameReady} frame → host: mounted, ready for props (no payload).
|
|
32
|
+
* - {@link HostProps} host → frame: `{ props }`; repeatable to re-render.
|
|
33
|
+
* - {@link FrameIntent} frame → host: `{ intent }` — the host validates+performs.
|
|
34
|
+
* - {@link FrameError} frame → host: `{ message }` — the render threw.
|
|
35
|
+
*/
|
|
36
|
+
export enum CustomUiFrameMessageType {
|
|
37
|
+
FrameReady = 'xema.customui.frameReady',
|
|
38
|
+
HostProps = 'xema.customui.hostProps',
|
|
39
|
+
FrameIntent = 'xema.customui.frameIntent',
|
|
40
|
+
FrameError = 'xema.customui.frameError',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** frame → host: emitted once on load. */
|
|
44
|
+
export interface FrameReadyMessage {
|
|
45
|
+
readonly type: CustomUiFrameMessageType.FrameReady;
|
|
46
|
+
readonly protocol: typeof CUSTOM_UI_FRAME_PROTOCOL;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** host → frame: the props to render the component with. */
|
|
50
|
+
export interface HostPropsMessage {
|
|
51
|
+
readonly type: CustomUiFrameMessageType.HostProps;
|
|
52
|
+
readonly protocol: typeof CUSTOM_UI_FRAME_PROTOCOL;
|
|
53
|
+
readonly props: CustomUiComponentProps;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** frame → host: the component asked the host to perform an action. */
|
|
57
|
+
export interface FrameIntentMessage {
|
|
58
|
+
readonly type: CustomUiFrameMessageType.FrameIntent;
|
|
59
|
+
readonly protocol: typeof CUSTOM_UI_FRAME_PROTOCOL;
|
|
60
|
+
readonly intent: unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** frame → host: the component's render threw. */
|
|
64
|
+
export interface FrameErrorMessage {
|
|
65
|
+
readonly type: CustomUiFrameMessageType.FrameError;
|
|
66
|
+
readonly protocol: typeof CUSTOM_UI_FRAME_PROTOCOL;
|
|
67
|
+
readonly message: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The read-only props payload handed to a custom-UI component. It MUST be a
|
|
72
|
+
* plain, structured-clone-serializable object — it crosses the frame boundary
|
|
73
|
+
* via `postMessage`. The component also receives an injected `xemaEmit(intent)`
|
|
74
|
+
* function (added inside the frame by the self-mount wrapper, NOT sent over the
|
|
75
|
+
* wire), so this shape carries data only.
|
|
76
|
+
*/
|
|
77
|
+
export type CustomUiComponentProps = Record<string, unknown>;
|
|
78
|
+
|
|
79
|
+
/** Build the host→frame props message (single seam so the shape can't drift). */
|
|
80
|
+
export function hostPropsMessage(
|
|
81
|
+
props: CustomUiComponentProps,
|
|
82
|
+
): HostPropsMessage {
|
|
83
|
+
return {
|
|
84
|
+
type: CustomUiFrameMessageType.HostProps,
|
|
85
|
+
protocol: CUSTOM_UI_FRAME_PROTOCOL,
|
|
86
|
+
props,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom-UI intent parsing/dispatch — the frame asks, the host validates+
|
|
3
|
+
* performs. Proves the closed set rejects unknown kinds and out-of-policy
|
|
4
|
+
* (external) navigation targets, that a valid intent dispatches to its handler,
|
|
5
|
+
* and that the two shipped intent SETS behave as their names promise: the
|
|
6
|
+
* built-in navigate set parses+performs exactly one kind, and
|
|
7
|
+
* `NO_CUSTOM_UI_INTENTS` grants a frame no authority at all.
|
|
8
|
+
*
|
|
9
|
+
* Migrated from `app-forge-web` when the frame was promoted into the UI kernel;
|
|
10
|
+
* runs under the host-web vitest mirror (this package carries no vitest dep).
|
|
11
|
+
*/
|
|
12
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createNavigateIntentSet,
|
|
16
|
+
CustomUiIntentKind,
|
|
17
|
+
NO_CUSTOM_UI_INTENTS,
|
|
18
|
+
parseCustomUiIntent,
|
|
19
|
+
performCustomUiIntent,
|
|
20
|
+
type CustomUiIntentHandlers,
|
|
21
|
+
} from './custom-ui-intents';
|
|
22
|
+
|
|
23
|
+
describe('parseCustomUiIntent', () => {
|
|
24
|
+
it('parses a valid host-relative navigate intent', () => {
|
|
25
|
+
expect(
|
|
26
|
+
parseCustomUiIntent({ kind: 'navigate', href: '/apps/x/page' }),
|
|
27
|
+
).toEqual({ kind: CustomUiIntentKind.Navigate, href: '/apps/x/page' });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects an unknown intent kind', () => {
|
|
31
|
+
expect(parseCustomUiIntent({ kind: 'delete-everything' })).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('rejects a navigate to an external (protocol-relative) URL', () => {
|
|
35
|
+
expect(
|
|
36
|
+
parseCustomUiIntent({ kind: 'navigate', href: '//evil.example.com' }),
|
|
37
|
+
).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects a navigate to an absolute external URL', () => {
|
|
41
|
+
expect(
|
|
42
|
+
parseCustomUiIntent({ kind: 'navigate', href: 'https://evil.example' }),
|
|
43
|
+
).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Regression: a backslash normalises to a forward slash (WHATWG), so `/\host`
|
|
47
|
+
// is protocol-relative and resolves to a FOREIGN origin — the exact bypass a
|
|
48
|
+
// prefix-only check (`startsWith('/') && !startsWith('//')`) missed. The
|
|
49
|
+
// origin-resolution validator MUST reject every backslash form.
|
|
50
|
+
it('rejects a navigate whose backslash form resolves to a foreign origin', () => {
|
|
51
|
+
for (const href of [
|
|
52
|
+
'/\\evil.example.com',
|
|
53
|
+
'/\\\\evil.example.com',
|
|
54
|
+
'/\\/evil.example.com',
|
|
55
|
+
'\\/evil.example.com',
|
|
56
|
+
]) {
|
|
57
|
+
expect(parseCustomUiIntent({ kind: 'navigate', href })).toBeNull();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects a navigate href containing whitespace or control chars', () => {
|
|
62
|
+
expect(
|
|
63
|
+
parseCustomUiIntent({ kind: 'navigate', href: '/ /evil' }),
|
|
64
|
+
).toBeNull();
|
|
65
|
+
expect(parseCustomUiIntent({ kind: 'navigate', href: '/\tx' })).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects a navigate with a non-string href', () => {
|
|
69
|
+
expect(parseCustomUiIntent({ kind: 'navigate', href: 42 })).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('rejects non-object payloads', () => {
|
|
73
|
+
expect(parseCustomUiIntent(null)).toBeNull();
|
|
74
|
+
expect(parseCustomUiIntent('navigate')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('performCustomUiIntent', () => {
|
|
79
|
+
it('dispatches a navigate intent to the navigate handler', () => {
|
|
80
|
+
const navigate = vi.fn();
|
|
81
|
+
const handlers: CustomUiIntentHandlers = {
|
|
82
|
+
[CustomUiIntentKind.Navigate]: navigate,
|
|
83
|
+
};
|
|
84
|
+
performCustomUiIntent(
|
|
85
|
+
{ kind: CustomUiIntentKind.Navigate, href: '/apps/x' },
|
|
86
|
+
handlers,
|
|
87
|
+
);
|
|
88
|
+
expect(navigate).toHaveBeenCalledWith({
|
|
89
|
+
kind: CustomUiIntentKind.Navigate,
|
|
90
|
+
href: '/apps/x',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('createNavigateIntentSet', () => {
|
|
96
|
+
it('parses and performs a host-relative navigate, and only that', () => {
|
|
97
|
+
const navigate = vi.fn();
|
|
98
|
+
const set = createNavigateIntentSet({
|
|
99
|
+
[CustomUiIntentKind.Navigate]: navigate,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const intent = set.parse({ kind: 'navigate', href: '/apps/x' });
|
|
103
|
+
expect(intent).toEqual({
|
|
104
|
+
kind: CustomUiIntentKind.Navigate,
|
|
105
|
+
href: '/apps/x',
|
|
106
|
+
});
|
|
107
|
+
if (intent === null) throw new Error('expected a parsed navigate intent');
|
|
108
|
+
set.perform(intent);
|
|
109
|
+
expect(navigate).toHaveBeenCalledWith({
|
|
110
|
+
kind: CustomUiIntentKind.Navigate,
|
|
111
|
+
href: '/apps/x',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Anything outside the closed set is rejected by `parse` — never performed.
|
|
115
|
+
expect(set.parse({ kind: 'wipe-db' })).toBeNull();
|
|
116
|
+
expect(
|
|
117
|
+
set.parse({ kind: 'navigate', href: 'https://evil.example' }),
|
|
118
|
+
).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('NO_CUSTOM_UI_INTENTS', () => {
|
|
123
|
+
it('grants the frame NO authority: every payload is rejected', () => {
|
|
124
|
+
for (const raw of [
|
|
125
|
+
{ kind: 'navigate', href: '/apps/x' },
|
|
126
|
+
{ kind: 'wipe-db' },
|
|
127
|
+
null,
|
|
128
|
+
'navigate',
|
|
129
|
+
]) {
|
|
130
|
+
expect(NO_CUSTOM_UI_INTENTS.parse(raw)).toBeNull();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intent plane of the custom-UI frame bridge — now PLUGGABLE.
|
|
3
|
+
*
|
|
4
|
+
* A component running inside the sandbox frame NEVER acts with the user's
|
|
5
|
+
* authority. It posts an intent across the boundary; the host validates it
|
|
6
|
+
* against a CLOSED set and performs it. An unrecognised or malformed intent is
|
|
7
|
+
* rejected — never coerced, never silently dropped into a default action.
|
|
8
|
+
*
|
|
9
|
+
* Promoting this out of App Forge required one change and one only: WHICH
|
|
10
|
+
* closed set applies is now supplied by the mounting surface
|
|
11
|
+
* ({@link CustomUiIntentSet}) instead of being hard-wired. The security shape is
|
|
12
|
+
* unchanged — a set is still a `parse` that returns `null` for anything outside
|
|
13
|
+
* it, plus a `perform` that is exhaustive over it. There is no "accept any
|
|
14
|
+
* intent" set and no free-form action strings.
|
|
15
|
+
*
|
|
16
|
+
* {@link createNavigateIntentSet} is the built-in set every host gets.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A closed intent set: parse-or-reject, then perform. `parse` MUST be total and
|
|
21
|
+
* MUST NOT throw — returning `null` is the ONLY way to reject, and the bridge
|
|
22
|
+
* turns that into `ignored`.
|
|
23
|
+
*/
|
|
24
|
+
export interface CustomUiIntentSet<TIntent> {
|
|
25
|
+
parse(raw: unknown): TIntent | null;
|
|
26
|
+
perform(intent: TIntent): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Closed set of intent kinds the built-in set understands. */
|
|
30
|
+
export enum CustomUiIntentKind {
|
|
31
|
+
/** Navigate the host to an in-app path. */
|
|
32
|
+
Navigate = 'navigate',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Navigate intent — the host routes to `href`. Constrained to a host-relative
|
|
37
|
+
* path (leading `/`, no scheme, no protocol-relative `//`) so a frame can never
|
|
38
|
+
* drive the host to an arbitrary external origin.
|
|
39
|
+
*/
|
|
40
|
+
export interface NavigateIntent {
|
|
41
|
+
readonly kind: CustomUiIntentKind.Navigate;
|
|
42
|
+
readonly href: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The discriminated union of every intent the BUILT-IN set performs. */
|
|
46
|
+
export type CustomUiIntent = NavigateIntent;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Typed handler set for the built-in intents. Exhaustive over
|
|
50
|
+
* {@link CustomUiIntentKind} by construction — adding a kind forces adding a
|
|
51
|
+
* handler here (a compile error otherwise), so no intent kind can ship without
|
|
52
|
+
* a host-side performer.
|
|
53
|
+
*/
|
|
54
|
+
export interface CustomUiIntentHandlers {
|
|
55
|
+
readonly [CustomUiIntentKind.Navigate]: (intent: NavigateIntent) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True for a same-origin, host-relative path — the only href a frame may request.
|
|
60
|
+
*
|
|
61
|
+
* A prefix check (`startsWith('/') && !startsWith('//')`) is NOT sufficient:
|
|
62
|
+
* browsers (WHATWG URL) normalize a backslash to a forward slash for special
|
|
63
|
+
* schemes, so `"/\\evil.com"` is protocol-relative and resolves to a FOREIGN
|
|
64
|
+
* origin — a `router.push` of it hard-navigates the victim's top window off to
|
|
65
|
+
* `evil.com` (open redirect / phishing). Author code runs inside the frame and
|
|
66
|
+
* an authored app is viewed by OTHER users, so this is a real cross-user
|
|
67
|
+
* surface. We therefore RESOLVE the href against the current origin and require
|
|
68
|
+
* the resolved origin to be identical — the browser's own parser decides, so no
|
|
69
|
+
* textual-prefix trick (`\`, control chars, whitespace, embedded scheme) can
|
|
70
|
+
* slip a foreign origin past us. A relative href that cannot be parsed, or that
|
|
71
|
+
* escapes the origin, is rejected.
|
|
72
|
+
*/
|
|
73
|
+
function isHostRelativePath(href: string): boolean {
|
|
74
|
+
// Must be rooted at '/'.
|
|
75
|
+
if (!href.startsWith('/')) return false;
|
|
76
|
+
// A backslash is a protocol-relative/scheme trick after WHATWG normalisation
|
|
77
|
+
// ("/\\evil.com" -> //evil.com -> foreign origin); whitespace / control
|
|
78
|
+
// chars can also confuse the parser. Reject any before resolving.
|
|
79
|
+
if (href.includes('\\')) return false;
|
|
80
|
+
// eslint-disable-next-line no-control-regex
|
|
81
|
+
if (/[\u0000-\u0020\u007f]/.test(href)) return false;
|
|
82
|
+
let resolved: URL;
|
|
83
|
+
try {
|
|
84
|
+
resolved = new URL(href, window.location.origin);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
// The browser's parser is authoritative: the resolved origin MUST equal ours.
|
|
89
|
+
return resolved.origin === window.location.origin;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse an untrusted intent payload (arrived from the sandbox frame) into a
|
|
94
|
+
* typed {@link CustomUiIntent}, or `null` if it is not a recognized, valid
|
|
95
|
+
* member of the closed set. Fail-safe: unknown `kind`, wrong payload shape, or
|
|
96
|
+
* an out-of-policy value all return `null` — the caller rejects it.
|
|
97
|
+
*/
|
|
98
|
+
export function parseCustomUiIntent(raw: unknown): CustomUiIntent | null {
|
|
99
|
+
if (raw === null || typeof raw !== 'object') return null;
|
|
100
|
+
const candidate = raw as { kind?: unknown; href?: unknown };
|
|
101
|
+
switch (candidate.kind) {
|
|
102
|
+
case CustomUiIntentKind.Navigate:
|
|
103
|
+
if (typeof candidate.href !== 'string') return null;
|
|
104
|
+
if (!isHostRelativePath(candidate.href)) return null;
|
|
105
|
+
return { kind: CustomUiIntentKind.Navigate, href: candidate.href };
|
|
106
|
+
default:
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Dispatch a parsed intent to its handler. Exhaustive `switch` over the closed
|
|
113
|
+
* set — a new kind added to {@link CustomUiIntentKind} without a `case` here is
|
|
114
|
+
* a compile error (the `never` guard), so the set can never be silently
|
|
115
|
+
* under-served.
|
|
116
|
+
*/
|
|
117
|
+
export function performCustomUiIntent(
|
|
118
|
+
intent: CustomUiIntent,
|
|
119
|
+
handlers: CustomUiIntentHandlers,
|
|
120
|
+
): void {
|
|
121
|
+
switch (intent.kind) {
|
|
122
|
+
case CustomUiIntentKind.Navigate:
|
|
123
|
+
handlers[CustomUiIntentKind.Navigate](intent);
|
|
124
|
+
return;
|
|
125
|
+
default: {
|
|
126
|
+
const exhaustive: never = intent.kind;
|
|
127
|
+
throw new Error(`Unhandled custom-UI intent kind: ${String(exhaustive)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The built-in intent set: host-relative navigation and nothing else. */
|
|
133
|
+
export function createNavigateIntentSet(
|
|
134
|
+
handlers: CustomUiIntentHandlers,
|
|
135
|
+
): CustomUiIntentSet<CustomUiIntent> {
|
|
136
|
+
return {
|
|
137
|
+
parse: parseCustomUiIntent,
|
|
138
|
+
perform: (intent) => performCustomUiIntent(intent, handlers),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The EMPTY intent set — parses nothing, performs nothing. Mount a frame with
|
|
144
|
+
* this when the rendered content must be strictly presentational (the Vista
|
|
145
|
+
* `html` surface's static-document form). It is an explicit "this frame has no
|
|
146
|
+
* authority", not an absent configuration.
|
|
147
|
+
*/
|
|
148
|
+
export const NO_CUSTOM_UI_INTENTS: CustomUiIntentSet<never> = {
|
|
149
|
+
parse: () => null,
|
|
150
|
+
perform: () => undefined,
|
|
151
|
+
};
|