@open-mercato/shared 0.6.8-develop.7015.1.af90a2ddc7 → 0.6.8-develop.7016.1.4ed7b1e49d

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.
@@ -195,13 +195,33 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
195
195
  : []
196
196
  const roleNames = roles.map((role) => role.name).filter((name): name is string => typeof name === 'string' && name.length > 0)
197
197
 
198
+ // A role-level super-admin grant is authorization-grade only when it is genuinely
199
+ // unrestricted: the grant itself must not be organization-scoped, it must belong to the
200
+ // key's tenant, and the key must not be bound to a single organization. RbacService applies
201
+ // the same intersection when projecting the scoped ACL, but generic guards
202
+ // (tenantAccess.resolveIsSuperAdmin, the scoped-API helpers) trust this raw bit *before*
203
+ // live RBAC runs — so an organization-restricted key must never carry it.
198
204
  let keyIsSuperAdmin = false
199
- if (roleIds.length) {
200
- const superAcl = await em.findOne(
205
+ const keyOrganizationId = typeof record.organizationId === 'string' && record.organizationId.trim().length > 0
206
+ ? record.organizationId.trim()
207
+ : null
208
+ if (roleIds.length && !keyOrganizationId) {
209
+ const superAcls = await em.find(
201
210
  RoleAcl,
202
- { role: { $in: roleIds } as any, isSuperAdmin: true, deletedAt: null } as any,
211
+ {
212
+ role: { $in: roleIds } as any,
213
+ tenantId: record.tenantId ?? null,
214
+ isSuperAdmin: true,
215
+ deletedAt: null,
216
+ } as any,
203
217
  )
204
- keyIsSuperAdmin = !!(superAcl && (superAcl as { isSuperAdmin?: boolean }).isSuperAdmin)
218
+ keyIsSuperAdmin = superAcls.some((acl) => {
219
+ const organizations = Array.isArray((acl as { organizationsJson?: string[] | null }).organizationsJson)
220
+ ? (acl as { organizationsJson?: string[] | null }).organizationsJson as string[]
221
+ : null
222
+ // An empty list means "inherit the key's own binding"; with no binding that is tenant-wide.
223
+ return !organizations || organizations.length === 0 || organizations.includes('__all__')
224
+ })
205
225
  }
206
226
 
207
227
  if (cache.shouldWriteLastUsed(record.id)) {
@@ -213,8 +233,25 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
213
233
  }
214
234
  }
215
235
 
216
- // For session keys, use sessionUserId; for regular keys, use createdBy
217
- const actualUserId = record.sessionUserId ?? record.createdBy ?? null
236
+ // Ephemeral session keys are always user-bound. Regular keys retain their
237
+ // legacy creator identity, while tenant-scoped regular keys ignore only the
238
+ // creator's concrete organization when validating the wider key scope.
239
+ // Keep every session marker fail-closed so a malformed session key cannot
240
+ // fall back to the regular key path and escape its user/scope binding.
241
+ const isSessionBoundKey = Boolean(
242
+ record.sessionToken
243
+ || record.sessionUserId
244
+ || record.sessionSecretEncrypted
245
+ || record.opencodeSessionId
246
+ )
247
+ const actualUserId = isSessionBoundKey
248
+ ? record.sessionUserId ?? null
249
+ : record.createdBy ?? null
250
+
251
+ if (isSessionBoundKey && !actualUserId) {
252
+ cache.setMiss(secret)
253
+ return null
254
+ }
218
255
 
219
256
  if (actualUserId) {
220
257
  const user = await em.findOne(User, { id: actualUserId, deletedAt: null })
@@ -226,7 +263,8 @@ async function resolveApiKeyAuth(secret: string): Promise<AuthContext> {
226
263
  cache.setMiss(secret)
227
264
  return null
228
265
  }
229
- if ((user.organizationId ?? null) !== (record.organizationId ?? null)) {
266
+ const requiresExactOrganization = isSessionBoundKey || Boolean(record.organizationId)
267
+ if (requiresExactOrganization && (user.organizationId ?? null) !== (record.organizationId ?? null)) {
230
268
  cache.setMiss(secret)
231
269
  return null
232
270
  }
@@ -68,7 +68,15 @@ describe('DataEngine event contract validation (issue #1421)', () => {
68
68
  })
69
69
 
70
70
  expect(emitted).toEqual([
71
- expect.objectContaining({ name: 'issue1421_test.widget.created' }),
71
+ expect.objectContaining({
72
+ name: 'issue1421_test.widget.created',
73
+ options: {
74
+ persistent: false,
75
+ tenantId: 'tenant-1',
76
+ organizationId: 'org-1',
77
+ emitterModuleId: 'issue1421_test',
78
+ },
79
+ }),
72
80
  ])
73
81
  expect(loggerWarn).not.toHaveBeenCalled()
74
82
  } finally {
@@ -226,7 +226,12 @@ export class DefaultDataEngine implements DataEngine {
226
226
  const eventName = `${mod}.${ent}.updated`
227
227
  warnIfUndeclaredEvent(eventName, 'setCustomFields')
228
228
  try {
229
- await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, { persistent: true })
229
+ await bus.emitEvent(eventName, { id: recordId, organizationId, tenantId }, {
230
+ persistent: true,
231
+ tenantId,
232
+ organizationId,
233
+ emitterModuleId: mod,
234
+ })
230
235
  } catch {
231
236
  // non-blocking
232
237
  }
@@ -627,6 +632,7 @@ export class DefaultDataEngine implements DataEngine {
627
632
  persistent: !!events.persistent,
628
633
  tenantId: ctx.identifiers.tenantId ?? null,
629
634
  organizationId: ctx.identifiers.organizationId ?? null,
635
+ emitterModuleId: events.module,
630
636
  })
631
637
  } catch {
632
638
  // non-blocking
@@ -0,0 +1,88 @@
1
+ const GLOBAL_EVENT_REGISTRY_KEY = '__openMercatoEventDefinitionRegistry__'
2
+
3
+ type EventFactoryModule = typeof import('../factory')
4
+
5
+ describe('event definition registry', () => {
6
+ const globalScope = globalThis as Record<string, unknown>
7
+ const originalRegistry = globalScope[GLOBAL_EVENT_REGISTRY_KEY]
8
+
9
+ beforeEach(() => {
10
+ delete globalScope[GLOBAL_EVENT_REGISTRY_KEY]
11
+ })
12
+
13
+ afterAll(() => {
14
+ if (originalRegistry === undefined) {
15
+ delete globalScope[GLOBAL_EVENT_REGISTRY_KEY]
16
+ } else {
17
+ globalScope[GLOBAL_EVENT_REGISTRY_KEY] = originalRegistry
18
+ }
19
+ })
20
+
21
+ it('shares declarations and registered configs across isolated module instances', () => {
22
+ let firstInstance: EventFactoryModule | undefined
23
+ let secondInstance: EventFactoryModule | undefined
24
+
25
+ jest.isolateModules(() => {
26
+ firstInstance = require('../factory') as EventFactoryModule
27
+ const config = firstInstance.createModuleEvents({
28
+ moduleId: 'isolated_events_test',
29
+ events: [{
30
+ id: 'isolated_events_test.invalidated',
31
+ label: 'Invalidated',
32
+ crossProcessBroadcast: true,
33
+ }] as const,
34
+ })
35
+ firstInstance.registerEventModuleConfigs([config])
36
+ })
37
+
38
+ jest.isolateModules(() => {
39
+ secondInstance = require('../factory') as EventFactoryModule
40
+ })
41
+
42
+ expect(secondInstance).not.toBe(firstInstance)
43
+ expect(secondInstance?.isEventDeclared('isolated_events_test.invalidated')).toBe(true)
44
+ expect(secondInstance?.isCrossProcessBroadcastEvent('isolated_events_test.invalidated')).toBe(true)
45
+ expect(secondInstance?.getDeclaredEvents()).toEqual(expect.arrayContaining([
46
+ expect.objectContaining({
47
+ id: 'isolated_events_test.invalidated',
48
+ module: 'isolated_events_test',
49
+ }),
50
+ ]))
51
+ expect(secondInstance?.getEventModuleConfigs()).toHaveLength(1)
52
+ })
53
+
54
+ it('refreshes a module definition during HMR without duplicating its event id', () => {
55
+ let firstInstance: EventFactoryModule | undefined
56
+ let secondInstance: EventFactoryModule | undefined
57
+
58
+ jest.isolateModules(() => {
59
+ firstInstance = require('../factory') as EventFactoryModule
60
+ firstInstance.createModuleEvents({
61
+ moduleId: 'hmr_events_test',
62
+ events: [{ id: 'hmr_events_test.changed', label: 'Before' }] as const,
63
+ })
64
+ })
65
+
66
+ jest.isolateModules(() => {
67
+ secondInstance = require('../factory') as EventFactoryModule
68
+ secondInstance.createModuleEvents({
69
+ moduleId: 'hmr_events_test',
70
+ events: [{
71
+ id: 'hmr_events_test.changed',
72
+ label: 'After',
73
+ clientBroadcast: true,
74
+ }] as const,
75
+ })
76
+ })
77
+
78
+ expect(firstInstance?.isBroadcastEvent('hmr_events_test.changed')).toBe(true)
79
+ expect(firstInstance?.getAllDeclaredEventIds()).toEqual(['hmr_events_test.changed'])
80
+ expect(firstInstance?.getDeclaredEvents()).toEqual([
81
+ expect.objectContaining({
82
+ id: 'hmr_events_test.changed',
83
+ label: 'After',
84
+ clientBroadcast: true,
85
+ }),
86
+ ])
87
+ })
88
+ })
@@ -65,17 +65,54 @@ export function getGlobalEventBus(): GlobalEventBus | null {
65
65
  // Event Registry for Validation
66
66
  // =============================================================================
67
67
 
68
- // Global set of all declared event IDs for runtime validation
69
- const allDeclaredEventIds = new Set<string>()
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
- // Global registry of all declared events with their full definitions
72
- const allDeclaredEvents: EventDefinition[] = []
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
- allDeclaredEventIds.add(event.id)
76
- // Avoid duplicates if createModuleEvents/registerEventModuleConfigs is called multiple times (e.g., HMR)
77
- if (!allDeclaredEvents.find(e => e.id === event.id)) {
78
- allDeclaredEvents.push(event)
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 allDeclaredEventIds.has(eventId)
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(allDeclaredEventIds)
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 [...allDeclaredEvents]
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 = allDeclaredEvents.find(e => e.id === eventId)
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 = allDeclaredEvents.find(e => e.id === eventId)
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
- if (_registeredEventConfigs !== null && process.env.NODE_ENV === 'development') {
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
- _registeredEventConfigs = configs
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 _registeredEventConfigs ?? []
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
- await eventBus.emit(eventId, payload, emitOptions)
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
  // =============================================================================