@xemahq/ui-kernel 0.9.1 → 0.9.2
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/README.md +2 -2
- package/dist/lib/biome-host/biome-builders.d.ts.map +1 -1
- package/dist/lib/biome-host/biome-builders.js +3 -1
- package/dist/lib/biome-host/biome-builders.js.map +1 -1
- package/dist/lib/biome-host/biome-registry.d.ts.map +1 -1
- package/dist/lib/biome-host/biome-registry.js.map +1 -1
- package/dist/lib/biome-host/biome-scope-hooks.d.ts +11 -0
- package/dist/lib/biome-host/biome-scope-hooks.d.ts.map +1 -0
- package/dist/lib/biome-host/biome-scope-hooks.js +25 -0
- package/dist/lib/biome-host/biome-scope-hooks.js.map +1 -0
- package/dist/lib/biome-host/biome-scope.d.ts +1 -10
- package/dist/lib/biome-host/biome-scope.d.ts.map +1 -1
- package/dist/lib/biome-host/biome-scope.js +1 -24
- package/dist/lib/biome-host/biome-scope.js.map +1 -1
- package/dist/lib/biome-host/biome-scoped-query.d.ts +1 -1
- package/dist/lib/biome-host/biome-scoped-query.d.ts.map +1 -1
- package/dist/lib/biome-host/biome-scoped-query.js +1 -1
- package/dist/lib/biome-host/biome-scoped-query.js.map +1 -1
- package/dist/lib/biome-host/create-biome-orval-config.js +2 -2
- package/dist/lib/biome-host/create-biome-orval-config.js.map +1 -1
- package/dist/lib/biome-host/errors.js.map +1 -1
- package/dist/lib/biome-host/index.d.ts +1 -1
- package/dist/lib/biome-host/index.d.ts.map +1 -1
- package/dist/lib/biome-host/index.js +1 -1
- package/dist/lib/biome-host/index.js.map +1 -1
- package/dist/lib/biome-host/session-profiles.d.ts.map +1 -1
- package/dist/lib/biome-host/session-profiles.js +4 -1
- package/dist/lib/biome-host/session-profiles.js.map +1 -1
- package/dist/lib/capabilities/capability-context.d.ts +9 -0
- package/dist/lib/capabilities/capability-context.d.ts.map +1 -0
- package/dist/lib/capabilities/capability-context.js +13 -0
- package/dist/lib/capabilities/capability-context.js.map +1 -0
- package/dist/lib/capabilities/capability-provider.d.ts +2 -8
- package/dist/lib/capabilities/capability-provider.d.ts.map +1 -1
- package/dist/lib/capabilities/capability-provider.js +1 -12
- package/dist/lib/capabilities/capability-provider.js.map +1 -1
- package/dist/lib/capabilities/index.d.ts +1 -0
- package/dist/lib/capabilities/index.d.ts.map +1 -1
- package/dist/lib/capabilities/index.js +1 -0
- package/dist/lib/capabilities/index.js.map +1 -1
- package/dist/lib/capabilities/use-capability.d.ts.map +1 -1
- package/dist/lib/capabilities/use-capability.js +1 -1
- package/dist/lib/capabilities/use-capability.js.map +1 -1
- package/dist/lib/system-bus/deeplink.js.map +1 -1
- package/dist/lib/system-bus/host-ports.d.ts +2 -1
- package/dist/lib/system-bus/host-ports.d.ts.map +1 -1
- package/dist/lib/system-bus/intent-registry.d.ts.map +1 -1
- package/dist/lib/system-bus/intent-registry.js.map +1 -1
- package/dist/offline/index.d.ts +2 -1
- package/dist/offline/index.d.ts.map +1 -1
- package/dist/offline/index.js +2 -1
- package/dist/offline/index.js.map +1 -1
- package/dist/offline/offline-status-badge.js +1 -1
- package/dist/offline/offline-status-badge.js.map +1 -1
- package/dist/offline/outbox.js.map +1 -1
- package/dist/offline/react-hooks.d.ts +7 -0
- package/dist/offline/react-hooks.d.ts.map +1 -0
- package/dist/offline/react-hooks.js +25 -0
- package/dist/offline/react-hooks.js.map +1 -0
- package/dist/offline/react.d.ts +0 -4
- package/dist/offline/react.d.ts.map +1 -1
- package/dist/offline/react.js +2 -24
- package/dist/offline/react.js.map +1 -1
- package/dist/offline/storage.d.ts.map +1 -1
- package/dist/offline/storage.js +8 -5
- package/dist/offline/storage.js.map +1 -1
- package/dist/registry/index.d.ts +1 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +1 -0
- package/dist/registry/index.js.map +1 -1
- package/dist/registry/lib/primitives/SessionEventCard.d.ts.map +1 -1
- package/dist/registry/lib/primitives/SessionEventCard.js +1 -1
- package/dist/registry/lib/primitives/SessionEventCard.js.map +1 -1
- package/dist/registry/lib/session-context-provider.d.ts +1 -3
- package/dist/registry/lib/session-context-provider.d.ts.map +1 -1
- package/dist/registry/lib/session-context-provider.js +2 -15
- package/dist/registry/lib/session-context-provider.js.map +1 -1
- package/dist/registry/lib/session-context.d.ts +5 -0
- package/dist/registry/lib/session-context.d.ts.map +1 -0
- package/dist/registry/lib/session-context.js +15 -0
- package/dist/registry/lib/session-context.js.map +1 -0
- package/dist/registry/lib/session-selectors.d.ts.map +1 -1
- package/dist/registry/lib/session-selectors.js +3 -1
- package/dist/registry/lib/session-selectors.js.map +1 -1
- package/dist/ui/branding/app-branding-context.d.ts +9 -0
- package/dist/ui/branding/app-branding-context.d.ts.map +1 -0
- package/dist/ui/branding/app-branding-context.js +10 -0
- package/dist/ui/branding/app-branding-context.js.map +1 -0
- package/dist/ui/branding/app-branding-provider.d.ts +0 -6
- package/dist/ui/branding/app-branding-provider.d.ts.map +1 -1
- package/dist/ui/branding/app-branding-provider.js +1 -8
- package/dist/ui/branding/app-branding-provider.js.map +1 -1
- package/dist/ui/capture/schema-field-input.js +1 -1
- package/dist/ui/capture/schema-field-input.js.map +1 -1
- package/dist/ui/capture/use-prefill.d.ts.map +1 -1
- package/dist/ui/capture/use-prefill.js +3 -1
- package/dist/ui/capture/use-prefill.js.map +1 -1
- package/dist/ui/capture/use-wizard.d.ts +1 -1
- package/dist/ui/capture/use-wizard.d.ts.map +1 -1
- package/dist/ui/capture/use-wizard.js +1 -1
- package/dist/ui/capture/use-wizard.js.map +1 -1
- package/dist/ui/capture/wizard.d.ts.map +1 -1
- package/dist/ui/capture/wizard.js.map +1 -1
- package/dist/ui/index.d.ts +5 -1
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +5 -1
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/primitives/alert-dialog.js +1 -1
- package/dist/ui/primitives/alert-dialog.js.map +1 -1
- package/dist/ui/primitives/badge-variants.d.ts +4 -0
- package/dist/ui/primitives/badge-variants.d.ts.map +1 -0
- package/dist/ui/primitives/badge-variants.js +15 -0
- package/dist/ui/primitives/badge-variants.js.map +1 -0
- package/dist/ui/primitives/badge.d.ts +3 -5
- package/dist/ui/primitives/badge.d.ts.map +1 -1
- package/dist/ui/primitives/badge.js +2 -15
- package/dist/ui/primitives/badge.js.map +1 -1
- package/dist/ui/primitives/button-variants.d.ts +5 -0
- package/dist/ui/primitives/button-variants.d.ts.map +1 -0
- package/dist/ui/primitives/button-variants.js +24 -0
- package/dist/ui/primitives/button-variants.js.map +1 -0
- package/dist/ui/primitives/button.d.ts +3 -6
- package/dist/ui/primitives/button.d.ts.map +1 -1
- package/dist/ui/primitives/button.js +2 -24
- package/dist/ui/primitives/button.js.map +1 -1
- package/dist/ui/primitives/form-stepper.d.ts +1 -13
- package/dist/ui/primitives/form-stepper.d.ts.map +1 -1
- package/dist/ui/primitives/form-stepper.js +1 -6
- package/dist/ui/primitives/form-stepper.js.map +1 -1
- package/dist/ui/primitives/form-stepper.types.d.ts +14 -0
- package/dist/ui/primitives/form-stepper.types.d.ts.map +1 -0
- package/dist/ui/primitives/form-stepper.types.js +7 -0
- package/dist/ui/primitives/form-stepper.types.js.map +1 -0
- package/dist/ui/primitives/preview-device-frame.js +1 -1
- package/dist/ui/primitives/preview-device-frame.js.map +1 -1
- package/dist/ui/primitives/tag-multi-select.d.ts.map +1 -1
- package/dist/ui/primitives/tag-multi-select.js +1 -1
- package/dist/ui/primitives/tag-multi-select.js.map +1 -1
- package/dist/vista/index.d.ts +4 -0
- package/dist/vista/index.d.ts.map +1 -1
- package/dist/vista/index.js +4 -0
- package/dist/vista/index.js.map +1 -1
- package/dist/vista/surfaces/FilesSurface.d.ts +1 -12
- package/dist/vista/surfaces/FilesSurface.d.ts.map +1 -1
- package/dist/vista/surfaces/FilesSurface.js +2 -46
- package/dist/vista/surfaces/FilesSurface.js.map +1 -1
- package/dist/vista/surfaces/FormSurface.d.ts.map +1 -1
- package/dist/vista/surfaces/FormSurface.js +39 -12
- package/dist/vista/surfaces/FormSurface.js.map +1 -1
- package/dist/vista/surfaces/elicitation-transport.d.ts +12 -3
- package/dist/vista/surfaces/elicitation-transport.d.ts.map +1 -1
- package/dist/vista/surfaces/elicitation-transport.js +96 -3
- package/dist/vista/surfaces/elicitation-transport.js.map +1 -1
- package/dist/vista/surfaces/file-surface-utils.d.ts +13 -0
- package/dist/vista/surfaces/file-surface-utils.d.ts.map +1 -0
- package/dist/vista/surfaces/file-surface-utils.js +46 -0
- package/dist/vista/surfaces/file-surface-utils.js.map +1 -0
- package/dist/vista/surfaces/index.d.ts +4 -3
- package/dist/vista/surfaces/index.d.ts.map +1 -1
- package/dist/vista/surfaces/index.js +4 -81
- package/dist/vista/surfaces/index.js.map +1 -1
- package/dist/vista/surfaces/register-surfaces.d.ts +2 -0
- package/dist/vista/surfaces/register-surfaces.d.ts.map +1 -0
- package/dist/vista/surfaces/register-surfaces.js +80 -0
- package/dist/vista/surfaces/register-surfaces.js.map +1 -0
- package/dist/vista/surfaces/text-surfaces.js +1 -1
- package/dist/vista/surfaces/text-surfaces.js.map +1 -1
- package/dist/vista/vista-code-renderer-context.d.ts +9 -0
- package/dist/vista/vista-code-renderer-context.d.ts.map +1 -0
- package/dist/vista/vista-code-renderer-context.js +6 -0
- package/dist/vista/vista-code-renderer-context.js.map +1 -0
- package/dist/vista/vista-code-renderer.d.ts +1 -6
- package/dist/vista/vista-code-renderer.d.ts.map +1 -1
- package/dist/vista/vista-code-renderer.js +1 -5
- package/dist/vista/vista-code-renderer.js.map +1 -1
- package/dist/vista/vista-file-context.d.ts +8 -0
- package/dist/vista/vista-file-context.d.ts.map +1 -0
- package/dist/vista/vista-file-context.js +6 -0
- package/dist/vista/vista-file-context.js.map +1 -0
- package/dist/vista/vista-file-source.d.ts +1 -6
- package/dist/vista/vista-file-source.d.ts.map +1 -1
- package/dist/vista/vista-file-source.js +2 -5
- package/dist/vista/vista-file-source.js.map +1 -1
- package/dist/vista/vista-form-context.d.ts +13 -0
- package/dist/vista/vista-form-context.d.ts.map +1 -0
- package/dist/vista/vista-form-context.js +6 -0
- package/dist/vista/vista-form-context.js.map +1 -0
- package/dist/vista/vista-form-host.d.ts +1 -10
- package/dist/vista/vista-form-host.d.ts.map +1 -1
- package/dist/vista/vista-form-host.js +1 -5
- package/dist/vista/vista-form-host.js.map +1 -1
- package/dist/vista/vista-open-context.d.ts +1 -3
- package/dist/vista/vista-open-context.d.ts.map +1 -1
- package/dist/vista/vista-open-context.js +1 -5
- package/dist/vista/vista-open-context.js.map +1 -1
- package/dist/vista/vista-open-state.d.ts +5 -0
- package/dist/vista/vista-open-state.d.ts.map +1 -0
- package/dist/vista/vista-open-state.js +6 -0
- package/dist/vista/vista-open-state.js.map +1 -0
- package/dist/vista/vista-payloads.d.ts +2 -3
- package/dist/vista/vista-payloads.d.ts.map +1 -1
- package/dist/vista/vista-payloads.js +18 -21
- package/dist/vista/vista-payloads.js.map +1 -1
- package/dist/vista/vista-tools.d.ts.map +1 -1
- package/dist/vista/vista-tools.js.map +1 -1
- package/package.json +3 -3
- package/src/lib/biome-host/biome-builders.ts +4 -1
- package/src/lib/biome-host/biome-registry.ts +0 -1
- package/src/lib/biome-host/biome-scope-hooks.ts +39 -0
- package/src/lib/biome-host/biome-scope.tsx +1 -68
- package/src/lib/biome-host/biome-scoped-query.ts +4 -1
- package/src/lib/biome-host/create-biome-orval-config.ts +2 -2
- package/src/lib/biome-host/errors.ts +1 -1
- package/src/lib/biome-host/index.ts +1 -1
- package/src/lib/biome-host/session-profiles.ts +7 -1
- package/src/lib/capabilities/capability-context.ts +25 -0
- package/src/lib/capabilities/capability-provider.tsx +5 -34
- package/src/lib/capabilities/index.ts +1 -0
- package/src/lib/capabilities/use-capability.ts +4 -1
- package/src/lib/system-bus/deeplink.ts +1 -1
- package/src/lib/system-bus/host-ports.ts +2 -1
- package/src/lib/system-bus/intent-registry.ts +1 -2
- package/src/offline/index.ts +4 -2
- package/src/offline/offline-status-badge.tsx +1 -1
- package/src/offline/outbox.test.ts +23 -16
- package/src/offline/outbox.ts +1 -1
- package/src/offline/react-hooks.ts +37 -0
- package/src/offline/react.tsx +1 -40
- package/src/offline/storage.ts +11 -6
- package/src/registry/index.ts +1 -0
- package/src/registry/lib/primitives/SessionEventCard.tsx +4 -2
- package/src/registry/lib/session-context-provider.tsx +8 -25
- package/src/registry/lib/session-context.ts +21 -0
- package/src/registry/lib/session-selectors.ts +3 -1
- package/src/ui/branding/app-branding-context.ts +20 -0
- package/src/ui/branding/app-branding-provider.tsx +5 -27
- package/src/ui/capture/schema-field-input.tsx +7 -1
- package/src/ui/capture/use-prefill.ts +4 -1
- package/src/ui/capture/use-wizard.ts +2 -2
- package/src/ui/capture/wizard.tsx +2 -1
- package/src/ui/index.ts +7 -2
- package/src/ui/primitives/alert-dialog.tsx +1 -1
- package/src/ui/primitives/badge-variants.ts +21 -0
- package/src/ui/primitives/badge.tsx +3 -19
- package/src/ui/primitives/button-variants.ts +31 -0
- package/src/ui/primitives/button.tsx +3 -28
- package/src/ui/primitives/form-stepper.tsx +1 -16
- package/src/ui/primitives/form-stepper.types.ts +15 -0
- package/src/ui/primitives/preview-device-frame.tsx +1 -1
- package/src/ui/primitives/tag-multi-select.tsx +3 -4
- package/src/vista/form-payload.spec.ts +71 -25
- package/src/vista/index.ts +4 -0
- package/src/vista/surfaces/FilesSurface.tsx +2 -68
- package/src/vista/surfaces/FormSurface.tsx +125 -26
- package/src/vista/surfaces/elicitation-transport.spec.ts +205 -23
- package/src/vista/surfaces/elicitation-transport.ts +192 -14
- package/src/vista/surfaces/file-surface-utils.ts +62 -0
- package/src/vista/surfaces/index.ts +24 -0
- package/src/vista/surfaces/{index.tsx → register-surfaces.tsx} +1 -21
- package/src/vista/surfaces/text-surfaces.tsx +1 -1
- package/src/vista/vista-code-renderer-context.ts +16 -0
- package/src/vista/vista-code-renderer.tsx +4 -16
- package/src/vista/vista-file-context.ts +14 -0
- package/src/vista/vista-file-source.tsx +5 -28
- package/src/vista/vista-form-context.ts +20 -0
- package/src/vista/vista-form-host.tsx +11 -36
- package/src/vista/vista-open-context.tsx +4 -14
- package/src/vista/vista-open-state.ts +11 -0
- package/src/vista/vista-payloads.ts +44 -46
- package/src/vista/vista-tools.ts +2 -2
- package/dist/lib/biome-host/response-envelope.d.ts +0 -3
- package/dist/lib/biome-host/response-envelope.d.ts.map +0 -1
- package/dist/lib/biome-host/response-envelope.js +0 -21
- package/dist/lib/biome-host/response-envelope.js.map +0 -1
- package/src/lib/biome-host/response-envelope.ts +0 -69
|
@@ -12,11 +12,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
12
12
|
import {
|
|
13
13
|
extractServiceMessage,
|
|
14
14
|
readElicitationStatus,
|
|
15
|
+
resolveTrustedElicitationEndpoints,
|
|
16
|
+
resolveTrustedElicitationLaunchUrl,
|
|
15
17
|
submitElicitationFields,
|
|
16
18
|
VistaFormSubmitError,
|
|
19
|
+
VistaFormTransportConfigError,
|
|
17
20
|
} from './elicitation-transport';
|
|
18
21
|
|
|
19
22
|
const SERVICE_ORIGIN = 'https://connector-gateway.example.test';
|
|
23
|
+
const API_BASE_URL = `${SERVICE_ORIGIN}/credential-elicitations`;
|
|
20
24
|
const SUBMIT_URL = `${SERVICE_ORIGIN}/credential-elicitations/ref-1/submit`;
|
|
21
25
|
const STATUS_URL = `${SERVICE_ORIGIN}/credential-elicitations/ref-1`;
|
|
22
26
|
const SECRET = 'sk-live-DO-NOT-LEAK';
|
|
@@ -39,14 +43,128 @@ afterEach(() => {
|
|
|
39
43
|
vi.restoreAllMocks();
|
|
40
44
|
});
|
|
41
45
|
|
|
46
|
+
describe('trusted elicitation endpoint resolution', () => {
|
|
47
|
+
it('derives exact status and submit paths from the host trust anchor', () => {
|
|
48
|
+
expect(resolveTrustedElicitationEndpoints(API_BASE_URL, 'ref-1')).toEqual({
|
|
49
|
+
statusUrl: STATUS_URL,
|
|
50
|
+
submitUrl: SUBMIT_URL,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it.each([
|
|
55
|
+
['relative', '/credential-elicitations'],
|
|
56
|
+
['network-path', '//evil.example/credential-elicitations'],
|
|
57
|
+
['scheme confusion', 'javascript:https://trusted.example'],
|
|
58
|
+
[
|
|
59
|
+
'authority confusion',
|
|
60
|
+
'https://trusted.example@evil.example/credential-elicitations',
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
'query',
|
|
64
|
+
'https://trusted.example/credential-elicitations?next=https://evil.example',
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
'fragment',
|
|
68
|
+
'https://trusted.example/credential-elicitations#https://evil.example',
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
'path traversal',
|
|
72
|
+
'https://trusted.example/credential-elicitations/../evil',
|
|
73
|
+
],
|
|
74
|
+
[
|
|
75
|
+
'encoded path traversal',
|
|
76
|
+
'https://trusted.example/credential-elicitations/%2e%2e/evil',
|
|
77
|
+
],
|
|
78
|
+
])('rejects %s base URL input', (_label, apiBaseUrl) => {
|
|
79
|
+
expect(() =>
|
|
80
|
+
resolveTrustedElicitationEndpoints(apiBaseUrl, 'ref-1'),
|
|
81
|
+
).toThrow(VistaFormTransportConfigError);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('requires HTTPS for a non-local service', () => {
|
|
85
|
+
expect(() =>
|
|
86
|
+
resolveTrustedElicitationEndpoints(
|
|
87
|
+
'http://api.example.test/credential-elicitations',
|
|
88
|
+
'ref-1',
|
|
89
|
+
'http://host.example.test:8080',
|
|
90
|
+
),
|
|
91
|
+
).toThrow(/Insecure elicitation HTTP/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('permits explicit loopback and same-host HTTP development', () => {
|
|
95
|
+
expect(
|
|
96
|
+
resolveTrustedElicitationEndpoints(
|
|
97
|
+
'http://localhost:3010/credential-elicitations',
|
|
98
|
+
'ref-1',
|
|
99
|
+
'http://localhost:8080',
|
|
100
|
+
).statusUrl,
|
|
101
|
+
).toBe('http://localhost:3010/credential-elicitations/ref-1');
|
|
102
|
+
expect(
|
|
103
|
+
resolveTrustedElicitationEndpoints(
|
|
104
|
+
'http://192.168.1.20:3010/credential-elicitations',
|
|
105
|
+
'ref-1',
|
|
106
|
+
'http://192.168.1.20:8080',
|
|
107
|
+
).statusUrl,
|
|
108
|
+
).toBe('http://192.168.1.20:3010/credential-elicitations/ref-1');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it.each(['.', '..', '../steal', 'a/b', '%2e%2e', 'réf'])(
|
|
112
|
+
'rejects unsafe ref %s before URL construction',
|
|
113
|
+
(ref) => {
|
|
114
|
+
expect(() =>
|
|
115
|
+
resolveTrustedElicitationEndpoints(API_BASE_URL, ref),
|
|
116
|
+
).toThrow(VistaFormTransportConfigError);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
it('keeps launch paths on the trusted origin and rejects authority/traversal', () => {
|
|
121
|
+
expect(
|
|
122
|
+
resolveTrustedElicitationLaunchUrl(
|
|
123
|
+
API_BASE_URL,
|
|
124
|
+
'/oauth/acme/authorize?flow=1',
|
|
125
|
+
),
|
|
126
|
+
).toBe(`${SERVICE_ORIGIN}/oauth/acme/authorize?flow=1`);
|
|
127
|
+
|
|
128
|
+
for (const path of [
|
|
129
|
+
'https://evil.example/steal',
|
|
130
|
+
'//evil.example/steal',
|
|
131
|
+
'/oauth/../steal',
|
|
132
|
+
'/oauth/%2e%2e/steal',
|
|
133
|
+
'/oauth\\..\\steal',
|
|
134
|
+
'/oauth/start#https://evil.example',
|
|
135
|
+
]) {
|
|
136
|
+
expect(() =>
|
|
137
|
+
resolveTrustedElicitationLaunchUrl(API_BASE_URL, path),
|
|
138
|
+
).toThrow(VistaFormTransportConfigError);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not call fetch when the trusted base or ref is invalid', async () => {
|
|
143
|
+
const fetchMock = vi.fn();
|
|
144
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
145
|
+
await expect(
|
|
146
|
+
submitElicitationFields({
|
|
147
|
+
apiBaseUrl:
|
|
148
|
+
'https://trusted.example@evil.example/credential-elicitations',
|
|
149
|
+
ref: '../steal',
|
|
150
|
+
accessToken: 'user-jwt',
|
|
151
|
+
fields: { apiKey: SECRET },
|
|
152
|
+
}),
|
|
153
|
+
).rejects.toBeInstanceOf(VistaFormTransportConfigError);
|
|
154
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
42
158
|
describe('submitElicitationFields', () => {
|
|
43
159
|
it('POSTs to the SERVICE origin directly — not a host-relative path', async () => {
|
|
44
160
|
const fetchMock = stubFetch(
|
|
45
|
-
jsonResponse(201, {
|
|
161
|
+
jsonResponse(201, {
|
|
162
|
+
data: { submitted: true, ref: 'ref-1', connectionId: 'conn-9' },
|
|
163
|
+
}),
|
|
46
164
|
);
|
|
47
165
|
|
|
48
166
|
await submitElicitationFields({
|
|
49
|
-
|
|
167
|
+
apiBaseUrl: API_BASE_URL,
|
|
50
168
|
ref: 'ref-1',
|
|
51
169
|
accessToken: 'user-jwt',
|
|
52
170
|
fields: { apiKey: SECRET },
|
|
@@ -57,8 +175,11 @@ describe('submitElicitationFields', () => {
|
|
|
57
175
|
expect(url).toBe(SUBMIT_URL);
|
|
58
176
|
expect(new URL(url).origin).toBe(SERVICE_ORIGIN);
|
|
59
177
|
expect(init.method).toBe('POST');
|
|
178
|
+
expect(init.redirect).toBe('error');
|
|
60
179
|
// The SUBJECT's own bearer — not a service token, not the agent's.
|
|
61
|
-
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
180
|
+
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
181
|
+
'Bearer user-jwt',
|
|
182
|
+
);
|
|
62
183
|
});
|
|
63
184
|
|
|
64
185
|
it('never hands a secret back to the caller, even when the service echoes it', async () => {
|
|
@@ -76,26 +197,36 @@ describe('submitElicitationFields', () => {
|
|
|
76
197
|
);
|
|
77
198
|
|
|
78
199
|
const outcome = await submitElicitationFields({
|
|
79
|
-
|
|
200
|
+
apiBaseUrl: API_BASE_URL,
|
|
80
201
|
ref: 'ref-1',
|
|
81
202
|
accessToken: 'user-jwt',
|
|
82
203
|
fields: { apiKey: SECRET },
|
|
83
204
|
});
|
|
84
205
|
|
|
85
206
|
// The outcome is a FRESH object with exactly three keys.
|
|
86
|
-
expect(Object.keys(outcome).sort()).toEqual([
|
|
87
|
-
|
|
207
|
+
expect(Object.keys(outcome).sort()).toEqual([
|
|
208
|
+
'connectionId',
|
|
209
|
+
'ref',
|
|
210
|
+
'submitted',
|
|
211
|
+
]);
|
|
212
|
+
expect(outcome).toEqual({
|
|
213
|
+
submitted: true,
|
|
214
|
+
ref: 'ref-1',
|
|
215
|
+
connectionId: 'conn-9',
|
|
216
|
+
});
|
|
88
217
|
expect(JSON.stringify(outcome)).not.toContain(SECRET);
|
|
89
218
|
expect(JSON.stringify(outcome)).not.toContain('apiKey');
|
|
90
219
|
});
|
|
91
220
|
|
|
92
221
|
it('round-trips a NON-secret field to the service body', async () => {
|
|
93
222
|
const fetchMock = stubFetch(
|
|
94
|
-
jsonResponse(201, {
|
|
223
|
+
jsonResponse(201, {
|
|
224
|
+
data: { submitted: true, ref: 'ref-1', connectionId: 'conn-9' },
|
|
225
|
+
}),
|
|
95
226
|
);
|
|
96
227
|
|
|
97
228
|
await submitElicitationFields({
|
|
98
|
-
|
|
229
|
+
apiBaseUrl: API_BASE_URL,
|
|
99
230
|
ref: 'ref-1',
|
|
100
231
|
accessToken: 'user-jwt',
|
|
101
232
|
fields: { baseUrl: 'https://acme.example.com', apiKey: SECRET },
|
|
@@ -108,10 +239,25 @@ describe('submitElicitationFields', () => {
|
|
|
108
239
|
});
|
|
109
240
|
|
|
110
241
|
it('accepts a flat (un-enveloped) response body', async () => {
|
|
111
|
-
stubFetch(
|
|
242
|
+
stubFetch(
|
|
243
|
+
jsonResponse(200, {
|
|
244
|
+
submitted: true,
|
|
245
|
+
ref: 'ref-1',
|
|
246
|
+
connectionId: 'conn-2',
|
|
247
|
+
}),
|
|
248
|
+
);
|
|
112
249
|
await expect(
|
|
113
|
-
submitElicitationFields({
|
|
114
|
-
|
|
250
|
+
submitElicitationFields({
|
|
251
|
+
apiBaseUrl: API_BASE_URL,
|
|
252
|
+
ref: 'ref-1',
|
|
253
|
+
accessToken: 't',
|
|
254
|
+
fields: {},
|
|
255
|
+
}),
|
|
256
|
+
).resolves.toEqual({
|
|
257
|
+
submitted: true,
|
|
258
|
+
ref: 'ref-1',
|
|
259
|
+
connectionId: 'conn-2',
|
|
260
|
+
});
|
|
115
261
|
});
|
|
116
262
|
|
|
117
263
|
it("surfaces the backend's empty-schema 400 as a usable message", async () => {
|
|
@@ -126,7 +272,7 @@ describe('submitElicitationFields', () => {
|
|
|
126
272
|
|
|
127
273
|
// An empty field schema submits an empty map; the SERVICE is the authority.
|
|
128
274
|
const promise = submitElicitationFields({
|
|
129
|
-
|
|
275
|
+
apiBaseUrl: API_BASE_URL,
|
|
130
276
|
ref: 'ref-1',
|
|
131
277
|
accessToken: 'user-jwt',
|
|
132
278
|
fields: {},
|
|
@@ -139,35 +285,67 @@ describe('submitElicitationFields', () => {
|
|
|
139
285
|
it('refuses a 2xx that does not report the elicitation as submitted', async () => {
|
|
140
286
|
stubFetch(jsonResponse(200, { data: { ref: 'ref-1' } }));
|
|
141
287
|
await expect(
|
|
142
|
-
submitElicitationFields({
|
|
288
|
+
submitElicitationFields({
|
|
289
|
+
apiBaseUrl: API_BASE_URL,
|
|
290
|
+
ref: 'ref-1',
|
|
291
|
+
accessToken: 't',
|
|
292
|
+
fields: {},
|
|
293
|
+
}),
|
|
143
294
|
).rejects.toThrow(/did not report the elicitation as submitted/);
|
|
144
295
|
});
|
|
145
296
|
});
|
|
146
297
|
|
|
147
298
|
describe('readElicitationStatus', () => {
|
|
148
299
|
it('returns null while pending — an explicit "not yet", never a guess', async () => {
|
|
149
|
-
stubFetch(
|
|
300
|
+
stubFetch(
|
|
301
|
+
jsonResponse(200, {
|
|
302
|
+
data: { ref: 'ref-1', status: 'pending', connectionId: null },
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
150
305
|
await expect(
|
|
151
|
-
readElicitationStatus({
|
|
306
|
+
readElicitationStatus({
|
|
307
|
+
apiBaseUrl: API_BASE_URL,
|
|
308
|
+
ref: 'ref-1',
|
|
309
|
+
accessToken: 't',
|
|
310
|
+
}),
|
|
152
311
|
).resolves.toBeNull();
|
|
153
312
|
});
|
|
154
313
|
|
|
155
314
|
it('settles on the service-reported submitted status', async () => {
|
|
156
315
|
stubFetch(
|
|
157
|
-
jsonResponse(200, {
|
|
316
|
+
jsonResponse(200, {
|
|
317
|
+
data: { ref: 'ref-1', status: 'submitted', connectionId: 'conn-3' },
|
|
318
|
+
}),
|
|
158
319
|
);
|
|
159
320
|
await expect(
|
|
160
|
-
readElicitationStatus({
|
|
161
|
-
|
|
321
|
+
readElicitationStatus({
|
|
322
|
+
apiBaseUrl: API_BASE_URL,
|
|
323
|
+
ref: 'ref-1',
|
|
324
|
+
accessToken: 't',
|
|
325
|
+
}),
|
|
326
|
+
).resolves.toEqual({
|
|
327
|
+
submitted: true,
|
|
328
|
+
ref: 'ref-1',
|
|
329
|
+
connectionId: 'conn-3',
|
|
330
|
+
});
|
|
162
331
|
});
|
|
163
332
|
|
|
164
333
|
it('GETs the service origin with the subject bearer', async () => {
|
|
165
|
-
const fetchMock = stubFetch(
|
|
166
|
-
|
|
334
|
+
const fetchMock = stubFetch(
|
|
335
|
+
jsonResponse(200, { data: { ref: 'ref-1', status: 'pending' } }),
|
|
336
|
+
);
|
|
337
|
+
await readElicitationStatus({
|
|
338
|
+
apiBaseUrl: API_BASE_URL,
|
|
339
|
+
ref: 'ref-1',
|
|
340
|
+
accessToken: 'user-jwt',
|
|
341
|
+
});
|
|
167
342
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
168
343
|
expect(new URL(url).origin).toBe(SERVICE_ORIGIN);
|
|
169
344
|
expect(init.method).toBe('GET');
|
|
170
|
-
expect(
|
|
345
|
+
expect(init.redirect).toBe('error');
|
|
346
|
+
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
347
|
+
'Bearer user-jwt',
|
|
348
|
+
);
|
|
171
349
|
});
|
|
172
350
|
});
|
|
173
351
|
|
|
@@ -175,11 +353,15 @@ describe('extractServiceMessage', () => {
|
|
|
175
353
|
it('reads a string message, a message array, and a nested error message', () => {
|
|
176
354
|
expect(extractServiceMessage({ message: 'nope' }, 400)).toBe('nope');
|
|
177
355
|
expect(extractServiceMessage({ message: ['a', 'b'] }, 400)).toBe('a b');
|
|
178
|
-
expect(extractServiceMessage({ error: { message: 'deep' } }, 400)).toBe(
|
|
356
|
+
expect(extractServiceMessage({ error: { message: 'deep' } }, 400)).toBe(
|
|
357
|
+
'deep',
|
|
358
|
+
);
|
|
179
359
|
});
|
|
180
360
|
|
|
181
361
|
it('never yields an empty string', () => {
|
|
182
|
-
expect(extractServiceMessage(null, 503)).toBe(
|
|
362
|
+
expect(extractServiceMessage(null, 503)).toBe(
|
|
363
|
+
'The service rejected the submission (HTTP 503).',
|
|
364
|
+
);
|
|
183
365
|
expect(extractServiceMessage({ message: '' }, 500)).toBe(
|
|
184
366
|
'The service rejected the submission (HTTP 500).',
|
|
185
367
|
);
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
//
|
|
4
4
|
// This module is the ONLY code that ever reads a captured field value out of
|
|
5
5
|
// the form surface, and it does exactly one thing with it: put it in the body
|
|
6
|
-
// of a `fetch` to
|
|
7
|
-
//
|
|
6
|
+
// of a `fetch` to an endpoint derived from the HOST-CONFIGURED trusted
|
|
7
|
+
// elicitation-service base URL, bearing the SUBJECT's own JWT.
|
|
8
8
|
//
|
|
9
9
|
// Three properties make "the agent cannot receive the value" structural:
|
|
10
10
|
//
|
|
@@ -15,15 +15,17 @@
|
|
|
15
15
|
// • the response is PROJECTED, not passed through: `toOutcome` constructs a
|
|
16
16
|
// fresh object with exactly three keys, so a service that echoed a secret
|
|
17
17
|
// back could still not leak one through this boundary;
|
|
18
|
-
// • the
|
|
19
|
-
//
|
|
20
|
-
//
|
|
18
|
+
// • the payload carries only an opaque ref. It has NO network authority. This
|
|
19
|
+
// module validates the host-configured base URL and derives the exact
|
|
20
|
+
// status/submit paths before a bearer token or field value reaches fetch.
|
|
21
21
|
//
|
|
22
22
|
// Errors carry the SERVICE's message verbatim (that is how the backend's hard
|
|
23
23
|
// 400 on an empty credential form becomes readable UI copy) and never the
|
|
24
24
|
// request body.
|
|
25
25
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
-
import
|
|
26
|
+
import { isValidVistaFormRef } from '../vista-payloads';
|
|
27
|
+
|
|
28
|
+
import type { VistaFormOutcome } from '../vista-form-context';
|
|
27
29
|
|
|
28
30
|
/** A non-2xx answer from the owning service, carrying its message verbatim. */
|
|
29
31
|
export class VistaFormSubmitError extends Error {
|
|
@@ -36,6 +38,19 @@ export class VistaFormSubmitError extends Error {
|
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
/** Invalid trusted-host transport configuration or unsafe path input. */
|
|
42
|
+
export class VistaFormTransportConfigError extends Error {
|
|
43
|
+
constructor(message: string) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'VistaFormTransportConfigError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TrustedElicitationEndpoints {
|
|
50
|
+
readonly statusUrl: string;
|
|
51
|
+
readonly submitUrl: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
39
54
|
type UnknownRecord = Readonly<Record<string, unknown>>;
|
|
40
55
|
|
|
41
56
|
function asRecord(value: unknown): UnknownRecord | null {
|
|
@@ -69,7 +84,9 @@ export function extractServiceMessage(body: unknown, status: number): string {
|
|
|
69
84
|
: (record.message ?? asRecord(record.error)?.message ?? record.error);
|
|
70
85
|
if (typeof candidate === 'string' && candidate.length > 0) return candidate;
|
|
71
86
|
if (Array.isArray(candidate)) {
|
|
72
|
-
const joined = candidate
|
|
87
|
+
const joined = candidate
|
|
88
|
+
.filter((m): m is string => typeof m === 'string')
|
|
89
|
+
.join(' ');
|
|
73
90
|
if (joined.length > 0) return joined;
|
|
74
91
|
}
|
|
75
92
|
if (typeof body === 'string' && body.length > 0) return body;
|
|
@@ -108,7 +125,10 @@ function toOutcome(body: unknown, ref: string): VistaFormOutcome | null {
|
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
/** The `Authorization`/`Content-Type` pair every request here carries. */
|
|
111
|
-
function authHeaders(
|
|
128
|
+
function authHeaders(
|
|
129
|
+
accessToken: string,
|
|
130
|
+
json: boolean,
|
|
131
|
+
): Record<string, string> {
|
|
112
132
|
const headers: Record<string, string> = {
|
|
113
133
|
Authorization: `Bearer ${accessToken}`,
|
|
114
134
|
Accept: 'application/json',
|
|
@@ -117,6 +137,146 @@ function authHeaders(accessToken: string, json: boolean): Record<string, string>
|
|
|
117
137
|
return headers;
|
|
118
138
|
}
|
|
119
139
|
|
|
140
|
+
function browserOrigin(): string | undefined {
|
|
141
|
+
return typeof globalThis.location === 'undefined'
|
|
142
|
+
? undefined
|
|
143
|
+
: globalThis.location.origin;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
147
|
+
return (
|
|
148
|
+
hostname === 'localhost' ||
|
|
149
|
+
hostname.endsWith('.localhost') ||
|
|
150
|
+
hostname === '127.0.0.1' ||
|
|
151
|
+
hostname === '[::1]'
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Parse the host's deploy-time trust anchor. It must be a canonical absolute
|
|
157
|
+
* HTTP(S) URL without credentials/query/fragment. HTTPS is mandatory except
|
|
158
|
+
* for loopback or same-host HTTP when the page itself is also served over HTTP.
|
|
159
|
+
*/
|
|
160
|
+
function trustedApiBaseUrl(
|
|
161
|
+
raw: string,
|
|
162
|
+
activeBrowserOrigin: string | undefined,
|
|
163
|
+
): URL {
|
|
164
|
+
if (raw.length === 0 || raw.trim() !== raw) {
|
|
165
|
+
throw new VistaFormTransportConfigError(
|
|
166
|
+
'The trusted elicitation API base URL is empty or contains whitespace.',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let url: URL;
|
|
171
|
+
try {
|
|
172
|
+
url = new URL(raw);
|
|
173
|
+
} catch {
|
|
174
|
+
throw new VistaFormTransportConfigError(
|
|
175
|
+
'The trusted elicitation API base URL must be absolute.',
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
(url.protocol !== 'https:' && url.protocol !== 'http:') ||
|
|
181
|
+
url.username.length > 0 ||
|
|
182
|
+
url.password.length > 0 ||
|
|
183
|
+
url.search.length > 0 ||
|
|
184
|
+
url.hash.length > 0
|
|
185
|
+
) {
|
|
186
|
+
throw new VistaFormTransportConfigError(
|
|
187
|
+
'The trusted elicitation API base URL must use HTTP(S) and contain no credentials, query, or fragment.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const canonicalPath =
|
|
192
|
+
url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, '');
|
|
193
|
+
const canonical = `${url.origin}${canonicalPath}`;
|
|
194
|
+
const suppliedWithoutOneTrailingSlash = raw.endsWith('/')
|
|
195
|
+
? raw.slice(0, -1)
|
|
196
|
+
: raw;
|
|
197
|
+
if (suppliedWithoutOneTrailingSlash !== canonical) {
|
|
198
|
+
throw new VistaFormTransportConfigError(
|
|
199
|
+
'The trusted elicitation API base URL must be canonical and contain no traversal or ambiguous path.',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (url.protocol === 'http:' && !isLoopbackHostname(url.hostname)) {
|
|
204
|
+
let page: URL | null = null;
|
|
205
|
+
if (activeBrowserOrigin !== undefined) {
|
|
206
|
+
try {
|
|
207
|
+
page = new URL(activeBrowserOrigin);
|
|
208
|
+
} catch {
|
|
209
|
+
page = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (
|
|
213
|
+
page === null ||
|
|
214
|
+
page.protocol !== 'http:' ||
|
|
215
|
+
page.hostname !== url.hostname
|
|
216
|
+
) {
|
|
217
|
+
throw new VistaFormTransportConfigError(
|
|
218
|
+
'Insecure elicitation HTTP is allowed only on loopback or the same host as an HTTP development page.',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return new URL(canonical);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Derive the only two authenticated endpoints from trusted host configuration
|
|
228
|
+
* plus one opaque path segment. Calling this directly remains safe even if a
|
|
229
|
+
* caller bypasses the surface-payload validator.
|
|
230
|
+
*/
|
|
231
|
+
export function resolveTrustedElicitationEndpoints(
|
|
232
|
+
apiBaseUrl: string,
|
|
233
|
+
ref: string,
|
|
234
|
+
activeBrowserOrigin: string | undefined = browserOrigin(),
|
|
235
|
+
): TrustedElicitationEndpoints {
|
|
236
|
+
if (!isValidVistaFormRef(ref)) {
|
|
237
|
+
throw new VistaFormTransportConfigError(
|
|
238
|
+
'The elicitation reference is not a valid opaque base64url path segment.',
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const base = trustedApiBaseUrl(apiBaseUrl, activeBrowserOrigin)
|
|
242
|
+
.toString()
|
|
243
|
+
.replace(/\/$/, '');
|
|
244
|
+
const statusUrl = `${base}/${ref}`;
|
|
245
|
+
return { statusUrl, submitUrl: `${statusUrl}/submit` };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Resolve an unauthenticated launch path under the SAME trusted service origin.
|
|
250
|
+
* Absolute/network-path URLs and dot-segment traversal fail closed.
|
|
251
|
+
*/
|
|
252
|
+
export function resolveTrustedElicitationLaunchUrl(
|
|
253
|
+
apiBaseUrl: string,
|
|
254
|
+
path: string,
|
|
255
|
+
activeBrowserOrigin: string | undefined = browserOrigin(),
|
|
256
|
+
): string {
|
|
257
|
+
const base = trustedApiBaseUrl(apiBaseUrl, activeBrowserOrigin);
|
|
258
|
+
if (
|
|
259
|
+
!path.startsWith('/') ||
|
|
260
|
+
path.startsWith('//') ||
|
|
261
|
+
path.includes('\\') ||
|
|
262
|
+
path.includes('#')
|
|
263
|
+
) {
|
|
264
|
+
throw new VistaFormTransportConfigError(
|
|
265
|
+
'The elicitation launch target must be a root-relative service path.',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
const resolved = new URL(path, `${base.origin}/`);
|
|
269
|
+
if (
|
|
270
|
+
resolved.origin !== base.origin ||
|
|
271
|
+
`${resolved.pathname}${resolved.search}` !== path
|
|
272
|
+
) {
|
|
273
|
+
throw new VistaFormTransportConfigError(
|
|
274
|
+
'The elicitation launch path contains traversal or an ambiguous encoding.',
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return resolved.toString();
|
|
278
|
+
}
|
|
279
|
+
|
|
120
280
|
/**
|
|
121
281
|
* POST the captured fields to the owning service.
|
|
122
282
|
*
|
|
@@ -125,20 +285,30 @@ function authHeaders(accessToken: string, json: boolean): Record<string, string>
|
|
|
125
285
|
* user reads.
|
|
126
286
|
*/
|
|
127
287
|
export async function submitElicitationFields(args: {
|
|
128
|
-
readonly
|
|
288
|
+
readonly apiBaseUrl: string;
|
|
129
289
|
readonly ref: string;
|
|
130
290
|
readonly accessToken: string;
|
|
131
291
|
readonly fields: Readonly<Record<string, string>>;
|
|
132
292
|
}): Promise<VistaFormOutcome> {
|
|
133
|
-
const
|
|
293
|
+
const { submitUrl } = resolveTrustedElicitationEndpoints(
|
|
294
|
+
args.apiBaseUrl,
|
|
295
|
+
args.ref,
|
|
296
|
+
);
|
|
297
|
+
const response = await fetch(submitUrl, {
|
|
134
298
|
method: 'POST',
|
|
135
299
|
headers: authHeaders(args.accessToken, true),
|
|
136
300
|
body: JSON.stringify({ fields: args.fields }),
|
|
137
301
|
credentials: 'omit',
|
|
302
|
+
// A 307/308 could otherwise replay the credential body to a different
|
|
303
|
+
// origin. Authenticated elicitation endpoints must never redirect.
|
|
304
|
+
redirect: 'error',
|
|
138
305
|
});
|
|
139
306
|
const body = await readBody(response);
|
|
140
307
|
if (!response.ok) {
|
|
141
|
-
throw new VistaFormSubmitError(
|
|
308
|
+
throw new VistaFormSubmitError(
|
|
309
|
+
response.status,
|
|
310
|
+
extractServiceMessage(body, response.status),
|
|
311
|
+
);
|
|
142
312
|
}
|
|
143
313
|
const outcome = toOutcome(body, args.ref);
|
|
144
314
|
if (outcome === null) {
|
|
@@ -158,18 +328,26 @@ export async function submitElicitationFields(args: {
|
|
|
158
328
|
* Returns `null` while still pending — an explicit "not yet", never a guess.
|
|
159
329
|
*/
|
|
160
330
|
export async function readElicitationStatus(args: {
|
|
161
|
-
readonly
|
|
331
|
+
readonly apiBaseUrl: string;
|
|
162
332
|
readonly ref: string;
|
|
163
333
|
readonly accessToken: string;
|
|
164
334
|
}): Promise<VistaFormOutcome | null> {
|
|
165
|
-
const
|
|
335
|
+
const { statusUrl } = resolveTrustedElicitationEndpoints(
|
|
336
|
+
args.apiBaseUrl,
|
|
337
|
+
args.ref,
|
|
338
|
+
);
|
|
339
|
+
const response = await fetch(statusUrl, {
|
|
166
340
|
method: 'GET',
|
|
167
341
|
headers: authHeaders(args.accessToken, false),
|
|
168
342
|
credentials: 'omit',
|
|
343
|
+
redirect: 'error',
|
|
169
344
|
});
|
|
170
345
|
const body = await readBody(response);
|
|
171
346
|
if (!response.ok) {
|
|
172
|
-
throw new VistaFormSubmitError(
|
|
347
|
+
throw new VistaFormSubmitError(
|
|
348
|
+
response.status,
|
|
349
|
+
extractServiceMessage(body, response.status),
|
|
350
|
+
);
|
|
173
351
|
}
|
|
174
352
|
return toOutcome(body, args.ref);
|
|
175
353
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { VistaFileEntry } from '../vista-payloads';
|
|
2
|
+
|
|
3
|
+
interface FileExtMeta {
|
|
4
|
+
readonly color: string;
|
|
5
|
+
readonly tag: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const EXT_META: Readonly<Record<string, FileExtMeta>> = {
|
|
9
|
+
ts: { color: 'hsl(var(--info))', tag: 'TS' },
|
|
10
|
+
tsx: { color: 'hsl(var(--info))', tag: 'TSX' },
|
|
11
|
+
js: { color: 'hsl(var(--warning))', tag: 'JS' },
|
|
12
|
+
jsx: { color: 'hsl(var(--warning))', tag: 'JSX' },
|
|
13
|
+
json: { color: 'hsl(var(--ink-3))', tag: 'JSON' },
|
|
14
|
+
md: { color: 'hsl(var(--p-people))', tag: 'MD' },
|
|
15
|
+
css: { color: 'hsl(var(--p-sales))', tag: 'CSS' },
|
|
16
|
+
html: { color: 'hsl(var(--p-sales))', tag: 'HTML' },
|
|
17
|
+
yaml: { color: 'hsl(var(--ink-3))', tag: 'YAML' },
|
|
18
|
+
yml: { color: 'hsl(var(--ink-3))', tag: 'YAML' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function fileExtMeta(name: string): FileExtMeta {
|
|
22
|
+
if (/\.test\.[tj]sx?$/.test(name) || /\.spec\.[tj]sx?$/.test(name)) {
|
|
23
|
+
return { color: 'hsl(var(--success))', tag: 'TEST' };
|
|
24
|
+
}
|
|
25
|
+
const ext = name.split('.').pop() ?? '';
|
|
26
|
+
return (
|
|
27
|
+
EXT_META[ext] ?? {
|
|
28
|
+
color: 'hsl(var(--ink-4))',
|
|
29
|
+
tag: (ext || 'FILE').toUpperCase().slice(0, 4),
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VistaFileGroup {
|
|
35
|
+
readonly dir: string;
|
|
36
|
+
readonly files: readonly VistaFileEntry[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compareDirs(a: string, b: string): number {
|
|
40
|
+
if (a === '') return -1;
|
|
41
|
+
if (b === '') return 1;
|
|
42
|
+
return a.localeCompare(b);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function groupFilesByFolder(
|
|
46
|
+
files: readonly VistaFileEntry[],
|
|
47
|
+
): VistaFileGroup[] {
|
|
48
|
+
const groups = new Map<string, VistaFileEntry[]>();
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
const slash = file.path.lastIndexOf('/');
|
|
51
|
+
const dir = slash < 0 ? '' : file.path.slice(0, slash);
|
|
52
|
+
const bucket = groups.get(dir);
|
|
53
|
+
if (bucket) bucket.push(file);
|
|
54
|
+
else groups.set(dir, [file]);
|
|
55
|
+
}
|
|
56
|
+
return [...groups.keys()].sort(compareDirs).map((dir) => ({
|
|
57
|
+
dir,
|
|
58
|
+
files: [...(groups.get(dir) ?? [])].sort((a, b) =>
|
|
59
|
+
a.path.localeCompare(b.path),
|
|
60
|
+
),
|
|
61
|
+
}));
|
|
62
|
+
}
|