@open-mercato/scheduler 0.7.0 → 0.7.1-develop.7103.1.41ff100d93

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.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js +2 -2
  3. package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js.map +2 -2
  4. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +9 -4
  5. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +2 -2
  6. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +3 -3
  7. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +2 -2
  8. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +72 -0
  9. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +7 -0
  10. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +66 -0
  11. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +7 -0
  12. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js +3 -3
  13. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
  14. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +107 -44
  15. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
  16. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +13 -3
  17. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
  18. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +7 -13
  19. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
  20. package/dist/modules/scheduler/api/targets/route.js +2 -12
  21. package/dist/modules/scheduler/api/targets/route.js.map +2 -2
  22. package/dist/modules/scheduler/api/trigger/route.js +7 -13
  23. package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
  24. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +3 -1
  25. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
  26. package/dist/modules/scheduler/cli.js +32 -1
  27. package/dist/modules/scheduler/cli.js.map +2 -2
  28. package/dist/modules/scheduler/commands/jobs.js +84 -2
  29. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  30. package/dist/modules/scheduler/data/validators.js +27 -3
  31. package/dist/modules/scheduler/data/validators.js.map +2 -2
  32. package/dist/modules/scheduler/i18n/de.json +2 -0
  33. package/dist/modules/scheduler/i18n/en.json +2 -0
  34. package/dist/modules/scheduler/i18n/es.json +2 -0
  35. package/dist/modules/scheduler/i18n/ko.json +2 -0
  36. package/dist/modules/scheduler/i18n/pl.json +2 -0
  37. package/dist/modules/scheduler/lib/safeQueueTargets.js +157 -0
  38. package/dist/modules/scheduler/lib/safeQueueTargets.js.map +7 -0
  39. package/dist/modules/scheduler/lib/scheduleAccess.js +22 -0
  40. package/dist/modules/scheduler/lib/scheduleAccess.js.map +7 -0
  41. package/dist/modules/scheduler/services/localSchedulerService.js +31 -6
  42. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  43. package/dist/modules/scheduler/workers/execute-schedule.worker.js +64 -6
  44. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  45. package/dist/modules/scheduler/workers/test-echo-queue.worker.js +22 -0
  46. package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +7 -0
  47. package/package.json +7 -6
  48. package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
  49. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +14 -8
  50. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
  51. package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +90 -0
  52. package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +94 -0
  53. package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
  54. package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +154 -66
  55. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +24 -5
  56. package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +9 -19
  57. package/src/modules/scheduler/api/targets/__tests__/route.test.ts +3 -4
  58. package/src/modules/scheduler/api/targets/route.ts +6 -17
  59. package/src/modules/scheduler/api/trigger/route.ts +9 -19
  60. package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +4 -1
  61. package/src/modules/scheduler/cli.ts +44 -1
  62. package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +177 -0
  63. package/src/modules/scheduler/commands/jobs.ts +137 -3
  64. package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +110 -0
  65. package/src/modules/scheduler/data/__tests__/validators.test.ts +26 -4
  66. package/src/modules/scheduler/data/validators.ts +37 -2
  67. package/src/modules/scheduler/i18n/de.json +2 -0
  68. package/src/modules/scheduler/i18n/en.json +2 -0
  69. package/src/modules/scheduler/i18n/es.json +2 -0
  70. package/src/modules/scheduler/i18n/ko.json +2 -0
  71. package/src/modules/scheduler/i18n/pl.json +2 -0
  72. package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +311 -0
  73. package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +164 -0
  74. package/src/modules/scheduler/lib/safeQueueTargets.ts +324 -0
  75. package/src/modules/scheduler/lib/scheduleAccess.ts +104 -0
  76. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +179 -0
  77. package/src/modules/scheduler/services/localSchedulerService.ts +39 -6
  78. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +25 -3
  79. package/src/modules/scheduler/workers/execute-schedule.worker.ts +76 -7
  80. package/src/modules/scheduler/workers/test-echo-queue.worker.ts +25 -0
@@ -6,6 +6,7 @@ import {
6
6
  clearSchedulerSafeCommandsForTests,
7
7
  registerSchedulerSafeCommands,
8
8
  } from '../../lib/scheduler-safe-commands'
9
+ import { registerModules } from '@open-mercato/shared/lib/modules/registry'
9
10
  import { createLogger } from '@open-mercato/shared/lib/logger'
10
11
 
11
12
  jest.mock('@open-mercato/shared/lib/logger', () => {
@@ -61,6 +62,40 @@ describe('LocalSchedulerService', () => {
61
62
  let mockRbacService: { tenantHasFeature: jest.Mock; userHasAllFeatures: jest.Mock }
62
63
  let mockLockStrategy: { runWithLock: jest.Mock }
63
64
 
65
+ beforeAll(() => {
66
+ // Queue-target dispatch reauthorizes against scheduler-safe queues (#5213);
67
+ // seed one safe queue and one module-owned internal queue for the guards.
68
+ registerModules([
69
+ {
70
+ id: 'test_module',
71
+ workers: [
72
+ {
73
+ id: 'test_module:workers:test-queue',
74
+ queue: 'test-queue',
75
+ concurrency: 1,
76
+ schedulerSafe: true,
77
+ handler: async () => {},
78
+ },
79
+ ],
80
+ },
81
+ {
82
+ id: 'legacy_module',
83
+ workers: [
84
+ {
85
+ id: 'legacy_module:workers:internal',
86
+ queue: 'legacy-internal',
87
+ concurrency: 1,
88
+ handler: async () => {},
89
+ },
90
+ ],
91
+ },
92
+ ] as never)
93
+ })
94
+
95
+ afterAll(() => {
96
+ ;(globalThis as Record<string, unknown>).__openMercatoModulesRegistry__ = null
97
+ })
98
+
64
99
  beforeEach(() => {
65
100
  jest.clearAllMocks()
66
101
  jest.useFakeTimers()
@@ -296,8 +331,151 @@ describe('LocalSchedulerService', () => {
296
331
  tenantId: null,
297
332
  organizationId: null,
298
333
  _idempotencyKey: expect.stringMatching(/^scheduler-test-1-/),
334
+ _jobOrigin: 'scheduler',
335
+ })
336
+ )
337
+ })
338
+
339
+ it('should sanitize author-supplied scope and envelope keys from the target payload', async () => {
340
+ const schedule = createSchedule({
341
+ tenantId: 'tenant-1',
342
+ organizationId: 'org-1',
343
+ sourceType: 'module',
344
+ sourceModule: 'test_module',
345
+ targetQueue: 'test-queue',
346
+ targetPayload: {
347
+ scope: { tenantId: 'forged-tenant', organizationId: 'forged-org' },
348
+ tenantId: 'forged-tenant',
349
+ _jobOrigin: 'inbound-webhook',
350
+ payload: { nested: 'data', _idempotencyKey: 'forged' },
351
+ legit: 'kept',
352
+ },
353
+ })
354
+
355
+ mockForkedEm.find.mockResolvedValue([schedule])
356
+ mockForkedEm.findOne.mockResolvedValue({ ...schedule })
357
+ mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
358
+ await fn()
359
+ return { acquired: true }
360
+ })
361
+ mockQueue.enqueue.mockResolvedValue(undefined as any)
362
+
363
+ await service.start()
364
+
365
+ expect(mockQueue.enqueue).toHaveBeenCalledWith(
366
+ expect.objectContaining({
367
+ tenantId: 'tenant-1',
368
+ organizationId: 'org-1',
369
+ payload: { nested: 'data' },
370
+ legit: 'kept',
371
+ scope: { tenantId: 'tenant-1', organizationId: 'org-1' },
372
+ _jobOrigin: 'scheduler',
299
373
  })
300
374
  )
375
+ const enqueued = mockQueue.enqueue.mock.calls[0][0] as Record<string, unknown>
376
+ expect(enqueued.payload).toEqual({ nested: 'data' })
377
+ })
378
+
379
+ it('should refuse user-authored schedules targeting non-safe internal queues', async () => {
380
+ const schedule = createSchedule({
381
+ targetQueue: 'stripe-webhook',
382
+ sourceType: 'user',
383
+ })
384
+
385
+ mockForkedEm.find.mockResolvedValue([schedule])
386
+ mockForkedEm.findOne.mockResolvedValue({ ...schedule })
387
+ mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
388
+ await fn()
389
+ return { acquired: true }
390
+ })
391
+
392
+ await service.start()
393
+
394
+ expect(mockQueueFactory).not.toHaveBeenCalled()
395
+ expect(loggerError).toHaveBeenCalled()
396
+ expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
397
+ 'scheduler.job.failed',
398
+ expect.objectContaining({
399
+ id: 'test-1',
400
+ error: expect.stringContaining('not an approved scheduler target'),
401
+ })
402
+ )
403
+ })
404
+
405
+ it('should refuse a pre-upgrade module row whose sourceModule MATCHES the owner but carries an actor stamp (#5213 B1)', async () => {
406
+ // forged pre-upgrade row: attacker set sourceType/sourceModule to the real
407
+ // owning module, but API writes always stamp createdByUserId — which
408
+ // schedulerService.register() never does. The row must fail closed.
409
+ const schedule = createSchedule({
410
+ targetQueue: 'legacy-internal',
411
+ sourceType: 'module',
412
+ sourceModule: 'legacy_module',
413
+ createdByUserId: 'attacker-user',
414
+ })
415
+
416
+ mockForkedEm.find.mockResolvedValue([schedule])
417
+ mockForkedEm.findOne.mockResolvedValue({ ...schedule })
418
+ mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
419
+ await fn()
420
+ return { acquired: true }
421
+ })
422
+
423
+ await service.start()
424
+
425
+ expect(mockQueueFactory).not.toHaveBeenCalled()
426
+ expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
427
+ 'scheduler.job.failed',
428
+ expect.objectContaining({
429
+ id: 'test-1',
430
+ error: expect.stringContaining('not an approved scheduler target'),
431
+ })
432
+ )
433
+ })
434
+
435
+ it('should refuse a pre-upgrade module row whose sourceModule does not own its internal queue (#5213 B1)', async () => {
436
+ const schedule = createSchedule({
437
+ targetQueue: 'stripe-webhook',
438
+ sourceType: 'module',
439
+ sourceModule: 'legacy_module',
440
+ })
441
+
442
+ mockForkedEm.find.mockResolvedValue([schedule])
443
+ mockForkedEm.findOne.mockResolvedValue({ ...schedule })
444
+ mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
445
+ await fn()
446
+ return { acquired: true }
447
+ })
448
+
449
+ await service.start()
450
+
451
+ expect(mockQueueFactory).not.toHaveBeenCalled()
452
+ expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
453
+ 'scheduler.job.failed',
454
+ expect.objectContaining({
455
+ id: 'test-1',
456
+ error: expect.stringContaining('not an approved scheduler target'),
457
+ })
458
+ )
459
+ })
460
+
461
+ it('should allow module-authored schedules to keep targeting queues their own module owns (#5213 B1)', async () => {
462
+ const schedule = createSchedule({
463
+ targetQueue: 'legacy-internal',
464
+ sourceType: 'module',
465
+ sourceModule: 'legacy_module',
466
+ })
467
+
468
+ mockForkedEm.find.mockResolvedValue([schedule])
469
+ mockForkedEm.findOne.mockResolvedValue({ ...schedule })
470
+ mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
471
+ await fn()
472
+ return { acquired: true }
473
+ })
474
+ mockQueue.enqueue.mockResolvedValue(undefined as any)
475
+
476
+ await service.start()
477
+
478
+ expect(mockQueue.enqueue).toHaveBeenCalled()
301
479
  })
302
480
 
303
481
  it('should execute command target', async () => {
@@ -670,6 +848,7 @@ describe('LocalSchedulerService', () => {
670
848
  tenantId: schedule.tenantId,
671
849
  organizationId: schedule.organizationId,
672
850
  _idempotencyKey: expect.stringMatching(new RegExp(`^scheduler-${schedule.id}-`)),
851
+ _jobOrigin: 'scheduler',
673
852
  })
674
853
  )
675
854
  const enqueued = mockQueue.enqueue.mock.calls[0][0] as Record<string, unknown>
@@ -11,6 +11,12 @@ import { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-comm
11
11
  import { buildScheduledCommandContext } from '../lib/commandContext.js'
12
12
  import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
13
13
  import { createLogger } from '@open-mercato/shared/lib/logger'
14
+ import {
15
+ canDispatchScheduleQueueTarget,
16
+ getSchedulerQueueRequiredFeatures,
17
+ sanitizeSchedulerTargetPayload,
18
+ validateSchedulerTargetPayload,
19
+ } from '../lib/safeQueueTargets'
14
20
 
15
21
  const logger = createLogger('scheduler').child({ component: 'local' })
16
22
 
@@ -261,18 +267,45 @@ export class LocalSchedulerService {
261
267
  throw new Error('Target queue is required for queue target type')
262
268
  }
263
269
 
270
+ // Dispatch-time reauthorization (#5213): provenance is verified against the
271
+ // live registry (module rows must own their queue), API rows must target
272
+ // opted-in queues.
273
+ if (!canDispatchScheduleQueueTarget(schedule)) {
274
+ throw new Error(`Queue is not an approved scheduler target: ${schedule.targetQueue}`)
275
+ }
276
+
277
+ const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)
278
+ if (payloadIssue) {
279
+ throw new Error(`Invalid payload for scheduler queue ${schedule.targetQueue}: ${payloadIssue}`)
280
+ }
281
+
282
+ if (schedule.scopeType !== 'system') {
283
+ for (const feature of getSchedulerQueueRequiredFeatures(schedule.targetQueue)) {
284
+ const hasFeature = await this.checkFeature({ ...schedule, requireFeature: feature } as ScheduledJob)
285
+ if (!hasFeature) {
286
+ throw new Error(`Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`)
287
+ }
288
+ }
289
+ }
290
+
264
291
  const queue = this.queueFactory(schedule.targetQueue)
265
292
 
266
293
  // Deliver the same flat contract as the asynchronous execute-schedule
267
294
  // worker: targetPayload fields on the root, scheduler-owned scope and
268
295
  // idempotency fields applied last. Local mode runs each firing exactly
269
296
  // once, so the firing timestamp is a valid logical execution key.
270
- await queue.enqueue(buildQueueTargetPayload({
271
- targetPayload: schedule.targetPayload,
272
- tenantId: schedule.tenantId,
273
- organizationId: schedule.organizationId,
274
- idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),
275
- }))
297
+ // Author-supplied scope/envelope keys never survive dispatch (#5213).
298
+ const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule)
299
+
300
+ await queue.enqueue({
301
+ ...buildQueueTargetPayload({
302
+ targetPayload: sanitizedPayload,
303
+ tenantId: schedule.tenantId,
304
+ organizationId: schedule.organizationId,
305
+ idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),
306
+ }),
307
+ _jobOrigin: 'scheduler' as const,
308
+ })
276
309
 
277
310
  logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })
278
311
  }
@@ -3,8 +3,25 @@ import { ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
3
3
  import { createQueue } from '@open-mercato/queue'
4
4
  import { ScheduledJob } from '../../data/entities'
5
5
  import { registerSchedulerSafeCommands } from '../../lib/scheduler-safe-commands'
6
+ import { registerModules } from '@open-mercato/shared/lib/modules/registry'
6
7
  import executeScheduleWorker from '../execute-schedule.worker'
7
8
 
9
+ // Queue-target dispatch verifies module provenance against the live registry
10
+ // (#5213 B1): register the module that owns the fixture queue.
11
+ registerModules([
12
+ {
13
+ id: 'worker_test_module',
14
+ workers: [
15
+ {
16
+ id: 'worker_test_module:workers:example',
17
+ queue: 'example',
18
+ concurrency: 1,
19
+ handler: async () => {},
20
+ },
21
+ ],
22
+ },
23
+ ] as never)
24
+
8
25
  const mockCommandExecute = jest.fn()
9
26
 
10
27
  jest.mock('@open-mercato/shared/lib/commands', () => ({
@@ -127,7 +144,11 @@ describe('executeScheduleWorker queue target payload contract', () => {
127
144
  targetType: 'queue',
128
145
  targetQueue: 'example',
129
146
  targetCommand: null,
130
- targetPayload: { connectionId: 'connection-id', scope: 'organization' },
147
+ sourceType: 'module',
148
+ // module registration never stamps an acting user (#5213 B1)
149
+ createdByUserId: null,
150
+ sourceModule: 'worker_test_module',
151
+ targetPayload: { connectionId: 'connection-id' },
131
152
  ...overrides,
132
153
  })
133
154
  }
@@ -157,7 +178,7 @@ describe('executeScheduleWorker queue target payload contract', () => {
157
178
  ;(createQueue as jest.Mock).mockReturnValue({ enqueue, close })
158
179
  })
159
180
 
160
- it('delivers the flat targetPayload contract with scheduler-owned fields applied last', async () => {
181
+ it('delivers the flat targetPayload contract with scheduler-owned fields applied last (#5213)', async () => {
161
182
  const schedule = buildQueueSchedule({
162
183
  targetPayload: {
163
184
  connectionId: 'connection-id',
@@ -171,11 +192,12 @@ describe('executeScheduleWorker queue target payload contract', () => {
171
192
 
172
193
  expect(enqueue).toHaveBeenCalledWith({
173
194
  connectionId: 'connection-id',
174
- scope: 'organization',
195
+ scope: { tenantId: 'tenant-a', organizationId: 'org-a' },
175
196
  payload: { nested: true },
176
197
  tenantId: 'tenant-a',
177
198
  organizationId: 'org-a',
178
199
  _idempotencyKey: `scheduler-${scheduleId}-worker-job-1`,
200
+ _jobOrigin: 'scheduler',
179
201
  })
180
202
  expect(close).toHaveBeenCalledTimes(1)
181
203
  })
@@ -10,6 +10,12 @@ import { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-comm
10
10
  import { buildScheduledCommandContext } from '../lib/commandContext.js'
11
11
  import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
12
12
  import { createLogger } from '@open-mercato/shared/lib/logger'
13
+ import {
14
+ canDispatchScheduleQueueTarget,
15
+ getSchedulerQueueRequiredFeatures,
16
+ sanitizeSchedulerTargetPayload,
17
+ validateSchedulerTargetPayload,
18
+ } from '../lib/safeQueueTargets'
13
19
 
14
20
  const logger = createLogger('scheduler').child({ component: 'worker' })
15
21
 
@@ -167,12 +173,69 @@ export default async function executeScheduleWorker(
167
173
 
168
174
  // Enqueue target job or execute command
169
175
  if (schedule.targetType === 'queue' && schedule.targetQueue) {
176
+ // Dispatch-time reauthorization (#5213): provenance is verified, never
177
+ // trusted — module-authored rows must still be owned by their recorded
178
+ // sourceModule and carry no acting-user stamp; API-authored rows may only
179
+ // target workers that opted into scheduling.
180
+ if (!canDispatchScheduleQueueTarget(schedule)) {
181
+ logger.error('Refusing non-safe queue target for schedule', {
182
+ scheduleId,
183
+ targetQueue: schedule.targetQueue,
184
+ sourceType: schedule.sourceType,
185
+ sourceModule: schedule.sourceType === 'module' ? schedule.sourceModule : undefined,
186
+ })
187
+ await emitSchedulerEvent('scheduler.job.skipped', {
188
+ id: schedule.id,
189
+ tenantId: schedule.tenantId,
190
+ organizationId: schedule.organizationId,
191
+ reason: `Queue is not an approved scheduler target: ${schedule.targetQueue}`,
192
+ })
193
+ return
194
+ }
195
+
196
+ // Re-validate the stored payload against the target's registered schema and
197
+ // the tenant-level features the target requires (#5213, defense in depth).
198
+ const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)
199
+ if (payloadIssue) {
200
+ logger.error('Refusing schedule with invalid payload for queue target', {
201
+ scheduleId,
202
+ targetQueue: schedule.targetQueue,
203
+ issue: payloadIssue,
204
+ })
205
+ await emitSchedulerEvent('scheduler.job.skipped', {
206
+ id: schedule.id,
207
+ tenantId: schedule.tenantId,
208
+ organizationId: schedule.organizationId,
209
+ reason: `Invalid payload for scheduler queue ${schedule.targetQueue}`,
210
+ })
211
+ return
212
+ }
213
+
214
+ const requiredQueueFeatures = getSchedulerQueueRequiredFeatures(schedule.targetQueue)
215
+ if (schedule.scopeType !== 'system' && requiredQueueFeatures.length > 0) {
216
+ for (const feature of requiredQueueFeatures) {
217
+ if (await rbacService.tenantHasFeature(schedule.tenantId, feature, { organizationId: schedule.organizationId })) continue
218
+ logger.error('Refusing schedule whose tenant lacks a required feature of the queue target', {
219
+ scheduleId,
220
+ targetQueue: schedule.targetQueue,
221
+ feature,
222
+ })
223
+ await emitSchedulerEvent('scheduler.job.skipped', {
224
+ id: schedule.id,
225
+ tenantId: schedule.tenantId,
226
+ organizationId: schedule.organizationId,
227
+ reason: `Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`,
228
+ })
229
+ return
230
+ }
231
+ }
232
+
170
233
  // Determine queue strategy from environment
171
234
  const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'
172
235
  const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {
173
236
  connection: { url: getRedisUrlOrThrow('QUEUE') },
174
237
  })
175
-
238
+
176
239
  let targetJobId: string | undefined
177
240
  try {
178
241
  // The execute-schedule job id is stable across BullMQ retries, so if
@@ -180,12 +243,18 @@ export default async function executeScheduleWorker(
180
243
  // reuses the same idempotency key and downstream workers can dedupe.
181
244
  const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())
182
245
 
183
- targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({
184
- targetPayload: schedule.targetPayload,
185
- tenantId: schedule.tenantId,
186
- organizationId: schedule.organizationId,
187
- idempotencyKey,
188
- }))
246
+ // Rebuild tenant/org authority context and the trusted dispatch origin
247
+ // server-side; author-supplied scope/envelope keys never survive (#5213).
248
+ const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule)
249
+ targetJobId = await targetQueue.enqueue({
250
+ ...buildQueueTargetPayload({
251
+ targetPayload: sanitizedPayload,
252
+ tenantId: schedule.tenantId,
253
+ organizationId: schedule.organizationId,
254
+ idempotencyKey,
255
+ }),
256
+ _jobOrigin: 'scheduler' as const,
257
+ })
189
258
  } finally {
190
259
  // Always close the queue instance to free Redis connections
191
260
  await targetQueue.close()
@@ -0,0 +1,25 @@
1
+ import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+
4
+ const logger = createLogger('scheduler').child({ component: 'test-echo-queue' })
5
+
6
+ export const metadata: WorkerMeta = {
7
+ queue: 'scheduler-test',
8
+ id: 'scheduler:test-echo-queue',
9
+ concurrency: 1,
10
+ /**
11
+ * Deliberate scheduler-safe target for QA fixtures and manual experiments,
12
+ * mirroring the `scheduler.test.echo` command. The handler is side-effect
13
+ * free: it only logs that a delivery arrived.
14
+ */
15
+ schedulerSafe: true,
16
+ }
17
+
18
+ type TestEchoPayload = {
19
+ message?: unknown
20
+ }
21
+
22
+ export default async function handle(job: QueuedJob<TestEchoPayload>, _ctx: JobContext): Promise<void> {
23
+ const message = typeof job.payload?.message === 'string' ? job.payload.message : ''
24
+ logger.info('Test queue target received a scheduled delivery', { messageId: message.slice(0, 80) })
25
+ }