@morscherlab/mint-sdk 1.1.10 → 1.1.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morscherlab/mint-sdk",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
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,47 @@
1
+ import { computed, ref } from 'vue'
2
+ import { mount } from '@vue/test-utils'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import AppTopBar from '../../components/AppTopBar.vue'
6
+
7
+ const heartbeat = vi.hoisted(() => vi.fn((_options: unknown) => ({
8
+ clientId: 'presence-tab',
9
+ isRunning: ref(false),
10
+ sendNow: vi.fn(),
11
+ })))
12
+
13
+ vi.mock('../../composables/usePresenceHeartbeat', () => ({
14
+ usePresenceHeartbeat: heartbeat,
15
+ }))
16
+
17
+ vi.mock('../../composables/usePlatformContext', () => ({
18
+ usePlatformContext: vi.fn(() => ({
19
+ isIntegrated: computed(() => true),
20
+ context: ref({ isIntegrated: true, theme: 'system' }),
21
+ plugin: computed(() => ({ id: 'LEAF', name: 'Leaf Analysis' })),
22
+ user: computed(() => undefined),
23
+ theme: computed(() => 'system' as const),
24
+ features: computed(() => undefined),
25
+ navigate: vi.fn(),
26
+ notify: vi.fn(),
27
+ sendToPlatform: vi.fn(),
28
+ })),
29
+ }))
30
+
31
+ describe('AppTopBar presence', () => {
32
+ beforeEach(() => vi.clearAllMocks())
33
+
34
+ it('should report the integrated plugin id as the presence scope', () => {
35
+ const wrapper = mount(AppTopBar, { props: { title: 'LEAF' } })
36
+
37
+ const options = heartbeat.mock.calls[0]?.[0] as {
38
+ enabled: { value: boolean }
39
+ scope: { value: string }
40
+ }
41
+ expect({ enabled: options.enabled.value, scope: options.scope.value }).toEqual({
42
+ enabled: true,
43
+ scope: 'LEAF',
44
+ })
45
+ wrapper.unmount()
46
+ })
47
+ })
@@ -0,0 +1,147 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { defineComponent, nextTick } from 'vue'
3
+ import { mount } from '@vue/test-utils'
4
+
5
+ import { usePresenceHeartbeat } from '../../composables/usePresenceHeartbeat'
6
+
7
+ class MemoryStorage implements Pick<Storage, 'getItem' | 'setItem'> {
8
+ private readonly values = new Map<string, string>()
9
+
10
+ getItem(key: string): string | null {
11
+ return this.values.get(key) ?? null
12
+ }
13
+
14
+ setItem(key: string, value: string): void {
15
+ this.values.set(key, value)
16
+ }
17
+ }
18
+
19
+ function mountHeartbeat(options: Parameters<typeof usePresenceHeartbeat>[0]) {
20
+ let heartbeat: ReturnType<typeof usePresenceHeartbeat> | undefined
21
+ const wrapper = mount(defineComponent({
22
+ setup() {
23
+ heartbeat = usePresenceHeartbeat(options)
24
+ return () => null
25
+ },
26
+ }))
27
+ return { wrapper, get heartbeat() { return heartbeat! } }
28
+ }
29
+
30
+ describe('usePresenceHeartbeat', () => {
31
+ afterEach(() => {
32
+ vi.useRealTimers()
33
+ vi.restoreAllMocks()
34
+ })
35
+
36
+ it('should reuse one sessionStorage client id and send the integrated scope immediately', async () => {
37
+ const storage = new MemoryStorage()
38
+ const send = vi.fn().mockResolvedValue(undefined)
39
+ const first = mountHeartbeat({
40
+ scope: 'LEAF',
41
+ enabled: true,
42
+ storage,
43
+ createClientId: () => 'tab-1',
44
+ send,
45
+ })
46
+ await nextTick()
47
+
48
+ expect(send).toHaveBeenCalledWith({ client_id: 'tab-1', scope: 'LEAF' })
49
+
50
+ first.wrapper.unmount()
51
+ send.mockClear()
52
+ const second = mountHeartbeat({
53
+ scope: 'HMDB',
54
+ enabled: true,
55
+ storage,
56
+ createClientId: () => 'tab-2',
57
+ send,
58
+ })
59
+ await nextTick()
60
+
61
+ expect({ clientId: second.heartbeat.clientId, payload: send.mock.calls[0]?.[0] }).toEqual({
62
+ clientId: 'tab-1',
63
+ payload: { client_id: 'tab-1', scope: 'HMDB' },
64
+ })
65
+ second.wrapper.unmount()
66
+ })
67
+
68
+ it('should heartbeat every 30 seconds only while the page is visible', async () => {
69
+ vi.useFakeTimers()
70
+ const send = vi.fn().mockResolvedValue(undefined)
71
+ const visibility = { value: 'visible' }
72
+ vi.spyOn(document, 'visibilityState', 'get').mockImplementation(
73
+ () => visibility.value as DocumentVisibilityState,
74
+ )
75
+ const { wrapper } = mountHeartbeat({
76
+ scope: 'platform',
77
+ enabled: true,
78
+ storage: new MemoryStorage(),
79
+ createClientId: () => 'platform-tab',
80
+ send,
81
+ })
82
+ await nextTick()
83
+ expect(send).toHaveBeenCalledTimes(1)
84
+
85
+ await vi.advanceTimersByTimeAsync(30_000)
86
+ expect(send).toHaveBeenCalledTimes(2)
87
+
88
+ visibility.value = 'hidden'
89
+ document.dispatchEvent(new Event('visibilitychange'))
90
+ await vi.advanceTimersByTimeAsync(60_000)
91
+ expect(send).toHaveBeenCalledTimes(2)
92
+
93
+ visibility.value = 'visible'
94
+ document.dispatchEvent(new Event('visibilitychange'))
95
+ await nextTick()
96
+ expect(send).toHaveBeenCalledTimes(3)
97
+
98
+ wrapper.unmount()
99
+ await vi.advanceTimersByTimeAsync(60_000)
100
+ expect(send).toHaveBeenCalledTimes(3)
101
+ })
102
+
103
+ it('should make no request when the caller is standalone', async () => {
104
+ vi.useFakeTimers()
105
+ const send = vi.fn().mockResolvedValue(undefined)
106
+ const { wrapper } = mountHeartbeat({
107
+ scope: 'LEAF',
108
+ enabled: false,
109
+ storage: new MemoryStorage(),
110
+ createClientId: () => 'standalone-tab',
111
+ send,
112
+ })
113
+ await nextTick()
114
+ await vi.advanceTimersByTimeAsync(90_000)
115
+
116
+ expect(send).not.toHaveBeenCalled()
117
+ wrapper.unmount()
118
+ })
119
+
120
+ it('should keep a page-local id when sessionStorage is unavailable', async () => {
121
+ const storage = {
122
+ getItem: vi.fn(() => { throw new Error('blocked') }),
123
+ setItem: vi.fn(() => { throw new Error('blocked') }),
124
+ }
125
+ const send = vi.fn().mockResolvedValue(undefined)
126
+ const first = mountHeartbeat({
127
+ scope: 'LEAF',
128
+ enabled: true,
129
+ storage,
130
+ createClientId: () => 'fallback-tab',
131
+ send,
132
+ })
133
+ await nextTick()
134
+ first.wrapper.unmount()
135
+ const second = mountHeartbeat({
136
+ scope: 'HMDB',
137
+ enabled: true,
138
+ storage,
139
+ createClientId: () => 'different-tab',
140
+ send,
141
+ })
142
+ await nextTick()
143
+
144
+ expect(second.heartbeat.clientId).toBe('fallback-tab')
145
+ second.wrapper.unmount()
146
+ })
147
+ })
@@ -39,6 +39,18 @@ describe('permission helpers', () => {
39
39
  expect(canAccessPlugin(member, 'reports')).toBe(false)
40
40
  })
41
41
 
42
+ it('allows notice publishers to enter the admin panel', () => {
43
+ const publisher = {
44
+ role: 'notice-publisher',
45
+ role_obj: {
46
+ slug: 'notice-publisher',
47
+ permissions: ['notices.publish'],
48
+ },
49
+ }
50
+
51
+ expect(canAccessAdmin(publisher)).toBe(true)
52
+ })
53
+
42
54
  it('evaluates access policies consistently', () => {
43
55
  expect(canAccessByPolicy(member, { visibleFor: 'user' })).toBe(true)
44
56
  expect(canAccessByPolicy(member, { visibleFor: 'admin' })).toBe(false)
@@ -22,6 +22,7 @@ import AppAvatarMenu from './AppAvatarMenu.vue'
22
22
  import AppPluginSwitcher from './AppPluginSwitcher.vue'
23
23
  import PluginIcon from './PluginIcon.vue'
24
24
  import { usePlatformContext } from '../composables/usePlatformContext'
25
+ import { usePresenceHeartbeat } from '../composables/usePresenceHeartbeat'
25
26
  import { APP_EXPERIMENT_KEY } from '../composables/useAppExperiment'
26
27
  import {
27
28
  currentItemIdFromLocation,
@@ -115,6 +116,11 @@ const emit = defineEmits<{
115
116
  const settingsOpen = ref(false)
116
117
  const { isIntegrated, plugin } = usePlatformContext()
117
118
  const isStandalone = computed(() => !isIntegrated.value)
119
+ const presenceScope = computed(() => plugin.value?.id || plugin.value?.name || '')
120
+ usePresenceHeartbeat({
121
+ enabled: isIntegrated,
122
+ scope: presenceScope,
123
+ })
118
124
  const appExperiment = inject(APP_EXPERIMENT_KEY, null)
119
125
  const canBindExperiment = computed(() => plugin.value?.plugin_type !== 'static')
120
126
 
@@ -10,6 +10,13 @@ export { useTheme } from './useTheme'
10
10
  export { useToast } from './useToast'
11
11
  export { usePlatformContext } from './usePlatformContext'
12
12
  export { resolvePluginFrontendBase } from './pluginFrontendBase'
13
+ export {
14
+ getPresenceClientId,
15
+ usePresenceHeartbeat,
16
+ type PresenceHeartbeatOptions,
17
+ type PresenceHeartbeatPayload,
18
+ type UsePresenceHeartbeatReturn,
19
+ } from './usePresenceHeartbeat'
13
20
  export {
14
21
  useForm,
15
22
  type ValidationRule,
@@ -0,0 +1,194 @@
1
+ import {
2
+ computed,
3
+ onMounted,
4
+ onScopeDispose,
5
+ readonly,
6
+ ref,
7
+ toValue,
8
+ watch,
9
+ type MaybeRefOrGetter,
10
+ type Ref,
11
+ } from 'vue'
12
+
13
+ import { useApi } from './useApi'
14
+
15
+ const PRESENCE_CLIENT_ID_KEY = 'mint:presence:client-id'
16
+ const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000
17
+
18
+ let pageFallbackClientId: string | null = null
19
+
20
+ export interface PresenceHeartbeatPayload {
21
+ client_id: string
22
+ scope: string
23
+ }
24
+
25
+ interface PresenceDocument {
26
+ readonly visibilityState: DocumentVisibilityState
27
+ addEventListener(type: 'visibilitychange', listener: EventListener): void
28
+ removeEventListener(type: 'visibilitychange', listener: EventListener): void
29
+ }
30
+
31
+ interface PresenceStorage {
32
+ getItem(key: string): string | null
33
+ setItem(key: string, value: string): void
34
+ }
35
+
36
+ export interface PresenceHeartbeatOptions {
37
+ /** Platform, plugin id, or plugin name reported for this browser tab. */
38
+ scope: MaybeRefOrGetter<string | null | undefined>
39
+ /** Disable all traffic, including for standalone plugins. */
40
+ enabled?: MaybeRefOrGetter<boolean>
41
+ /** Heartbeat interval. Defaults to 30 seconds. */
42
+ intervalMs?: number
43
+ /** Test or host storage seam. Defaults to sessionStorage. */
44
+ storage?: PresenceStorage | null
45
+ /** Test or host document seam. Defaults to the current document. */
46
+ document?: PresenceDocument | null
47
+ /** Test seam for generating the per-tab id. */
48
+ createClientId?: () => string
49
+ /** Test or custom transport seam. Defaults to authenticated POST /api/presence/heartbeat. */
50
+ send?: (payload: PresenceHeartbeatPayload) => Promise<unknown> | unknown
51
+ /** Test timing seam. Defaults to the browser scheduler. */
52
+ setInterval?: (callback: () => void, intervalMs: number) => unknown
53
+ /** Test timing seam. Defaults to the browser scheduler. */
54
+ clearInterval?: (intervalId: unknown) => void
55
+ }
56
+
57
+ export interface UsePresenceHeartbeatReturn {
58
+ clientId: string
59
+ isRunning: Readonly<Ref<boolean>>
60
+ sendNow: () => Promise<void>
61
+ }
62
+
63
+ function defaultClientId(): string {
64
+ if (typeof globalThis.crypto?.randomUUID === 'function') {
65
+ return globalThis.crypto.randomUUID()
66
+ }
67
+ return `presence-${Date.now()}-${Math.random().toString(36).slice(2)}`
68
+ }
69
+
70
+ function defaultStorage(): PresenceStorage | null {
71
+ try {
72
+ return globalThis.sessionStorage ?? null
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ function defaultDocument(): PresenceDocument | null {
79
+ return typeof globalThis.document === 'undefined' ? null : globalThis.document
80
+ }
81
+
82
+ /** Return the stable id shared by presence callers in the current browser tab. */
83
+ export function getPresenceClientId(
84
+ storage: PresenceStorage | null = defaultStorage(),
85
+ createClientId: () => string = defaultClientId,
86
+ ): string {
87
+ try {
88
+ const stored = storage?.getItem(PRESENCE_CLIENT_ID_KEY)
89
+ if (stored) return stored
90
+ } catch {
91
+ // Keep the tab usable when storage is blocked.
92
+ }
93
+
94
+ const nextClientId = createClientId()
95
+ try {
96
+ if (storage) {
97
+ storage.setItem(PRESENCE_CLIENT_ID_KEY, nextClientId)
98
+ return nextClientId
99
+ }
100
+ } catch {
101
+ // Fall through to the page-local fallback when storage is blocked.
102
+ }
103
+
104
+ if (!pageFallbackClientId) pageFallbackClientId = nextClientId
105
+ return pageFallbackClientId
106
+ }
107
+
108
+ /** Report one visible integrated MINT browser tab to the platform presence tracker. */
109
+ export function usePresenceHeartbeat(
110
+ options: PresenceHeartbeatOptions,
111
+ ): UsePresenceHeartbeatReturn {
112
+ const presenceDocument = options.document === undefined ? defaultDocument() : options.document
113
+ const clientId = getPresenceClientId(
114
+ options.storage === undefined ? defaultStorage() : options.storage,
115
+ options.createClientId ?? defaultClientId,
116
+ )
117
+ const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS
118
+ const scheduleInterval = options.setInterval ?? ((callback: () => void, delayMs: number) => (
119
+ globalThis.setInterval(callback, delayMs)
120
+ ))
121
+ const cancelInterval = options.clearInterval ?? ((intervalId: unknown) => {
122
+ globalThis.clearInterval(intervalId as ReturnType<typeof globalThis.setInterval>)
123
+ })
124
+ if (intervalMs <= 0) throw new Error('Presence heartbeat interval must be positive')
125
+
126
+ const api = options.send ? null : useApi({ baseUrl: '/api' })
127
+ const send = options.send ?? ((payload: PresenceHeartbeatPayload) => (
128
+ api!.post('/presence/heartbeat', payload)
129
+ ))
130
+ const enabled = computed(() => toValue(options.enabled ?? true))
131
+ const scope = computed(() => toValue(options.scope)?.trim() ?? '')
132
+ const isRunning = ref(false)
133
+ let intervalId: unknown | null = null
134
+ let sending = false
135
+ let mounted = false
136
+
137
+ function isVisible(): boolean {
138
+ return presenceDocument?.visibilityState !== 'hidden'
139
+ }
140
+
141
+ function shouldRun(): boolean {
142
+ return mounted && enabled.value && Boolean(scope.value) && isVisible()
143
+ }
144
+
145
+ async function sendNow(): Promise<void> {
146
+ if (!shouldRun() || sending) return
147
+ sending = true
148
+ try {
149
+ await send({ client_id: clientId, scope: scope.value })
150
+ } catch {
151
+ // Presence is advisory and must never break plugin or platform UI.
152
+ } finally {
153
+ sending = false
154
+ }
155
+ }
156
+
157
+ function stop(): void {
158
+ if (intervalId !== null) cancelInterval(intervalId)
159
+ intervalId = null
160
+ isRunning.value = false
161
+ }
162
+
163
+ function start(): void {
164
+ stop()
165
+ if (!shouldRun()) return
166
+ isRunning.value = true
167
+ void sendNow()
168
+ intervalId = scheduleInterval(() => void sendNow(), intervalMs)
169
+ }
170
+
171
+ function handleVisibilityChange(): void {
172
+ start()
173
+ }
174
+
175
+ watch([enabled, scope], start)
176
+
177
+ onMounted(() => {
178
+ mounted = true
179
+ presenceDocument?.addEventListener('visibilitychange', handleVisibilityChange)
180
+ start()
181
+ })
182
+
183
+ onScopeDispose(() => {
184
+ mounted = false
185
+ presenceDocument?.removeEventListener('visibilitychange', handleVisibilityChange)
186
+ stop()
187
+ })
188
+
189
+ return {
190
+ clientId,
191
+ isRunning: readonly(isRunning),
192
+ sendNow,
193
+ }
194
+ }
@@ -8,6 +8,7 @@ export const ADMIN_PANEL_PERMISSIONS = [
8
8
  'platform.view_logs',
9
9
  'plugins.configure',
10
10
  'plugins.install',
11
+ 'notices.publish',
11
12
  ] as const
12
13
 
13
14
  export type AccessAudience = 'all' | 'admin' | 'user'