@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +9 -4
- package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +3 -3
- package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +72 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +7 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +66 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +7 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js +3 -3
- package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +107 -44
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js +13 -3
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
- package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +7 -13
- package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
- package/dist/modules/scheduler/api/targets/route.js +2 -12
- package/dist/modules/scheduler/api/targets/route.js.map +2 -2
- package/dist/modules/scheduler/api/trigger/route.js +7 -13
- package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
- package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +3 -1
- package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
- package/dist/modules/scheduler/cli.js +32 -1
- package/dist/modules/scheduler/cli.js.map +2 -2
- package/dist/modules/scheduler/commands/jobs.js +84 -2
- package/dist/modules/scheduler/commands/jobs.js.map +2 -2
- package/dist/modules/scheduler/data/validators.js +27 -3
- package/dist/modules/scheduler/data/validators.js.map +2 -2
- package/dist/modules/scheduler/i18n/de.json +2 -0
- package/dist/modules/scheduler/i18n/en.json +2 -0
- package/dist/modules/scheduler/i18n/es.json +2 -0
- package/dist/modules/scheduler/i18n/ko.json +2 -0
- package/dist/modules/scheduler/i18n/pl.json +2 -0
- package/dist/modules/scheduler/lib/safeQueueTargets.js +157 -0
- package/dist/modules/scheduler/lib/safeQueueTargets.js.map +7 -0
- package/dist/modules/scheduler/lib/scheduleAccess.js +22 -0
- package/dist/modules/scheduler/lib/scheduleAccess.js.map +7 -0
- package/dist/modules/scheduler/services/localSchedulerService.js +31 -6
- package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
- package/dist/modules/scheduler/workers/execute-schedule.worker.js +64 -6
- package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js +22 -0
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +7 -0
- package/package.json +7 -6
- package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
- package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +14 -8
- package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
- package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +90 -0
- package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +94 -0
- package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
- package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +154 -66
- package/src/modules/scheduler/__integration__/helpers/scheduler.ts +24 -5
- package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +9 -19
- package/src/modules/scheduler/api/targets/__tests__/route.test.ts +3 -4
- package/src/modules/scheduler/api/targets/route.ts +6 -17
- package/src/modules/scheduler/api/trigger/route.ts +9 -19
- package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +4 -1
- package/src/modules/scheduler/cli.ts +44 -1
- package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +177 -0
- package/src/modules/scheduler/commands/jobs.ts +137 -3
- package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +110 -0
- package/src/modules/scheduler/data/__tests__/validators.test.ts +26 -4
- package/src/modules/scheduler/data/validators.ts +37 -2
- package/src/modules/scheduler/i18n/de.json +2 -0
- package/src/modules/scheduler/i18n/en.json +2 -0
- package/src/modules/scheduler/i18n/es.json +2 -0
- package/src/modules/scheduler/i18n/ko.json +2 -0
- package/src/modules/scheduler/i18n/pl.json +2 -0
- package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +311 -0
- package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +164 -0
- package/src/modules/scheduler/lib/safeQueueTargets.ts +324 -0
- package/src/modules/scheduler/lib/scheduleAccess.ts +104 -0
- package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +179 -0
- package/src/modules/scheduler/services/localSchedulerService.ts +39 -6
- package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +25 -3
- package/src/modules/scheduler/workers/execute-schedule.worker.ts +76 -7
- 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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
}
|