@morscherlab/mint-sdk 1.0.53 → 1.0.55

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 (32) hide show
  1. package/README.md +18 -0
  2. package/dist/{ExperimentSelectorModal-BwPbQN1g.js → ExperimentSelectorModal-Cg4-LXBD.js} +1 -1
  3. package/dist/{ExperimentSelectorModal-B2qek_YG.js → ExperimentSelectorModal-DZeSd-yr.js} +2 -2
  4. package/dist/{ExperimentSelectorModal-B2qek_YG.js.map → ExperimentSelectorModal-DZeSd-yr.js.map} +1 -1
  5. package/dist/__tests__/composables/resolvePluginFrontendBase.test.d.ts +1 -0
  6. package/dist/components/index.js +2 -2
  7. package/dist/{components-Lk_h_rON.js → components-BQwOeyiy.js} +5 -5
  8. package/dist/{components-Lk_h_rON.js.map → components-BQwOeyiy.js.map} +1 -1
  9. package/dist/composables/index.d.ts +3 -2
  10. package/dist/composables/index.js +4 -4
  11. package/dist/composables/pluginFrontendBase.d.ts +7 -0
  12. package/dist/composables/useApi.d.ts +9 -0
  13. package/dist/composables/usePluginClient.d.ts +12 -2
  14. package/dist/{composables-DyfGpYZw.js → composables-TpXyhRZZ.js} +97 -30
  15. package/dist/composables-TpXyhRZZ.js.map +1 -0
  16. package/dist/index.js +6 -6
  17. package/dist/install.js +2 -2
  18. package/dist/{useExperimentSelector-DdCy5VNv.js → useExperimentSelector-DRbj6vse.js} +33 -3
  19. package/dist/useExperimentSelector-DRbj6vse.js.map +1 -0
  20. package/dist/{useJobsStatusTray-DAH999_N.js → useJobsStatusTray-CVecN6Ao.js} +2 -2
  21. package/dist/{useJobsStatusTray-DAH999_N.js.map → useJobsStatusTray-CVecN6Ao.js.map} +1 -1
  22. package/package.json +1 -1
  23. package/src/__tests__/composables/resolvePluginFrontendBase.test.ts +65 -0
  24. package/src/__tests__/composables/useApi.test.ts +76 -2
  25. package/src/__tests__/composables/usePluginClient.test.ts +158 -0
  26. package/src/composables/index.ts +8 -1
  27. package/src/composables/pluginEndpointBuilder.ts +13 -4
  28. package/src/composables/pluginFrontendBase.ts +22 -0
  29. package/src/composables/useApi.ts +48 -1
  30. package/src/composables/usePluginClient.ts +125 -21
  31. package/dist/composables-DyfGpYZw.js.map +0 -1
  32. package/dist/useExperimentSelector-DdCy5VNv.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.0.53",
3
+ "version": "1.0.55",
4
4
  "description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec INtegrated Toolkit.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+
3
+ import { resolvePluginFrontendBase } from '../../composables/pluginFrontendBase'
4
+
5
+ type InjectedPlatformWindow = Window & {
6
+ __MINT_PLATFORM__?: unknown
7
+ }
8
+
9
+ describe('resolvePluginFrontendBase', () => {
10
+ afterEach(() => {
11
+ delete (window as InjectedPlatformWindow).__MINT_PLATFORM__
12
+ })
13
+
14
+ it('should use the normalized route prefix when the platform context is integrated', () => {
15
+ ;(window as InjectedPlatformWindow).__MINT_PLATFORM__ = {
16
+ isIntegrated: true,
17
+ theme: 'light',
18
+ plugin: {
19
+ id: 'leaf',
20
+ name: 'LEAF',
21
+ version: '0.7.0',
22
+ route_prefix: '/leaf///',
23
+ api_prefix: '/api/leaf',
24
+ },
25
+ }
26
+
27
+ expect(resolvePluginFrontendBase('./')).toBe('/leaf/')
28
+ })
29
+
30
+ it('should preserve the caller fallback when the platform context is absent', () => {
31
+ expect(resolvePluginFrontendBase('./')).toBe('./')
32
+ })
33
+
34
+ it('should preserve the caller fallback when the platform context is standalone', () => {
35
+ ;(window as InjectedPlatformWindow).__MINT_PLATFORM__ = {
36
+ isIntegrated: false,
37
+ theme: 'light',
38
+ plugin: {
39
+ id: 'leaf',
40
+ name: 'LEAF',
41
+ version: '0.7.0',
42
+ route_prefix: '/leaf',
43
+ api_prefix: '/api/leaf',
44
+ },
45
+ }
46
+
47
+ expect(resolvePluginFrontendBase('/dev/')).toBe('/dev/')
48
+ })
49
+
50
+ it('should preserve root when the integrated route prefix is root', () => {
51
+ ;(window as InjectedPlatformWindow).__MINT_PLATFORM__ = {
52
+ isIntegrated: true,
53
+ theme: 'light',
54
+ plugin: {
55
+ id: 'root',
56
+ name: 'Root',
57
+ version: '1.0.0',
58
+ route_prefix: '/',
59
+ api_prefix: '/api',
60
+ },
61
+ }
62
+
63
+ expect(resolvePluginFrontendBase('./')).toBe('/')
64
+ })
65
+ })
@@ -1,8 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { createPinia, setActivePinia } from 'pinia'
3
- import type { InternalAxiosRequestConfig } from 'axios'
3
+ import { AxiosError, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
4
4
 
5
- import { useApi } from '../../composables/useApi'
5
+ import { AuthenticationRequiredError, useApi } from '../../composables/useApi'
6
6
  import { useAuthStore } from '../../stores/auth'
7
7
 
8
8
  function readAuthorizationHeader(headers: InternalAxiosRequestConfig['headers']): unknown {
@@ -12,6 +12,23 @@ function readAuthorizationHeader(headers: InternalAxiosRequestConfig['headers'])
12
12
  ?? bag.authorization
13
13
  }
14
14
 
15
+ function unauthorizedAdapter(config: AxiosRequestConfig): Promise<never> {
16
+ const error = new AxiosError(
17
+ 'Unauthorized',
18
+ AxiosError.ERR_BAD_REQUEST,
19
+ config as InternalAxiosRequestConfig,
20
+ undefined,
21
+ {
22
+ data: { detail: 'Authentication required' },
23
+ status: 401,
24
+ statusText: 'Unauthorized',
25
+ headers: {},
26
+ config: config as InternalAxiosRequestConfig,
27
+ },
28
+ )
29
+ return Promise.reject(error)
30
+ }
31
+
15
32
  describe('useApi URL helpers', () => {
16
33
  beforeEach(() => {
17
34
  setActivePinia(createPinia())
@@ -72,4 +89,61 @@ describe('useApi URL helpers', () => {
72
89
  expect(result).toEqual({ ok: true })
73
90
  expect(authorization).toBeUndefined()
74
91
  })
92
+
93
+ it('normalizes authenticated 401 responses and clears the rejected token', async () => {
94
+ const authStore = useAuthStore()
95
+ authStore.setToken('expired-token', 3600)
96
+ const api = useApi({ baseUrl: '/api' })
97
+
98
+ const adapter = vi.fn(unauthorizedAdapter)
99
+ const error = await api.get('/protected', { adapter }).catch((reason: unknown) => reason)
100
+
101
+ expect({ attempts: adapter.mock.calls.length, errorType: error?.constructor, token: authStore.token }).toEqual({
102
+ attempts: 1,
103
+ errorType: AuthenticationRequiredError,
104
+ token: null,
105
+ })
106
+ })
107
+
108
+ it('does not clear auth state for public requests that return 401', async () => {
109
+ const authStore = useAuthStore()
110
+ authStore.setToken('still-valid-token', 3600)
111
+ const api = useApi({ baseUrl: '/api', withAuth: false })
112
+
113
+ const error = await api.get('/public', { adapter: unauthorizedAdapter }).catch((reason: unknown) => reason)
114
+
115
+ expect({ normalized: error instanceof AuthenticationRequiredError, token: authStore.token }).toEqual({
116
+ normalized: false,
117
+ token: 'still-valid-token',
118
+ })
119
+ })
120
+
121
+ it('does not clear a replacement token after an older request returns 401', async () => {
122
+ const authStore = useAuthStore()
123
+ authStore.setToken('rejected-token', 3600)
124
+ const api = useApi({ baseUrl: '/api' })
125
+
126
+ await api.get('/protected', {
127
+ adapter: async (config) => {
128
+ authStore.setToken('replacement-token', 3600)
129
+ return unauthorizedAdapter(config)
130
+ },
131
+ }).catch(() => undefined)
132
+
133
+ expect(authStore.token).toBe('replacement-token')
134
+ })
135
+
136
+ it('does not clear a token created while an unauthenticated request is in flight', async () => {
137
+ const authStore = useAuthStore()
138
+ const api = useApi({ baseUrl: '/api' })
139
+
140
+ await api.get('/protected', {
141
+ adapter: async (config) => {
142
+ authStore.setToken('new-login-token', 3600)
143
+ return unauthorizedAdapter(config)
144
+ },
145
+ }).catch(() => undefined)
146
+
147
+ expect(authStore.token).toBe('new-login-token')
148
+ })
75
149
  })
@@ -18,6 +18,7 @@ import {
18
18
  usePluginSettings,
19
19
  type PluginContract,
20
20
  } from '../../composables/usePluginClient'
21
+ import { AuthenticationRequiredError } from '../../composables/useApi'
21
22
  import { useAuthStore } from '../../stores/auth'
22
23
 
23
24
  const contract: PluginContract = {
@@ -282,6 +283,21 @@ describe('usePluginClient', () => {
282
283
  expect(url).toBe('/templates/plate%20map')
283
284
  })
284
285
 
286
+ it('builds URLs from FastAPI converted path parameters', () => {
287
+ const url = buildPluginEndpointUrl(
288
+ contract,
289
+ {
290
+ method: 'get',
291
+ path: '/raw/{folder}/{filename:path}',
292
+ pathParams: ['folder', 'filename'],
293
+ },
294
+ { folder: 'batch 1', filename: 'sample.mzML' },
295
+ { includeBaseUrl: false },
296
+ )
297
+
298
+ expect(url).toBe('/raw/batch%201/sample.mzML')
299
+ })
300
+
285
301
  it('preserves flat scalar query fields when no structured query object is used', () => {
286
302
  const url = buildPluginEndpointUrl(
287
303
  contract,
@@ -333,6 +349,29 @@ describe('usePluginClient', () => {
333
349
  expect(form.get('dryRun')).toBe('false')
334
350
  })
335
351
 
352
+ it('uploads generated octet-stream endpoint bodies without multipart conversion', async () => {
353
+ const body = new Uint8Array([1, 2, 3])
354
+
355
+ await uploadPluginEndpoint(
356
+ contract,
357
+ {
358
+ method: 'put',
359
+ path: '/chunks',
360
+ hasBody: true,
361
+ requestContentType: 'application/octet-stream',
362
+ },
363
+ body,
364
+ )
365
+
366
+ expect({
367
+ body: requestBodies[0],
368
+ contentType: (requestConfigs[0]!.headers as Record<string, unknown>)['Content-Type'],
369
+ }).toEqual({
370
+ body,
371
+ contentType: 'application/octet-stream',
372
+ })
373
+ })
374
+
336
375
  it('builds multipart form data from repeated scalar values', () => {
337
376
  const form = pluginFormDataFromPayload({
338
377
  tags: ['qc', 'plate'],
@@ -371,6 +410,96 @@ describe('usePluginClient', () => {
371
410
  expect(requestConfigs[0]!.responseType).toBe('blob')
372
411
  })
373
412
 
413
+ it.each([
414
+ new Blob(['chunk']),
415
+ new Uint8Array([1, 2, 3]),
416
+ new Uint8Array([4, 5, 6]).buffer,
417
+ ])('sends generated octet-stream request bodies without encoding them', async (body) => {
418
+ const client = createPluginClient(contract, {
419
+ endpoints: {
420
+ uploadChunk: {
421
+ method: 'post',
422
+ path: '/chunks',
423
+ hasBody: true,
424
+ requestContentType: 'application/octet-stream',
425
+ },
426
+ },
427
+ })
428
+
429
+ await client.uploadChunk(body)
430
+
431
+ expect({
432
+ body: requestBodies[0],
433
+ contentType: (requestConfigs[0]!.headers as Record<string, unknown>)['Content-Type'],
434
+ }).toEqual({
435
+ body,
436
+ contentType: 'application/octet-stream',
437
+ })
438
+ })
439
+
440
+ it('returns generated octet-stream responses as blobs', async () => {
441
+ const expectedBlob = new Blob(['result'])
442
+ nextGetData = expectedBlob
443
+ const client = createPluginClient(contract, {
444
+ endpoints: {
445
+ downloadResult: {
446
+ method: 'get',
447
+ path: '/result',
448
+ responseContentType: 'application/octet-stream',
449
+ },
450
+ },
451
+ })
452
+
453
+ const result = await client.downloadResult()
454
+
455
+ expect({ result, responseType: requestConfigs[0]!.responseType }).toEqual({
456
+ result: expectedBlob,
457
+ responseType: 'blob',
458
+ })
459
+ })
460
+
461
+ it('passes AbortSignal through generated endpoint calls', async () => {
462
+ const controller = new AbortController()
463
+ const client = createPluginClient(contract, {
464
+ endpoints: {
465
+ health: { method: 'get', path: '/health' },
466
+ },
467
+ })
468
+
469
+ await client.health({ signal: controller.signal })
470
+
471
+ expect(requestConfigs[0]!.signal).toBe(controller.signal)
472
+ })
473
+
474
+ it('encodes generated multipart endpoint bodies as FormData', async () => {
475
+ const file = new Blob(['csv'])
476
+ const client = createPluginClient(contract, {
477
+ endpoints: {
478
+ uploadFile: {
479
+ method: 'post',
480
+ path: '/files',
481
+ hasBody: true,
482
+ requestContentType: 'multipart/form-data',
483
+ },
484
+ },
485
+ })
486
+
487
+ await client.uploadFile({ file, label: 'sample' })
488
+
489
+ const body = requestBodies[0] as FormData
490
+ expect({
491
+ isFormData: body instanceof FormData,
492
+ fileIsBlob: body.get('file') instanceof Blob,
493
+ label: body.get('label'),
494
+ contentType: (requestConfigs[0]!.headers as Record<string, unknown>)['Content-Type'],
495
+ }).toEqual({
496
+ isFormData: true,
497
+ fileIsBlob: true,
498
+ label: 'sample',
499
+ contentType: undefined,
500
+ })
501
+ })
502
+
374
503
  it('triggers browser downloads for existing blobs', () => {
375
504
  vi.useFakeTimers()
376
505
  const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test')
@@ -551,6 +680,35 @@ describe('usePluginClient', () => {
551
680
  expect(stream.lastEventId.value).toBe('7')
552
681
  })
553
682
 
683
+ it('normalizes stream auth failures and does not reconnect rejected requests', async () => {
684
+ const authStore = useAuthStore()
685
+ authStore.setToken('expired-stream-token', 3600)
686
+ const onError = vi.fn()
687
+ const fetchMock = vi.fn(async () => new Response(null, { status: 401 }))
688
+ vi.stubGlobal('fetch', fetchMock)
689
+
690
+ const stream = usePluginEventStream(
691
+ contract,
692
+ { method: 'get', path: '/downloads/events' },
693
+ { reconnectDelayMs: 1, onError },
694
+ )
695
+
696
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce())
697
+ await new Promise((resolve) => setTimeout(resolve, 10))
698
+
699
+ expect({
700
+ attempts: fetchMock.mock.calls.length,
701
+ errorType: onError.mock.calls[0]![0]?.constructor,
702
+ message: stream.error.value,
703
+ token: authStore.token,
704
+ }).toEqual({
705
+ attempts: 1,
706
+ errorType: AuthenticationRequiredError,
707
+ message: 'Authentication required. Please sign in again.',
708
+ token: null,
709
+ })
710
+ })
711
+
554
712
  it('parses generated typed event streams as JSON by default', async () => {
555
713
  const onMessage = vi.fn()
556
714
  const streamBody = new ReadableStream({
@@ -1,9 +1,15 @@
1
- export { useApi, type ApiClientOptions } from './useApi'
1
+ export {
2
+ AuthenticationRequiredError,
3
+ useApi,
4
+ type ApiClientOptions,
5
+ type UseApiReturn,
6
+ } from './useApi'
2
7
  export { useAuth } from './useAuth'
3
8
  export { usePasskey } from './usePasskey'
4
9
  export { useTheme } from './useTheme'
5
10
  export { useToast } from './useToast'
6
11
  export { usePlatformContext } from './usePlatformContext'
12
+ export { resolvePluginFrontendBase } from './pluginFrontendBase'
7
13
  export {
8
14
  useForm,
9
15
  type ValidationRule,
@@ -452,6 +458,7 @@ export {
452
458
  type DownloadBlobOptions,
453
459
  type DownloadPluginEndpointOptions,
454
460
  type PluginContract,
461
+ type PluginClientRequestOptions,
455
462
  type PluginEndpointContract,
456
463
  type PluginEndpointDefinition,
457
464
  type PluginHttpMethod,
@@ -30,6 +30,17 @@ function endpointParamFieldName(param: PluginEndpointParamDefinition): string {
30
30
  return typeof param === 'string' ? param : param.fieldName ?? param.name
31
31
  }
32
32
 
33
+ function escapeRegExp(value: string): string {
34
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
35
+ }
36
+
37
+ function replacePathParam(path: string, name: string, encoded: string): string {
38
+ const escapedName = escapeRegExp(name)
39
+ return path
40
+ .replace(new RegExp(`\\{${escapedName}(?::[^{}]+)?\\}`, 'g'), () => encoded)
41
+ .replace(new RegExp(`:${escapedName}(?=/|$)`, 'g'), () => encoded)
42
+ }
43
+
33
44
  /** Resolve the runtime plugin API base URL from env, explicit options, platform injection, or contract metadata. */
34
45
  export function resolvePluginBaseUrl(contract: PluginContract, explicitBaseUrl?: string): string {
35
46
  const envPrefix = (import.meta.env?.VITE_API_PREFIX as string | undefined) || undefined
@@ -59,11 +70,9 @@ function encodePath(
59
70
  throw new Error(`[MINT SDK] Missing path parameter '${fieldName}' for plugin endpoint ${path}`)
60
71
  }
61
72
  const encoded = encodeURIComponent(String(value))
62
- nextPath = nextPath.replace(`{${name}}`, encoded)
63
- nextPath = nextPath.replace(`:${name}`, encoded)
73
+ nextPath = replacePathParam(nextPath, name, encoded)
64
74
  if (fieldName !== name) {
65
- nextPath = nextPath.replace(`{${fieldName}}`, encoded)
66
- nextPath = nextPath.replace(`:${fieldName}`, encoded)
75
+ nextPath = replacePathParam(nextPath, fieldName, encoded)
67
76
  }
68
77
  }
69
78
  return nextPath
@@ -0,0 +1,22 @@
1
+ import { getInjectedPlatformContext } from './platformContextHelpers'
2
+
3
+ function normalizePluginRoutePrefix(prefix: string | undefined): string | undefined {
4
+ const candidate = prefix?.trim()
5
+ if (!candidate) return undefined
6
+
7
+ const path = candidate.split('/').filter(Boolean).join('/')
8
+ return path ? `/${path}/` : '/'
9
+ }
10
+
11
+ /**
12
+ * Resolve the Vue Router base for a plugin frontend.
13
+ *
14
+ * Integrated plugins use the platform-injected mount prefix. Standalone and
15
+ * development builds retain the caller's Vite base unchanged.
16
+ */
17
+ export function resolvePluginFrontendBase(fallbackBase: string = '/'): string {
18
+ const context = getInjectedPlatformContext()
19
+ if (!context?.isIntegrated) return fallbackBase
20
+
21
+ return normalizePluginRoutePrefix(context.plugin?.route_prefix) ?? fallbackBase
22
+ }
@@ -7,10 +7,12 @@ let interceptorAttached = false
7
7
 
8
8
  interface MintAxiosRequestConfig extends AxiosRequestConfig {
9
9
  _mintSkipAuth?: boolean
10
+ _mintAuthToken?: string
10
11
  }
11
12
 
12
13
  interface MintInternalAxiosRequestConfig extends InternalAxiosRequestConfig {
13
14
  _mintSkipAuth?: boolean
15
+ _mintAuthToken?: string
14
16
  }
15
17
 
16
18
  type MutableHeaders = Record<string, unknown> & {
@@ -92,6 +94,37 @@ function deleteAuthorizationHeader(headers: AxiosRequestConfig['headers']): void
92
94
  }
93
95
  }
94
96
 
97
+ function getAuthorizationHeader(headers: AxiosRequestConfig['headers']): unknown {
98
+ const bag = asMutableHeaders(headers) as (MutableHeaders & { get?: (header: string) => unknown }) | null
99
+ if (!bag) return undefined
100
+ if (typeof bag.get === 'function') return bag.get('Authorization')
101
+ const key = Object.keys(bag).find((candidate) => candidate.toLowerCase() === 'authorization')
102
+ return key ? bag[key] : undefined
103
+ }
104
+
105
+ /** Raised when an authenticated SDK request is rejected because the login is no longer valid. */
106
+ export class AuthenticationRequiredError extends Error {
107
+ readonly code = 'AUTHENTICATION_REQUIRED'
108
+ readonly status = 401
109
+
110
+ constructor(readonly cause?: unknown) {
111
+ super('Authentication required. Please sign in again.')
112
+ this.name = 'AuthenticationRequiredError'
113
+ }
114
+ }
115
+
116
+ /** @internal Convert a rejected authenticated request into the SDK's public auth error. */
117
+ export function authenticationRequiredError(
118
+ cause: unknown,
119
+ rejectedToken?: string,
120
+ ): AuthenticationRequiredError {
121
+ const currentAuthStore = useAuthStore()
122
+ if (rejectedToken && currentAuthStore.token === rejectedToken) {
123
+ currentAuthStore.clearToken()
124
+ }
125
+ return new AuthenticationRequiredError(cause)
126
+ }
127
+
95
128
  function getApiClient(): AxiosInstance {
96
129
  if (!apiClientInstance) {
97
130
  apiClientInstance = axios.create({
@@ -138,7 +171,6 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
138
171
  apiClient.interceptors.request.use((config) => {
139
172
  const request = config as MintInternalAxiosRequestConfig
140
173
  if (request._mintSkipAuth) {
141
- delete request._mintSkipAuth
142
174
  deleteAuthorizationHeader(request.headers)
143
175
  return request
144
176
  }
@@ -147,8 +179,23 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
147
179
  if (currentAuthStore.token && config.headers && !hasAuthorizationHeader(config.headers)) {
148
180
  setAuthorizationHeader(config.headers, `Bearer ${currentAuthStore.token}`)
149
181
  }
182
+ if (getAuthorizationHeader(config.headers) === `Bearer ${currentAuthStore.token}`) {
183
+ request._mintAuthToken = currentAuthStore.token ?? undefined
184
+ }
150
185
  return config
151
186
  })
187
+ apiClient.interceptors.response.use(
188
+ (response) => response,
189
+ (error: unknown) => {
190
+ if (axios.isAxiosError(error) && error.response?.status === 401) {
191
+ const request = error.config as MintInternalAxiosRequestConfig | undefined
192
+ if (request?._mintSkipAuth !== true) {
193
+ return Promise.reject(authenticationRequiredError(error, request?._mintAuthToken))
194
+ }
195
+ }
196
+ return Promise.reject(error)
197
+ },
198
+ )
152
199
  interceptorAttached = true
153
200
  }
154
201