@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.
Files changed (275) hide show
  1. package/README.md +2 -2
  2. package/dist/lib/biome-host/biome-builders.d.ts.map +1 -1
  3. package/dist/lib/biome-host/biome-builders.js +3 -1
  4. package/dist/lib/biome-host/biome-builders.js.map +1 -1
  5. package/dist/lib/biome-host/biome-registry.d.ts.map +1 -1
  6. package/dist/lib/biome-host/biome-registry.js.map +1 -1
  7. package/dist/lib/biome-host/biome-scope-hooks.d.ts +11 -0
  8. package/dist/lib/biome-host/biome-scope-hooks.d.ts.map +1 -0
  9. package/dist/lib/biome-host/biome-scope-hooks.js +25 -0
  10. package/dist/lib/biome-host/biome-scope-hooks.js.map +1 -0
  11. package/dist/lib/biome-host/biome-scope.d.ts +1 -10
  12. package/dist/lib/biome-host/biome-scope.d.ts.map +1 -1
  13. package/dist/lib/biome-host/biome-scope.js +1 -24
  14. package/dist/lib/biome-host/biome-scope.js.map +1 -1
  15. package/dist/lib/biome-host/biome-scoped-query.d.ts +1 -1
  16. package/dist/lib/biome-host/biome-scoped-query.d.ts.map +1 -1
  17. package/dist/lib/biome-host/biome-scoped-query.js +1 -1
  18. package/dist/lib/biome-host/biome-scoped-query.js.map +1 -1
  19. package/dist/lib/biome-host/create-biome-orval-config.js +2 -2
  20. package/dist/lib/biome-host/create-biome-orval-config.js.map +1 -1
  21. package/dist/lib/biome-host/errors.js.map +1 -1
  22. package/dist/lib/biome-host/index.d.ts +1 -1
  23. package/dist/lib/biome-host/index.d.ts.map +1 -1
  24. package/dist/lib/biome-host/index.js +1 -1
  25. package/dist/lib/biome-host/index.js.map +1 -1
  26. package/dist/lib/biome-host/session-profiles.d.ts.map +1 -1
  27. package/dist/lib/biome-host/session-profiles.js +4 -1
  28. package/dist/lib/biome-host/session-profiles.js.map +1 -1
  29. package/dist/lib/capabilities/capability-context.d.ts +9 -0
  30. package/dist/lib/capabilities/capability-context.d.ts.map +1 -0
  31. package/dist/lib/capabilities/capability-context.js +13 -0
  32. package/dist/lib/capabilities/capability-context.js.map +1 -0
  33. package/dist/lib/capabilities/capability-provider.d.ts +2 -8
  34. package/dist/lib/capabilities/capability-provider.d.ts.map +1 -1
  35. package/dist/lib/capabilities/capability-provider.js +1 -12
  36. package/dist/lib/capabilities/capability-provider.js.map +1 -1
  37. package/dist/lib/capabilities/index.d.ts +1 -0
  38. package/dist/lib/capabilities/index.d.ts.map +1 -1
  39. package/dist/lib/capabilities/index.js +1 -0
  40. package/dist/lib/capabilities/index.js.map +1 -1
  41. package/dist/lib/capabilities/use-capability.d.ts.map +1 -1
  42. package/dist/lib/capabilities/use-capability.js +1 -1
  43. package/dist/lib/capabilities/use-capability.js.map +1 -1
  44. package/dist/lib/system-bus/deeplink.js.map +1 -1
  45. package/dist/lib/system-bus/host-ports.d.ts +2 -1
  46. package/dist/lib/system-bus/host-ports.d.ts.map +1 -1
  47. package/dist/lib/system-bus/intent-registry.d.ts.map +1 -1
  48. package/dist/lib/system-bus/intent-registry.js.map +1 -1
  49. package/dist/offline/index.d.ts +2 -1
  50. package/dist/offline/index.d.ts.map +1 -1
  51. package/dist/offline/index.js +2 -1
  52. package/dist/offline/index.js.map +1 -1
  53. package/dist/offline/offline-status-badge.js +1 -1
  54. package/dist/offline/offline-status-badge.js.map +1 -1
  55. package/dist/offline/outbox.js.map +1 -1
  56. package/dist/offline/react-hooks.d.ts +7 -0
  57. package/dist/offline/react-hooks.d.ts.map +1 -0
  58. package/dist/offline/react-hooks.js +25 -0
  59. package/dist/offline/react-hooks.js.map +1 -0
  60. package/dist/offline/react.d.ts +0 -4
  61. package/dist/offline/react.d.ts.map +1 -1
  62. package/dist/offline/react.js +2 -24
  63. package/dist/offline/react.js.map +1 -1
  64. package/dist/offline/storage.d.ts.map +1 -1
  65. package/dist/offline/storage.js +8 -5
  66. package/dist/offline/storage.js.map +1 -1
  67. package/dist/registry/index.d.ts +1 -0
  68. package/dist/registry/index.d.ts.map +1 -1
  69. package/dist/registry/index.js +1 -0
  70. package/dist/registry/index.js.map +1 -1
  71. package/dist/registry/lib/primitives/SessionEventCard.d.ts.map +1 -1
  72. package/dist/registry/lib/primitives/SessionEventCard.js +1 -1
  73. package/dist/registry/lib/primitives/SessionEventCard.js.map +1 -1
  74. package/dist/registry/lib/session-context-provider.d.ts +1 -3
  75. package/dist/registry/lib/session-context-provider.d.ts.map +1 -1
  76. package/dist/registry/lib/session-context-provider.js +2 -15
  77. package/dist/registry/lib/session-context-provider.js.map +1 -1
  78. package/dist/registry/lib/session-context.d.ts +5 -0
  79. package/dist/registry/lib/session-context.d.ts.map +1 -0
  80. package/dist/registry/lib/session-context.js +15 -0
  81. package/dist/registry/lib/session-context.js.map +1 -0
  82. package/dist/registry/lib/session-selectors.d.ts.map +1 -1
  83. package/dist/registry/lib/session-selectors.js +3 -1
  84. package/dist/registry/lib/session-selectors.js.map +1 -1
  85. package/dist/ui/branding/app-branding-context.d.ts +9 -0
  86. package/dist/ui/branding/app-branding-context.d.ts.map +1 -0
  87. package/dist/ui/branding/app-branding-context.js +10 -0
  88. package/dist/ui/branding/app-branding-context.js.map +1 -0
  89. package/dist/ui/branding/app-branding-provider.d.ts +0 -6
  90. package/dist/ui/branding/app-branding-provider.d.ts.map +1 -1
  91. package/dist/ui/branding/app-branding-provider.js +1 -8
  92. package/dist/ui/branding/app-branding-provider.js.map +1 -1
  93. package/dist/ui/capture/schema-field-input.js +1 -1
  94. package/dist/ui/capture/schema-field-input.js.map +1 -1
  95. package/dist/ui/capture/use-prefill.d.ts.map +1 -1
  96. package/dist/ui/capture/use-prefill.js +3 -1
  97. package/dist/ui/capture/use-prefill.js.map +1 -1
  98. package/dist/ui/capture/use-wizard.d.ts +1 -1
  99. package/dist/ui/capture/use-wizard.d.ts.map +1 -1
  100. package/dist/ui/capture/use-wizard.js +1 -1
  101. package/dist/ui/capture/use-wizard.js.map +1 -1
  102. package/dist/ui/capture/wizard.d.ts.map +1 -1
  103. package/dist/ui/capture/wizard.js.map +1 -1
  104. package/dist/ui/index.d.ts +5 -1
  105. package/dist/ui/index.d.ts.map +1 -1
  106. package/dist/ui/index.js +5 -1
  107. package/dist/ui/index.js.map +1 -1
  108. package/dist/ui/primitives/alert-dialog.js +1 -1
  109. package/dist/ui/primitives/alert-dialog.js.map +1 -1
  110. package/dist/ui/primitives/badge-variants.d.ts +4 -0
  111. package/dist/ui/primitives/badge-variants.d.ts.map +1 -0
  112. package/dist/ui/primitives/badge-variants.js +15 -0
  113. package/dist/ui/primitives/badge-variants.js.map +1 -0
  114. package/dist/ui/primitives/badge.d.ts +3 -5
  115. package/dist/ui/primitives/badge.d.ts.map +1 -1
  116. package/dist/ui/primitives/badge.js +2 -15
  117. package/dist/ui/primitives/badge.js.map +1 -1
  118. package/dist/ui/primitives/button-variants.d.ts +5 -0
  119. package/dist/ui/primitives/button-variants.d.ts.map +1 -0
  120. package/dist/ui/primitives/button-variants.js +24 -0
  121. package/dist/ui/primitives/button-variants.js.map +1 -0
  122. package/dist/ui/primitives/button.d.ts +3 -6
  123. package/dist/ui/primitives/button.d.ts.map +1 -1
  124. package/dist/ui/primitives/button.js +2 -24
  125. package/dist/ui/primitives/button.js.map +1 -1
  126. package/dist/ui/primitives/form-stepper.d.ts +1 -13
  127. package/dist/ui/primitives/form-stepper.d.ts.map +1 -1
  128. package/dist/ui/primitives/form-stepper.js +1 -6
  129. package/dist/ui/primitives/form-stepper.js.map +1 -1
  130. package/dist/ui/primitives/form-stepper.types.d.ts +14 -0
  131. package/dist/ui/primitives/form-stepper.types.d.ts.map +1 -0
  132. package/dist/ui/primitives/form-stepper.types.js +7 -0
  133. package/dist/ui/primitives/form-stepper.types.js.map +1 -0
  134. package/dist/ui/primitives/preview-device-frame.js +1 -1
  135. package/dist/ui/primitives/preview-device-frame.js.map +1 -1
  136. package/dist/ui/primitives/tag-multi-select.d.ts.map +1 -1
  137. package/dist/ui/primitives/tag-multi-select.js +1 -1
  138. package/dist/ui/primitives/tag-multi-select.js.map +1 -1
  139. package/dist/vista/index.d.ts +4 -0
  140. package/dist/vista/index.d.ts.map +1 -1
  141. package/dist/vista/index.js +4 -0
  142. package/dist/vista/index.js.map +1 -1
  143. package/dist/vista/surfaces/FilesSurface.d.ts +1 -12
  144. package/dist/vista/surfaces/FilesSurface.d.ts.map +1 -1
  145. package/dist/vista/surfaces/FilesSurface.js +2 -46
  146. package/dist/vista/surfaces/FilesSurface.js.map +1 -1
  147. package/dist/vista/surfaces/FormSurface.d.ts.map +1 -1
  148. package/dist/vista/surfaces/FormSurface.js +39 -12
  149. package/dist/vista/surfaces/FormSurface.js.map +1 -1
  150. package/dist/vista/surfaces/elicitation-transport.d.ts +12 -3
  151. package/dist/vista/surfaces/elicitation-transport.d.ts.map +1 -1
  152. package/dist/vista/surfaces/elicitation-transport.js +96 -3
  153. package/dist/vista/surfaces/elicitation-transport.js.map +1 -1
  154. package/dist/vista/surfaces/file-surface-utils.d.ts +13 -0
  155. package/dist/vista/surfaces/file-surface-utils.d.ts.map +1 -0
  156. package/dist/vista/surfaces/file-surface-utils.js +46 -0
  157. package/dist/vista/surfaces/file-surface-utils.js.map +1 -0
  158. package/dist/vista/surfaces/index.d.ts +4 -3
  159. package/dist/vista/surfaces/index.d.ts.map +1 -1
  160. package/dist/vista/surfaces/index.js +4 -81
  161. package/dist/vista/surfaces/index.js.map +1 -1
  162. package/dist/vista/surfaces/register-surfaces.d.ts +2 -0
  163. package/dist/vista/surfaces/register-surfaces.d.ts.map +1 -0
  164. package/dist/vista/surfaces/register-surfaces.js +80 -0
  165. package/dist/vista/surfaces/register-surfaces.js.map +1 -0
  166. package/dist/vista/surfaces/text-surfaces.js +1 -1
  167. package/dist/vista/surfaces/text-surfaces.js.map +1 -1
  168. package/dist/vista/vista-code-renderer-context.d.ts +9 -0
  169. package/dist/vista/vista-code-renderer-context.d.ts.map +1 -0
  170. package/dist/vista/vista-code-renderer-context.js +6 -0
  171. package/dist/vista/vista-code-renderer-context.js.map +1 -0
  172. package/dist/vista/vista-code-renderer.d.ts +1 -6
  173. package/dist/vista/vista-code-renderer.d.ts.map +1 -1
  174. package/dist/vista/vista-code-renderer.js +1 -5
  175. package/dist/vista/vista-code-renderer.js.map +1 -1
  176. package/dist/vista/vista-file-context.d.ts +8 -0
  177. package/dist/vista/vista-file-context.d.ts.map +1 -0
  178. package/dist/vista/vista-file-context.js +6 -0
  179. package/dist/vista/vista-file-context.js.map +1 -0
  180. package/dist/vista/vista-file-source.d.ts +1 -6
  181. package/dist/vista/vista-file-source.d.ts.map +1 -1
  182. package/dist/vista/vista-file-source.js +2 -5
  183. package/dist/vista/vista-file-source.js.map +1 -1
  184. package/dist/vista/vista-form-context.d.ts +13 -0
  185. package/dist/vista/vista-form-context.d.ts.map +1 -0
  186. package/dist/vista/vista-form-context.js +6 -0
  187. package/dist/vista/vista-form-context.js.map +1 -0
  188. package/dist/vista/vista-form-host.d.ts +1 -10
  189. package/dist/vista/vista-form-host.d.ts.map +1 -1
  190. package/dist/vista/vista-form-host.js +1 -5
  191. package/dist/vista/vista-form-host.js.map +1 -1
  192. package/dist/vista/vista-open-context.d.ts +1 -3
  193. package/dist/vista/vista-open-context.d.ts.map +1 -1
  194. package/dist/vista/vista-open-context.js +1 -5
  195. package/dist/vista/vista-open-context.js.map +1 -1
  196. package/dist/vista/vista-open-state.d.ts +5 -0
  197. package/dist/vista/vista-open-state.d.ts.map +1 -0
  198. package/dist/vista/vista-open-state.js +6 -0
  199. package/dist/vista/vista-open-state.js.map +1 -0
  200. package/dist/vista/vista-payloads.d.ts +2 -3
  201. package/dist/vista/vista-payloads.d.ts.map +1 -1
  202. package/dist/vista/vista-payloads.js +18 -21
  203. package/dist/vista/vista-payloads.js.map +1 -1
  204. package/dist/vista/vista-tools.d.ts.map +1 -1
  205. package/dist/vista/vista-tools.js.map +1 -1
  206. package/package.json +3 -3
  207. package/src/lib/biome-host/biome-builders.ts +4 -1
  208. package/src/lib/biome-host/biome-registry.ts +0 -1
  209. package/src/lib/biome-host/biome-scope-hooks.ts +39 -0
  210. package/src/lib/biome-host/biome-scope.tsx +1 -68
  211. package/src/lib/biome-host/biome-scoped-query.ts +4 -1
  212. package/src/lib/biome-host/create-biome-orval-config.ts +2 -2
  213. package/src/lib/biome-host/errors.ts +1 -1
  214. package/src/lib/biome-host/index.ts +1 -1
  215. package/src/lib/biome-host/session-profiles.ts +7 -1
  216. package/src/lib/capabilities/capability-context.ts +25 -0
  217. package/src/lib/capabilities/capability-provider.tsx +5 -34
  218. package/src/lib/capabilities/index.ts +1 -0
  219. package/src/lib/capabilities/use-capability.ts +4 -1
  220. package/src/lib/system-bus/deeplink.ts +1 -1
  221. package/src/lib/system-bus/host-ports.ts +2 -1
  222. package/src/lib/system-bus/intent-registry.ts +1 -2
  223. package/src/offline/index.ts +4 -2
  224. package/src/offline/offline-status-badge.tsx +1 -1
  225. package/src/offline/outbox.test.ts +23 -16
  226. package/src/offline/outbox.ts +1 -1
  227. package/src/offline/react-hooks.ts +37 -0
  228. package/src/offline/react.tsx +1 -40
  229. package/src/offline/storage.ts +11 -6
  230. package/src/registry/index.ts +1 -0
  231. package/src/registry/lib/primitives/SessionEventCard.tsx +4 -2
  232. package/src/registry/lib/session-context-provider.tsx +8 -25
  233. package/src/registry/lib/session-context.ts +21 -0
  234. package/src/registry/lib/session-selectors.ts +3 -1
  235. package/src/ui/branding/app-branding-context.ts +20 -0
  236. package/src/ui/branding/app-branding-provider.tsx +5 -27
  237. package/src/ui/capture/schema-field-input.tsx +7 -1
  238. package/src/ui/capture/use-prefill.ts +4 -1
  239. package/src/ui/capture/use-wizard.ts +2 -2
  240. package/src/ui/capture/wizard.tsx +2 -1
  241. package/src/ui/index.ts +7 -2
  242. package/src/ui/primitives/alert-dialog.tsx +1 -1
  243. package/src/ui/primitives/badge-variants.ts +21 -0
  244. package/src/ui/primitives/badge.tsx +3 -19
  245. package/src/ui/primitives/button-variants.ts +31 -0
  246. package/src/ui/primitives/button.tsx +3 -28
  247. package/src/ui/primitives/form-stepper.tsx +1 -16
  248. package/src/ui/primitives/form-stepper.types.ts +15 -0
  249. package/src/ui/primitives/preview-device-frame.tsx +1 -1
  250. package/src/ui/primitives/tag-multi-select.tsx +3 -4
  251. package/src/vista/form-payload.spec.ts +71 -25
  252. package/src/vista/index.ts +4 -0
  253. package/src/vista/surfaces/FilesSurface.tsx +2 -68
  254. package/src/vista/surfaces/FormSurface.tsx +125 -26
  255. package/src/vista/surfaces/elicitation-transport.spec.ts +205 -23
  256. package/src/vista/surfaces/elicitation-transport.ts +192 -14
  257. package/src/vista/surfaces/file-surface-utils.ts +62 -0
  258. package/src/vista/surfaces/index.ts +24 -0
  259. package/src/vista/surfaces/{index.tsx → register-surfaces.tsx} +1 -21
  260. package/src/vista/surfaces/text-surfaces.tsx +1 -1
  261. package/src/vista/vista-code-renderer-context.ts +16 -0
  262. package/src/vista/vista-code-renderer.tsx +4 -16
  263. package/src/vista/vista-file-context.ts +14 -0
  264. package/src/vista/vista-file-source.tsx +5 -28
  265. package/src/vista/vista-form-context.ts +20 -0
  266. package/src/vista/vista-form-host.tsx +11 -36
  267. package/src/vista/vista-open-context.tsx +4 -14
  268. package/src/vista/vista-open-state.ts +11 -0
  269. package/src/vista/vista-payloads.ts +44 -46
  270. package/src/vista/vista-tools.ts +2 -2
  271. package/dist/lib/biome-host/response-envelope.d.ts +0 -3
  272. package/dist/lib/biome-host/response-envelope.d.ts.map +0 -1
  273. package/dist/lib/biome-host/response-envelope.js +0 -21
  274. package/dist/lib/biome-host/response-envelope.js.map +0 -1
  275. 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, { data: { submitted: true, ref: 'ref-1', connectionId: 'conn-9' } }),
161
+ jsonResponse(201, {
162
+ data: { submitted: true, ref: 'ref-1', connectionId: 'conn-9' },
163
+ }),
46
164
  );
47
165
 
48
166
  await submitElicitationFields({
49
- submitUrl: SUBMIT_URL,
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('Bearer user-jwt');
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
- submitUrl: SUBMIT_URL,
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(['connectionId', 'ref', 'submitted']);
87
- expect(outcome).toEqual({ submitted: true, ref: 'ref-1', connectionId: 'conn-9' });
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, { data: { submitted: true, ref: 'ref-1', connectionId: 'conn-9' } }),
223
+ jsonResponse(201, {
224
+ data: { submitted: true, ref: 'ref-1', connectionId: 'conn-9' },
225
+ }),
95
226
  );
96
227
 
97
228
  await submitElicitationFields({
98
- submitUrl: SUBMIT_URL,
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(jsonResponse(200, { submitted: true, ref: 'ref-1', connectionId: 'conn-2' }));
242
+ stubFetch(
243
+ jsonResponse(200, {
244
+ submitted: true,
245
+ ref: 'ref-1',
246
+ connectionId: 'conn-2',
247
+ }),
248
+ );
112
249
  await expect(
113
- submitElicitationFields({ submitUrl: SUBMIT_URL, ref: 'ref-1', accessToken: 't', fields: {} }),
114
- ).resolves.toEqual({ submitted: true, ref: 'ref-1', connectionId: 'conn-2' });
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
- submitUrl: SUBMIT_URL,
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({ submitUrl: SUBMIT_URL, ref: 'ref-1', accessToken: 't', fields: {} }),
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(jsonResponse(200, { data: { ref: 'ref-1', status: 'pending', connectionId: null } }));
300
+ stubFetch(
301
+ jsonResponse(200, {
302
+ data: { ref: 'ref-1', status: 'pending', connectionId: null },
303
+ }),
304
+ );
150
305
  await expect(
151
- readElicitationStatus({ statusUrl: STATUS_URL, ref: 'ref-1', accessToken: 't' }),
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, { data: { ref: 'ref-1', status: 'submitted', connectionId: 'conn-3' } }),
316
+ jsonResponse(200, {
317
+ data: { ref: 'ref-1', status: 'submitted', connectionId: 'conn-3' },
318
+ }),
158
319
  );
159
320
  await expect(
160
- readElicitationStatus({ statusUrl: STATUS_URL, ref: 'ref-1', accessToken: 't' }),
161
- ).resolves.toEqual({ submitted: true, ref: 'ref-1', connectionId: 'conn-3' });
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(jsonResponse(200, { data: { ref: 'ref-1', status: 'pending' } }));
166
- await readElicitationStatus({ statusUrl: STATUS_URL, ref: 'ref-1', accessToken: 'user-jwt' });
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((init.headers as Record<string, string>).Authorization).toBe('Bearer user-jwt');
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('deep');
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('The service rejected the submission (HTTP 503).');
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 the ABSOLUTE service URL carried by the (already validated)
7
- // surface payload, bearing the SUBJECT's own JWT.
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 URL is absolute and comes from the payload validator, which rejects
19
- // anything that is not `http(s)://…` — there is no host-relative path, so
20
- // the request cannot be aimed at the app's own BFF or the chat transport.
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 type { VistaFormOutcome } from '../vista-form-host';
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.filter((m): m is string => typeof m === 'string').join(' ');
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(accessToken: string, json: boolean): Record<string, string> {
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 submitUrl: string;
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 response = await fetch(args.submitUrl, {
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(response.status, extractServiceMessage(body, response.status));
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 statusUrl: string;
331
+ readonly apiBaseUrl: string;
162
332
  readonly ref: string;
163
333
  readonly accessToken: string;
164
334
  }): Promise<VistaFormOutcome | null> {
165
- const response = await fetch(args.statusUrl, {
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(response.status, extractServiceMessage(body, response.status));
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
+ }