@open-mercato/shared 0.6.8-develop.7015.1.af90a2ddc7 → 0.6.8-develop.7019.1.f4c01c4b5c
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/.turbo/turbo-build.log +1 -1
- package/dist/lib/auth/principal-service.js +1 -0
- package/dist/lib/auth/principal-service.js.map +7 -0
- package/dist/lib/auth/server.js +23 -6
- package/dist/lib/auth/server.js.map +2 -2
- package/dist/lib/data/engine.js +8 -2
- package/dist/lib/data/engine.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/events/factory.js +69 -15
- package/dist/modules/events/factory.js.map +2 -2
- package/dist/modules/registry.js +15 -0
- package/dist/modules/registry.js.map +2 -2
- package/package.json +6 -2
- package/src/lib/auth/__tests__/principalServiceExport.test.ts +67 -0
- package/src/lib/auth/__tests__/server.apiKeyCache.test.ts +324 -0
- package/src/lib/auth/principal-service.ts +110 -0
- package/src/lib/auth/server.ts +45 -7
- package/src/lib/data/__tests__/engine.event-validation.test.ts +9 -1
- package/src/lib/data/engine.ts +7 -1
- package/src/modules/events/__tests__/factory.test.ts +88 -0
- package/src/modules/events/factory.ts +111 -19
- package/src/modules/events/types.ts +17 -0
- package/src/modules/registry.ts +36 -0
|
@@ -65,17 +65,54 @@ export function getGlobalEventBus(): GlobalEventBus | null {
|
|
|
65
65
|
// Event Registry for Validation
|
|
66
66
|
// =============================================================================
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
type EventRegistryState = {
|
|
69
|
+
declaredEventIds: Set<string>
|
|
70
|
+
declaredEvents: EventDefinition[]
|
|
71
|
+
registeredEventConfigs: EventModuleConfig[] | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'
|
|
75
|
+
|
|
76
|
+
const fallbackEventRegistryState: EventRegistryState = {
|
|
77
|
+
declaredEventIds: new Set<string>(),
|
|
78
|
+
declaredEvents: [],
|
|
79
|
+
registeredEventConfigs: null,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isEventRegistryState(value: unknown): value is EventRegistryState {
|
|
83
|
+
if (!value || typeof value !== 'object') return false
|
|
84
|
+
const candidate = value as Partial<EventRegistryState>
|
|
85
|
+
return candidate.declaredEventIds instanceof Set
|
|
86
|
+
&& Array.isArray(candidate.declaredEvents)
|
|
87
|
+
&& (candidate.registeredEventConfigs === null || Array.isArray(candidate.registeredEventConfigs))
|
|
88
|
+
}
|
|
70
89
|
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
function getEventRegistryState(): EventRegistryState {
|
|
91
|
+
try {
|
|
92
|
+
const globalScope = globalThis as Record<string, unknown>
|
|
93
|
+
const existing = globalScope[GLOBAL_EVENT_REGISTRY_KEY]
|
|
94
|
+
if (isEventRegistryState(existing)) return existing
|
|
95
|
+
globalScope[GLOBAL_EVENT_REGISTRY_KEY] = fallbackEventRegistryState
|
|
96
|
+
return fallbackEventRegistryState
|
|
97
|
+
} catch {
|
|
98
|
+
// Restricted runtimes may deny global access. Keep the previous
|
|
99
|
+
// module-local behavior as a safe fallback.
|
|
100
|
+
return fallbackEventRegistryState
|
|
101
|
+
}
|
|
102
|
+
}
|
|
73
103
|
|
|
74
104
|
function addDeclaredEvent(event: EventDefinition): void {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
const state = getEventRegistryState()
|
|
106
|
+
state.declaredEventIds.add(event.id)
|
|
107
|
+
const existingIndex = state.declaredEvents.findIndex((candidate) => candidate.id === event.id)
|
|
108
|
+
if (existingIndex < 0) {
|
|
109
|
+
state.declaredEvents.push(event)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
// Refresh a module's own definition in place during HMR without allowing a
|
|
113
|
+
// duplicate declaration from another module to take over the event id.
|
|
114
|
+
if (state.declaredEvents[existingIndex]?.module === event.module) {
|
|
115
|
+
state.declaredEvents[existingIndex] = event
|
|
79
116
|
}
|
|
80
117
|
}
|
|
81
118
|
|
|
@@ -84,7 +121,7 @@ function addDeclaredEvent(event: EventDefinition): void {
|
|
|
84
121
|
* Used for runtime validation to ensure only declared events are emitted.
|
|
85
122
|
*/
|
|
86
123
|
export function isEventDeclared(eventId: string): boolean {
|
|
87
|
-
return
|
|
124
|
+
return getEventRegistryState().declaredEventIds.has(eventId)
|
|
88
125
|
}
|
|
89
126
|
|
|
90
127
|
/**
|
|
@@ -92,7 +129,7 @@ export function isEventDeclared(eventId: string): boolean {
|
|
|
92
129
|
* Useful for debugging and introspection.
|
|
93
130
|
*/
|
|
94
131
|
export function getAllDeclaredEventIds(): string[] {
|
|
95
|
-
return Array.from(
|
|
132
|
+
return Array.from(getEventRegistryState().declaredEventIds)
|
|
96
133
|
}
|
|
97
134
|
|
|
98
135
|
/**
|
|
@@ -100,7 +137,7 @@ export function getAllDeclaredEventIds(): string[] {
|
|
|
100
137
|
* Used by the API to return available events for workflow triggers.
|
|
101
138
|
*/
|
|
102
139
|
export function getDeclaredEvents(): EventDefinition[] {
|
|
103
|
-
return [...
|
|
140
|
+
return [...getEventRegistryState().declaredEvents]
|
|
104
141
|
}
|
|
105
142
|
|
|
106
143
|
/**
|
|
@@ -108,16 +145,52 @@ export function getDeclaredEvents(): EventDefinition[] {
|
|
|
108
145
|
* Used by the SSE endpoint to filter events for the DOM Event Bridge.
|
|
109
146
|
*/
|
|
110
147
|
export function isBroadcastEvent(eventId: string): boolean {
|
|
111
|
-
const event =
|
|
148
|
+
const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
|
|
112
149
|
return event?.clientBroadcast === true
|
|
113
150
|
}
|
|
114
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Check if an event should be published over the server-to-server event bridge.
|
|
154
|
+
* Browser-broadcast events remain eligible for backward compatibility, while
|
|
155
|
+
* crossProcessBroadcast supports private process coordination without SSE.
|
|
156
|
+
*/
|
|
157
|
+
export function isCrossProcessBroadcastEvent(eventId: string): boolean {
|
|
158
|
+
const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
|
|
159
|
+
return event?.clientBroadcast === true || event?.crossProcessBroadcast === true
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Check whether an event is reserved for private server-to-server
|
|
164
|
+
* coordination. Workflow-authored EMIT_EVENT activities must not emit these
|
|
165
|
+
* events because their payload and event id are tenant-managed input.
|
|
166
|
+
*/
|
|
167
|
+
export function isPrivateCrossProcessBroadcastEvent(eventId: string): boolean {
|
|
168
|
+
const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
|
|
169
|
+
return event?.crossProcessBroadcast === true && event?.clientBroadcast !== true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Verify provenance for a private cross-process event. The module id is
|
|
174
|
+
* stamped by a declared module emitter or another trusted server-side seam;
|
|
175
|
+
* tenant-managed event payloads never participate in this decision.
|
|
176
|
+
*/
|
|
177
|
+
export function isPrivateCrossProcessEventEmitter(
|
|
178
|
+
eventId: string,
|
|
179
|
+
emitterModuleId: string | undefined,
|
|
180
|
+
): boolean {
|
|
181
|
+
const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
|
|
182
|
+
if (event?.crossProcessBroadcast !== true) return true
|
|
183
|
+
return typeof event.module === 'string'
|
|
184
|
+
&& event.module.length > 0
|
|
185
|
+
&& event.module === emitterModuleId
|
|
186
|
+
}
|
|
187
|
+
|
|
115
188
|
/**
|
|
116
189
|
* Check if an event has portalBroadcast enabled.
|
|
117
190
|
* Used by the portal SSE endpoint to filter events for the Portal Event Bridge.
|
|
118
191
|
*/
|
|
119
192
|
export function isPortalBroadcastEvent(eventId: string): boolean {
|
|
120
|
-
const event =
|
|
193
|
+
const event = getEventRegistryState().declaredEvents.find(e => e.id === eventId)
|
|
121
194
|
return event?.portalBroadcast === true
|
|
122
195
|
}
|
|
123
196
|
|
|
@@ -125,17 +198,16 @@ export function isPortalBroadcastEvent(eventId: string): boolean {
|
|
|
125
198
|
// Bootstrap Registration (similar to searchModuleConfigs pattern)
|
|
126
199
|
// =============================================================================
|
|
127
200
|
|
|
128
|
-
let _registeredEventConfigs: EventModuleConfig[] | null = null
|
|
129
|
-
|
|
130
201
|
/**
|
|
131
202
|
* Register event module configurations globally.
|
|
132
203
|
* Called during app bootstrap with configs from events.generated.ts.
|
|
133
204
|
*/
|
|
134
205
|
export function registerEventModuleConfigs(configs: EventModuleConfig[]): void {
|
|
135
|
-
|
|
206
|
+
const state = getEventRegistryState()
|
|
207
|
+
if (state.registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {
|
|
136
208
|
logger.debug('Event module configs re-registered (this may occur during HMR)')
|
|
137
209
|
}
|
|
138
|
-
|
|
210
|
+
state.registeredEventConfigs = configs
|
|
139
211
|
for (const config of configs) {
|
|
140
212
|
for (const event of config.events) {
|
|
141
213
|
addDeclaredEvent(event)
|
|
@@ -148,7 +220,7 @@ export function registerEventModuleConfigs(configs: EventModuleConfig[]): void {
|
|
|
148
220
|
* Returns empty array if not registered.
|
|
149
221
|
*/
|
|
150
222
|
export function getEventModuleConfigs(): EventModuleConfig[] {
|
|
151
|
-
return
|
|
223
|
+
return getEventRegistryState().registeredEventConfigs ?? []
|
|
152
224
|
}
|
|
153
225
|
|
|
154
226
|
// =============================================================================
|
|
@@ -239,7 +311,27 @@ export function createModuleEvents<
|
|
|
239
311
|
return
|
|
240
312
|
}
|
|
241
313
|
|
|
242
|
-
|
|
314
|
+
const eventDefinition = fullEvents.find((event) => event.id === eventId)
|
|
315
|
+
const isClientBroadcast = eventDefinition?.clientBroadcast === true
|
|
316
|
+
const trustedOptions = eventDefinition?.crossProcessBroadcast === true || isClientBroadcast
|
|
317
|
+
? {
|
|
318
|
+
...emitOptions,
|
|
319
|
+
// Browser-broadcast module emitters historically accepted scope in
|
|
320
|
+
// their typed payload. Preserve that contract at the trusted module
|
|
321
|
+
// boundary while the event bus itself relies only on options.
|
|
322
|
+
...(isClientBroadcast && emitOptions?.tenantId === undefined
|
|
323
|
+
? { tenantId: payload.tenantId ?? null }
|
|
324
|
+
: {}),
|
|
325
|
+
...(isClientBroadcast && emitOptions?.organizationId === undefined
|
|
326
|
+
? { organizationId: payload.organizationId ?? null }
|
|
327
|
+
: {}),
|
|
328
|
+
...(isClientBroadcast && emitOptions?.organizationIds === undefined && Array.isArray(payload.organizationIds)
|
|
329
|
+
? { organizationIds: payload.organizationIds.filter((value): value is string => typeof value === 'string') }
|
|
330
|
+
: {}),
|
|
331
|
+
emitterModuleId: moduleId,
|
|
332
|
+
}
|
|
333
|
+
: emitOptions
|
|
334
|
+
await eventBus.emit(eventId, payload, trustedOptions)
|
|
243
335
|
}
|
|
244
336
|
|
|
245
337
|
return {
|
|
@@ -34,6 +34,8 @@ export interface EventDefinition {
|
|
|
34
34
|
excludeFromTriggers?: boolean
|
|
35
35
|
/** When true, this event is bridged to the browser via SSE (DOM Event Bridge). Default: false */
|
|
36
36
|
clientBroadcast?: boolean
|
|
37
|
+
/** When true, this event is bridged between server processes without enabling browser delivery. Default: false */
|
|
38
|
+
crossProcessBroadcast?: boolean
|
|
37
39
|
/** When true, this event is bridged to the customer portal via SSE (Portal Event Bridge). Default: false */
|
|
38
40
|
portalBroadcast?: boolean
|
|
39
41
|
}
|
|
@@ -66,6 +68,21 @@ export interface EmitOptions {
|
|
|
66
68
|
tenantId?: string | null
|
|
67
69
|
/** Trusted organization scope forwarded to subscribers separately from the payload */
|
|
68
70
|
organizationId?: string | null
|
|
71
|
+
/**
|
|
72
|
+
* Trusted multi-organization audience forwarded to subscribers separately
|
|
73
|
+
* from the payload. Mirrors the documented SSE audience contract, where a
|
|
74
|
+
* clientBroadcast event may target several organizations at once; carried in
|
|
75
|
+
* trusted options so the receiver never falls back to payload scope.
|
|
76
|
+
*/
|
|
77
|
+
organizationIds?: string[] | null
|
|
78
|
+
/**
|
|
79
|
+
* Module that emitted a private cross-process coordination event.
|
|
80
|
+
*
|
|
81
|
+
* This is stamped by `createModuleEvents`; application inputs must not set or
|
|
82
|
+
* derive it from event payloads.
|
|
83
|
+
* @internal
|
|
84
|
+
*/
|
|
85
|
+
emitterModuleId?: string
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
// =============================================================================
|
package/src/modules/registry.ts
CHANGED
|
@@ -196,6 +196,14 @@ export type ModuleWorker = {
|
|
|
196
196
|
concurrency: number
|
|
197
197
|
lockDuration?: number
|
|
198
198
|
maxStalledCount?: number
|
|
199
|
+
/**
|
|
200
|
+
* Reports a job the queue abandoned without running the handler.
|
|
201
|
+
*
|
|
202
|
+
* Present only for workers whose metadata declares it; the generator emits it as a lazy import
|
|
203
|
+
* beside the handler, because the registry serializes metadata as literals and a function cannot
|
|
204
|
+
* survive that.
|
|
205
|
+
*/
|
|
206
|
+
onJobAbandoned?: (payload: unknown, info: { jobId: string | null; reason: string }) => void | Promise<void>
|
|
199
207
|
handler: ModuleWorkerHandler
|
|
200
208
|
}
|
|
201
209
|
|
|
@@ -554,3 +562,31 @@ export function createLazyModuleWorker(
|
|
|
554
562
|
return handler(job, ctx)
|
|
555
563
|
}
|
|
556
564
|
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Resolves a worker's `metadata.onJobAbandoned` on first use.
|
|
568
|
+
*
|
|
569
|
+
* The generator serializes worker metadata as literals, so a function declared there cannot be
|
|
570
|
+
* emitted inline the way `concurrency` or `lockDuration` are. It is emitted as this thunk instead —
|
|
571
|
+
* the same lazy-import treatment the handler already gets — and only for workers whose metadata
|
|
572
|
+
* declares the callback, so no other queue acquires one (and with it, a sweep) by accident.
|
|
573
|
+
*/
|
|
574
|
+
export function createLazyModuleWorkerAbandonHook(
|
|
575
|
+
loadModule: () => Promise<unknown>,
|
|
576
|
+
id: string
|
|
577
|
+
): (payload: unknown, info: { jobId: string | null; reason: string }) => Promise<void> {
|
|
578
|
+
let hookPromise: Promise<((payload: unknown, info: { jobId: string | null; reason: string }) => unknown) | null> | null = null
|
|
579
|
+
return async (payload, info) => {
|
|
580
|
+
hookPromise ??= loadModule().then((loaded) => {
|
|
581
|
+
const metadata = (loaded as { metadata?: { onJobAbandoned?: unknown } } | null)?.metadata
|
|
582
|
+
return typeof metadata?.onJobAbandoned === 'function'
|
|
583
|
+
? (metadata.onJobAbandoned as (payload: unknown, info: { jobId: string | null; reason: string }) => unknown)
|
|
584
|
+
: null
|
|
585
|
+
})
|
|
586
|
+
const hook = await hookPromise
|
|
587
|
+
if (!hook) {
|
|
588
|
+
throw new Error(`[registry] Worker "${id}" was registered with an abandoned-job hook but its metadata no longer declares one`)
|
|
589
|
+
}
|
|
590
|
+
await hook(payload, info)
|
|
591
|
+
}
|
|
592
|
+
}
|