@open-mercato/events 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51

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/AGENTS.md CHANGED
@@ -184,3 +184,7 @@ useAppEvent('mymod.entity.created', (event) => {
184
184
  - Client deduplicates events within a 500ms window
185
185
  - `isBroadcastEvent(eventId)` checks if an event has `clientBroadcast: true`
186
186
  - The `useEventBridge()` hook must be mounted once in the app shell to start receiving events
187
+
188
+ ### Private cross-process coordination
189
+
190
+ Use `crossProcessBroadcast: true` for server-to-server invalidation or coordination that must cross process boundaries but must not be exposed through browser SSE. The event bus publishes both `clientBroadcast` and `crossProcessBroadcast` events to the cross-process bridge, while the DOM Event Bridge delivers only `clientBroadcast` events. Do not use `clientBroadcast` merely to reach another server process when the payload contains record-scoped identifiers or activity that the organization-level SSE audience cannot authorize.
package/dist/bridge.js CHANGED
@@ -1,10 +1,21 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Client, Pool } from "pg";
2
3
  import { getSslConfig } from "@open-mercato/shared/lib/db/ssl";
3
4
  import { createLogger } from "@open-mercato/shared/lib/logger";
4
5
  const BRIDGE_CHANNEL = "om_event_bridge";
5
6
  const MAX_MESSAGE_BYTES = 7e3;
6
7
  const RECONNECT_DELAY_MS = 1e3;
8
+ const CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = "__openMercatoCrossProcessEventInstanceId__";
7
9
  const logger = createLogger("events");
10
+ function getCrossProcessEventInstanceId() {
11
+ const globalScope = globalThis;
12
+ const existing = globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY];
13
+ if (typeof existing === "string" && existing.length > 0) return existing;
14
+ const created = randomUUID();
15
+ globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY] = created;
16
+ return created;
17
+ }
18
+ const CROSS_PROCESS_EVENT_INSTANCE_ID = getCrossProcessEventInstanceId();
8
19
  let publisherPool;
9
20
  let listenerClient = null;
10
21
  let listenerConnectPromise = null;
@@ -122,7 +133,8 @@ async function publishCrossProcessEvent(event, payload, options) {
122
133
  event,
123
134
  payload,
124
135
  options: bridgeOptions,
125
- originPid: process.pid
136
+ originPid: process.pid,
137
+ originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID
126
138
  };
127
139
  const serialized = JSON.stringify(envelope);
128
140
  if (Buffer.byteLength(serialized, "utf8") > MAX_MESSAGE_BYTES) {
@@ -143,6 +155,7 @@ function registerCrossProcessEventListener(listener) {
143
155
  };
144
156
  }
145
157
  export {
158
+ CROSS_PROCESS_EVENT_INSTANCE_ID,
146
159
  publishCrossProcessEvent,
147
160
  registerCrossProcessEventListener
148
161
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/bridge.ts"],
4
- "sourcesContent": ["import { Client, Pool } from 'pg'\nimport { getSslConfig } from '@open-mercato/shared/lib/db/ssl'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { EmitOptions, EventPayload } from './types'\n\nconst BRIDGE_CHANNEL = 'om_event_bridge'\nconst MAX_MESSAGE_BYTES = 7_000\nconst RECONNECT_DELAY_MS = 1_000\n\nconst logger = createLogger('events')\n\ntype BridgeEnvelope = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n originPid: number\n}\n\ntype CrossProcessEventListener = (envelope: BridgeEnvelope) => void | Promise<void>\ntype PgNotificationMessage = {\n channel: string\n payload?: string\n}\n\nlet publisherPool: InstanceType<typeof Pool> | null | undefined\nlet listenerClient: InstanceType<typeof Client> | null = null\nlet listenerConnectPromise: Promise<void> | null = null\nlet listenerReconnectTimer: ReturnType<typeof setTimeout> | null = null\nconst listeners = new Set<CrossProcessEventListener>()\n\nfunction getDatabaseUrl(): string | null {\n const value = process.env.DATABASE_URL?.trim()\n return value && value.length > 0 ? value : null\n}\n\nfunction getPgBridgeConnectionString(): string | null {\n const connectionString = getDatabaseUrl()\n if (!connectionString) return null\n\n try {\n const url = new URL(connectionString)\n for (const key of ['sslmode', 'sslcert', 'sslkey', 'sslrootcert', 'sslcrl']) {\n url.searchParams.delete(key)\n }\n return url.toString()\n } catch {\n return connectionString\n }\n}\n\nfunction getPublisherPool(): InstanceType<typeof Pool> | null {\n if (publisherPool !== undefined) return publisherPool\n const connectionString = getPgBridgeConnectionString()\n if (!connectionString) {\n publisherPool = null\n return null\n }\n const ssl = getSslConfig()\n publisherPool = new Pool({\n connectionString,\n max: 2,\n ...(ssl ? { ssl } : {}),\n })\n return publisherPool\n}\n\nasync function dispatchEnvelope(envelope: BridgeEnvelope): Promise<void> {\n for (const listener of listeners) {\n try {\n await Promise.resolve(listener(envelope))\n } catch (error) {\n logger.error('Cross-process listener error', { event: envelope.event, err: error })\n }\n }\n}\n\nfunction clearReconnectTimer(): void {\n if (!listenerReconnectTimer) return\n clearTimeout(listenerReconnectTimer)\n listenerReconnectTimer = null\n}\n\nasync function closeListenerClient(): Promise<void> {\n if (!listenerClient) return\n const client = listenerClient\n listenerClient = null\n try {\n client.removeAllListeners('notification')\n client.removeAllListeners('error')\n client.removeAllListeners('end')\n await client.end()\n } catch {\n // Ignore shutdown errors.\n }\n}\n\nfunction scheduleReconnect(): void {\n if (listenerReconnectTimer || listeners.size === 0) return\n listenerReconnectTimer = setTimeout(() => {\n listenerReconnectTimer = null\n void ensureCrossProcessListener()\n }, RECONNECT_DELAY_MS)\n}\n\nasync function ensureCrossProcessListener(): Promise<void> {\n if (listenerClient || listenerConnectPromise || listeners.size === 0) return\n const connectionString = getPgBridgeConnectionString()\n if (!connectionString) return\n\n listenerConnectPromise = (async () => {\n const ssl = getSslConfig()\n const client = new Client({\n connectionString,\n ...(ssl ? { ssl } : {}),\n })\n\n client.on('notification', (message: PgNotificationMessage) => {\n if (message.channel !== BRIDGE_CHANNEL || !message.payload) return\n try {\n const parsed = JSON.parse(message.payload) as BridgeEnvelope\n if (!parsed || typeof parsed.event !== 'string') return\n void dispatchEnvelope(parsed)\n } catch (error) {\n logger.warn('Failed to parse cross-process bridge payload', { err: error })\n }\n })\n\n const handleDisconnect = () => {\n if (listenerClient === client) {\n listenerClient = null\n }\n scheduleReconnect()\n }\n\n client.on('error', handleDisconnect)\n client.on('end', handleDisconnect)\n\n await client.connect()\n await client.query(`LISTEN ${BRIDGE_CHANNEL}`)\n listenerClient = client\n })()\n .catch((error) => {\n logger.warn('Cross-process event bridge listener failed', { err: error })\n scheduleReconnect()\n })\n .finally(() => {\n listenerConnectPromise = null\n })\n\n await listenerConnectPromise\n}\n\nexport async function publishCrossProcessEvent(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n): Promise<void> {\n const pool = getPublisherPool()\n if (!pool) return\n\n const bridgeOptions = options\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n && options.tenantId === undefined\n ? { ...options, tenantId: null }\n : options\n\n const envelope: BridgeEnvelope = {\n event,\n payload,\n options: bridgeOptions,\n originPid: process.pid,\n }\n\n const serialized = JSON.stringify(envelope)\n if (Buffer.byteLength(serialized, 'utf8') > MAX_MESSAGE_BYTES) {\n logger.warn('Cross-process event dropped: payload exceeds size limit', { event, maxBytes: MAX_MESSAGE_BYTES })\n return\n }\n\n await pool.query('SELECT pg_notify($1, $2)', [BRIDGE_CHANNEL, serialized])\n}\n\nexport function registerCrossProcessEventListener(listener: CrossProcessEventListener): () => void {\n listeners.add(listener)\n void ensureCrossProcessListener()\n\n return () => {\n listeners.delete(listener)\n if (listeners.size === 0) {\n clearReconnectTimer()\n void closeListenerClient()\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomUUID } from 'node:crypto'\nimport { Client, Pool } from 'pg'\nimport { getSslConfig } from '@open-mercato/shared/lib/db/ssl'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { EmitOptions, EventPayload } from './types'\n\nconst BRIDGE_CHANNEL = 'om_event_bridge'\nconst MAX_MESSAGE_BYTES = 7_000\nconst RECONNECT_DELAY_MS = 1_000\nconst CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'\n\nconst logger = createLogger('events')\n\n/**\n * PIDs can collide across containers, so self-echo suppression must not rely\n * on originPid alone. Every publisher stamps envelopes with this random\n * per-process instance id; consumers prefer it and fall back to originPid\n * only for envelopes published by older processes during a rolling deploy.\n */\nfunction getCrossProcessEventInstanceId(): string {\n const globalScope = globalThis as Record<string, unknown>\n const existing = globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY]\n if (typeof existing === 'string' && existing.length > 0) return existing\n const created = randomUUID()\n globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY] = created\n return created\n}\n\nexport const CROSS_PROCESS_EVENT_INSTANCE_ID = getCrossProcessEventInstanceId()\n\ntype BridgeEnvelope = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n originPid: number\n originInstanceId?: string\n}\n\ntype CrossProcessEventListener = (envelope: BridgeEnvelope) => void | Promise<void>\ntype PgNotificationMessage = {\n channel: string\n payload?: string\n}\n\nlet publisherPool: InstanceType<typeof Pool> | null | undefined\nlet listenerClient: InstanceType<typeof Client> | null = null\nlet listenerConnectPromise: Promise<void> | null = null\nlet listenerReconnectTimer: ReturnType<typeof setTimeout> | null = null\nconst listeners = new Set<CrossProcessEventListener>()\n\nfunction getDatabaseUrl(): string | null {\n const value = process.env.DATABASE_URL?.trim()\n return value && value.length > 0 ? value : null\n}\n\nfunction getPgBridgeConnectionString(): string | null {\n const connectionString = getDatabaseUrl()\n if (!connectionString) return null\n\n try {\n const url = new URL(connectionString)\n for (const key of ['sslmode', 'sslcert', 'sslkey', 'sslrootcert', 'sslcrl']) {\n url.searchParams.delete(key)\n }\n return url.toString()\n } catch {\n return connectionString\n }\n}\n\nfunction getPublisherPool(): InstanceType<typeof Pool> | null {\n if (publisherPool !== undefined) return publisherPool\n const connectionString = getPgBridgeConnectionString()\n if (!connectionString) {\n publisherPool = null\n return null\n }\n const ssl = getSslConfig()\n publisherPool = new Pool({\n connectionString,\n max: 2,\n ...(ssl ? { ssl } : {}),\n })\n return publisherPool\n}\n\nasync function dispatchEnvelope(envelope: BridgeEnvelope): Promise<void> {\n for (const listener of listeners) {\n try {\n await Promise.resolve(listener(envelope))\n } catch (error) {\n logger.error('Cross-process listener error', { event: envelope.event, err: error })\n }\n }\n}\n\nfunction clearReconnectTimer(): void {\n if (!listenerReconnectTimer) return\n clearTimeout(listenerReconnectTimer)\n listenerReconnectTimer = null\n}\n\nasync function closeListenerClient(): Promise<void> {\n if (!listenerClient) return\n const client = listenerClient\n listenerClient = null\n try {\n client.removeAllListeners('notification')\n client.removeAllListeners('error')\n client.removeAllListeners('end')\n await client.end()\n } catch {\n // Ignore shutdown errors.\n }\n}\n\nfunction scheduleReconnect(): void {\n if (listenerReconnectTimer || listeners.size === 0) return\n listenerReconnectTimer = setTimeout(() => {\n listenerReconnectTimer = null\n void ensureCrossProcessListener()\n }, RECONNECT_DELAY_MS)\n}\n\nasync function ensureCrossProcessListener(): Promise<void> {\n if (listenerClient || listenerConnectPromise || listeners.size === 0) return\n const connectionString = getPgBridgeConnectionString()\n if (!connectionString) return\n\n listenerConnectPromise = (async () => {\n const ssl = getSslConfig()\n const client = new Client({\n connectionString,\n ...(ssl ? { ssl } : {}),\n })\n\n client.on('notification', (message: PgNotificationMessage) => {\n if (message.channel !== BRIDGE_CHANNEL || !message.payload) return\n try {\n const parsed = JSON.parse(message.payload) as BridgeEnvelope\n if (!parsed || typeof parsed.event !== 'string') return\n void dispatchEnvelope(parsed)\n } catch (error) {\n logger.warn('Failed to parse cross-process bridge payload', { err: error })\n }\n })\n\n const handleDisconnect = () => {\n if (listenerClient === client) {\n listenerClient = null\n }\n scheduleReconnect()\n }\n\n client.on('error', handleDisconnect)\n client.on('end', handleDisconnect)\n\n await client.connect()\n await client.query(`LISTEN ${BRIDGE_CHANNEL}`)\n listenerClient = client\n })()\n .catch((error) => {\n logger.warn('Cross-process event bridge listener failed', { err: error })\n scheduleReconnect()\n })\n .finally(() => {\n listenerConnectPromise = null\n })\n\n await listenerConnectPromise\n}\n\nexport async function publishCrossProcessEvent(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n): Promise<void> {\n const pool = getPublisherPool()\n if (!pool) return\n\n const bridgeOptions = options\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n && options.tenantId === undefined\n ? { ...options, tenantId: null }\n : options\n\n const envelope: BridgeEnvelope = {\n event,\n payload,\n options: bridgeOptions,\n originPid: process.pid,\n originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID,\n }\n\n const serialized = JSON.stringify(envelope)\n if (Buffer.byteLength(serialized, 'utf8') > MAX_MESSAGE_BYTES) {\n logger.warn('Cross-process event dropped: payload exceeds size limit', { event, maxBytes: MAX_MESSAGE_BYTES })\n return\n }\n\n await pool.query('SELECT pg_notify($1, $2)', [BRIDGE_CHANNEL, serialized])\n}\n\nexport function registerCrossProcessEventListener(listener: CrossProcessEventListener): () => void {\n listeners.add(listener)\n void ensureCrossProcessListener()\n\n return () => {\n listeners.delete(listener)\n if (listeners.size === 0) {\n clearReconnectTimer()\n void closeListenerClient()\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/bus.js CHANGED
@@ -4,12 +4,16 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
4
4
  import { isSingleDeliveryRequested } from "./single-delivery.js";
5
5
  import { matchEventPattern } from "@open-mercato/shared/lib/events/patterns";
6
6
  import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
7
- import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
7
+ import {
8
+ isBroadcastEvent,
9
+ isCrossProcessBroadcastEvent,
10
+ isPrivateCrossProcessEventEmitter
11
+ } from "@open-mercato/shared/modules/events";
8
12
  import {
9
13
  inferModuleIdFromResourceId,
10
14
  withModuleResourceUsage
11
15
  } from "@open-mercato/shared/lib/modules/resource-usage";
12
- import { registerCrossProcessEventListener } from "./bridge.js";
16
+ import { registerCrossProcessEventListener, CROSS_PROCESS_EVENT_INSTANCE_ID } from "./bridge.js";
13
17
  import { publishCrossProcessEvent } from "./bridge.js";
14
18
  const EVENTS_QUEUE_NAME = "events";
15
19
  const logger = createLogger("events");
@@ -17,8 +21,26 @@ function isSingleDeliveryEnabled() {
17
21
  return isSingleDeliveryRequested();
18
22
  }
19
23
  const GLOBAL_EVENT_TAPS_KEY = "__openMercatoEventBusGlobalTaps__";
20
- function hasTenantScope(payload) {
21
- return typeof payload?.tenantId === "string" && String(payload.tenantId).trim().length > 0;
24
+ function hasTrustedTenantScope(options) {
25
+ return typeof options?.tenantId === "string" && options.tenantId.trim().length > 0;
26
+ }
27
+ function normalizePayloadScope(value) {
28
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
29
+ }
30
+ function resolveCrossProcessEmitOptions(event, payload, options) {
31
+ if (hasTrustedTenantScope(options)) return options;
32
+ if (!isBroadcastEvent(event)) return options;
33
+ const tenantId = normalizePayloadScope(payload?.tenantId);
34
+ if (!tenantId) return options;
35
+ const organizationId = normalizePayloadScope(payload?.organizationId);
36
+ const organizationIdsValue = payload?.organizationIds;
37
+ const organizationIds = Array.isArray(organizationIdsValue) ? organizationIdsValue.filter((value) => typeof value === "string") : [];
38
+ return {
39
+ ...options,
40
+ tenantId,
41
+ ...organizationId ? { organizationId } : {},
42
+ ...organizationIds.length > 0 ? { organizationIds } : {}
43
+ };
22
44
  }
23
45
  function getGlobalEventTaps() {
24
46
  const existing = globalThis[GLOBAL_EVENT_TAPS_KEY];
@@ -208,9 +230,10 @@ function createEventBus(opts) {
208
230
  const delivered = await deliver(event, payload, options, skipPersistentInline);
209
231
  inlinePersistentFailed = delivered.persistentFailures > 0;
210
232
  }
211
- if (isBroadcastEvent(event) && hasTenantScope(payload)) {
233
+ const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options);
234
+ if (isCrossProcessBroadcastEvent(event) && hasTrustedTenantScope(crossProcessOptions) && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)) {
212
235
  try {
213
- await publishCrossProcessEvent(event, payload, options);
236
+ await publishCrossProcessEvent(event, payload, crossProcessOptions);
214
237
  } catch (error) {
215
238
  logger.error("Cross-process publish error", { event, err: error });
216
239
  }
@@ -248,6 +271,7 @@ function createEventBus(opts) {
248
271
  };
249
272
  }
250
273
  export {
274
+ CROSS_PROCESS_EVENT_INSTANCE_ID,
251
275
  createEventBus,
252
276
  registerCrossProcessEventListener,
253
277
  registerGlobalEventTap
package/dist/bus.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/bus.ts"],
4
- "sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isSingleDeliveryRequested } from './single-delivery'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport {\n inferModuleIdFromResourceId,\n withModuleResourceUsage,\n} from '@open-mercato/shared/lib/modules/resource-usage'\nexport { registerCrossProcessEventListener } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n QueuedDispatchResult,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\ntype RegisteredSubscriber = {\n id?: string\n moduleId?: string\n event: string\n handler: SubscriberHandler\n persistent?: boolean\n}\n\n/**\n * When enabled, a persistent emit delivers each subscriber on exactly one path:\n * persistent-marked subscribers are skipped inline (the events worker dispatches\n * them via pattern match, so wildcard persistent subscribers are reached), while\n * ephemeral subscribers keep running inline. Defaults ON.\n *\n * The bus never second-guesses this against \"is a worker running\": the queue is\n * durable, so a persistent emit with no worker yet is delayed, not lost, and a\n * worker started later drains the backlog. Delivering inline instead would move\n * the work back onto the caller's request path \u2014 exactly what a split app/worker\n * deployment sets `AUTO_SPAWN_WORKERS=false` to avoid.\n *\n * The events worker asks this same bus for its subscribers via `dispatchQueued`,\n * so both halves of single-delivery read one decision.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTenantScope(payload: EventPayload): boolean {\n return typeof (payload as Record<string, unknown>)?.tenantId === 'string'\n && String((payload as Record<string, unknown>).tenantId).trim().length > 0\n}\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n /**\n * Set by the producer when it already ran the persistent subscribers inline\n * (single-delivery reconciled off). The worker skips such jobs, so the app and\n * the worker staying exactly-once no longer depends on both processes agreeing\n * about `OM_EVENTS_SINGLE_DELIVERY`. Absent on jobs queued before this field\n * existed, which keeps their behavior unchanged.\n */\n persistentDeliveredInline?: boolean\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<RegisteredSubscriber>>()\n // Subscribers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentSubscribers = new Set<RegisteredSubscriber>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n *\n * The async (BullMQ) producer is memoized process-wide so the per-request\n * event bus reuses one Redis connection instead of leaking one per write\n * request. The local strategy stays per-bus (no pooled connection, cwd-relative\n * base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.\n */\n function getQueue(): Queue<EventJobData> {\n if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {\n if (!queue) {\n queue = queueStrategy === 'async'\n ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n return queue\n }\n\n const redisUrl = getRedisUrlOrThrow('QUEUE')\n const registry = getProducerQueueRegistry()\n const cacheKey = `async:${redisUrl}`\n let shared = registry.get(cacheKey)\n if (!shared) {\n shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: redisUrl },\n })\n registry.set(cacheKey, shared)\n registerProducerShutdownHook()\n }\n return shared\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n *\n * Handler errors are logged and delivery continues - one bad subscriber must\n * not stop the others. The count of failed PERSISTENT handlers is returned so\n * the caller can decide whether the inline run may be recorded as complete:\n * a persistent subscriber that threw inline has no retry of its own, so the\n * queued job must stay unstamped and let the worker run it again.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<{ persistentFailures: number }> {\n let persistentFailures = 0\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const subscriber of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentSubscribers.has(subscriber)) continue\n try {\n // Pass eventName in context for wildcard handlers\n const moduleId = subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id)\n await withModuleResourceUsage(\n {\n moduleId,\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n )\n } catch (error) {\n if (persistentSubscribers.has(subscriber)) {\n persistentFailures += 1\n }\n logger.error('Handler error', { event, pattern, err: error })\n if (options?.rethrowHandlerErrors) {\n throw error\n }\n }\n }\n }\n return { persistentFailures }\n }\n\n /**\n * Selects the subscribers the events worker owns for a queued event: every\n * pattern matching `event`, keeping persistent subscribers only, so wildcard\n * (`event: '*'`) persistent subscribers are reached exactly once here.\n *\n * Deliberately does NOT read `OM_EVENTS_SINGLE_DELIVERY`. Whether inline\n * delivery already happened is carried by the job's `persistentDeliveredInline`\n * stamp, so the producer owns that decision entirely and the worker cannot\n * disagree with it across processes. Reading the flag here would reintroduce\n * exactly that skew: a worker whose env has the flag off would fall back to\n * exact-match, re-running ephemeral subscribers the producer already ran inline\n * and never reaching wildcard persistent subscribers - the failure this whole\n * change exists to remove.\n *\n * Every job that reaches this point wants persistent-pattern selection: a job\n * is only dispatched when its stamp is false, which means either the producer\n * had single-delivery on, or the emit was enqueue-only (`deliverInline: false`,\n * documented as \"every subscriber to the event is persistent\").\n */\n function selectQueuedSubscribers(event: string): RegisteredSubscriber[] {\n const matched: RegisteredSubscriber[] = []\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) {\n continue\n }\n for (const subscriber of handlers) {\n if (subscriber.persistent) {\n matched.push(subscriber)\n }\n }\n }\n return matched\n }\n\n /**\n * Dispatches a queued event on behalf of the events worker.\n *\n * Handler failures are returned instead of logged-and-swallowed the way\n * `deliver()` does them: the worker needs them to propagate so the queue retries\n * the job and eventually dead-letters it.\n *\n * Subscribers run against the caller's `resolve` when one is given, falling back\n * to the bus's own. The events worker passes its per-job `ctx.resolve`, which is\n * the container `createPerJobWorkerHandler` built for that job - the handle the\n * queue contract already hands it. Defaulting to `opts.resolve` instead would\n * bind subscribers to whichever container built the bus; that is the same object\n * on the default configuration, but not under `OM_BOOTSTRAP_CACHE`, where the\n * cached bus is replayed into later containers while its captured resolver stays\n * bound to the first.\n */\n async function dispatchQueued(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n resolve?: <T = unknown>(name: string) => T,\n ): Promise<QueuedDispatchResult[]> {\n const subscribers = selectQueuedSubscribers(event)\n if (subscribers.length === 0) {\n return []\n }\n\n const resolver = resolve ?? opts.resolve\n\n const settled = await Promise.allSettled(\n subscribers.map((subscriber) =>\n withModuleResourceUsage(\n {\n moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: resolver,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n ),\n ),\n )\n\n return settled.map((result, index) => {\n const subscriberId = subscribers[index].id ?? subscribers[index].event\n return result.status === 'rejected'\n ? { subscriberId, ok: false, error: result.reason }\n : { subscriberId, ok: true }\n })\n }\n\n /**\n * Registers a handler for an event. Pass `moduleId` whenever the subscribing\n * module is known (e.g. a module reacting to another module's event) so\n * resource-usage telemetry attributes the handler correctly instead of\n * guessing a module id from the event name.\n */\n function on(\n event: string,\n handler: SubscriberHandler,\n options?: { persistent?: boolean; moduleId?: string; id?: string },\n ): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n const subscriber: RegisteredSubscriber = {\n id: options?.id,\n moduleId: options?.moduleId,\n event,\n handler,\n persistent: options?.persistent,\n }\n listeners.get(event)!.add(subscriber)\n if (options?.persistent) {\n persistentSubscribers.add(subscriber)\n }\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler, { persistent: sub.persistent, moduleId: sub.moduleId, id: sub.id })\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n const taps = getGlobalEventTaps()\n for (const tap of taps) {\n try {\n await Promise.resolve(tap(event, payload, options))\n } catch (error) {\n logger.error('Global tap error', { event, err: error })\n }\n }\n\n // Deliver to in-memory handlers first. Under single-delivery, persistent\n // subscribers are skipped inline on a persistent emit because the events\n // worker will dispatch them from the queue.\n //\n // `deliverInline: false` on a persistent emit is enqueue-only: skip inline\n // delivery entirely so a heavy persistent job runs solely in the events\n // worker, off the caller's request path (the queued enqueue below still\n // happens). Without this, a persistent emit dual-dispatches by default and\n // runs the subscriber inline in the caller's request too.\n const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false\n // Read the mode once so the inline skip and the stamp below cannot disagree.\n const singleDelivery = isSingleDeliveryEnabled()\n const skipPersistentInline = options?.skipPersistentSubscribersInline === true\n || (Boolean(options?.persistent) && singleDelivery)\n let inlinePersistentFailed = false\n if (!enqueueOnly) {\n const delivered = await deliver(event, payload, options, skipPersistentInline)\n inlinePersistentFailed = delivered.persistentFailures > 0\n }\n\n if (isBroadcastEvent(event) && hasTenantScope(payload)) {\n try {\n await publishCrossProcessEvent(event, payload, options)\n } catch (error) {\n logger.error('Cross-process publish error', { event, err: error })\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({\n event,\n payload,\n options,\n // Only record the inline run as complete when every persistent\n // subscriber actually succeeded. Inline delivery logs-and-continues, so a\n // handler that threw has no retry of its own; leaving the job unstamped\n // hands it to the worker, where a failure gets queue retry and\n // dead-lettering. Without this, flipping the flag off (which the\n // `mercato server` guard can do automatically) silently downgrades\n // persistent delivery to at-most-once.\n persistentDeliveredInline: !enqueueOnly && !skipPersistentInline && !inlinePersistentFailed,\n })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n dispatchQueued,\n clearQueue,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isSingleDeliveryRequested } from './single-delivery'\nimport { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport {\n isBroadcastEvent,\n isCrossProcessBroadcastEvent,\n isPrivateCrossProcessEventEmitter,\n} from '@open-mercato/shared/modules/events'\nimport {\n inferModuleIdFromResourceId,\n withModuleResourceUsage,\n} from '@open-mercato/shared/lib/modules/resource-usage'\nexport { registerCrossProcessEventListener, CROSS_PROCESS_EVENT_INSTANCE_ID } from './bridge'\nimport { publishCrossProcessEvent } from './bridge'\nimport type {\n EventBus,\n CreateBusOptions,\n SubscriberHandler,\n SubscriberDescriptor,\n EventPayload,\n EmitOptions,\n QueuedDispatchResult,\n} from './types'\n\n/** Queue name for persistent events */\nconst EVENTS_QUEUE_NAME = 'events'\n\nconst logger = createLogger('events')\n\ntype RegisteredSubscriber = {\n id?: string\n moduleId?: string\n event: string\n handler: SubscriberHandler\n persistent?: boolean\n}\n\n/**\n * When enabled, a persistent emit delivers each subscriber on exactly one path:\n * persistent-marked subscribers are skipped inline (the events worker dispatches\n * them via pattern match, so wildcard persistent subscribers are reached), while\n * ephemeral subscribers keep running inline. Defaults ON.\n *\n * The bus never second-guesses this against \"is a worker running\": the queue is\n * durable, so a persistent emit with no worker yet is delayed, not lost, and a\n * worker started later drains the backlog. Delivering inline instead would move\n * the work back onto the caller's request path \u2014 exactly what a split app/worker\n * deployment sets `AUTO_SPAWN_WORKERS=false` to avoid.\n *\n * The events worker asks this same bus for its subscribers via `dispatchQueued`,\n * so both halves of single-delivery read one decision.\n */\nfunction isSingleDeliveryEnabled(): boolean {\n return isSingleDeliveryRequested()\n}\n\ntype GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>\nconst GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'\n\nfunction hasTrustedTenantScope(options?: EmitOptions): boolean {\n return typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n}\n\nfunction normalizePayloadScope(value: unknown): string | null {\n return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null\n}\n\nfunction resolveCrossProcessEmitOptions(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n): EmitOptions | undefined {\n if (hasTrustedTenantScope(options)) return options\n // Preserve the public raw EventBus contract for legacy clientBroadcast\n // emitters. These callers are trusted server code and historically supplied\n // their scope in the typed payload. Tenant-managed workflow execution passes\n // scope in options, while private cross-process events never take this path.\n if (!isBroadcastEvent(event)) return options\n const tenantId = normalizePayloadScope((payload as Record<string, unknown>)?.tenantId)\n if (!tenantId) return options\n const organizationId = normalizePayloadScope((payload as Record<string, unknown>)?.organizationId)\n const organizationIdsValue = (payload as Record<string, unknown>)?.organizationIds\n const organizationIds = Array.isArray(organizationIdsValue)\n ? organizationIdsValue.filter((value): value is string => typeof value === 'string')\n : []\n return {\n ...options,\n tenantId,\n ...(organizationId ? { organizationId } : {}),\n ...(organizationIds.length > 0 ? { organizationIds } : {}),\n }\n}\n\nfunction getGlobalEventTaps(): Set<GlobalEventTap> {\n const existing = (globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY]\n if (existing instanceof Set) {\n return existing as Set<GlobalEventTap>\n }\n const created = new Set<GlobalEventTap>()\n ;(globalThis as Record<string, unknown>)[GLOBAL_EVENT_TAPS_KEY] = created\n return created\n}\n\nexport function registerGlobalEventTap(handler: GlobalEventTap): () => void {\n const taps = getGlobalEventTaps()\n taps.add(handler)\n return () => {\n taps.delete(handler)\n }\n}\n\n/** Job data structure for queued events */\ntype EventJobData = {\n event: string\n payload: EventPayload\n options?: EmitOptions\n /**\n * Set by the producer when it already ran the persistent subscribers inline\n * (single-delivery reconciled off). The worker skips such jobs, so the app and\n * the worker staying exactly-once no longer depends on both processes agreeing\n * about `OM_EVENTS_SINGLE_DELIVERY`. Absent on jobs queued before this field\n * existed, which keeps their behavior unchanged.\n */\n persistentDeliveredInline?: boolean\n}\n\n// Process-wide cache of the async (BullMQ) persistent-events producer queue.\n// Each authenticated request builds a fresh DI container and event bus, so a\n// per-bus producer queue opened a new ioredis connection per write request that\n// was never closed \u2014 leaking one Redis connection per request until maxclients\n// exhaustion. Memoizing the producer on `globalThis` (keyed by Redis URL, so a\n// reconfigured URL still gets its own queue) keeps it at one connection per\n// process, mirroring the `GLOBAL_EVENT_TAPS_KEY` and `getCachedRateLimiterService`\n// patterns. The local (file-based) strategy holds no pooled connection and its\n// base dir is cwd-relative, so it stays per-bus.\nconst EVENTS_PRODUCER_QUEUE_KEY = '__openMercatoEventsProducerQueues__'\nconst EVENTS_PRODUCER_SHUTDOWN_KEY = '__openMercatoEventsProducerShutdown__'\n\nfunction isSharedProducerEnabled(): boolean {\n return parseBooleanWithDefault(process.env.OM_EVENTS_SHARED_PRODUCER, true)\n}\n\nfunction getProducerQueueRegistry(): Map<string, Queue<EventJobData>> {\n const existing = (globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY]\n if (existing instanceof Map) {\n return existing as Map<string, Queue<EventJobData>>\n }\n const created = new Map<string, Queue<EventJobData>>()\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_QUEUE_KEY] = created\n return created\n}\n\nfunction registerProducerShutdownHook(): void {\n if ((globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY]) return\n const shutdown = () => {\n const registry = getProducerQueueRegistry()\n for (const sharedQueue of registry.values()) {\n Promise.resolve(sharedQueue.close()).catch(() => {})\n }\n registry.clear()\n }\n process.once('SIGTERM', shutdown)\n process.once('SIGINT', shutdown)\n ;(globalThis as Record<string, unknown>)[EVENTS_PRODUCER_SHUTDOWN_KEY] = true\n}\n\n/**\n * Creates an event bus instance.\n *\n * The event bus provides:\n * - In-memory event delivery to registered handlers\n * - Optional persistence via the queue package when `persistent: true`\n *\n * @param opts - Configuration options\n * @returns An EventBus instance\n *\n * @example\n * ```typescript\n * const bus = createEventBus({\n * resolve: container.resolve.bind(container),\n * queueStrategy: 'local', // or 'async' for BullMQ\n * })\n *\n * // Register a handler\n * bus.on('user.created', async (payload, ctx) => {\n * const userService = ctx.resolve('userService')\n * await userService.sendWelcomeEmail(payload.userId)\n * })\n *\n * // Emit an event (immediate delivery)\n * await bus.emit('user.created', { userId: '123' })\n *\n * // Emit with persistence (for async worker processing)\n * await bus.emit('order.placed', { orderId: '456' }, { persistent: true })\n * ```\n */\nexport function createEventBus(opts: CreateBusOptions): EventBus {\n // In-memory listeners for immediate event delivery\n const listeners = new Map<string, Set<RegisteredSubscriber>>()\n // Subscribers registered as persistent (worker-dispatched). Used by the\n // single-delivery path to skip them inline on a persistent emit.\n const persistentSubscribers = new Set<RegisteredSubscriber>()\n\n // Determine queue strategy from options or environment\n const queueStrategy = opts.queueStrategy ??\n (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')\n\n // Lazy-initialized queue for persistent events\n let queue: Queue<EventJobData> | null = null\n\n /**\n * Gets or creates the queue instance for persistent events.\n *\n * The async (BullMQ) producer is memoized process-wide so the per-request\n * event bus reuses one Redis connection instead of leaking one per write\n * request. The local strategy stays per-bus (no pooled connection, cwd-relative\n * base dir). Set `OM_EVENTS_SHARED_PRODUCER=0` to fall back to per-bus producers.\n */\n function getQueue(): Queue<EventJobData> {\n if (queueStrategy !== 'async' || !isSharedProducerEnabled()) {\n if (!queue) {\n queue = queueStrategy === 'async'\n ? createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n : createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'local')\n }\n return queue\n }\n\n const redisUrl = getRedisUrlOrThrow('QUEUE')\n const registry = getProducerQueueRegistry()\n const cacheKey = `async:${redisUrl}`\n let shared = registry.get(cacheKey)\n if (!shared) {\n shared = createQueue<EventJobData>(EVENTS_QUEUE_NAME, 'async', {\n connection: { url: redisUrl },\n })\n registry.set(cacheKey, shared)\n registerProducerShutdownHook()\n }\n return shared\n }\n\n /**\n * Delivers an event to all registered in-memory handlers.\n * Supports wildcard pattern matching for event patterns.\n *\n * Handler errors are logged and delivery continues - one bad subscriber must\n * not stop the others. The count of failed PERSISTENT handlers is returned so\n * the caller can decide whether the inline run may be recorded as complete:\n * a persistent subscriber that threw inline has no retry of its own, so the\n * queued job must stay unstamped and let the worker run it again.\n */\n async function deliver(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n skipPersistent = false,\n ): Promise<{ persistentFailures: number }> {\n let persistentFailures = 0\n // Check all registered patterns (including wildcards)\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) continue\n if (!handlers || handlers.size === 0) continue\n\n for (const subscriber of handlers) {\n // Single-delivery: persistent subscribers are dispatched by the worker,\n // so skip them inline to avoid double execution.\n if (skipPersistent && persistentSubscribers.has(subscriber)) continue\n try {\n // Pass eventName in context for wildcard handlers\n const moduleId = subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id)\n await withModuleResourceUsage(\n {\n moduleId,\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: opts.resolve,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n )\n } catch (error) {\n if (persistentSubscribers.has(subscriber)) {\n persistentFailures += 1\n }\n logger.error('Handler error', { event, pattern, err: error })\n if (options?.rethrowHandlerErrors) {\n throw error\n }\n }\n }\n }\n return { persistentFailures }\n }\n\n /**\n * Selects the subscribers the events worker owns for a queued event: every\n * pattern matching `event`, keeping persistent subscribers only, so wildcard\n * (`event: '*'`) persistent subscribers are reached exactly once here.\n *\n * Deliberately does NOT read `OM_EVENTS_SINGLE_DELIVERY`. Whether inline\n * delivery already happened is carried by the job's `persistentDeliveredInline`\n * stamp, so the producer owns that decision entirely and the worker cannot\n * disagree with it across processes. Reading the flag here would reintroduce\n * exactly that skew: a worker whose env has the flag off would fall back to\n * exact-match, re-running ephemeral subscribers the producer already ran inline\n * and never reaching wildcard persistent subscribers - the failure this whole\n * change exists to remove.\n *\n * Every job that reaches this point wants persistent-pattern selection: a job\n * is only dispatched when its stamp is false, which means either the producer\n * had single-delivery on, or the emit was enqueue-only (`deliverInline: false`,\n * documented as \"every subscriber to the event is persistent\").\n */\n function selectQueuedSubscribers(event: string): RegisteredSubscriber[] {\n const matched: RegisteredSubscriber[] = []\n for (const [pattern, handlers] of listeners) {\n if (!matchEventPattern(event, pattern)) {\n continue\n }\n for (const subscriber of handlers) {\n if (subscriber.persistent) {\n matched.push(subscriber)\n }\n }\n }\n return matched\n }\n\n /**\n * Dispatches a queued event on behalf of the events worker.\n *\n * Handler failures are returned instead of logged-and-swallowed the way\n * `deliver()` does them: the worker needs them to propagate so the queue retries\n * the job and eventually dead-letters it.\n *\n * Subscribers run against the caller's `resolve` when one is given, falling back\n * to the bus's own. The events worker passes its per-job `ctx.resolve`, which is\n * the container `createPerJobWorkerHandler` built for that job - the handle the\n * queue contract already hands it. Defaulting to `opts.resolve` instead would\n * bind subscribers to whichever container built the bus; that is the same object\n * on the default configuration, but not under `OM_BOOTSTRAP_CACHE`, where the\n * cached bus is replayed into later containers while its captured resolver stays\n * bound to the first.\n */\n async function dispatchQueued(\n event: string,\n payload: EventPayload,\n options?: EmitOptions,\n resolve?: <T = unknown>(name: string) => T,\n ): Promise<QueuedDispatchResult[]> {\n const subscribers = selectQueuedSubscribers(event)\n if (subscribers.length === 0) {\n return []\n }\n\n const resolver = resolve ?? opts.resolve\n\n const settled = await Promise.allSettled(\n subscribers.map((subscriber) =>\n withModuleResourceUsage(\n {\n moduleId: subscriber.moduleId ?? inferModuleIdFromResourceId(subscriber.id),\n surface: 'subscriber',\n operation: `${event} -> ${subscriber.id ?? subscriber.event}`,\n resourceId: subscriber.id ?? subscriber.event,\n },\n () => subscriber.handler(payload, {\n resolve: resolver,\n eventName: event,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n }),\n ),\n ),\n )\n\n return settled.map((result, index) => {\n const subscriberId = subscribers[index].id ?? subscribers[index].event\n return result.status === 'rejected'\n ? { subscriberId, ok: false, error: result.reason }\n : { subscriberId, ok: true }\n })\n }\n\n /**\n * Registers a handler for an event. Pass `moduleId` whenever the subscribing\n * module is known (e.g. a module reacting to another module's event) so\n * resource-usage telemetry attributes the handler correctly instead of\n * guessing a module id from the event name.\n */\n function on(\n event: string,\n handler: SubscriberHandler,\n options?: { persistent?: boolean; moduleId?: string; id?: string },\n ): void {\n if (!listeners.has(event)) {\n listeners.set(event, new Set())\n }\n const subscriber: RegisteredSubscriber = {\n id: options?.id,\n moduleId: options?.moduleId,\n event,\n handler,\n persistent: options?.persistent,\n }\n listeners.get(event)!.add(subscriber)\n if (options?.persistent) {\n persistentSubscribers.add(subscriber)\n }\n }\n\n /**\n * Registers multiple module subscribers at once.\n */\n function registerModuleSubscribers(subs: SubscriberDescriptor[]): void {\n for (const sub of subs) {\n on(sub.event, sub.handler, { persistent: sub.persistent, moduleId: sub.moduleId, id: sub.id })\n }\n }\n\n /**\n * Emits an event to all registered handlers.\n *\n * If `persistent: true`, also enqueues the event for async processing.\n */\n async function emit(\n event: string,\n payload: EventPayload,\n options?: EmitOptions\n ): Promise<void> {\n const taps = getGlobalEventTaps()\n for (const tap of taps) {\n try {\n await Promise.resolve(tap(event, payload, options))\n } catch (error) {\n logger.error('Global tap error', { event, err: error })\n }\n }\n\n // Deliver to in-memory handlers first. Under single-delivery, persistent\n // subscribers are skipped inline on a persistent emit because the events\n // worker will dispatch them from the queue.\n //\n // `deliverInline: false` on a persistent emit is enqueue-only: skip inline\n // delivery entirely so a heavy persistent job runs solely in the events\n // worker, off the caller's request path (the queued enqueue below still\n // happens). Without this, a persistent emit dual-dispatches by default and\n // runs the subscriber inline in the caller's request too.\n const enqueueOnly = Boolean(options?.persistent) && options?.deliverInline === false\n // Read the mode once so the inline skip and the stamp below cannot disagree.\n const singleDelivery = isSingleDeliveryEnabled()\n const skipPersistentInline = options?.skipPersistentSubscribersInline === true\n || (Boolean(options?.persistent) && singleDelivery)\n let inlinePersistentFailed = false\n if (!enqueueOnly) {\n const delivered = await deliver(event, payload, options, skipPersistentInline)\n inlinePersistentFailed = delivered.persistentFailures > 0\n }\n\n // Private coordination always requires trusted envelope scope and module\n // provenance. Legacy declared browser events may promote their typed scope\n // for raw EventBus compatibility; tenant-managed workflow adapters pass\n // authoritative scope in options, which always wins over payload values.\n const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)\n if (\n isCrossProcessBroadcastEvent(event)\n && hasTrustedTenantScope(crossProcessOptions)\n && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)\n ) {\n try {\n await publishCrossProcessEvent(event, payload, crossProcessOptions)\n } catch (error) {\n logger.error('Cross-process publish error', { event, err: error })\n }\n }\n\n // If persistent, also enqueue for async processing\n if (options?.persistent) {\n const q = getQueue()\n await q.enqueue({\n event,\n payload,\n options,\n // Only record the inline run as complete when every persistent\n // subscriber actually succeeded. Inline delivery logs-and-continues, so a\n // handler that threw has no retry of its own; leaving the job unstamped\n // hands it to the worker, where a failure gets queue retry and\n // dead-lettering. Without this, flipping the flag off (which the\n // `mercato server` guard can do automatically) silently downgrades\n // persistent delivery to at-most-once.\n persistentDeliveredInline: !enqueueOnly && !skipPersistentInline && !inlinePersistentFailed,\n })\n }\n }\n\n /**\n * Clears all events from the persistent queue.\n */\n async function clearQueue(): Promise<{ removed: number }> {\n const q = getQueue()\n return q.clear()\n }\n\n // Backward compatibility alias\n const emitEvent = emit\n\n return {\n emit,\n emitEvent, // Alias for backward compatibility\n on,\n registerModuleSubscribers,\n dispatchQueued,\n clearQueue,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -13,6 +13,7 @@ const eventDefinitionSchema = z.object({
13
13
  entity: z.string().optional(),
14
14
  excludeFromTriggers: z.boolean().optional(),
15
15
  clientBroadcast: z.boolean().optional(),
16
+ crossProcessBroadcast: z.boolean().optional(),
16
17
  portalBroadcast: z.boolean().optional()
17
18
  });
18
19
  const eventsResponseSchema = z.object({
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/events/api/route.ts"],
4
- "sourcesContent": ["/**\n * Events registry API \u2014 returns declared events from module `events.ts` files.\n *\n * Uses the globally registered event configs (populated during bootstrap). Data\n * leaked here (event ids, module ids, entity ids, clientBroadcast/portalBroadcast\n * flags) is recon-grade and MUST NOT be served to anonymous callers; the route\n * is protected by the standard `[...slug]` dispatcher with `requireAuth: true`.\n * Consumer: `packages/ui/src/backend/inputs/EventSelect.tsx` (workflow triggers,\n * business rules, webhook config).\n */\n\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\n\nexport const metadata = {\n path: '/events',\n GET: { requireAuth: true, requireFeatures: ['workflows.view'] },\n}\n\nconst eventDefinitionSchema = z.object({\n id: z.string(),\n label: z.string(),\n description: z.string().optional(),\n category: z.enum(['crud', 'lifecycle', 'system', 'custom']).optional(),\n module: z.string().optional(),\n entity: z.string().optional(),\n excludeFromTriggers: z.boolean().optional(),\n clientBroadcast: z.boolean().optional(),\n portalBroadcast: z.boolean().optional(),\n})\n\nconst eventsResponseSchema = z.object({\n data: z.array(eventDefinitionSchema),\n total: z.number().int().nonnegative(),\n})\n\nexport async function GET(req: Request): Promise<Response> {\n const { searchParams } = new URL(req.url)\n const category = searchParams.get('category')\n const moduleId = searchParams.get('module')\n const excludeTriggerExcluded = searchParams.get('excludeTriggerExcluded') !== 'false'\n\n let filteredEvents = getDeclaredEvents()\n\n if (excludeTriggerExcluded) {\n filteredEvents = filteredEvents.filter((e) => !e.excludeFromTriggers)\n }\n if (category) {\n filteredEvents = filteredEvents.filter((e) => e.category === category)\n }\n if (moduleId) {\n filteredEvents = filteredEvents.filter((e) => e.module === moduleId)\n }\n\n return Response.json({\n data: filteredEvents,\n total: filteredEvents.length,\n })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'List declared platform events',\n description: 'Returns all declared platform events from every enabled module. Supports optional category and module filters.',\n methods: {\n GET: {\n summary: 'List declared events',\n description: 'Returns every declared event. Filters: category, module, excludeTriggerExcluded (default true).',\n responses: [{ status: 200, description: 'Declared events', schema: eventsResponseSchema }],\n },\n },\n}\n"],
5
- "mappings": "AAWA,SAAS,SAAS;AAElB,SAAS,yBAAyB;AAE3B,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AAChE;AAEA,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,IAAI,EAAE,OAAO;AAAA,EACb,OAAO,EAAE,OAAO;AAAA,EAChB,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,EACjC,UAAU,EAAE,KAAK,CAAC,QAAQ,aAAa,UAAU,QAAQ,CAAC,EAAE,SAAS;AAAA,EACrE,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,qBAAqB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC1C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AACxC,CAAC;AAED,MAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,MAAM,EAAE,MAAM,qBAAqB;AAAA,EACnC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY;AACtC,CAAC;AAED,eAAsB,IAAI,KAAiC;AACzD,QAAM,EAAE,aAAa,IAAI,IAAI,IAAI,IAAI,GAAG;AACxC,QAAM,WAAW,aAAa,IAAI,UAAU;AAC5C,QAAM,WAAW,aAAa,IAAI,QAAQ;AAC1C,QAAM,yBAAyB,aAAa,IAAI,wBAAwB,MAAM;AAE9E,MAAI,iBAAiB,kBAAkB;AAEvC,MAAI,wBAAwB;AAC1B,qBAAiB,eAAe,OAAO,CAAC,MAAM,CAAC,EAAE,mBAAmB;AAAA,EACtE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,aAAa,QAAQ;AAAA,EACvE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,WAAW,QAAQ;AAAA,EACrE;AAEA,SAAO,SAAS,KAAK;AAAA,IACnB,MAAM;AAAA,IACN,OAAO,eAAe;AAAA,EACxB,CAAC;AACH;AAEO,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,qBAAqB,CAAC;AAAA,IAC3F;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["/**\n * Events registry API \u2014 returns declared events from module `events.ts` files.\n *\n * Uses the globally registered event configs (populated during bootstrap). Data\n * leaked here (event ids, module ids, entity ids, broadcast flags) is\n * recon-grade and MUST NOT be served to anonymous callers; the route\n * is protected by the standard `[...slug]` dispatcher with `requireAuth: true`.\n * Consumer: `packages/ui/src/backend/inputs/EventSelect.tsx` (workflow triggers,\n * business rules, webhook config).\n */\n\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getDeclaredEvents } from '@open-mercato/shared/modules/events'\n\nexport const metadata = {\n path: '/events',\n GET: { requireAuth: true, requireFeatures: ['workflows.view'] },\n}\n\nconst eventDefinitionSchema = z.object({\n id: z.string(),\n label: z.string(),\n description: z.string().optional(),\n category: z.enum(['crud', 'lifecycle', 'system', 'custom']).optional(),\n module: z.string().optional(),\n entity: z.string().optional(),\n excludeFromTriggers: z.boolean().optional(),\n clientBroadcast: z.boolean().optional(),\n crossProcessBroadcast: z.boolean().optional(),\n portalBroadcast: z.boolean().optional(),\n})\n\nconst eventsResponseSchema = z.object({\n data: z.array(eventDefinitionSchema),\n total: z.number().int().nonnegative(),\n})\n\nexport async function GET(req: Request): Promise<Response> {\n const { searchParams } = new URL(req.url)\n const category = searchParams.get('category')\n const moduleId = searchParams.get('module')\n const excludeTriggerExcluded = searchParams.get('excludeTriggerExcluded') !== 'false'\n\n let filteredEvents = getDeclaredEvents()\n\n if (excludeTriggerExcluded) {\n filteredEvents = filteredEvents.filter((e) => !e.excludeFromTriggers)\n }\n if (category) {\n filteredEvents = filteredEvents.filter((e) => e.category === category)\n }\n if (moduleId) {\n filteredEvents = filteredEvents.filter((e) => e.module === moduleId)\n }\n\n return Response.json({\n data: filteredEvents,\n total: filteredEvents.length,\n })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n summary: 'List declared platform events',\n description: 'Returns all declared platform events from every enabled module. Supports optional category and module filters.',\n methods: {\n GET: {\n summary: 'List declared events',\n description: 'Returns every declared event. Filters: category, module, excludeTriggerExcluded (default true).',\n responses: [{ status: 200, description: 'Declared events', schema: eventsResponseSchema }],\n },\n },\n}\n"],
5
+ "mappings": "AAWA,SAAS,SAAS;AAElB,SAAS,yBAAyB;AAE3B,MAAM,WAAW;AAAA,EACtB,MAAM;AAAA,EACN,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,gBAAgB,EAAE;AAChE;AAEA,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,IAAI,EAAE,OAAO;AAAA,EACb,OAAO,EAAE,OAAO;AAAA,EAChB,aAAa,EAAE,OAAO,EAAE,SAAS;AAAA,EACjC,UAAU,EAAE,KAAK,CAAC,QAAQ,aAAa,UAAU,QAAQ,CAAC,EAAE,SAAS;AAAA,EACrE,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,qBAAqB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC1C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AAAA,EACtC,uBAAuB,EAAE,QAAQ,EAAE,SAAS;AAAA,EAC5C,iBAAiB,EAAE,QAAQ,EAAE,SAAS;AACxC,CAAC;AAED,MAAM,uBAAuB,EAAE,OAAO;AAAA,EACpC,MAAM,EAAE,MAAM,qBAAqB;AAAA,EACnC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,YAAY;AACtC,CAAC;AAED,eAAsB,IAAI,KAAiC;AACzD,QAAM,EAAE,aAAa,IAAI,IAAI,IAAI,IAAI,GAAG;AACxC,QAAM,WAAW,aAAa,IAAI,UAAU;AAC5C,QAAM,WAAW,aAAa,IAAI,QAAQ;AAC1C,QAAM,yBAAyB,aAAa,IAAI,wBAAwB,MAAM;AAE9E,MAAI,iBAAiB,kBAAkB;AAEvC,MAAI,wBAAwB;AAC1B,qBAAiB,eAAe,OAAO,CAAC,MAAM,CAAC,EAAE,mBAAmB;AAAA,EACtE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,aAAa,QAAQ;AAAA,EACvE;AACA,MAAI,UAAU;AACZ,qBAAiB,eAAe,OAAO,CAAC,MAAM,EAAE,WAAW,QAAQ;AAAA,EACrE;AAEA,SAAO,SAAS,KAAK;AAAA,IACnB,MAAM;AAAA,IACN,OAAO,eAAe;AAAA,EACxB,CAAC;AACH;AAEO,MAAM,UAA2B;AAAA,EACtC,SAAS;AAAA,EACT,aAAa;AAAA,EACb,SAAS;AAAA,IACP,KAAK;AAAA,MACH,SAAS;AAAA,MACT,aAAa;AAAA,MACb,WAAW,CAAC,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,qBAAqB,CAAC;AAAA,IAC3F;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,11 @@
1
1
  import { resolveRequestContext } from "@open-mercato/shared/lib/api/context";
2
2
  import { createLogger } from "@open-mercato/shared/lib/logger";
3
3
  import { isBroadcastEvent } from "@open-mercato/shared/modules/events";
4
- import { registerCrossProcessEventListener, registerGlobalEventTap } from "../../../../bus.js";
4
+ import {
5
+ CROSS_PROCESS_EVENT_INSTANCE_ID,
6
+ registerCrossProcessEventListener,
7
+ registerGlobalEventTap
8
+ } from "../../../../bus.js";
5
9
  const metadata = {
6
10
  GET: { requireAuth: true }
7
11
  };
@@ -27,6 +31,9 @@ function normalizeAudience(data, options) {
27
31
  const trustedOrganizationId = typeof options?.organizationId === "string" && options.organizationId.trim().length > 0 ? options.organizationId.trim() : null;
28
32
  if (hasTrustedScope) {
29
33
  if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId);
34
+ for (const organizationId of collectStringValues(options?.organizationIds)) {
35
+ organizationScopes.add(organizationId);
36
+ }
30
37
  } else {
31
38
  if (typeof data.organizationId === "string" && data.organizationId.trim().length > 0) {
32
39
  organizationScopes.add(data.organizationId.trim());
@@ -131,7 +138,7 @@ function ensureGlobalTapSubscription() {
131
138
  await broadcastEventToConnections(eventName, payload ?? {}, options);
132
139
  });
133
140
  registerCrossProcessEventListener(async (envelope) => {
134
- if (envelope.originPid === process.pid) return;
141
+ if (envelope.originInstanceId === CROSS_PROCESS_EVENT_INSTANCE_ID) return;
135
142
  await broadcastEventToConnections(
136
143
  envelope.event,
137
144
  envelope.payload ?? {},
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/events/api/stream/route.ts"],
4
- "sourcesContent": ["/**\n * SSE Event Stream \u2014 DOM Event Bridge\n *\n * Server-Sent Events endpoint that bridges server-side events to the browser.\n * Only events with `clientBroadcast: true` in their EventDefinition are sent.\n * Events are scoped to the authenticated user's tenant.\n *\n * Client consumer: `packages/ui/src/backend/injection/eventBridge.ts`\n */\n\nimport { resolveRequestContext } from '@open-mercato/shared/lib/api/context'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport { registerCrossProcessEventListener, registerGlobalEventTap } from '../../../../bus'\nimport type { EmitOptions } from '../../../../types'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst HEARTBEAT_INTERVAL_MS = 30_000\nconst MAX_PAYLOAD_BYTES = 4096\n\nconst logger = createLogger('events').child({ component: 'stream' })\n\ntype SseConnection = {\n tenantId: string\n organizationId: string | null\n userId: string\n roleIds: string[]\n send: (data: string) => void\n close: () => void\n}\n\nfunction collectStringValues(input: unknown): string[] {\n if (!Array.isArray(input)) return []\n const values: string[] = []\n for (const value of input) {\n if (typeof value !== 'string') continue\n const trimmed = value.trim()\n if (!trimmed) continue\n values.push(trimmed)\n }\n return values\n}\n\nfunction normalizeAudience(data: Record<string, unknown>, options?: EmitOptions): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const hasTrustedScope = options != null\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n const trustedTenantId = typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n ? options.tenantId.trim()\n : null\n const tenantId = hasTrustedScope\n ? trustedTenantId\n : (typeof data.tenantId === 'string' ? data.tenantId : null)\n const organizationScopes = new Set<string>()\n const trustedOrganizationId = typeof options?.organizationId === 'string' && options.organizationId.trim().length > 0\n ? options.organizationId.trim()\n : null\n if (hasTrustedScope) {\n if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)\n } else {\n if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {\n organizationScopes.add(data.organizationId.trim())\n }\n for (const organizationId of collectStringValues(data.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n }\n\n const recipientUserScopes = new Set<string>()\n if (typeof data.recipientUserId === 'string' && data.recipientUserId.trim().length > 0) {\n recipientUserScopes.add(data.recipientUserId.trim())\n }\n for (const userId of collectStringValues(data.recipientUserIds)) {\n recipientUserScopes.add(userId)\n }\n\n const recipientRoleScopes = new Set<string>()\n if (typeof data.recipientRoleId === 'string' && data.recipientRoleId.trim().length > 0) {\n recipientRoleScopes.add(data.recipientRoleId.trim())\n }\n for (const roleId of collectStringValues(data.recipientRoleIds)) {\n recipientRoleScopes.add(roleId)\n }\n\n return {\n tenantId,\n organizationScopes: Array.from(organizationScopes),\n recipientUserScopes: Array.from(recipientUserScopes),\n recipientRoleScopes: Array.from(recipientRoleScopes),\n }\n}\n\nfunction matchesAudience(conn: SseConnection, audience: ReturnType<typeof normalizeAudience>): boolean {\n if (!audience.tenantId) return false\n if (conn.tenantId !== audience.tenantId) return false\n\n if (audience.organizationScopes.length > 0) {\n if (!conn.organizationId) return false\n if (!audience.organizationScopes.includes(conn.organizationId)) return false\n }\n\n if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.userId)) {\n return false\n }\n\n if (audience.recipientRoleScopes.length > 0) {\n const roleMatched = conn.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId))\n if (!roleMatched) return false\n }\n\n return true\n}\n\n/**\n * Global connection registry.\n * All active SSE connections are tracked here.\n * The event bus subscriber iterates this set on each broadcast event.\n */\nconst connections = new Set<SseConnection>()\n\nlet globalTapRegistered = false\n\nasync function broadcastEventToConnections(\n eventName: string,\n payload: Record<string, unknown>,\n options?: EmitOptions,\n): Promise<void> {\n if (!eventName || connections.size === 0) return\n\n if (!isBroadcastEvent(eventName)) return\n\n const data = payload ?? {}\n const audience = normalizeAudience(data, options)\n\n const organizationId = audience.organizationScopes[0] ?? ''\n let ssePayload = buildSsePayload(eventName, data, organizationId)\n\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n ssePayload = buildTruncatedPayload(eventName, data, organizationId)\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes: MAX_PAYLOAD_BYTES })\n return\n }\n }\n\n for (const conn of connections) {\n if (!matchesAudience(conn, audience)) continue\n\n try {\n conn.send(ssePayload)\n } catch {\n // Connection may have been closed; cleanup happens via abort handler\n }\n }\n}\n\nfunction buildSsePayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n return JSON.stringify({\n id: eventName,\n payload: data,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\nfunction buildTruncatedPayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n const entityRef: Record<string, unknown> = {\n truncated: true,\n }\n if (typeof data.id === 'string' && data.id.trim().length > 0) {\n entityRef.id = data.id.trim()\n }\n if (typeof data.entityId === 'string' && data.entityId.trim().length > 0) {\n entityRef.entityId = data.entityId.trim()\n }\n if (typeof data.entityType === 'string' && data.entityType.trim().length > 0) {\n entityRef.entityType = data.entityType.trim()\n }\n return JSON.stringify({\n id: eventName,\n payload: entityRef,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\n/**\n * Ensure a process-wide event tap is registered (once).\n * This captures emits from all request-scoped EventBus instances.\n */\nfunction ensureGlobalTapSubscription(): void {\n if (globalTapRegistered) return\n globalTapRegistered = true\n\n registerGlobalEventTap(async (eventName, payload, options) => {\n await broadcastEventToConnections(eventName, (payload ?? {}) as Record<string, unknown>, options)\n })\n\n registerCrossProcessEventListener(async (envelope) => {\n if (envelope.originPid === process.pid) return\n await broadcastEventToConnections(\n envelope.event,\n (envelope.payload ?? {}) as Record<string, unknown>,\n envelope.options,\n )\n })\n}\n\nexport async function GET(req: Request): Promise<Response> {\n const { ctx } = await resolveRequestContext(req)\n\n if (!ctx.auth?.tenantId || !ctx.auth?.sub) {\n return new Response('Unauthorized', { status: 401 })\n }\n\n const tenantId = ctx.auth.tenantId\n const organizationId = (ctx.selectedOrganizationId as string) ?? ctx.auth.orgId ?? null\n const userId = ctx.auth.sub\n const roleIds = Array.isArray(ctx.auth.roles)\n ? ctx.auth.roles.filter((role): role is string => typeof role === 'string' && role.trim().length > 0)\n : []\n\n ensureGlobalTapSubscription()\n\n const encoder = new TextEncoder()\n let heartbeatTimer: ReturnType<typeof setInterval> | null = null\n let connection: SseConnection | null = null\n const onAbort = () => cleanup()\n\n const stream = new ReadableStream({\n start(controller) {\n const send = (data: string) => {\n controller.enqueue(encoder.encode(`data: ${data}\\n\\n`))\n }\n\n connection = {\n tenantId,\n organizationId,\n userId,\n roleIds,\n send,\n close: () => controller.close(),\n }\n connections.add(connection)\n\n // Flush an initial comment so the runtime sends the response headers and\n // first body byte immediately. Without it, the streamed Response stays\n // unflushed until the first heartbeat (30s) or matching event, which\n // delays the browser EventSource `open` event \u2014 clients that gate work on\n // a \"connected\" signal would otherwise stall for up to 30s after mount.\n // Comment lines (`:` prefix) are ignored by EventSource message parsing.\n controller.enqueue(encoder.encode(': connected\\n\\n'))\n\n // Start heartbeat to keep connection alive\n heartbeatTimer = setInterval(() => {\n try {\n controller.enqueue(encoder.encode(':heartbeat\\n\\n'))\n } catch {\n // Stream may have been closed\n }\n }, HEARTBEAT_INTERVAL_MS)\n },\n cancel() {\n cleanup()\n },\n })\n\n function cleanup() {\n if (heartbeatTimer) {\n clearInterval(heartbeatTimer)\n heartbeatTimer = null\n }\n if (connection) {\n connections.delete(connection)\n connection = null\n }\n // Detach from the request signal so reconnect churn does not accumulate\n // listeners and closures on long-lived AbortSignals.\n req.signal.removeEventListener('abort', onAbort)\n }\n\n req.signal.addEventListener('abort', onAbort, { once: true })\n\n return new Response(stream, {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n 'Connection': 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n })\n}\n\nexport const openApi = {\n GET: {\n summary: 'Subscribe to server events via SSE (DOM Event Bridge)',\n description: 'Long-lived SSE connection that receives server-side events marked with clientBroadcast: true. Events are server-filtered by tenant, organization, recipient user, and recipient role.',\n tags: ['Events'],\n responses: {\n 200: {\n description: 'Event stream (text/event-stream)',\n content: {\n 'text/event-stream': {\n schema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Event identifier (e.g., example.todo.created)' },\n payload: { type: 'object', description: 'Event-specific data' },\n timestamp: { type: 'number', description: 'Server timestamp (ms since epoch)' },\n organizationId: { type: 'string', description: 'Organization scope' },\n },\n },\n },\n },\n },\n 401: { description: 'Not authenticated' },\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * SSE Event Stream \u2014 DOM Event Bridge\n *\n * Server-Sent Events endpoint that bridges server-side events to the browser.\n * Only events with `clientBroadcast: true` in their EventDefinition are sent.\n * Events are scoped to the authenticated user's tenant.\n *\n * Client consumer: `packages/ui/src/backend/injection/eventBridge.ts`\n */\n\nimport { resolveRequestContext } from '@open-mercato/shared/lib/api/context'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isBroadcastEvent } from '@open-mercato/shared/modules/events'\nimport {\n CROSS_PROCESS_EVENT_INSTANCE_ID,\n registerCrossProcessEventListener,\n registerGlobalEventTap,\n} from '../../../../bus'\nimport type { EmitOptions } from '../../../../types'\n\nexport const metadata = {\n GET: { requireAuth: true },\n}\n\nconst HEARTBEAT_INTERVAL_MS = 30_000\nconst MAX_PAYLOAD_BYTES = 4096\n\nconst logger = createLogger('events').child({ component: 'stream' })\n\ntype SseConnection = {\n tenantId: string\n organizationId: string | null\n userId: string\n roleIds: string[]\n send: (data: string) => void\n close: () => void\n}\n\nfunction collectStringValues(input: unknown): string[] {\n if (!Array.isArray(input)) return []\n const values: string[] = []\n for (const value of input) {\n if (typeof value !== 'string') continue\n const trimmed = value.trim()\n if (!trimmed) continue\n values.push(trimmed)\n }\n return values\n}\n\nfunction normalizeAudience(data: Record<string, unknown>, options?: EmitOptions): {\n tenantId: string | null\n organizationScopes: string[]\n recipientUserScopes: string[]\n recipientRoleScopes: string[]\n} {\n const hasTrustedScope = options != null\n && Object.prototype.hasOwnProperty.call(options, 'tenantId')\n const trustedTenantId = typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0\n ? options.tenantId.trim()\n : null\n const tenantId = hasTrustedScope\n ? trustedTenantId\n : (typeof data.tenantId === 'string' ? data.tenantId : null)\n const organizationScopes = new Set<string>()\n const trustedOrganizationId = typeof options?.organizationId === 'string' && options.organizationId.trim().length > 0\n ? options.organizationId.trim()\n : null\n if (hasTrustedScope) {\n if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)\n for (const organizationId of collectStringValues(options?.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n } else {\n if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {\n organizationScopes.add(data.organizationId.trim())\n }\n for (const organizationId of collectStringValues(data.organizationIds)) {\n organizationScopes.add(organizationId)\n }\n }\n\n const recipientUserScopes = new Set<string>()\n if (typeof data.recipientUserId === 'string' && data.recipientUserId.trim().length > 0) {\n recipientUserScopes.add(data.recipientUserId.trim())\n }\n for (const userId of collectStringValues(data.recipientUserIds)) {\n recipientUserScopes.add(userId)\n }\n\n const recipientRoleScopes = new Set<string>()\n if (typeof data.recipientRoleId === 'string' && data.recipientRoleId.trim().length > 0) {\n recipientRoleScopes.add(data.recipientRoleId.trim())\n }\n for (const roleId of collectStringValues(data.recipientRoleIds)) {\n recipientRoleScopes.add(roleId)\n }\n\n return {\n tenantId,\n organizationScopes: Array.from(organizationScopes),\n recipientUserScopes: Array.from(recipientUserScopes),\n recipientRoleScopes: Array.from(recipientRoleScopes),\n }\n}\n\nfunction matchesAudience(conn: SseConnection, audience: ReturnType<typeof normalizeAudience>): boolean {\n if (!audience.tenantId) return false\n if (conn.tenantId !== audience.tenantId) return false\n\n if (audience.organizationScopes.length > 0) {\n if (!conn.organizationId) return false\n if (!audience.organizationScopes.includes(conn.organizationId)) return false\n }\n\n if (audience.recipientUserScopes.length > 0 && !audience.recipientUserScopes.includes(conn.userId)) {\n return false\n }\n\n if (audience.recipientRoleScopes.length > 0) {\n const roleMatched = conn.roleIds.some((roleId) => audience.recipientRoleScopes.includes(roleId))\n if (!roleMatched) return false\n }\n\n return true\n}\n\n/**\n * Global connection registry.\n * All active SSE connections are tracked here.\n * The event bus subscriber iterates this set on each broadcast event.\n */\nconst connections = new Set<SseConnection>()\n\nlet globalTapRegistered = false\n\nasync function broadcastEventToConnections(\n eventName: string,\n payload: Record<string, unknown>,\n options?: EmitOptions,\n): Promise<void> {\n if (!eventName || connections.size === 0) return\n\n if (!isBroadcastEvent(eventName)) return\n\n const data = payload ?? {}\n const audience = normalizeAudience(data, options)\n\n const organizationId = audience.organizationScopes[0] ?? ''\n let ssePayload = buildSsePayload(eventName, data, organizationId)\n\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n ssePayload = buildTruncatedPayload(eventName, data, organizationId)\n if (new TextEncoder().encode(ssePayload).length > MAX_PAYLOAD_BYTES) {\n logger.warn('Event payload exceeds size limit, skipping', { event: eventName, maxBytes: MAX_PAYLOAD_BYTES })\n return\n }\n }\n\n for (const conn of connections) {\n if (!matchesAudience(conn, audience)) continue\n\n try {\n conn.send(ssePayload)\n } catch {\n // Connection may have been closed; cleanup happens via abort handler\n }\n }\n}\n\nfunction buildSsePayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n return JSON.stringify({\n id: eventName,\n payload: data,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\nfunction buildTruncatedPayload(eventName: string, data: Record<string, unknown>, organizationId: string): string {\n const entityRef: Record<string, unknown> = {\n truncated: true,\n }\n if (typeof data.id === 'string' && data.id.trim().length > 0) {\n entityRef.id = data.id.trim()\n }\n if (typeof data.entityId === 'string' && data.entityId.trim().length > 0) {\n entityRef.entityId = data.entityId.trim()\n }\n if (typeof data.entityType === 'string' && data.entityType.trim().length > 0) {\n entityRef.entityType = data.entityType.trim()\n }\n return JSON.stringify({\n id: eventName,\n payload: entityRef,\n timestamp: Date.now(),\n organizationId,\n })\n}\n\n/**\n * Ensure a process-wide event tap is registered (once).\n * This captures emits from all request-scoped EventBus instances.\n */\nfunction ensureGlobalTapSubscription(): void {\n if (globalTapRegistered) return\n globalTapRegistered = true\n\n registerGlobalEventTap(async (eventName, payload, options) => {\n await broadcastEventToConnections(eventName, (payload ?? {}) as Record<string, unknown>, options)\n })\n\n registerCrossProcessEventListener(async (envelope) => {\n // Every envelope this process publishes carries originInstanceId, so an\n // envelope without one can only come from an older process during a\n // rolling deploy and is always foreign. Falling back to originPid would\n // discard it whenever both containers run under the same pid \u2014 commonly\n // pid 1 \u2014 silently dropping browser events across the upgrade window.\n if (envelope.originInstanceId === CROSS_PROCESS_EVENT_INSTANCE_ID) return\n await broadcastEventToConnections(\n envelope.event,\n (envelope.payload ?? {}) as Record<string, unknown>,\n envelope.options,\n )\n })\n}\n\nexport async function GET(req: Request): Promise<Response> {\n const { ctx } = await resolveRequestContext(req)\n\n if (!ctx.auth?.tenantId || !ctx.auth?.sub) {\n return new Response('Unauthorized', { status: 401 })\n }\n\n const tenantId = ctx.auth.tenantId\n const organizationId = (ctx.selectedOrganizationId as string) ?? ctx.auth.orgId ?? null\n const userId = ctx.auth.sub\n const roleIds = Array.isArray(ctx.auth.roles)\n ? ctx.auth.roles.filter((role): role is string => typeof role === 'string' && role.trim().length > 0)\n : []\n\n ensureGlobalTapSubscription()\n\n const encoder = new TextEncoder()\n let heartbeatTimer: ReturnType<typeof setInterval> | null = null\n let connection: SseConnection | null = null\n const onAbort = () => cleanup()\n\n const stream = new ReadableStream({\n start(controller) {\n const send = (data: string) => {\n controller.enqueue(encoder.encode(`data: ${data}\\n\\n`))\n }\n\n connection = {\n tenantId,\n organizationId,\n userId,\n roleIds,\n send,\n close: () => controller.close(),\n }\n connections.add(connection)\n\n // Flush an initial comment so the runtime sends the response headers and\n // first body byte immediately. Without it, the streamed Response stays\n // unflushed until the first heartbeat (30s) or matching event, which\n // delays the browser EventSource `open` event \u2014 clients that gate work on\n // a \"connected\" signal would otherwise stall for up to 30s after mount.\n // Comment lines (`:` prefix) are ignored by EventSource message parsing.\n controller.enqueue(encoder.encode(': connected\\n\\n'))\n\n // Start heartbeat to keep connection alive\n heartbeatTimer = setInterval(() => {\n try {\n controller.enqueue(encoder.encode(':heartbeat\\n\\n'))\n } catch {\n // Stream may have been closed\n }\n }, HEARTBEAT_INTERVAL_MS)\n },\n cancel() {\n cleanup()\n },\n })\n\n function cleanup() {\n if (heartbeatTimer) {\n clearInterval(heartbeatTimer)\n heartbeatTimer = null\n }\n if (connection) {\n connections.delete(connection)\n connection = null\n }\n // Detach from the request signal so reconnect churn does not accumulate\n // listeners and closures on long-lived AbortSignals.\n req.signal.removeEventListener('abort', onAbort)\n }\n\n req.signal.addEventListener('abort', onAbort, { once: true })\n\n return new Response(stream, {\n status: 200,\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache, no-transform',\n 'Connection': 'keep-alive',\n 'X-Accel-Buffering': 'no',\n },\n })\n}\n\nexport const openApi = {\n GET: {\n summary: 'Subscribe to server events via SSE (DOM Event Bridge)',\n description: 'Long-lived SSE connection that receives server-side events marked with clientBroadcast: true. Events are server-filtered by tenant, organization, recipient user, and recipient role.',\n tags: ['Events'],\n responses: {\n 200: {\n description: 'Event stream (text/event-stream)',\n content: {\n 'text/event-stream': {\n schema: {\n type: 'object',\n properties: {\n id: { type: 'string', description: 'Event identifier (e.g., example.todo.created)' },\n payload: { type: 'object', description: 'Event-specific data' },\n timestamp: { type: 'number', description: 'Server timestamp (ms since epoch)' },\n organizationId: { type: 'string', description: 'Organization scope' },\n },\n },\n },\n },\n },\n 401: { description: 'Not authenticated' },\n },\n },\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/events",
3
- "version": "0.7.0",
3
+ "version": "0.7.1-develop.7102.1.b41f7e3e51",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -33,8 +33,8 @@
33
33
  }
34
34
  },
35
35
  "dependencies": {
36
- "@open-mercato/queue": "0.7.0",
37
- "@open-mercato/shared": "0.7.0",
36
+ "@open-mercato/queue": "0.7.1-develop.7102.1.b41f7e3e51",
37
+ "@open-mercato/shared": "0.7.1-develop.7102.1.b41f7e3e51",
38
38
  "pg": "8.22.0"
39
39
  },
40
40
  "devDependencies": {
@@ -50,5 +50,6 @@
50
50
  "type": "git",
51
51
  "url": "https://github.com/open-mercato/open-mercato",
52
52
  "directory": "packages/events"
53
- }
53
+ },
54
+ "stableVersion": "0.7.0"
54
55
  }
@@ -0,0 +1,170 @@
1
+ const publishCrossProcessEventMock = jest.fn(async () => undefined)
2
+
3
+ jest.mock('../bridge', () => ({
4
+ publishCrossProcessEvent: (...args: unknown[]) => publishCrossProcessEventMock(...args),
5
+ registerCrossProcessEventListener: jest.fn(),
6
+ CROSS_PROCESS_EVENT_INSTANCE_ID: 'test-instance',
7
+ }))
8
+
9
+ import { createModuleEvents, setGlobalEventBus } from '@open-mercato/shared/modules/events'
10
+ import { createEventBus } from '@open-mercato/events/index'
11
+
12
+ const testEventsConfig = createModuleEvents({
13
+ moduleId: 'cross_process_test',
14
+ events: [
15
+ { id: 'cross_process_test.browser', label: 'Browser', clientBroadcast: true },
16
+ { id: 'cross_process_test.private', label: 'Private', crossProcessBroadcast: true },
17
+ { id: 'cross_process_test.local', label: 'Local' },
18
+ ] as const,
19
+ })
20
+
21
+ describe('cross-process event publication', () => {
22
+ const resolve = ((name: string) => name) as never
23
+
24
+ beforeEach(() => {
25
+ publishCrossProcessEventMock.mockClear()
26
+ })
27
+
28
+ it('publishes browser and private cross-process events without changing local events', async () => {
29
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
30
+ const payload = { id: 'record-1', tenantId: 'tenant-1', organizationId: 'org-1' }
31
+ const trustedScope = { tenantId: 'tenant-1', organizationId: 'org-1' }
32
+ const privateTrustedScope = {
33
+ ...trustedScope,
34
+ emitterModuleId: 'cross_process_test',
35
+ }
36
+
37
+ await bus.emit('cross_process_test.browser', payload, trustedScope)
38
+ await bus.emit('cross_process_test.private', payload, privateTrustedScope)
39
+ await bus.emit('cross_process_test.local', payload)
40
+
41
+ expect(publishCrossProcessEventMock).toHaveBeenCalledTimes(2)
42
+ expect(publishCrossProcessEventMock).toHaveBeenNthCalledWith(
43
+ 1,
44
+ 'cross_process_test.browser',
45
+ payload,
46
+ trustedScope,
47
+ )
48
+ expect(publishCrossProcessEventMock).toHaveBeenNthCalledWith(
49
+ 2,
50
+ 'cross_process_test.private',
51
+ payload,
52
+ privateTrustedScope,
53
+ )
54
+ })
55
+
56
+ it('does not treat caller-controlled payload scope as trusted bridge scope', async () => {
57
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
58
+
59
+ await bus.emit('cross_process_test.private', {
60
+ id: 'record-1',
61
+ tenantId: 'payload-tenant',
62
+ organizationId: 'payload-org',
63
+ })
64
+
65
+ expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
66
+ })
67
+
68
+ it('preserves raw EventBus cross-process delivery for declared browser events', async () => {
69
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
70
+ const payload = {
71
+ id: 'record-1',
72
+ tenantId: ' legacy-tenant ',
73
+ organizationId: ' legacy-org ',
74
+ }
75
+
76
+ await bus.emit('cross_process_test.browser', payload)
77
+
78
+ expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
79
+ 'cross_process_test.browser',
80
+ payload,
81
+ { tenantId: 'legacy-tenant', organizationId: 'legacy-org' },
82
+ )
83
+ })
84
+
85
+ it('prefers trusted workflow scope over browser-event payload scope', async () => {
86
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
87
+ const trustedScope = { tenantId: 'workflow-tenant', organizationId: 'workflow-org' }
88
+ const payload = {
89
+ id: 'record-1',
90
+ tenantId: 'forged-tenant',
91
+ organizationId: 'forged-org',
92
+ }
93
+
94
+ await bus.emit('cross_process_test.browser', payload, trustedScope)
95
+
96
+ expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
97
+ 'cross_process_test.browser',
98
+ payload,
99
+ trustedScope,
100
+ )
101
+ })
102
+
103
+ it('publishes from trusted options even when the payload has no scope', async () => {
104
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
105
+
106
+ await bus.emit(
107
+ 'cross_process_test.private',
108
+ { id: 'record-1' },
109
+ {
110
+ tenantId: 'trusted-tenant',
111
+ organizationId: 'trusted-org',
112
+ emitterModuleId: 'cross_process_test',
113
+ },
114
+ )
115
+
116
+ expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
117
+ 'cross_process_test.private',
118
+ { id: 'record-1' },
119
+ {
120
+ tenantId: 'trusted-tenant',
121
+ organizationId: 'trusted-org',
122
+ emitterModuleId: 'cross_process_test',
123
+ },
124
+ )
125
+ })
126
+
127
+ it('rejects private-event scope without declaring-module provenance', async () => {
128
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
129
+ const scope = { tenantId: 'tenant-1', organizationId: 'org-1' }
130
+
131
+ await bus.emit('cross_process_test.private', { id: 'record-1' }, scope)
132
+ await bus.emit('cross_process_test.private', { id: 'record-1' }, {
133
+ ...scope,
134
+ emitterModuleId: 'workflows',
135
+ })
136
+
137
+ expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
138
+ })
139
+
140
+ it('preserves typed browser-event scope at the trusted module boundary', async () => {
141
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
142
+ setGlobalEventBus(bus)
143
+ const payload = { id: 'record-1', tenantId: 'tenant-1', organizationId: 'org-1' }
144
+
145
+ await testEventsConfig.emit('cross_process_test.browser', payload)
146
+
147
+ expect(publishCrossProcessEventMock).toHaveBeenCalledWith(
148
+ 'cross_process_test.browser',
149
+ payload,
150
+ {
151
+ tenantId: 'tenant-1',
152
+ organizationId: 'org-1',
153
+ emitterModuleId: 'cross_process_test',
154
+ },
155
+ )
156
+ })
157
+
158
+ it('does not promote private-event payload scope at the module boundary', async () => {
159
+ const bus = createEventBus({ resolve, queueStrategy: 'local' })
160
+ setGlobalEventBus(bus)
161
+
162
+ await testEventsConfig.emit('cross_process_test.private', {
163
+ id: 'record-1',
164
+ tenantId: 'payload-tenant',
165
+ organizationId: 'payload-org',
166
+ })
167
+
168
+ expect(publishCrossProcessEventMock).not.toHaveBeenCalled()
169
+ })
170
+ })
@@ -0,0 +1,31 @@
1
+ const INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'
2
+
3
+ describe('cross-process bridge instance identity', () => {
4
+ const globalScope = globalThis as Record<string, unknown>
5
+ const originalInstanceId = globalScope[INSTANCE_ID_KEY]
6
+
7
+ beforeEach(() => {
8
+ delete globalScope[INSTANCE_ID_KEY]
9
+ jest.resetModules()
10
+ })
11
+
12
+ afterAll(() => {
13
+ if (originalInstanceId === undefined) delete globalScope[INSTANCE_ID_KEY]
14
+ else globalScope[INSTANCE_ID_KEY] = originalInstanceId
15
+ })
16
+
17
+ it('reuses one process identity across isolated module copies', () => {
18
+ let first: string | undefined
19
+ let second: string | undefined
20
+
21
+ jest.isolateModules(() => {
22
+ first = require('../bridge').CROSS_PROCESS_EVENT_INSTANCE_ID as string
23
+ })
24
+ jest.isolateModules(() => {
25
+ second = require('../bridge').CROSS_PROCESS_EVENT_INSTANCE_ID as string
26
+ })
27
+
28
+ expect(first).toEqual(expect.any(String))
29
+ expect(second).toBe(first)
30
+ })
31
+ })
package/src/bridge.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto'
1
2
  import { Client, Pool } from 'pg'
2
3
  import { getSslConfig } from '@open-mercato/shared/lib/db/ssl'
3
4
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -6,14 +7,33 @@ import type { EmitOptions, EventPayload } from './types'
6
7
  const BRIDGE_CHANNEL = 'om_event_bridge'
7
8
  const MAX_MESSAGE_BYTES = 7_000
8
9
  const RECONNECT_DELAY_MS = 1_000
10
+ const CROSS_PROCESS_EVENT_INSTANCE_ID_KEY = '__openMercatoCrossProcessEventInstanceId__'
9
11
 
10
12
  const logger = createLogger('events')
11
13
 
14
+ /**
15
+ * PIDs can collide across containers, so self-echo suppression must not rely
16
+ * on originPid alone. Every publisher stamps envelopes with this random
17
+ * per-process instance id; consumers prefer it and fall back to originPid
18
+ * only for envelopes published by older processes during a rolling deploy.
19
+ */
20
+ function getCrossProcessEventInstanceId(): string {
21
+ const globalScope = globalThis as Record<string, unknown>
22
+ const existing = globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY]
23
+ if (typeof existing === 'string' && existing.length > 0) return existing
24
+ const created = randomUUID()
25
+ globalScope[CROSS_PROCESS_EVENT_INSTANCE_ID_KEY] = created
26
+ return created
27
+ }
28
+
29
+ export const CROSS_PROCESS_EVENT_INSTANCE_ID = getCrossProcessEventInstanceId()
30
+
12
31
  type BridgeEnvelope = {
13
32
  event: string
14
33
  payload: EventPayload
15
34
  options?: EmitOptions
16
35
  originPid: number
36
+ originInstanceId?: string
17
37
  }
18
38
 
19
39
  type CrossProcessEventListener = (envelope: BridgeEnvelope) => void | Promise<void>
@@ -169,6 +189,7 @@ export async function publishCrossProcessEvent(
169
189
  payload,
170
190
  options: bridgeOptions,
171
191
  originPid: process.pid,
192
+ originInstanceId: CROSS_PROCESS_EVENT_INSTANCE_ID,
172
193
  }
173
194
 
174
195
  const serialized = JSON.stringify(envelope)
package/src/bus.ts CHANGED
@@ -5,12 +5,16 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
5
5
  import { isSingleDeliveryRequested } from './single-delivery'
6
6
  import { matchEventPattern } from '@open-mercato/shared/lib/events/patterns'
7
7
  import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
8
- import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
8
+ import {
9
+ isBroadcastEvent,
10
+ isCrossProcessBroadcastEvent,
11
+ isPrivateCrossProcessEventEmitter,
12
+ } from '@open-mercato/shared/modules/events'
9
13
  import {
10
14
  inferModuleIdFromResourceId,
11
15
  withModuleResourceUsage,
12
16
  } from '@open-mercato/shared/lib/modules/resource-usage'
13
- export { registerCrossProcessEventListener } from './bridge'
17
+ export { registerCrossProcessEventListener, CROSS_PROCESS_EVENT_INSTANCE_ID } from './bridge'
14
18
  import { publishCrossProcessEvent } from './bridge'
15
19
  import type {
16
20
  EventBus,
@@ -57,9 +61,38 @@ function isSingleDeliveryEnabled(): boolean {
57
61
  type GlobalEventTap = (event: string, payload: EventPayload, options?: EmitOptions) => void | Promise<void>
58
62
  const GLOBAL_EVENT_TAPS_KEY = '__openMercatoEventBusGlobalTaps__'
59
63
 
60
- function hasTenantScope(payload: EventPayload): boolean {
61
- return typeof (payload as Record<string, unknown>)?.tenantId === 'string'
62
- && String((payload as Record<string, unknown>).tenantId).trim().length > 0
64
+ function hasTrustedTenantScope(options?: EmitOptions): boolean {
65
+ return typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0
66
+ }
67
+
68
+ function normalizePayloadScope(value: unknown): string | null {
69
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
70
+ }
71
+
72
+ function resolveCrossProcessEmitOptions(
73
+ event: string,
74
+ payload: EventPayload,
75
+ options?: EmitOptions,
76
+ ): EmitOptions | undefined {
77
+ if (hasTrustedTenantScope(options)) return options
78
+ // Preserve the public raw EventBus contract for legacy clientBroadcast
79
+ // emitters. These callers are trusted server code and historically supplied
80
+ // their scope in the typed payload. Tenant-managed workflow execution passes
81
+ // scope in options, while private cross-process events never take this path.
82
+ if (!isBroadcastEvent(event)) return options
83
+ const tenantId = normalizePayloadScope((payload as Record<string, unknown>)?.tenantId)
84
+ if (!tenantId) return options
85
+ const organizationId = normalizePayloadScope((payload as Record<string, unknown>)?.organizationId)
86
+ const organizationIdsValue = (payload as Record<string, unknown>)?.organizationIds
87
+ const organizationIds = Array.isArray(organizationIdsValue)
88
+ ? organizationIdsValue.filter((value): value is string => typeof value === 'string')
89
+ : []
90
+ return {
91
+ ...options,
92
+ tenantId,
93
+ ...(organizationId ? { organizationId } : {}),
94
+ ...(organizationIds.length > 0 ? { organizationIds } : {}),
95
+ }
63
96
  }
64
97
 
65
98
  function getGlobalEventTaps(): Set<GlobalEventTap> {
@@ -435,9 +468,18 @@ export function createEventBus(opts: CreateBusOptions): EventBus {
435
468
  inlinePersistentFailed = delivered.persistentFailures > 0
436
469
  }
437
470
 
438
- if (isBroadcastEvent(event) && hasTenantScope(payload)) {
471
+ // Private coordination always requires trusted envelope scope and module
472
+ // provenance. Legacy declared browser events may promote their typed scope
473
+ // for raw EventBus compatibility; tenant-managed workflow adapters pass
474
+ // authoritative scope in options, which always wins over payload values.
475
+ const crossProcessOptions = resolveCrossProcessEmitOptions(event, payload, options)
476
+ if (
477
+ isCrossProcessBroadcastEvent(event)
478
+ && hasTrustedTenantScope(crossProcessOptions)
479
+ && isPrivateCrossProcessEventEmitter(event, crossProcessOptions?.emitterModuleId)
480
+ ) {
439
481
  try {
440
- await publishCrossProcessEvent(event, payload, options)
482
+ await publishCrossProcessEvent(event, payload, crossProcessOptions)
441
483
  } catch (error) {
442
484
  logger.error('Cross-process publish error', { event, err: error })
443
485
  }
@@ -2,8 +2,8 @@
2
2
  * Events registry API — returns declared events from module `events.ts` files.
3
3
  *
4
4
  * Uses the globally registered event configs (populated during bootstrap). Data
5
- * leaked here (event ids, module ids, entity ids, clientBroadcast/portalBroadcast
6
- * flags) is recon-grade and MUST NOT be served to anonymous callers; the route
5
+ * leaked here (event ids, module ids, entity ids, broadcast flags) is
6
+ * recon-grade and MUST NOT be served to anonymous callers; the route
7
7
  * is protected by the standard `[...slug]` dispatcher with `requireAuth: true`.
8
8
  * Consumer: `packages/ui/src/backend/inputs/EventSelect.tsx` (workflow triggers,
9
9
  * business rules, webhook config).
@@ -27,6 +27,7 @@ const eventDefinitionSchema = z.object({
27
27
  entity: z.string().optional(),
28
28
  excludeFromTriggers: z.boolean().optional(),
29
29
  clientBroadcast: z.boolean().optional(),
30
+ crossProcessBroadcast: z.boolean().optional(),
30
31
  portalBroadcast: z.boolean().optional(),
31
32
  })
32
33
 
@@ -11,6 +11,7 @@ jest.mock('@open-mercato/shared/lib/api/context', () => ({
11
11
  type EmitOptions = {
12
12
  tenantId?: string | null
13
13
  organizationId?: string | null
14
+ organizationIds?: string[] | null
14
15
  }
15
16
 
16
17
  type GlobalEventTap = (
@@ -21,19 +22,36 @@ type GlobalEventTap = (
21
22
 
22
23
  let mockGlobalEventTap: GlobalEventTap | undefined
23
24
 
24
- jest.mock('../../../../../bus', () => ({
25
- registerGlobalEventTap: jest.fn((handler: GlobalEventTap) => {
26
- mockGlobalEventTap = handler
27
- }),
28
- registerCrossProcessEventListener: jest.fn(),
29
- }))
25
+ const registerGlobalEventTapMock = jest.fn((handler: GlobalEventTap) => {
26
+ mockGlobalEventTap = handler
27
+ })
28
+ const registerCrossProcessEventListenerMock = jest.fn()
30
29
 
31
- jest.mock('@open-mercato/shared/modules/events', () => ({
32
- isBroadcastEvent: jest.fn(() => true),
30
+ jest.mock('../../../../../bus', () => ({
31
+ registerGlobalEventTap: (handler: GlobalEventTap) => registerGlobalEventTapMock(handler),
32
+ registerCrossProcessEventListener: (...args: unknown[]) => registerCrossProcessEventListenerMock(...args),
33
+ CROSS_PROCESS_EVENT_INSTANCE_ID: 'web-instance',
33
34
  }))
34
35
 
36
+ import { createModuleEvents } from '@open-mercato/shared/modules/events'
35
37
  import { GET } from '@open-mercato/events/modules/events/api/stream/route'
36
38
 
39
+ createModuleEvents({
40
+ moduleId: 'stream_privacy_test',
41
+ events: [
42
+ {
43
+ id: 'stream_privacy_test.browser',
44
+ label: 'Browser event',
45
+ clientBroadcast: true,
46
+ },
47
+ {
48
+ id: 'stream_privacy_test.private',
49
+ label: 'Private cross-process invalidation',
50
+ crossProcessBroadcast: true,
51
+ },
52
+ ] as const,
53
+ })
54
+
37
55
  // req.signal is a linked/derived signal in Node, so we spy AFTER the
38
56
  // Request is constructed to intercept the handler's real calls.
39
57
  function makeTrackedRequest() {
@@ -114,12 +132,12 @@ describe('SSE event stream — abort listener hygiene', () => {
114
132
 
115
133
  expect(mockGlobalEventTap).toBeDefined()
116
134
  await mockGlobalEventTap?.(
117
- 'progress.job.updated',
135
+ 'stream_privacy_test.browser',
118
136
  { tenantId: 't1', marker: 'must-not-arrive' },
119
137
  { tenantId: 't1', organizationId: 'o2' },
120
138
  )
121
139
  await mockGlobalEventTap?.(
122
- 'progress.job.updated',
140
+ 'stream_privacy_test.browser',
123
141
  { tenantId: 't1', marker: 'expected' },
124
142
  { tenantId: 't1', organizationId: 'o1' },
125
143
  )
@@ -131,6 +149,124 @@ describe('SSE event stream — abort listener hygiene', () => {
131
149
  try { await reader.cancel() } catch {}
132
150
  })
133
151
 
152
+ it('honors a trusted multi-organization audience for a clientBroadcast event', async () => {
153
+ const { req } = makeTrackedRequest()
154
+ const res = await GET(req)
155
+ expect(res.status).toBe(200)
156
+
157
+ const reader = (res.body as ReadableStream<Uint8Array>).getReader()
158
+ await reader.read()
159
+
160
+ expect(mockGlobalEventTap).toBeDefined()
161
+ // Connection is scoped to org o1; an audience array that omits it must not deliver.
162
+ await mockGlobalEventTap?.(
163
+ 'stream_privacy_test.browser',
164
+ { tenantId: 't1', marker: 'must-not-arrive' },
165
+ { tenantId: 't1', organizationIds: ['o2', 'o3'] },
166
+ )
167
+ // An audience array that includes o1 must deliver, even without a singular organizationId.
168
+ await mockGlobalEventTap?.(
169
+ 'stream_privacy_test.browser',
170
+ { tenantId: 't1', marker: 'expected' },
171
+ { tenantId: 't1', organizationIds: ['o1', 'o2'] },
172
+ )
173
+
174
+ const { value, done } = await reader.read()
175
+ expect(done).toBe(false)
176
+ expect(new TextDecoder().decode(value)).toContain('"marker":"expected"')
177
+
178
+ try { await reader.cancel() } catch {}
179
+ })
180
+
181
+ it('does not deliver a private cross-process event to a same-organization browser', async () => {
182
+ const { req } = makeTrackedRequest()
183
+ const res = await GET(req)
184
+ const reader = (res.body as ReadableStream<Uint8Array>).getReader()
185
+ await reader.read()
186
+
187
+ const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
188
+ | ((envelope: Record<string, unknown>) => Promise<void>)
189
+ | undefined
190
+ expect(listener).toBeDefined()
191
+
192
+ await listener?.({
193
+ event: 'stream_privacy_test.private',
194
+ payload: {
195
+ id: 'private-record',
196
+ tenantId: 't1',
197
+ organizationId: 'o1',
198
+ },
199
+ originPid: process.pid + 1,
200
+ originInstanceId: 'other-instance',
201
+ })
202
+
203
+ const pendingRead = reader.read().then(() => 'delivered')
204
+ const result = await Promise.race([
205
+ pendingRead,
206
+ new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
207
+ ])
208
+ expect(result).toBe('not-delivered')
209
+
210
+ try { await reader.cancel() } catch {}
211
+ })
212
+
213
+ it('does not let a forged global-tap payload override trusted tenant and organization scope', async () => {
214
+ const { req } = makeTrackedRequest()
215
+ const res = await GET(req)
216
+ const reader = (res.body as ReadableStream<Uint8Array>).getReader()
217
+ await reader.read()
218
+
219
+ const tap = registerGlobalEventTapMock.mock.calls[0]?.[0] as
220
+ | ((
221
+ event: string,
222
+ payload: Record<string, unknown>,
223
+ options?: { tenantId?: string | null; organizationId?: string | null },
224
+ ) => Promise<void>)
225
+ | undefined
226
+ expect(tap).toBeDefined()
227
+
228
+ await tap?.(
229
+ 'stream_privacy_test.browser',
230
+ { tenantId: 't1', organizationId: 'o1', marker: 'forged-payload' },
231
+ { tenantId: 'attacker-tenant', organizationId: 'attacker-org' },
232
+ )
233
+
234
+ const pendingRead = reader.read().then(() => 'delivered')
235
+ const result = await Promise.race([
236
+ pendingRead,
237
+ new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
238
+ ])
239
+ expect(result).toBe('not-delivered')
240
+
241
+ try { await reader.cancel() } catch {}
242
+ })
243
+
244
+ it('delivers a global-tap event when trusted scope matches despite forged payload scope', async () => {
245
+ const { req } = makeTrackedRequest()
246
+ const res = await GET(req)
247
+ const reader = (res.body as ReadableStream<Uint8Array>).getReader()
248
+ await reader.read()
249
+
250
+ const tap = registerGlobalEventTapMock.mock.calls[0]?.[0] as
251
+ | ((
252
+ event: string,
253
+ payload: Record<string, unknown>,
254
+ options?: { tenantId?: string | null; organizationId?: string | null },
255
+ ) => Promise<void>)
256
+ | undefined
257
+
258
+ await tap?.(
259
+ 'stream_privacy_test.browser',
260
+ { tenantId: 'forged-tenant', organizationId: 'forged-org', marker: 'trusted-delivery' },
261
+ { tenantId: 't1', organizationId: 'o1' },
262
+ )
263
+
264
+ const delivered = await reader.read()
265
+ expect(new TextDecoder().decode(delivered.value)).toContain('trusted-delivery')
266
+
267
+ try { await reader.cancel() } catch {}
268
+ })
269
+
134
270
  it('ignores conflicting payload scope when trusted scope matches the connection', async () => {
135
271
  const { req } = makeTrackedRequest()
136
272
  const res = await GET(req)
@@ -141,7 +277,7 @@ describe('SSE event stream — abort listener hygiene', () => {
141
277
 
142
278
  expect(mockGlobalEventTap).toBeDefined()
143
279
  await mockGlobalEventTap?.(
144
- 'progress.job.updated',
280
+ 'stream_privacy_test.browser',
145
281
  { tenantId: 'forged-tenant', organizationId: 'forged-organization', marker: 'expected' },
146
282
  { tenantId: 't1', organizationId: 'o1' },
147
283
  )
@@ -153,6 +289,35 @@ describe('SSE event stream — abort listener hygiene', () => {
153
289
  try { await reader.cancel() } catch {}
154
290
  })
155
291
 
292
+ it('does not let a forged cross-process payload override trusted envelope scope', async () => {
293
+ const { req } = makeTrackedRequest()
294
+ const res = await GET(req)
295
+ const reader = (res.body as ReadableStream<Uint8Array>).getReader()
296
+ await reader.read()
297
+
298
+ const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
299
+ | ((envelope: Record<string, unknown>) => Promise<void>)
300
+ | undefined
301
+ expect(listener).toBeDefined()
302
+
303
+ await listener?.({
304
+ event: 'stream_privacy_test.browser',
305
+ payload: { tenantId: 't1', organizationId: 'o1', marker: 'forged-payload' },
306
+ options: { tenantId: 'attacker-tenant', organizationId: 'attacker-org' },
307
+ originPid: process.pid + 1,
308
+ originInstanceId: 'other-instance',
309
+ })
310
+
311
+ const pendingRead = reader.read().then(() => 'delivered')
312
+ const result = await Promise.race([
313
+ pendingRead,
314
+ new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
315
+ ])
316
+ expect(result).toBe('not-delivered')
317
+
318
+ try { await reader.cancel() } catch {}
319
+ })
320
+
156
321
  it('preserves payload-authored scope for legacy emitters without a trusted scope marker', async () => {
157
322
  const { req } = makeTrackedRequest()
158
323
  const res = await GET(req)
@@ -163,7 +328,7 @@ describe('SSE event stream — abort listener hygiene', () => {
163
328
 
164
329
  expect(mockGlobalEventTap).toBeDefined()
165
330
  await mockGlobalEventTap?.(
166
- 'progress.job.updated',
331
+ 'stream_privacy_test.browser',
167
332
  { tenantId: 't1', organizationId: 'o1', marker: 'legacy-expected' },
168
333
  )
169
334
 
@@ -174,6 +339,88 @@ describe('SSE event stream — abort listener hygiene', () => {
174
339
  try { await reader.cancel() } catch {}
175
340
  })
176
341
 
342
+ it('delivers a cross-process event when trusted envelope scope matches despite forged payload scope', async () => {
343
+ const { req } = makeTrackedRequest()
344
+ const res = await GET(req)
345
+ const reader = (res.body as ReadableStream<Uint8Array>).getReader()
346
+ await reader.read()
347
+
348
+ const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
349
+ | ((envelope: Record<string, unknown>) => Promise<void>)
350
+ | undefined
351
+
352
+ await listener?.({
353
+ event: 'stream_privacy_test.browser',
354
+ payload: { tenantId: 'forged-tenant', organizationId: 'forged-org', marker: 'trusted-envelope' },
355
+ options: { tenantId: 't1', organizationId: 'o1' },
356
+ originPid: process.pid + 1,
357
+ originInstanceId: 'other-instance',
358
+ })
359
+
360
+ const delivered = await reader.read()
361
+ expect(new TextDecoder().decode(delivered.value)).toContain('trusted-envelope')
362
+
363
+ try { await reader.cancel() } catch {}
364
+ })
365
+
366
+ it('delivers a rolling-deploy envelope that omits the instance id and shares this pid', async () => {
367
+ const { req } = makeTrackedRequest()
368
+ const res = await GET(req)
369
+ const reader = (res.body as ReadableStream<Uint8Array>).getReader()
370
+ await reader.read()
371
+
372
+ const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
373
+ | ((envelope: Record<string, unknown>) => Promise<void>)
374
+ | undefined
375
+ expect(listener).toBeDefined()
376
+
377
+ // Containers commonly run as pid 1, so an older replica publishing without
378
+ // an instance id must not be mistaken for this process.
379
+ await listener?.({
380
+ event: 'stream_privacy_test.browser',
381
+ payload: { marker: 'legacy-replica' },
382
+ options: { tenantId: 't1', organizationId: 'o1' },
383
+ originPid: process.pid,
384
+ })
385
+
386
+ const pendingRead = reader.read().then((chunk) => new TextDecoder().decode(chunk.value))
387
+ const result = await Promise.race([
388
+ pendingRead,
389
+ new Promise<'dropped'>((resolve) => setTimeout(() => resolve('dropped'), 50)),
390
+ ])
391
+ expect(result).toContain('legacy-replica')
392
+
393
+ try { await reader.cancel() } catch {}
394
+ })
395
+
396
+ it('suppresses an envelope published by this instance even when the pid differs', async () => {
397
+ const { req } = makeTrackedRequest()
398
+ const res = await GET(req)
399
+ const reader = (res.body as ReadableStream<Uint8Array>).getReader()
400
+ await reader.read()
401
+
402
+ const listener = registerCrossProcessEventListenerMock.mock.calls[0]?.[0] as
403
+ | ((envelope: Record<string, unknown>) => Promise<void>)
404
+ | undefined
405
+
406
+ await listener?.({
407
+ event: 'stream_privacy_test.browser',
408
+ payload: { marker: 'self-echo' },
409
+ options: { tenantId: 't1', organizationId: 'o1' },
410
+ originPid: process.pid + 1,
411
+ originInstanceId: 'web-instance',
412
+ })
413
+
414
+ const pendingRead = reader.read().then(() => 'delivered')
415
+ const result = await Promise.race([
416
+ pendingRead,
417
+ new Promise<'not-delivered'>((resolve) => setTimeout(() => resolve('not-delivered'), 10)),
418
+ ])
419
+ expect(result).toBe('not-delivered')
420
+
421
+ try { await reader.cancel() } catch {}
422
+ })
423
+
177
424
  it('does not fall back to payload scope when the trusted tenant marker is empty', async () => {
178
425
  const { req } = makeTrackedRequest()
179
426
  const res = await GET(req)
@@ -184,12 +431,12 @@ describe('SSE event stream — abort listener hygiene', () => {
184
431
 
185
432
  expect(mockGlobalEventTap).toBeDefined()
186
433
  await mockGlobalEventTap?.(
187
- 'progress.job.updated',
434
+ 'stream_privacy_test.browser',
188
435
  { tenantId: 't1', organizationId: 'o1', marker: 'must-not-arrive' },
189
436
  { tenantId: null, organizationId: null },
190
437
  )
191
438
  await mockGlobalEventTap?.(
192
- 'progress.job.updated',
439
+ 'stream_privacy_test.browser',
193
440
  { marker: 'expected' },
194
441
  { tenantId: 't1', organizationId: 'o1' },
195
442
  )
@@ -11,7 +11,11 @@
11
11
  import { resolveRequestContext } from '@open-mercato/shared/lib/api/context'
12
12
  import { createLogger } from '@open-mercato/shared/lib/logger'
13
13
  import { isBroadcastEvent } from '@open-mercato/shared/modules/events'
14
- import { registerCrossProcessEventListener, registerGlobalEventTap } from '../../../../bus'
14
+ import {
15
+ CROSS_PROCESS_EVENT_INSTANCE_ID,
16
+ registerCrossProcessEventListener,
17
+ registerGlobalEventTap,
18
+ } from '../../../../bus'
15
19
  import type { EmitOptions } from '../../../../types'
16
20
 
17
21
  export const metadata = {
@@ -64,6 +68,9 @@ function normalizeAudience(data: Record<string, unknown>, options?: EmitOptions)
64
68
  : null
65
69
  if (hasTrustedScope) {
66
70
  if (trustedOrganizationId) organizationScopes.add(trustedOrganizationId)
71
+ for (const organizationId of collectStringValues(options?.organizationIds)) {
72
+ organizationScopes.add(organizationId)
73
+ }
67
74
  } else {
68
75
  if (typeof data.organizationId === 'string' && data.organizationId.trim().length > 0) {
69
76
  organizationScopes.add(data.organizationId.trim())
@@ -204,7 +211,12 @@ function ensureGlobalTapSubscription(): void {
204
211
  })
205
212
 
206
213
  registerCrossProcessEventListener(async (envelope) => {
207
- if (envelope.originPid === process.pid) return
214
+ // Every envelope this process publishes carries originInstanceId, so an
215
+ // envelope without one can only come from an older process during a
216
+ // rolling deploy and is always foreign. Falling back to originPid would
217
+ // discard it whenever both containers run under the same pid — commonly
218
+ // pid 1 — silently dropping browser events across the upgrade window.
219
+ if (envelope.originInstanceId === CROSS_PROCESS_EVENT_INSTANCE_ID) return
208
220
  await broadcastEventToConnections(
209
221
  envelope.event,
210
222
  (envelope.payload ?? {}) as Record<string, unknown>,
package/src/types.ts CHANGED
@@ -100,6 +100,18 @@ export type EmitOptions = {
100
100
  tenantId?: string | null
101
101
  /** Trusted organization scope for subscribers that must not rely on payload scope */
102
102
  organizationId?: string | null
103
+ /**
104
+ * Trusted multi-organization audience for subscribers that must not rely on
105
+ * payload scope. Mirrors the SSE audience contract where a clientBroadcast
106
+ * event may target several organizations at once.
107
+ */
108
+ organizationIds?: string[] | null
109
+ /**
110
+ * Module provenance stamped by `createModuleEvents` for private
111
+ * cross-process coordination events. Never derive this from payload data.
112
+ * @internal
113
+ */
114
+ emitterModuleId?: string
103
115
  /** Opt-in path for callers that need inline subscriber failures to reject the emit. */
104
116
  rethrowHandlerErrors?: boolean
105
117
  }