@open-mercato/ui 0.9.1-develop.7567.1.b5e80ff620 → 0.9.1-develop.7573.1.d0ccc51d5b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/notifications/NotificationDispatcher.js +5 -0
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/notifications/NotificationDispatcher.ts +5 -0
- package/src/backend/notifications/__tests__/NotificationDispatcher.navigate.test.ts +76 -0
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
getNotificationHandlerEntries
|
|
4
4
|
} from "@open-mercato/shared/lib/notifications/handler-registry";
|
|
5
5
|
import { hasAllFeatures } from "@open-mercato/shared/security/features";
|
|
6
|
+
import { isSafeNotificationHref } from "@open-mercato/shared/modules/notifications/safeHref";
|
|
6
7
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
7
8
|
import { localizeNotificationCopy } from "./localizeNotification.js";
|
|
8
9
|
const logger = createLogger("ui").child({ component: "notifications" });
|
|
@@ -50,6 +51,10 @@ function buildHandlerContext(runtime) {
|
|
|
50
51
|
},
|
|
51
52
|
refreshNotifications: runtime.refreshNotifications,
|
|
52
53
|
navigate(href) {
|
|
54
|
+
if (!isSafeNotificationHref(href)) {
|
|
55
|
+
logger.warn("Notification handler navigation ignored: href is not a same-origin path");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
53
58
|
runtime.navigate(href);
|
|
54
59
|
},
|
|
55
60
|
markAsRead: runtime.markAsRead,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/notifications/NotificationDispatcher.ts"],
|
|
4
|
-
"sourcesContent": ["import { flash, type FlashKind } from '@open-mercato/ui/backend/FlashMessages'\nimport {\n getNotificationHandlerEntries,\n} from '@open-mercato/shared/lib/notifications/handler-registry'\nimport { hasAllFeatures } from '@open-mercato/shared/security/features'\nimport type {\n NotificationHandler,\n NotificationHandlerContext,\n NotificationHandlerPopupOptions,\n NotificationHandlerToastOptions,\n} from '@open-mercato/shared/modules/notifications/handler'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { localizeNotificationCopy } from './localizeNotification'\n\nconst logger = createLogger('ui').child({ component: 'notifications' })\n\ntype RuntimeContext = {\n userId?: string\n features: string[]\n currentPath: string\n t?: (key: string, fallback?: string) => string\n refreshNotifications: () => void\n navigate: (href: string) => void\n markAsRead: (notificationId: string) => Promise<void>\n dismiss: (notificationId: string) => Promise<void>\n}\n\ntype NotificationEffectListener = {\n id: number\n pattern: string | string[]\n effect: (notification: NotificationDto) => void\n}\n\nconst DEFAULT_PRIORITY = 50\nconst MAX_TRACKED_IDS = 500\nconst RETAINED_IDS_AFTER_PRUNE = 200\nconst POPUP_EVENT_NAME = 'om:notifications:popup'\n\nfunction toFlashKind(value: NotificationHandlerToastOptions['severity'] | NotificationHandlerPopupOptions['severity']): FlashKind {\n if (value === 'success') return 'success'\n if (value === 'warning') return 'warning'\n if (value === 'error') return 'error'\n return 'info'\n}\n\nfunction matchesType(pattern: string | string[], type: string): boolean {\n const patterns = Array.isArray(pattern) ? pattern : [pattern]\n return patterns.some((current) => {\n if (current === '*') return true\n if (current.endsWith('.*')) return type.startsWith(current.slice(0, -1))\n return current === type\n })\n}\n\nfunction matchesFeatures(required: string[] | undefined, current: string[]): boolean {\n return hasAllFeatures(current, required)\n}\n\nfunction buildHandlerContext(runtime: RuntimeContext): NotificationHandlerContext {\n return {\n userId: runtime.userId,\n features: runtime.features,\n currentPath: runtime.currentPath,\n t: runtime.t,\n toast(options) {\n const text = options.body ? `${options.title}: ${options.body}` : options.title\n flash(text, toFlashKind(options.severity))\n },\n popup(options) {\n if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') {\n window.dispatchEvent(new CustomEvent(POPUP_EVENT_NAME, { detail: options }))\n }\n const text = options.body ? `${options.title}: ${options.body}` : options.title\n flash(text, toFlashKind(options.severity))\n },\n emitEvent(eventName, detail) {\n if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') return\n window.dispatchEvent(new CustomEvent(eventName, { detail }))\n },\n refreshNotifications: runtime.refreshNotifications,\n navigate(href) {\n runtime.navigate(href)\n },\n markAsRead: runtime.markAsRead,\n dismiss: runtime.dismiss,\n }\n}\n\nclass NotificationDispatcher {\n private handledIds = new Set<string>()\n private handledOrder: string[] = []\n private listeners = new Map<number, NotificationEffectListener>()\n private listenerIdSeq = 1\n private debounceByHandler = new Map<string, number>()\n\n dispatch(notifications: NotificationDto[], runtime: RuntimeContext) {\n const entries = getNotificationHandlerEntries()\n if (notifications.length === 0 && this.listeners.size === 0) return\n const context = buildHandlerContext(runtime)\n const sortedEntries = entries\n .slice()\n .sort((a, b) => (b.handler.priority ?? DEFAULT_PRIORITY) - (a.handler.priority ?? DEFAULT_PRIORITY))\n\n for (const notification of notifications) {\n if (this.handledIds.has(notification.id)) continue\n\n let handlerRan = false\n let hadFeatureBlockedHandler = false\n const localized = localizeNotificationCopy(notification, runtime.t)\n\n for (const entry of sortedEntries) {\n const handler = entry.handler\n if (!matchesType(handler.notificationType, notification.type)) continue\n if (!matchesFeatures(handler.features, runtime.features)) {\n hadFeatureBlockedHandler = true\n continue\n }\n if (this.shouldDebounce(handler, notification.id)) continue\n handlerRan = true\n try {\n void Promise.resolve(handler.handle(localized, context))\n } catch (error) {\n logger.error('Notification handler failed', { handlerId: handler.id, err: error })\n }\n }\n\n for (const listener of this.listeners.values()) {\n if (!matchesType(listener.pattern, notification.type)) continue\n handlerRan = true\n try {\n listener.effect(notification)\n } catch (error) {\n logger.error('Notification effect listener failed', { err: error })\n }\n }\n\n // Only mark as permanently handled when something actually ran, or when no handler\n // was blocked by missing features. This prevents a race where the initial notification\n // fetch completes before the feature-check API resolves (features: []), causing all\n // feature-gated handlers to be skipped \u2014 but the notification gets permanently\n // deduped so it never fires when features are available on the next dispatch.\n if (handlerRan || !hadFeatureBlockedHandler) {\n this.markHandled(notification.id)\n }\n }\n }\n\n subscribe(pattern: string | string[], effect: (notification: NotificationDto) => void): () => void {\n const id = this.listenerIdSeq++\n this.listeners.set(id, { id, pattern, effect })\n return () => {\n this.listeners.delete(id)\n }\n }\n\n resetForTests() {\n this.handledIds.clear()\n this.handledOrder = []\n this.listeners.clear()\n this.listenerIdSeq = 1\n this.debounceByHandler.clear()\n }\n\n private shouldDebounce(handler: NotificationHandler, notificationId: string): boolean {\n if (!handler.debounceMs || handler.debounceMs <= 0) return false\n const key = `${handler.id}:${notificationId}`\n const now = Date.now()\n const previous = this.debounceByHandler.get(key)\n if (typeof previous === 'number' && now - previous < handler.debounceMs) {\n return true\n }\n this.debounceByHandler.set(key, now)\n return false\n }\n\n private markHandled(notificationId: string) {\n this.handledIds.add(notificationId)\n this.handledOrder.push(notificationId)\n if (this.handledOrder.length <= MAX_TRACKED_IDS) return\n const removeCount = this.handledOrder.length - RETAINED_IDS_AFTER_PRUNE\n const removed = this.handledOrder.splice(0, removeCount)\n for (const id of removed) {\n this.handledIds.delete(id)\n }\n }\n}\n\nconst dispatcher = new NotificationDispatcher()\n\nexport function dispatchNotificationHandlers(\n notifications: NotificationDto[],\n runtime: RuntimeContext,\n) {\n dispatcher.dispatch(notifications, runtime)\n}\n\nexport function subscribeNotificationEffects(\n pattern: string | string[],\n effect: (notification: NotificationDto) => void,\n): () => void {\n return dispatcher.subscribe(pattern, effect)\n}\n\nexport function getRequiredNotificationHandlerFeatures(): string[] {\n const required = new Set<string>()\n const entries = getNotificationHandlerEntries()\n for (const entry of entries) {\n for (const feature of entry.handler.features ?? []) {\n if (feature && feature.trim().length > 0) {\n required.add(feature)\n }\n }\n }\n return Array.from(required)\n}\n\nexport function __resetNotificationDispatcherForTests() {\n dispatcher.resetForTests()\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { flash, type FlashKind } from '@open-mercato/ui/backend/FlashMessages'\nimport {\n getNotificationHandlerEntries,\n} from '@open-mercato/shared/lib/notifications/handler-registry'\nimport { hasAllFeatures } from '@open-mercato/shared/security/features'\nimport type {\n NotificationHandler,\n NotificationHandlerContext,\n NotificationHandlerPopupOptions,\n NotificationHandlerToastOptions,\n} from '@open-mercato/shared/modules/notifications/handler'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\nimport { isSafeNotificationHref } from '@open-mercato/shared/modules/notifications/safeHref'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { localizeNotificationCopy } from './localizeNotification'\n\nconst logger = createLogger('ui').child({ component: 'notifications' })\n\ntype RuntimeContext = {\n userId?: string\n features: string[]\n currentPath: string\n t?: (key: string, fallback?: string) => string\n refreshNotifications: () => void\n navigate: (href: string) => void\n markAsRead: (notificationId: string) => Promise<void>\n dismiss: (notificationId: string) => Promise<void>\n}\n\ntype NotificationEffectListener = {\n id: number\n pattern: string | string[]\n effect: (notification: NotificationDto) => void\n}\n\nconst DEFAULT_PRIORITY = 50\nconst MAX_TRACKED_IDS = 500\nconst RETAINED_IDS_AFTER_PRUNE = 200\nconst POPUP_EVENT_NAME = 'om:notifications:popup'\n\nfunction toFlashKind(value: NotificationHandlerToastOptions['severity'] | NotificationHandlerPopupOptions['severity']): FlashKind {\n if (value === 'success') return 'success'\n if (value === 'warning') return 'warning'\n if (value === 'error') return 'error'\n return 'info'\n}\n\nfunction matchesType(pattern: string | string[], type: string): boolean {\n const patterns = Array.isArray(pattern) ? pattern : [pattern]\n return patterns.some((current) => {\n if (current === '*') return true\n if (current.endsWith('.*')) return type.startsWith(current.slice(0, -1))\n return current === type\n })\n}\n\nfunction matchesFeatures(required: string[] | undefined, current: string[]): boolean {\n return hasAllFeatures(current, required)\n}\n\nfunction buildHandlerContext(runtime: RuntimeContext): NotificationHandlerContext {\n return {\n userId: runtime.userId,\n features: runtime.features,\n currentPath: runtime.currentPath,\n t: runtime.t,\n toast(options) {\n const text = options.body ? `${options.title}: ${options.body}` : options.title\n flash(text, toFlashKind(options.severity))\n },\n popup(options) {\n if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') {\n window.dispatchEvent(new CustomEvent(POPUP_EVENT_NAME, { detail: options }))\n }\n const text = options.body ? `${options.title}: ${options.body}` : options.title\n flash(text, toFlashKind(options.severity))\n },\n emitEvent(eventName, detail) {\n if (typeof window === 'undefined' || typeof CustomEvent === 'undefined') return\n window.dispatchEvent(new CustomEvent(eventName, { detail }))\n },\n refreshNotifications: runtime.refreshNotifications,\n navigate(href) {\n if (!isSafeNotificationHref(href)) {\n logger.warn('Notification handler navigation ignored: href is not a same-origin path')\n return\n }\n runtime.navigate(href)\n },\n markAsRead: runtime.markAsRead,\n dismiss: runtime.dismiss,\n }\n}\n\nclass NotificationDispatcher {\n private handledIds = new Set<string>()\n private handledOrder: string[] = []\n private listeners = new Map<number, NotificationEffectListener>()\n private listenerIdSeq = 1\n private debounceByHandler = new Map<string, number>()\n\n dispatch(notifications: NotificationDto[], runtime: RuntimeContext) {\n const entries = getNotificationHandlerEntries()\n if (notifications.length === 0 && this.listeners.size === 0) return\n const context = buildHandlerContext(runtime)\n const sortedEntries = entries\n .slice()\n .sort((a, b) => (b.handler.priority ?? DEFAULT_PRIORITY) - (a.handler.priority ?? DEFAULT_PRIORITY))\n\n for (const notification of notifications) {\n if (this.handledIds.has(notification.id)) continue\n\n let handlerRan = false\n let hadFeatureBlockedHandler = false\n const localized = localizeNotificationCopy(notification, runtime.t)\n\n for (const entry of sortedEntries) {\n const handler = entry.handler\n if (!matchesType(handler.notificationType, notification.type)) continue\n if (!matchesFeatures(handler.features, runtime.features)) {\n hadFeatureBlockedHandler = true\n continue\n }\n if (this.shouldDebounce(handler, notification.id)) continue\n handlerRan = true\n try {\n void Promise.resolve(handler.handle(localized, context))\n } catch (error) {\n logger.error('Notification handler failed', { handlerId: handler.id, err: error })\n }\n }\n\n for (const listener of this.listeners.values()) {\n if (!matchesType(listener.pattern, notification.type)) continue\n handlerRan = true\n try {\n listener.effect(notification)\n } catch (error) {\n logger.error('Notification effect listener failed', { err: error })\n }\n }\n\n // Only mark as permanently handled when something actually ran, or when no handler\n // was blocked by missing features. This prevents a race where the initial notification\n // fetch completes before the feature-check API resolves (features: []), causing all\n // feature-gated handlers to be skipped \u2014 but the notification gets permanently\n // deduped so it never fires when features are available on the next dispatch.\n if (handlerRan || !hadFeatureBlockedHandler) {\n this.markHandled(notification.id)\n }\n }\n }\n\n subscribe(pattern: string | string[], effect: (notification: NotificationDto) => void): () => void {\n const id = this.listenerIdSeq++\n this.listeners.set(id, { id, pattern, effect })\n return () => {\n this.listeners.delete(id)\n }\n }\n\n resetForTests() {\n this.handledIds.clear()\n this.handledOrder = []\n this.listeners.clear()\n this.listenerIdSeq = 1\n this.debounceByHandler.clear()\n }\n\n private shouldDebounce(handler: NotificationHandler, notificationId: string): boolean {\n if (!handler.debounceMs || handler.debounceMs <= 0) return false\n const key = `${handler.id}:${notificationId}`\n const now = Date.now()\n const previous = this.debounceByHandler.get(key)\n if (typeof previous === 'number' && now - previous < handler.debounceMs) {\n return true\n }\n this.debounceByHandler.set(key, now)\n return false\n }\n\n private markHandled(notificationId: string) {\n this.handledIds.add(notificationId)\n this.handledOrder.push(notificationId)\n if (this.handledOrder.length <= MAX_TRACKED_IDS) return\n const removeCount = this.handledOrder.length - RETAINED_IDS_AFTER_PRUNE\n const removed = this.handledOrder.splice(0, removeCount)\n for (const id of removed) {\n this.handledIds.delete(id)\n }\n }\n}\n\nconst dispatcher = new NotificationDispatcher()\n\nexport function dispatchNotificationHandlers(\n notifications: NotificationDto[],\n runtime: RuntimeContext,\n) {\n dispatcher.dispatch(notifications, runtime)\n}\n\nexport function subscribeNotificationEffects(\n pattern: string | string[],\n effect: (notification: NotificationDto) => void,\n): () => void {\n return dispatcher.subscribe(pattern, effect)\n}\n\nexport function getRequiredNotificationHandlerFeatures(): string[] {\n const required = new Set<string>()\n const entries = getNotificationHandlerEntries()\n for (const entry of entries) {\n for (const feature of entry.handler.features ?? []) {\n if (feature && feature.trim().length > 0) {\n required.add(feature)\n }\n }\n }\n return Array.from(required)\n}\n\nexport function __resetNotificationDispatcherForTests() {\n dispatcher.resetForTests()\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7573.1.d0ccc51d5b",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.9.1-develop.7573.1.d0ccc51d5b",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.9.1-develop.7573.1.d0ccc51d5b",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
NotificationHandlerToastOptions,
|
|
11
11
|
} from '@open-mercato/shared/modules/notifications/handler'
|
|
12
12
|
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
13
|
+
import { isSafeNotificationHref } from '@open-mercato/shared/modules/notifications/safeHref'
|
|
13
14
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
14
15
|
import { localizeNotificationCopy } from './localizeNotification'
|
|
15
16
|
|
|
@@ -80,6 +81,10 @@ function buildHandlerContext(runtime: RuntimeContext): NotificationHandlerContex
|
|
|
80
81
|
},
|
|
81
82
|
refreshNotifications: runtime.refreshNotifications,
|
|
82
83
|
navigate(href) {
|
|
84
|
+
if (!isSafeNotificationHref(href)) {
|
|
85
|
+
logger.warn('Notification handler navigation ignored: href is not a same-origin path')
|
|
86
|
+
return
|
|
87
|
+
}
|
|
83
88
|
runtime.navigate(href)
|
|
84
89
|
},
|
|
85
90
|
markAsRead: runtime.markAsRead,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
2
|
+
import { registerNotificationHandlers } from '@open-mercato/shared/lib/notifications/handler-registry'
|
|
3
|
+
import {
|
|
4
|
+
__resetNotificationDispatcherForTests,
|
|
5
|
+
dispatchNotificationHandlers,
|
|
6
|
+
} from '../NotificationDispatcher'
|
|
7
|
+
|
|
8
|
+
jest.mock('@open-mercato/ui/backend/FlashMessages', () => ({
|
|
9
|
+
flash: jest.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
const TYPE = 'test.navigate'
|
|
13
|
+
|
|
14
|
+
function makeNotification(id: string, target: string): NotificationDto {
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
type: TYPE,
|
|
18
|
+
title: 'Navigate',
|
|
19
|
+
severity: 'info',
|
|
20
|
+
status: 'unread',
|
|
21
|
+
actions: [],
|
|
22
|
+
data: { target },
|
|
23
|
+
createdAt: new Date().toISOString(),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function runtime() {
|
|
28
|
+
return {
|
|
29
|
+
features: [],
|
|
30
|
+
currentPath: '/backend',
|
|
31
|
+
refreshNotifications: jest.fn(),
|
|
32
|
+
navigate: jest.fn(),
|
|
33
|
+
markAsRead: jest.fn(async () => {}),
|
|
34
|
+
dismiss: jest.fn(async () => {}),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('NotificationDispatcher — handler navigation stays on the application origin (#7240)', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
__resetNotificationDispatcherForTests()
|
|
41
|
+
registerNotificationHandlers([
|
|
42
|
+
{
|
|
43
|
+
moduleId: 'test',
|
|
44
|
+
handlers: [
|
|
45
|
+
{
|
|
46
|
+
id: 'test.navigate',
|
|
47
|
+
notificationType: TYPE,
|
|
48
|
+
handle(notification, context) {
|
|
49
|
+
const target = (notification.data as { target?: string } | null)?.target
|
|
50
|
+
if (target) context.navigate(target)
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
registerNotificationHandlers([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it.each(['/\\evil.example/phish', '/\t/evil.example', '//evil.example', 'https://evil.example'])(
|
|
63
|
+
'does not navigate to %j',
|
|
64
|
+
(target) => {
|
|
65
|
+
const ctx = runtime()
|
|
66
|
+
dispatchNotificationHandlers([makeNotification('n-unsafe', target)], ctx)
|
|
67
|
+
expect(ctx.navigate).not.toHaveBeenCalled()
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
it('navigates to a same-origin path', () => {
|
|
72
|
+
const ctx = runtime()
|
|
73
|
+
dispatchNotificationHandlers([makeNotification('n-safe', '/backend/notifications')], ctx)
|
|
74
|
+
expect(ctx.navigate).toHaveBeenCalledWith('/backend/notifications')
|
|
75
|
+
})
|
|
76
|
+
})
|