@open-mercato/scheduler 0.6.8-develop.7066.1.6bfa9ff69f → 0.6.8-develop.7067.1.82e9867ea1

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 (64) 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-CRUDFORM-001.spec.js +3 -3
  11. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
  12. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +107 -44
  13. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
  14. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +13 -3
  15. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
  16. package/dist/modules/scheduler/api/targets/route.js +2 -12
  17. package/dist/modules/scheduler/api/targets/route.js.map +2 -2
  18. package/dist/modules/scheduler/cli.js +32 -1
  19. package/dist/modules/scheduler/cli.js.map +2 -2
  20. package/dist/modules/scheduler/commands/jobs.js +84 -2
  21. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  22. package/dist/modules/scheduler/data/validators.js +27 -3
  23. package/dist/modules/scheduler/data/validators.js.map +2 -2
  24. package/dist/modules/scheduler/i18n/de.json +2 -0
  25. package/dist/modules/scheduler/i18n/en.json +2 -0
  26. package/dist/modules/scheduler/i18n/es.json +2 -0
  27. package/dist/modules/scheduler/i18n/ko.json +2 -0
  28. package/dist/modules/scheduler/i18n/pl.json +2 -0
  29. package/dist/modules/scheduler/lib/safeQueueTargets.js +157 -0
  30. package/dist/modules/scheduler/lib/safeQueueTargets.js.map +7 -0
  31. package/dist/modules/scheduler/services/localSchedulerService.js +31 -6
  32. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  33. package/dist/modules/scheduler/workers/execute-schedule.worker.js +64 -6
  34. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  35. package/dist/modules/scheduler/workers/test-echo-queue.worker.js +22 -0
  36. package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +7 -0
  37. package/package.json +5 -5
  38. package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
  39. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +14 -8
  40. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
  41. package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +90 -0
  42. package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
  43. package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +154 -66
  44. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +24 -5
  45. package/src/modules/scheduler/api/targets/__tests__/route.test.ts +3 -4
  46. package/src/modules/scheduler/api/targets/route.ts +6 -17
  47. package/src/modules/scheduler/cli.ts +44 -1
  48. package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +177 -0
  49. package/src/modules/scheduler/commands/jobs.ts +137 -3
  50. package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +110 -0
  51. package/src/modules/scheduler/data/__tests__/validators.test.ts +26 -4
  52. package/src/modules/scheduler/data/validators.ts +37 -2
  53. package/src/modules/scheduler/i18n/de.json +2 -0
  54. package/src/modules/scheduler/i18n/en.json +2 -0
  55. package/src/modules/scheduler/i18n/es.json +2 -0
  56. package/src/modules/scheduler/i18n/ko.json +2 -0
  57. package/src/modules/scheduler/i18n/pl.json +2 -0
  58. package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +311 -0
  59. package/src/modules/scheduler/lib/safeQueueTargets.ts +324 -0
  60. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +179 -0
  61. package/src/modules/scheduler/services/localSchedulerService.ts +39 -6
  62. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +25 -3
  63. package/src/modules/scheduler/workers/execute-schedule.worker.ts +76 -7
  64. package/src/modules/scheduler/workers/test-echo-queue.worker.ts +25 -0
@@ -0,0 +1,324 @@
1
+ import { getModules } from '@open-mercato/shared/lib/modules/registry'
2
+ import type { ModuleWorker } from '@open-mercato/shared/modules/registry'
3
+ import { z, type ZodTypeAny } from 'zod'
4
+
5
+ export type SchedulerSafeQueueTarget = {
6
+ queue: string
7
+ /**
8
+ * Indicative primary owner — the first registry module found among the
9
+ * queue's consumers. A queue may legitimately have consumers from more than
10
+ * one module; ownership for provenance checks is always resolved per module.
11
+ */
12
+ moduleId: string
13
+ /** Extra creator features a principal must hold to schedule onto this queue. */
14
+ requiredFeatures: string[]
15
+ }
16
+
17
+ export type TrustedScheduleScope = {
18
+ tenantId: string | null
19
+ organizationId: string | null
20
+ }
21
+
22
+ /**
23
+ * Runtime payload schemas for opted-in scheduler targets.
24
+ *
25
+ * Worker metadata cannot carry a Zod schema through static registry generation,
26
+ * so the module that owns a schedulable worker registers its target payload
27
+ * schema here at bootstrap. Targets without a registered schema accept
28
+ * arbitrary JSON payloads (documented escape hatch, mirroring scheduler-safe
29
+ * commands which also carry no payload schema).
30
+ */
31
+ const registeredPayloadSchemas = new Map<string, ZodTypeAny>()
32
+
33
+ export function registerSchedulerQueuePayloadSchema(queue: string, schema: ZodTypeAny): void {
34
+ registeredPayloadSchemas.set(queue, schema)
35
+ }
36
+
37
+ export function getSchedulerQueuePayloadSchema(queue: string): ZodTypeAny | null {
38
+ return registeredPayloadSchemas.get(queue) ?? null
39
+ }
40
+
41
+ export type SchedulerTargetRbacService = {
42
+ userHasAllFeatures?: (
43
+ userId: string,
44
+ required: readonly string[],
45
+ scope: { tenantId: string | null; organizationId: string | null },
46
+ ) => Promise<boolean>
47
+ }
48
+
49
+ /**
50
+ * Creator-side authorization for an opted-in queue target (#5213): a principal
51
+ * must hold every feature the target declares beyond `scheduler.jobs.manage`.
52
+ * Super administrators bypass target-specific checks, mirroring the rest of
53
+ * the scheduler's authorization model. Returns null when authorized,
54
+ * otherwise a short reason string.
55
+ */
56
+ export async function assertSchedulerQueueTargetAuthorized(params: {
57
+ queue: string
58
+ actorUserId?: string | null
59
+ tenantId?: string | null
60
+ organizationId?: string | null
61
+ isSuperAdmin?: boolean
62
+ rbacService?: SchedulerTargetRbacService | null
63
+ }): Promise<string | null> {
64
+ const requiredFeatures = getSchedulerQueueRequiredFeatures(params.queue)
65
+ if (requiredFeatures.length === 0) return null
66
+ if (params.isSuperAdmin === true) return null
67
+
68
+ const actorUserId = typeof params.actorUserId === 'string' ? params.actorUserId.trim() : ''
69
+ if (!actorUserId) return 'Scheduler queue target requires an authenticated creator'
70
+ if (typeof params.rbacService?.userHasAllFeatures !== 'function') {
71
+ return 'Scheduler queue target authorization is unavailable'
72
+ }
73
+
74
+ const authorized = await params.rbacService.userHasAllFeatures(actorUserId, requiredFeatures, {
75
+ tenantId: params.tenantId ?? null,
76
+ organizationId: params.organizationId ?? null,
77
+ })
78
+ if (!authorized) {
79
+ return `Scheduler queue target requires features the creator lacks: ${requiredFeatures.join(', ')}`
80
+ }
81
+ return null
82
+ }
83
+
84
+ /**
85
+ * Validates an author-supplied target payload against the target's registered
86
+ * schema, when one exists. Returns null when the payload is acceptable,
87
+ * otherwise a short reason string.
88
+ */
89
+ export function validateSchedulerTargetPayload(
90
+ queue: string,
91
+ targetPayload: Record<string, unknown> | null | undefined,
92
+ ): string | null {
93
+ const schema = getSchedulerQueuePayloadSchema(queue)
94
+ if (!schema) return null
95
+ const result = schema.safeParse(targetPayload ?? {})
96
+ if (result.success) return null
97
+ const firstIssue = result.error.issues[0]
98
+ return [
99
+ firstIssue?.path?.length ? `${firstIssue.path.join('.')}: ` : '',
100
+ firstIssue?.message ?? 'Invalid payload',
101
+ ].join('')
102
+ }
103
+
104
+ /**
105
+ * Keys a schedule author may never control on a dispatched target payload:
106
+ * tenant/organization authority context and the reserved `_`-prefixed envelope
107
+ * namespace (idempotency keys, dispatch-origin markers, …).
108
+ */
109
+ const RESERVED_PAYLOAD_KEYS = new Set(['tenantId', 'organizationId', 'scope'])
110
+
111
+ function isReservedPayloadKey(key: string): boolean {
112
+ return RESERVED_PAYLOAD_KEYS.has(key) || key.startsWith('_')
113
+ }
114
+
115
+ function stripReservedKeys(payload: Record<string, unknown>): Record<string, unknown> {
116
+ const result: Record<string, unknown> = {}
117
+ for (const [key, value] of Object.entries(payload)) {
118
+ if (isReservedPayloadKey(key)) continue
119
+ if (key === 'payload' && value !== null && typeof value === 'object' && !Array.isArray(value)) {
120
+ result[key] = stripReservedKeys(value as Record<string, unknown>)
121
+ continue
122
+ }
123
+ result[key] = value
124
+ }
125
+ return result
126
+ }
127
+
128
+ /**
129
+ * Rebuild the trusted scope for a dispatched schedule payload from the stored
130
+ * schedule row. Callers must never trust scope-shaped data from the schedule
131
+ * author's `targetPayload`.
132
+ */
133
+ export function buildTrustedScheduleScope(schedule: {
134
+ tenantId?: string | null
135
+ organizationId?: string | null
136
+ }): TrustedScheduleScope {
137
+ return {
138
+ tenantId: schedule.tenantId ?? null,
139
+ organizationId: schedule.organizationId ?? null,
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Sanitize an author-supplied schedule target payload before dispatch:
145
+ * deep-copies it, strips reserved authority and `_`-prefixed envelope keys at
146
+ * the payload root and inside the local-strategy `payload` wrapper level, then
147
+ * injects the trusted scope derived from the schedule row.
148
+ */
149
+ export function sanitizeSchedulerTargetPayload(
150
+ targetPayload: Record<string, unknown> | null | undefined,
151
+ schedule: Parameters<typeof buildTrustedScheduleScope>[0],
152
+ ): Record<string, unknown> {
153
+ const source = targetPayload ?? {}
154
+ const cloned = structuredClone(source)
155
+ const stripped = stripReservedKeys(cloned)
156
+ return {
157
+ ...stripped,
158
+ scope: buildTrustedScheduleScope(schedule),
159
+ }
160
+ }
161
+
162
+ const queueConsumerCache = new WeakMap<object, Map<string, ModuleWorker[]>>()
163
+
164
+ function collectQueueConsumers(): Map<string, ModuleWorker[]> {
165
+ let modules: ReturnType<typeof getModules>
166
+ try {
167
+ modules = getModules()
168
+ } catch {
169
+ return new Map()
170
+ }
171
+ // The registry returns the same array instance between registrations, so the
172
+ // grouped map can be memoized per instance instead of rebuilt per lookup.
173
+ const cached = queueConsumerCache.get(modules)
174
+ if (cached) return cached
175
+
176
+ const byQueue = new Map<string, ModuleWorker[]>()
177
+ for (const mod of modules) {
178
+ for (const worker of mod.workers ?? []) {
179
+ if (!worker.queue) continue
180
+ const consumers = byQueue.get(worker.queue) ?? []
181
+ consumers.push(worker)
182
+ byQueue.set(worker.queue, consumers)
183
+ }
184
+ }
185
+ queueConsumerCache.set(modules, byQueue)
186
+ return byQueue
187
+ }
188
+
189
+ /**
190
+ * Enumerate queue targets that are safe for user-facing scheduling (#5213):
191
+ * every worker consuming the queue must explicitly opt in — queue consumers
192
+ * compete for the same jobs, so a single non-opted-in consumer would otherwise
193
+ * receive scheduler-controlled payloads it never agreed to accept.
194
+ */
195
+ export function listSchedulerSafeQueueTargets(): SchedulerSafeQueueTarget[] {
196
+ const targets: SchedulerSafeQueueTarget[] = []
197
+ for (const [queue, consumers] of collectQueueConsumers()) {
198
+ const optedIn = consumers.filter((worker) => worker.schedulerSafe === true)
199
+ if (optedIn.length === 0 || optedIn.length !== consumers.length) continue
200
+
201
+ const requiredFeatures = [...new Set(optedIn.flatMap((worker) => (
202
+ Array.isArray(worker.schedulerRequiredFeatures) ? worker.schedulerRequiredFeatures : []
203
+ )))]
204
+ const ownerModuleIds = [...new Set(consumers.map((worker) => (
205
+ typeof worker.id === 'string' ? (worker.id.split(':')[0] ?? '') : ''
206
+ )))]
207
+ targets.push({ queue, moduleId: ownerModuleIds[0] ?? '', requiredFeatures })
208
+ }
209
+ targets.sort((a, b) => a.queue.localeCompare(b.queue))
210
+ return targets
211
+ }
212
+
213
+ export function isSchedulerSafeQueue(queue: string | null | undefined): boolean {
214
+ if (!queue) return false
215
+ return listSchedulerSafeQueueTargets().some((target) => target.queue === queue)
216
+ }
217
+
218
+ /**
219
+ * Extra creator features required by the target queue beyond
220
+ * `scheduler.jobs.manage` (#5213). Empty when the target declares none.
221
+ */
222
+ export function getSchedulerQueueRequiredFeatures(queue: string): string[] {
223
+ return listSchedulerSafeQueueTargets().find((target) => target.queue === queue)?.requiredFeatures ?? []
224
+ }
225
+
226
+ /**
227
+ * The module ids that own workers on this queue, from the live registry.
228
+ */
229
+ function findQueueOwnerModuleIds(queue: string): Set<string> {
230
+ const owners = new Set<string>()
231
+ for (const [candidate, consumers] of collectQueueConsumers()) {
232
+ if (candidate !== queue) continue
233
+ for (const worker of consumers) {
234
+ if (typeof worker.id === 'string') owners.add(worker.id.split(':')[0] ?? '')
235
+ }
236
+ }
237
+ return owners
238
+ }
239
+
240
+ /**
241
+ * Operator-facing audit for persisted module-authored queue rows (#5213).
242
+ *
243
+ * Provenance on pre-upgrade rows is not fully verifiable at rest: a row created
244
+ * by a non-user-bound API key carries no acting-user stamp, making it
245
+ * indistinguishable from a genuine module registration. This helper surfaces
246
+ * every module-authored queue-target row with its current dispatch verdict so
247
+ * operators can review the allowed ones against their known integrations
248
+ * (`mercato scheduler audit-queue-targets`).
249
+ */
250
+ export type SchedulerModuleQueueRowAudit = {
251
+ scheduleId: string
252
+ name?: string
253
+ sourceModule: string | null
254
+ targetQueue: string | null
255
+ isEnabled: boolean
256
+ /** Whether the dispatch guard currently lets this row fire. */
257
+ dispatchAllowed: boolean
258
+ }
259
+
260
+ export function auditSchedulerModuleQueueRows(
261
+ rows: ReadonlyArray<{
262
+ id: string
263
+ name?: string | null
264
+ targetQueue?: string | null
265
+ sourceType?: string | null
266
+ sourceModule?: string | null
267
+ createdByUserId?: string | null
268
+ isEnabled?: boolean
269
+ }>,
270
+ ): SchedulerModuleQueueRowAudit[] {
271
+ return rows
272
+ .filter((row) => row.sourceType === 'module' && row.targetQueue)
273
+ .map((row) => ({
274
+ scheduleId: row.id,
275
+ name: row.name ?? undefined,
276
+ sourceModule: row.sourceModule ?? null,
277
+ targetQueue: row.targetQueue ?? null,
278
+ isEnabled: row.isEnabled !== false,
279
+ dispatchAllowed: canDispatchScheduleQueueTarget(row),
280
+ }))
281
+ }
282
+
283
+ /**
284
+ * Dispatch-time authorization for queue-targeted schedules (#5213).
285
+ *
286
+ * Provenance is server-owned and verified, never trusted from the stored row:
287
+ * - API-authored schedules (`sourceType: 'user'`) may only target queues whose
288
+ * workers all opted into scheduling.
289
+ * - Module-authored schedules are accepted only when the recorded
290
+ * `sourceModule` owns the queue in the live registry AND the row carries no
291
+ * acting-user stamp (`createdByUserId == null`) — `schedulerService.register()`
292
+ * never stamps one while every interactive API write does. Residual limit: a
293
+ * pre-upgrade row authored by a non-user-bound API key also lacks the stamp;
294
+ * surface those with `auditSchedulerModuleQueueRows` /
295
+ * `mercato scheduler audit-queue-targets`.
296
+ */
297
+ export function canDispatchScheduleQueueTarget(schedule: {
298
+ targetQueue?: string | null
299
+ sourceType?: string | null
300
+ sourceModule?: string | null
301
+ /** Server-stamped actor on API-authored rows; module registration never sets it. */
302
+ createdByUserId?: string | null
303
+ }): boolean {
304
+ if (!schedule.targetQueue) return false
305
+ if (schedule.sourceType === 'module') {
306
+ // Provenance must be server-owned, and both stored fields were client-
307
+ // writable before #5213. A matching sourceModule alone is forgeable public
308
+ // knowledge, so a persisted module row is only trusted when it also carries
309
+ // no acting-user stamp: `schedulerService.register()` never sets one, while
310
+ // every API write does. Forged pre-upgrade rows fail closed here.
311
+ if (!schedule.sourceModule) return false
312
+ if (schedule.createdByUserId) return false
313
+ return findQueueOwnerModuleIds(schedule.targetQueue).has(schedule.sourceModule)
314
+ }
315
+ return isSchedulerSafeQueue(schedule.targetQueue)
316
+ }
317
+
318
+ // Built-in opted-in target owned by this module: the side-effect-free QA echo
319
+ // queue. The strict shape keeps scheduler-controlled payloads predictable and
320
+ // gives deployments a working reference for registering their own schemas.
321
+ registerSchedulerQueuePayloadSchema(
322
+ 'scheduler-test',
323
+ z.object({ message: z.string().max(200).optional() }).passthrough(),
324
+ )
@@ -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
  })