@open-mercato/events 0.4.11-develop.1966.1d166d19f6 → 0.4.11-develop.1968.48731d5ebc

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
@@ -85,6 +85,14 @@ packages/events/src/
85
85
 
86
86
  Workers in `modules/events/workers/` handle async event processing. Follow the standard worker contract: export default handler + `metadata` with `{ queue, id?, concurrency? }`.
87
87
 
88
+ ## Testing
89
+
90
+ - Tests inside `packages/events` SHOULD import the public `@open-mercato/events/...` API when validating package behavior
91
+ - `tenantId` and `organizationId` in subscriber context are trusted scope inputs from `emit(..., options)` or queued job `options`, not from arbitrary payload fields
92
+ - Add regression tests for both paths:
93
+ - trusted scope is forwarded when explicitly provided
94
+ - payload-provided scope is ignored when trusted scope is omitted
95
+
88
96
  ## Cross-Reference
89
97
 
90
98
  - **Declaring events in a module**: `packages/core/AGENTS.md` → Events
@@ -29,14 +29,18 @@ function getListenerMap() {
29
29
  return cachedListenerMap;
30
30
  }
31
31
  async function handle(job, ctx) {
32
- const { event, payload } = job.payload;
32
+ const { event, payload, options } = job.payload;
33
33
  const listeners = getListenerMap();
34
34
  const subscribers = listeners.get(event);
35
35
  if (!subscribers || subscribers.length === 0) return;
36
36
  const errors = [];
37
37
  for (const sub of subscribers) {
38
38
  try {
39
- await sub.handler(payload, { resolve: ctx.resolve });
39
+ await sub.handler(payload, {
40
+ resolve: ctx.resolve,
41
+ tenantId: options?.tenantId ?? null,
42
+ organizationId: options?.organizationId ?? null
43
+ });
40
44
  } catch (error) {
41
45
  console.error(`[events] Subscriber "${sub.id}" failed for event "${event}":`, error);
42
46
  errors.push({ subscriberId: sub.id, error });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/events/workers/events.worker.ts"],
4
- "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { getCliModules } from '@open-mercato/shared/modules/registry'\n\nexport const EVENTS_QUEUE_NAME = 'events'\n\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: EVENTS_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype EventJobPayload = {\n event: string\n payload: unknown\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\ntype SubscriberEntry = {\n id: string\n event: string\n handler: (payload: unknown, ctx: unknown) => Promise<void> | void\n}\n\n// Cached listener map - built once on first use\nlet cachedListenerMap: Map<string, SubscriberEntry[]> | null = null\n\n/**\n * Clear the cached listener map (for testing purposes).\n */\nexport function clearListenerCache(): void {\n cachedListenerMap = null\n}\n\n// Build listener map from module subscribers\nfunction buildListenerMap(): Map<string, SubscriberEntry[]> {\n const listeners = new Map<string, SubscriberEntry[]>()\n for (const mod of getCliModules()) {\n const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers\n if (!subs) continue\n for (const sub of subs) {\n if (!listeners.has(sub.event)) listeners.set(sub.event, [])\n listeners.get(sub.event)!.push(sub)\n }\n }\n return listeners\n}\n\n// Get cached listener map, building on first access\nfunction getListenerMap(): Map<string, SubscriberEntry[]> {\n if (!cachedListenerMap) {\n cachedListenerMap = buildListenerMap()\n }\n return cachedListenerMap\n}\n\n/**\n * Events worker handler.\n * Dispatches queued events to registered module subscribers.\n * Each subscriber is isolated - failures in one don't affect others.\n */\nexport default async function handle(\n job: QueuedJob<EventJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { event, payload } = job.payload\n const listeners = getListenerMap()\n const subscribers = listeners.get(event)\n\n if (!subscribers || subscribers.length === 0) return\n\n const errors: Array<{ subscriberId: string; error: unknown }> = []\n\n for (const sub of subscribers) {\n try {\n await sub.handler(payload, { resolve: ctx.resolve })\n } catch (error) {\n // Log error but continue processing other subscribers\n console.error(`[events] Subscriber \"${sub.id}\" failed for event \"${event}\":`, error)\n errors.push({ subscriberId: sub.id, error })\n }\n }\n\n // If all subscribers failed, throw to trigger retry\n if (errors.length === subscribers.length) {\n throw new Error(`All ${errors.length} subscriber(s) failed for event \"${event}\"`)\n }\n\n // Log partial failures but don't fail the job\n if (errors.length > 0) {\n console.warn(`[events] ${errors.length}/${subscribers.length} subscriber(s) failed for event \"${event}\"`)\n }\n}\n"],
5
- "mappings": "AACA,SAAS,qBAAqB;AAEvB,MAAM,oBAAoB;AAEjC,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,QAAQ,IAAI;AAE5B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa,iBAAiB,SAAS,gBAAgB,EAAE,IAAI;AAC/D;AAgBA,IAAI,oBAA2D;AAKxD,SAAS,qBAA2B;AACzC,sBAAoB;AACtB;AAGA,SAAS,mBAAmD;AAC1D,QAAM,YAAY,oBAAI,IAA+B;AACrD,aAAW,OAAO,cAAc,GAAG;AACjC,UAAM,OAAQ,IAA4C;AAC1D,QAAI,CAAC,KAAM;AACX,eAAW,OAAO,MAAM;AACtB,UAAI,CAAC,UAAU,IAAI,IAAI,KAAK,EAAG,WAAU,IAAI,IAAI,OAAO,CAAC,CAAC;AAC1D,gBAAU,IAAI,IAAI,KAAK,EAAG,KAAK,GAAG;AAAA,IACpC;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,iBAAiD;AACxD,MAAI,CAAC,mBAAmB;AACtB,wBAAoB,iBAAiB;AAAA,EACvC;AACA,SAAO;AACT;AAOA,eAAO,OACL,KACA,KACe;AACf,QAAM,EAAE,OAAO,QAAQ,IAAI,IAAI;AAC/B,QAAM,YAAY,eAAe;AACjC,QAAM,cAAc,UAAU,IAAI,KAAK;AAEvC,MAAI,CAAC,eAAe,YAAY,WAAW,EAAG;AAE9C,QAAM,SAA0D,CAAC;AAEjE,aAAW,OAAO,aAAa;AAC7B,QAAI;AACF,YAAM,IAAI,QAAQ,SAAS,EAAE,SAAS,IAAI,QAAQ,CAAC;AAAA,IACrD,SAAS,OAAO;AAEd,cAAQ,MAAM,wBAAwB,IAAI,EAAE,uBAAuB,KAAK,MAAM,KAAK;AACnF,aAAO,KAAK,EAAE,cAAc,IAAI,IAAI,MAAM,CAAC;AAAA,IAC7C;AAAA,EACF;AAGA,MAAI,OAAO,WAAW,YAAY,QAAQ;AACxC,UAAM,IAAI,MAAM,OAAO,OAAO,MAAM,oCAAoC,KAAK,GAAG;AAAA,EAClF;AAGA,MAAI,OAAO,SAAS,GAAG;AACrB,YAAQ,KAAK,YAAY,OAAO,MAAM,IAAI,YAAY,MAAM,oCAAoC,KAAK,GAAG;AAAA,EAC1G;AACF;",
4
+ "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { getCliModules } from '@open-mercato/shared/modules/registry'\n\nexport const EVENTS_QUEUE_NAME = 'events'\n\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_EVENTS_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: EVENTS_QUEUE_NAME,\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype EventJobPayload = {\n event: string\n payload: unknown\n options?: {\n tenantId?: string | null\n organizationId?: string | null\n }\n}\n\ntype HandlerContext = {\n resolve: <T = unknown>(name: string) => T\n tenantId?: string | null\n organizationId?: string | null\n}\n\ntype SubscriberEntry = {\n id: string\n event: string\n handler: (payload: unknown, ctx: unknown) => Promise<void> | void\n}\n\n// Cached listener map - built once on first use\nlet cachedListenerMap: Map<string, SubscriberEntry[]> | null = null\n\n/**\n * Clear the cached listener map (for testing purposes).\n */\nexport function clearListenerCache(): void {\n cachedListenerMap = null\n}\n\n// Build listener map from module subscribers\nfunction buildListenerMap(): Map<string, SubscriberEntry[]> {\n const listeners = new Map<string, SubscriberEntry[]>()\n for (const mod of getCliModules()) {\n const subs = (mod as { subscribers?: SubscriberEntry[] }).subscribers\n if (!subs) continue\n for (const sub of subs) {\n if (!listeners.has(sub.event)) listeners.set(sub.event, [])\n listeners.get(sub.event)!.push(sub)\n }\n }\n return listeners\n}\n\n// Get cached listener map, building on first access\nfunction getListenerMap(): Map<string, SubscriberEntry[]> {\n if (!cachedListenerMap) {\n cachedListenerMap = buildListenerMap()\n }\n return cachedListenerMap\n}\n\n/**\n * Events worker handler.\n * Dispatches queued events to registered module subscribers.\n * Each subscriber is isolated - failures in one don't affect others.\n */\nexport default async function handle(\n job: QueuedJob<EventJobPayload>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { event, payload, options } = job.payload\n const listeners = getListenerMap()\n const subscribers = listeners.get(event)\n\n if (!subscribers || subscribers.length === 0) return\n\n const errors: Array<{ subscriberId: string; error: unknown }> = []\n\n for (const sub of subscribers) {\n try {\n await sub.handler(payload, {\n resolve: ctx.resolve,\n tenantId: options?.tenantId ?? null,\n organizationId: options?.organizationId ?? null,\n })\n } catch (error) {\n // Log error but continue processing other subscribers\n console.error(`[events] Subscriber \"${sub.id}\" failed for event \"${event}\":`, error)\n errors.push({ subscriberId: sub.id, error })\n }\n }\n\n // If all subscribers failed, throw to trigger retry\n if (errors.length === subscribers.length) {\n throw new Error(`All ${errors.length} subscriber(s) failed for event \"${event}\"`)\n }\n\n // Log partial failures but don't fail the job\n if (errors.length > 0) {\n console.warn(`[events] ${errors.length}/${subscribers.length} subscriber(s) failed for event \"${event}\"`)\n }\n}\n"],
5
+ "mappings": "AACA,SAAS,qBAAqB;AAEvB,MAAM,oBAAoB;AAEjC,MAAM,sBAAsB;AAC5B,MAAM,iBAAiB,QAAQ,IAAI;AAE5B,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,aAAa,iBAAiB,SAAS,gBAAgB,EAAE,IAAI;AAC/D;AAwBA,IAAI,oBAA2D;AAKxD,SAAS,qBAA2B;AACzC,sBAAoB;AACtB;AAGA,SAAS,mBAAmD;AAC1D,QAAM,YAAY,oBAAI,IAA+B;AACrD,aAAW,OAAO,cAAc,GAAG;AACjC,UAAM,OAAQ,IAA4C;AAC1D,QAAI,CAAC,KAAM;AACX,eAAW,OAAO,MAAM;AACtB,UAAI,CAAC,UAAU,IAAI,IAAI,KAAK,EAAG,WAAU,IAAI,IAAI,OAAO,CAAC,CAAC;AAC1D,gBAAU,IAAI,IAAI,KAAK,EAAG,KAAK,GAAG;AAAA,IACpC;AAAA,EACF;AACA,SAAO;AACT;AAGA,SAAS,iBAAiD;AACxD,MAAI,CAAC,mBAAmB;AACtB,wBAAoB,iBAAiB;AAAA,EACvC;AACA,SAAO;AACT;AAOA,eAAO,OACL,KACA,KACe;AACf,QAAM,EAAE,OAAO,SAAS,QAAQ,IAAI,IAAI;AACxC,QAAM,YAAY,eAAe;AACjC,QAAM,cAAc,UAAU,IAAI,KAAK;AAEvC,MAAI,CAAC,eAAe,YAAY,WAAW,EAAG;AAE9C,QAAM,SAA0D,CAAC;AAEjE,aAAW,OAAO,aAAa;AAC7B,QAAI;AACF,YAAM,IAAI,QAAQ,SAAS;AAAA,QACzB,SAAS,IAAI;AAAA,QACb,UAAU,SAAS,YAAY;AAAA,QAC/B,gBAAgB,SAAS,kBAAkB;AAAA,MAC7C,CAAC;AAAA,IACH,SAAS,OAAO;AAEd,cAAQ,MAAM,wBAAwB,IAAI,EAAE,uBAAuB,KAAK,MAAM,KAAK;AACnF,aAAO,KAAK,EAAE,cAAc,IAAI,IAAI,MAAM,CAAC;AAAA,IAC7C;AAAA,EACF;AAGA,MAAI,OAAO,WAAW,YAAY,QAAQ;AACxC,UAAM,IAAI,MAAM,OAAO,OAAO,MAAM,oCAAoC,KAAK,GAAG;AAAA,EAClF;AAGA,MAAI,OAAO,SAAS,GAAG;AACrB,YAAQ,KAAK,YAAY,OAAO,MAAM,IAAI,YAAY,MAAM,oCAAoC,KAAK,GAAG;AAAA,EAC1G;AACF;",
6
6
  "names": []
7
7
  }
package/jest.config.cjs CHANGED
@@ -5,6 +5,11 @@ module.exports = {
5
5
  watchman: false,
6
6
  rootDir: '.',
7
7
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
8
+ moduleNameMapper: {
9
+ '^@open-mercato/events/(.*)$': '<rootDir>/src/$1',
10
+ '^@open-mercato/shared/(.*)$': '<rootDir>/../shared/src/$1',
11
+ '^@open-mercato/queue/(.*)$': '<rootDir>/../queue/src/$1',
12
+ },
8
13
  transform: {
9
14
  '^.+\\.(t|j)sx?$': [
10
15
  'ts-jest',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/events",
3
- "version": "0.4.11-develop.1966.1d166d19f6",
3
+ "version": "0.4.11-develop.1968.48731d5ebc",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -32,8 +32,8 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@open-mercato/queue": "0.4.11-develop.1966.1d166d19f6",
36
- "@open-mercato/shared": "0.4.11-develop.1966.1d166d19f6",
35
+ "@open-mercato/queue": "0.4.11-develop.1968.48731d5ebc",
36
+ "@open-mercato/shared": "0.4.11-develop.1968.48731d5ebc",
37
37
  "pg": "8.20.0"
38
38
  },
39
39
  "devDependencies": {
@@ -42,6 +42,18 @@ describe('Event bus', () => {
42
42
  expect(calls).toEqual([{ tenantId: 'tenant-1', organizationId: 'org-1' }])
43
43
  })
44
44
 
45
+ test('does not trust payload scope when trusted scope is omitted', async () => {
46
+ const calls: Array<{ tenantId?: string | null; organizationId?: string | null }> = []
47
+ const bus = createEventBus({ resolve: ((name: string) => name) as any })
48
+ bus.on('demo', async (_payload, ctx) => {
49
+ calls.push({ tenantId: ctx.tenantId, organizationId: ctx.organizationId })
50
+ })
51
+
52
+ await bus.emit('demo', { a: 1, tenantId: 'tenant-from-payload', organizationId: 'org-from-payload' } as any)
53
+
54
+ expect(calls).toEqual([{ tenantId: null, organizationId: null }])
55
+ })
56
+
45
57
  test('emitEvent alias works for backward compatibility', async () => {
46
58
  const calls: any[] = []
47
59
  const bus = createEventBus({ resolve: ((name: string) => name) as any })
@@ -33,9 +33,13 @@ describe('Events Worker', () => {
33
33
  })
34
34
 
35
35
  describe('handle', () => {
36
- const createMockJob = (event: string, payload: unknown): QueuedJob<{ event: string; payload: unknown }> => ({
36
+ const createMockJob = (
37
+ event: string,
38
+ payload: unknown,
39
+ options?: { tenantId?: string | null; organizationId?: string | null },
40
+ ): QueuedJob<{ event: string; payload: unknown; options?: { tenantId?: string | null; organizationId?: string | null } }> => ({
37
41
  id: 'test-job-id',
38
- payload: { event, payload },
42
+ payload: { event, payload, options },
39
43
  createdAt: new Date().toISOString(),
40
44
  })
41
45
 
@@ -184,6 +188,66 @@ describe('Events Worker', () => {
184
188
  expect((capturedContext as { resolve: unknown }).resolve).toBeDefined()
185
189
  })
186
190
 
191
+ it('should pass trusted tenant and organization scope to subscriber context', async () => {
192
+ let capturedContext: { tenantId?: string | null; organizationId?: string | null } | null = null
193
+
194
+ const mockModule: Module = {
195
+ id: 'test-module',
196
+ subscribers: [
197
+ {
198
+ id: 'test:subscriber',
199
+ event: 'test.event',
200
+ handler: async (_payload: unknown, ctx: unknown) => {
201
+ const typed = ctx as { tenantId?: string | null; organizationId?: string | null }
202
+ capturedContext = {
203
+ tenantId: typed.tenantId,
204
+ organizationId: typed.organizationId,
205
+ }
206
+ },
207
+ },
208
+ ],
209
+ }
210
+
211
+ registerCliModules([mockModule])
212
+
213
+ const job = createMockJob('test.event', {}, { tenantId: 'tenant-1', organizationId: 'org-1' })
214
+ const ctx = createMockContext()
215
+
216
+ await handle(job, ctx)
217
+
218
+ expect(capturedContext).toEqual({ tenantId: 'tenant-1', organizationId: 'org-1' })
219
+ })
220
+
221
+ it('should not trust payload scope when trusted scope is omitted', async () => {
222
+ let capturedContext: { tenantId?: string | null; organizationId?: string | null } | null = null
223
+
224
+ const mockModule: Module = {
225
+ id: 'test-module',
226
+ subscribers: [
227
+ {
228
+ id: 'test:subscriber',
229
+ event: 'test.event',
230
+ handler: async (_payload: unknown, ctx: unknown) => {
231
+ const typed = ctx as { tenantId?: string | null; organizationId?: string | null }
232
+ capturedContext = {
233
+ tenantId: typed.tenantId,
234
+ organizationId: typed.organizationId,
235
+ }
236
+ },
237
+ },
238
+ ],
239
+ }
240
+
241
+ registerCliModules([mockModule])
242
+
243
+ const job = createMockJob('test.event', { tenantId: 'payload-tenant', organizationId: 'payload-org' })
244
+ const ctx = createMockContext()
245
+
246
+ await handle(job, ctx)
247
+
248
+ expect(capturedContext).toEqual({ tenantId: null, organizationId: null })
249
+ })
250
+
187
251
  it('should handle modules without subscribers', async () => {
188
252
  const mockModule: Module = {
189
253
  id: 'module-without-subscribers',
@@ -14,9 +14,17 @@ export const metadata: WorkerMeta = {
14
14
  type EventJobPayload = {
15
15
  event: string
16
16
  payload: unknown
17
+ options?: {
18
+ tenantId?: string | null
19
+ organizationId?: string | null
20
+ }
17
21
  }
18
22
 
19
- type HandlerContext = { resolve: <T = unknown>(name: string) => T }
23
+ type HandlerContext = {
24
+ resolve: <T = unknown>(name: string) => T
25
+ tenantId?: string | null
26
+ organizationId?: string | null
27
+ }
20
28
 
21
29
  type SubscriberEntry = {
22
30
  id: string
@@ -65,7 +73,7 @@ export default async function handle(
65
73
  job: QueuedJob<EventJobPayload>,
66
74
  ctx: JobContext & HandlerContext
67
75
  ): Promise<void> {
68
- const { event, payload } = job.payload
76
+ const { event, payload, options } = job.payload
69
77
  const listeners = getListenerMap()
70
78
  const subscribers = listeners.get(event)
71
79
 
@@ -75,7 +83,11 @@ export default async function handle(
75
83
 
76
84
  for (const sub of subscribers) {
77
85
  try {
78
- await sub.handler(payload, { resolve: ctx.resolve })
86
+ await sub.handler(payload, {
87
+ resolve: ctx.resolve,
88
+ tenantId: options?.tenantId ?? null,
89
+ organizationId: options?.organizationId ?? null,
90
+ })
79
91
  } catch (error) {
80
92
  // Log error but continue processing other subscribers
81
93
  console.error(`[events] Subscriber "${sub.id}" failed for event "${event}":`, error)