@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
@@ -6,9 +6,25 @@ export const SCHEDULER_JOBS_PATH = '/api/scheduler/jobs'
6
6
  export const SCHEDULER_TARGETS_PATH = '/api/scheduler/targets'
7
7
  export const SCHEDULER_TRIGGER_PATH = '/api/scheduler/trigger'
8
8
 
9
- /** Queue registered by the scheduler module's execute-schedule worker. */
9
+ /** Queue registered by the scheduler module's execute-schedule worker (internal, not schedulable). */
10
10
  export const SCHEDULER_EXECUTION_QUEUE = 'scheduler-execution'
11
11
 
12
+ /** Deliberately side-effect-free queue whose worker opted into scheduling (#5213). */
13
+ export const SCHEDULER_TEST_QUEUE = 'scheduler-test'
14
+
15
+ /**
16
+ * Internal queues that must never be offered as scheduler targets (#5213).
17
+ * Workers must opt in via `schedulerSafe: true` to become schedulable.
18
+ */
19
+ export const SCHEDULER_FORBIDDEN_INTERNAL_QUEUES = [
20
+ SCHEDULER_EXECUTION_QUEUE,
21
+ 'stripe-webhook',
22
+ 'payment-gateways-webhook',
23
+ ] as const
24
+
25
+ /** Always-registered command used by fixtures now that queue targets are restricted. */
26
+ export const SCHEDULER_TEST_COMMAND = 'scheduler.test.echo'
27
+
12
28
  export type SchedulerJob = {
13
29
  id: string
14
30
  name: string
@@ -51,16 +67,19 @@ export function uniqueScheduleName(prefix: string): string {
51
67
  }
52
68
 
53
69
  /**
54
- * Create an organization-scoped queue-target schedule via the API and return its id.
70
+ * Create an organization-scoped schedule via the API and return its id.
55
71
  * Org/tenant are derived server-side from the caller's auth context, so the caller
56
72
  * only needs an admin (or otherwise scheduler.jobs.manage) token.
73
+ *
74
+ * Defaults to a command target: user-authored queue targets are restricted to
75
+ * scheduler-safe queues (#5213), so fixtures must not assume a queue is allowed.
57
76
  */
58
77
  export async function createScheduleJob(
59
78
  request: APIRequestContext,
60
79
  token: string,
61
80
  overrides: CreateScheduleOverrides = {},
62
81
  ): Promise<string> {
63
- const targetType = overrides.targetType ?? 'queue'
82
+ const targetType = overrides.targetType ?? 'command'
64
83
  const data: Record<string, unknown> = {
65
84
  name: overrides.name ?? uniqueScheduleName('Integration Scheduler'),
66
85
  description: overrides.description ?? 'Created by scheduler integration tests',
@@ -74,9 +93,9 @@ export async function createScheduleJob(
74
93
  sourceType: overrides.sourceType ?? 'user',
75
94
  }
76
95
  if (targetType === 'command') {
77
- data.targetCommand = overrides.targetCommand
96
+ data.targetCommand = overrides.targetCommand ?? SCHEDULER_TEST_COMMAND
78
97
  } else {
79
- data.targetQueue = overrides.targetQueue ?? SCHEDULER_EXECUTION_QUEUE
98
+ data.targetQueue = overrides.targetQueue ?? SCHEDULER_TEST_QUEUE
80
99
  }
81
100
 
82
101
  const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, { token, data })
@@ -50,8 +50,8 @@ describe('GET /api/scheduler/targets', () => {
50
50
  orgId: 'org-1',
51
51
  })
52
52
  ;(getModules as jest.Mock).mockReturnValue([
53
- { workers: [{ queue: 'z-queue' }] },
54
- { workers: [{ queue: 'a-queue' }] },
53
+ { workers: [{ queue: 'z-queue' }, { queue: 'internal-queue' }] },
54
+ { workers: [{ queue: 'a-queue', schedulerSafe: true }] },
55
55
  ])
56
56
  })
57
57
 
@@ -60,14 +60,13 @@ describe('GET /api/scheduler/targets', () => {
60
60
  unregisterCommand(unsafeCommand.id)
61
61
  })
62
62
 
63
- it('lists only scheduler-safe commands', async () => {
63
+ it('lists only scheduler-safe queues and commands', async () => {
64
64
  const response = await GET(new NextRequest('http://localhost/api/scheduler/targets'))
65
65
  const body = await response.json()
66
66
 
67
67
  expect(response.status).toBe(200)
68
68
  expect(body.queues).toEqual([
69
69
  { value: 'a-queue', label: 'a-queue' },
70
- { value: 'z-queue', label: 'z-queue' },
71
70
  ])
72
71
  expect(body.commands).toEqual([
73
72
  { value: safeCommand.id, label: safeCommand.id },
@@ -1,10 +1,10 @@
1
1
  import { NextRequest, NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
- import { getModules } from '@open-mercato/shared/lib/modules/registry'
5
4
  import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
6
5
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
6
  import { listSchedulerSafeCommands } from '../../lib/scheduler-safe-commands'
7
+ import { listSchedulerSafeQueueTargets } from '../../lib/safeQueueTargets'
8
8
 
9
9
  export const metadata = {
10
10
  requireAuth: true,
@@ -13,7 +13,9 @@ export const metadata = {
13
13
 
14
14
  /**
15
15
  * GET /api/scheduler/targets
16
- * Returns available queue names and command IDs for schedule target selection.
16
+ * Returns scheduler-safe queue names and command IDs for schedule target selection.
17
+ * Only queues whose workers opted into scheduling (`schedulerSafe: true`) are
18
+ * advertised; internal and system-only workers stay undiscoverable (#5213).
17
19
  */
18
20
  export async function GET(req: NextRequest) {
19
21
  const auth = await getAuthFromRequest(req)
@@ -21,20 +23,8 @@ export async function GET(req: NextRequest) {
21
23
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
22
24
  }
23
25
 
24
- const modules = getModules()
25
-
26
- const queueSet = new Set<string>()
27
- for (const mod of modules) {
28
- if (mod.workers) {
29
- for (const worker of mod.workers) {
30
- queueSet.add(worker.queue)
31
- }
32
- }
33
- }
34
-
35
- const queues = Array.from(queueSet)
36
- .sort((a, b) => a.localeCompare(b))
37
- .map((queue) => ({ value: queue, label: queue }))
26
+ const queues = listSchedulerSafeQueueTargets()
27
+ .map((target) => ({ value: target.queue, label: target.queue }))
38
28
 
39
29
  const commands = listSchedulerSafeCommands()
40
30
  .filter((command) => commandRegistry.has(command.commandId))
@@ -66,7 +56,6 @@ export const openApi: OpenApiRouteDoc = {
66
56
  GET: {
67
57
  operationId: 'listScheduleTargets',
68
58
  summary: 'List available queues and commands',
69
- description: 'Returns all registered queue names (from module workers) and explicitly scheduler-safe command IDs that can be used as schedule targets.',
70
59
  responses: [
71
60
  {
72
61
  status: 200,
@@ -3,6 +3,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import type { EntityManager } from '@mikro-orm/core'
4
4
  import { ScheduledJob } from './data/entities.js'
5
5
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
6
+ import { auditSchedulerModuleQueueRows } from './lib/safeQueueTargets'
6
7
 
7
8
  const writeLine = (text = '') => {
8
9
  process.stdout.write(`${text}\n`)
@@ -225,4 +226,46 @@ const startCommand: ModuleCli = {
225
226
  },
226
227
  }
227
228
 
228
- export default [listCommand, statusCommand, runCommand, startCommand]
229
+ // Lists every persisted module-authored queue-target row with its current
230
+ // dispatch verdict (#5213). Rows authored before the hardening by a
231
+ // non-user-bound API key are indistinguishable from genuine registrations at
232
+ // rest, so operators review the ALLOWED entries against their known integrations.
233
+ const auditQueueTargetsCommand: ModuleCli = {
234
+ command: 'audit-queue-targets',
235
+ async run(rest) {
236
+ const args = parseArgs(rest)
237
+ const { resolve } = await createRequestContainer()
238
+ const em = resolve('em') as EntityManager
239
+
240
+ const where: Record<string, unknown> = { deletedAt: null, sourceType: 'module' }
241
+ if (args.tenant || args.tenantId) where.tenantId = args.tenant || args.tenantId
242
+
243
+ const rows = await em.find(ScheduledJob, where, {
244
+ orderBy: { name: 'ASC' },
245
+ })
246
+
247
+ const audits = auditSchedulerModuleQueueRows(rows)
248
+
249
+ writeLine(`Module-authored queue-target schedules: ${audits.length}`)
250
+ let allowedCount = 0
251
+ for (const audit of audits) {
252
+ const verdict = audit.dispatchAllowed ? 'ALLOWED' : 'blocked'
253
+ if (audit.dispatchAllowed) allowedCount += 1
254
+ writeLine(
255
+ `[${verdict}] ${audit.scheduleId}${audit.name ? ` — ${audit.name}` : ''}` +
256
+ ` module=${audit.sourceModule ?? '(none)'} queue=${audit.targetQueue ?? '(none)'}` +
257
+ ` enabled=${audit.isEnabled ? 'yes' : 'no'}`,
258
+ )
259
+ }
260
+
261
+ if (allowedCount > 0) {
262
+ writeLine('')
263
+ writeLine(`Review the ${allowedCount} ALLOWED row(s) above against your known module integrations.`)
264
+ writeLine('Pre-upgrade rows created with a non-user-bound API key cannot be distinguished')
265
+ writeLine('from genuine registrations at rest; disable anything unrecognized:')
266
+ writeLine(' yarn mercato scheduler list --tenant <tenantId>')
267
+ }
268
+ },
269
+ }
270
+
271
+ export default [listCommand, statusCommand, runCommand, startCommand, auditQueueTargetsCommand]
@@ -0,0 +1,177 @@
1
+ export {}
2
+
3
+ const registerCommand = jest.fn()
4
+
5
+ jest.mock('@open-mercato/shared/lib/commands', () => ({
6
+ registerCommand,
7
+ }))
8
+
9
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
10
+ resolveTranslations: jest.fn().mockResolvedValue({
11
+ translate: (_key: string, fallback?: string) => fallback ?? _key,
12
+ }),
13
+ }))
14
+
15
+ function loadCommands() {
16
+ let update: any
17
+ jest.isolateModules(() => {
18
+ require('../jobs')
19
+ update = registerCommand.mock.calls.find(([cmd]) => cmd.id === 'scheduler.jobs.update')?.[0]
20
+ })
21
+ return update
22
+ }
23
+
24
+ const safeQueueRow = {
25
+ id: 'job-1',
26
+ name: 'QA queue schedule',
27
+ scopeType: 'organization',
28
+ tenantId: 'tenant-a',
29
+ organizationId: 'org-a',
30
+ isEnabled: true,
31
+ scheduleType: 'interval',
32
+ scheduleValue: '15m',
33
+ timezone: 'UTC',
34
+ targetType: 'queue',
35
+ targetQueue: 'scheduler-test',
36
+ targetCommand: null,
37
+ // nested payload the UI round-trips verbatim
38
+ targetPayload: { source: 'integration-test', retries: { max: 5 } },
39
+ sourceType: 'user',
40
+ sourceModule: null,
41
+ }
42
+
43
+ const moduleOwnedRow = {
44
+ ...safeQueueRow,
45
+ id: 'job-module',
46
+ name: 'Module-owned schedule',
47
+ sourceType: 'module',
48
+ sourceModule: 'legacy_module',
49
+ createdByUserId: null,
50
+ }
51
+
52
+ function makeEm(schedule: Record<string, unknown>) {
53
+ return {
54
+ fork: jest.fn().mockReturnThis(),
55
+ findOne: jest.fn().mockResolvedValue(schedule),
56
+ persist: jest.fn(),
57
+ flush: jest.fn().mockResolvedValue(undefined),
58
+ count: jest.fn().mockResolvedValue(0),
59
+ }
60
+ }
61
+
62
+ describe('scheduler.jobs.update target-change detection (#5213 N1)', () => {
63
+ beforeEach(() => {
64
+ jest.clearAllMocks()
65
+ jest.resetModules()
66
+ })
67
+
68
+ it('allows an operational-only save of a module-authored schedule whose form resends the unchanged target', async () => {
69
+ const update = loadCommands()
70
+ const em = makeEm(moduleOwnedRow)
71
+ const ctx = {
72
+ auth: { isSuperAdmin: false, tenantId: 'tenant-a', orgId: 'org-a', sub: 'user-1' },
73
+ container: { resolve: jest.fn(() => em) },
74
+ }
75
+
76
+ const result = await update.execute(
77
+ {
78
+ id: moduleOwnedRow.id,
79
+ // the shipped edit form always resends every field…
80
+ targetType: moduleOwnedRow.targetType,
81
+ targetQueue: moduleOwnedRow.targetQueue,
82
+ targetPayload: moduleOwnedRow.targetPayload,
83
+ // …while the operator only changed operational values
84
+ name: 'Renamed by ops',
85
+ scheduleValue: '30m',
86
+ scheduleType: 'interval',
87
+ isEnabled: false,
88
+ },
89
+ ctx,
90
+ )
91
+
92
+ expect(result).toEqual({ ok: true })
93
+ expect(em.flush).toHaveBeenCalled()
94
+ })
95
+
96
+ it('still blocks an actual target or payload change on a module-authored schedule', async () => {
97
+ const update = loadCommands()
98
+ const em = makeEm(moduleOwnedRow)
99
+ const ctx = {
100
+ auth: { isSuperAdmin: false, tenantId: 'tenant-a', orgId: 'org-a', sub: 'user-1' },
101
+ container: { resolve: jest.fn(() => em) },
102
+ }
103
+
104
+ await expect(
105
+ update.execute({ id: moduleOwnedRow.id, targetQueue: 'stripe-webhook' }, ctx),
106
+ ).rejects.toMatchObject({ status: 403 })
107
+ await expect(
108
+ update.execute({ id: moduleOwnedRow.id, targetPayload: { forged: true } }, ctx),
109
+ ).rejects.toMatchObject({ status: 403 })
110
+ expect(em.flush).not.toHaveBeenCalled()
111
+ })
112
+
113
+ it('lets admins disable a legacy user-authored row whose stored queue is no longer approved (#5213 N1)', async () => {
114
+ const update = loadCommands()
115
+ const legacyUnsafe = {
116
+ ...safeQueueRow,
117
+ id: 'job-legacy',
118
+ targetQueue: 'stripe-webhook',
119
+ targetPayload: null as unknown,
120
+ }
121
+ const em = makeEm(legacyUnsafe)
122
+ const ctx = {
123
+ auth: { isSuperAdmin: false, tenantId: 'tenant-a', orgId: 'org-a', sub: 'user-1' },
124
+ container: { resolve: jest.fn(() => em) },
125
+ }
126
+
127
+ // the shipped edit form resends every field — target fields included —
128
+ // while only flipping the switch
129
+ const result = await update.execute(
130
+ {
131
+ id: legacyUnsafe.id,
132
+ targetType: legacyUnsafe.targetType,
133
+ targetQueue: legacyUnsafe.targetQueue,
134
+ isEnabled: false,
135
+ },
136
+ ctx,
137
+ )
138
+ expect(result).toEqual({ ok: true })
139
+
140
+ // retargeting onto another unapproved queue is still rejected
141
+ await expect(
142
+ update.execute({ id: legacyUnsafe.id, targetQueue: 'payment-gateways-webhook' }, ctx),
143
+ ).rejects.toMatchObject({ status: 422 })
144
+ })
145
+
146
+ it('treats an empty-object stored payload as equal to the null the form submits (#5213 N1-residual)', async () => {
147
+ const update = loadCommands()
148
+ const emptyPayloadRow = {
149
+ ...moduleOwnedRow,
150
+ id: 'job-module-empty-payload',
151
+ targetPayload: {},
152
+ }
153
+ const em = makeEm(emptyPayloadRow)
154
+ const ctx = {
155
+ auth: { isSuperAdmin: true, tenantId: null, orgId: null, sub: 'super-1' },
156
+ container: { resolve: jest.fn(() => em) },
157
+ }
158
+
159
+ // ai_assistant-style registration stores `targetPayload: {}`; the edit form
160
+ // normalizes that to `targetPayload: null` on submit. An operational save
161
+ // must not trip the module-owned-target guard over the shape difference.
162
+ const result = await update.execute(
163
+ {
164
+ id: emptyPayloadRow.id,
165
+ targetType: emptyPayloadRow.targetType,
166
+ targetQueue: emptyPayloadRow.targetQueue,
167
+ targetPayload: null,
168
+ name: 'Renamed by ops',
169
+ isEnabled: true,
170
+ },
171
+ ctx,
172
+ )
173
+
174
+ expect(result).toEqual({ ok: true })
175
+ expect(em.flush).toHaveBeenCalled()
176
+ })
177
+ })
@@ -14,7 +14,12 @@ import type {
14
14
  } from '../data/validators.js'
15
15
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
16
16
  import type { BullMQSchedulerService } from '../services/bullmqSchedulerService.js'
17
-
17
+ import {
18
+ assertSchedulerQueueTargetAuthorized,
19
+ isSchedulerSafeQueue,
20
+ validateSchedulerTargetPayload,
21
+ } from '../lib/safeQueueTargets.js'
22
+ import type { SchedulerTargetRbacService } from '../lib/safeQueueTargets.js'
18
23
  /**
19
24
  * Snapshot of a schedule for undo/redo
20
25
  */
@@ -83,6 +88,64 @@ function toDate(value: Date | string | null | undefined): Date | null {
83
88
  return value instanceof Date ? value : new Date(value)
84
89
  }
85
90
 
91
+ function canonicalJson(value: unknown): string {
92
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)
93
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
94
+ const entries = Object.entries(value as Record<string, unknown>)
95
+ .filter(([, v]) => v !== undefined)
96
+ .sort(([a], [b]) => a.localeCompare(b))
97
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`
98
+ }
99
+
100
+ function normalizeTargetPayloadValue(value: unknown): unknown {
101
+ // `schedulerService.register()` persists `{}` as-is while the edit form sends
102
+ // `null` for an empty payload — treat the three empty shapes as equal (#5213).
103
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)
104
+ && Object.keys(value as Record<string, unknown>).length === 0) return null
105
+ return value
106
+ }
107
+
108
+ function targetPayloadChanged(inputValue: unknown, storedValue: unknown): boolean {
109
+ return targetValueChanged(
110
+ normalizeTargetPayloadValue(inputValue),
111
+ normalizeTargetPayloadValue(storedValue),
112
+ )
113
+ }
114
+
115
+ function targetValueChanged(inputValue: unknown, storedValue: unknown): boolean {
116
+ if (inputValue === undefined) return false
117
+ return canonicalJson(inputValue ?? null) !== canonicalJson(storedValue ?? null)
118
+ }
119
+
120
+ type QueueTargetAuthorizationContext = CommandRuntimeContext
121
+
122
+ async function assertQueueTargetAuthorizedForActor(
123
+ ctx: QueueTargetAuthorizationContext,
124
+ queue: string,
125
+ ): Promise<void> {
126
+ const rbacService = (() => {
127
+ try {
128
+ return ctx.container?.resolve<SchedulerTargetRbacService>('rbacService') ?? null
129
+ } catch {
130
+ return null
131
+ }
132
+ })()
133
+ const reason = await assertSchedulerQueueTargetAuthorized({
134
+ queue,
135
+ actorUserId: resolveCommandActorUserId(ctx),
136
+ tenantId: ctx.auth?.tenantId ?? null,
137
+ organizationId: ctx.auth?.orgId ?? null,
138
+ isSuperAdmin: ctx.auth?.isSuperAdmin === true,
139
+ rbacService,
140
+ })
141
+ if (reason) throw new CrudHttpError(403, { error: reason })
142
+ }
143
+
144
+ async function assertQueueTargetPayloadAllowed(queue: string, targetPayload: Record<string, unknown> | null | undefined): Promise<void> {
145
+ const reason = validateSchedulerTargetPayload(queue, targetPayload)
146
+ if (reason) throw new CrudHttpError(422, { error: `Invalid target payload for queue ${queue}: ${reason}` })
147
+ }
148
+
86
149
  function resolveCommandActorUserId(ctx: CommandRuntimeContext): string | null {
87
150
  const auth = ctx.auth
88
151
  if (!auth) return null
@@ -212,6 +275,11 @@ const createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }>
212
275
  throw new Error('Failed to calculate next run time')
213
276
  }
214
277
 
278
+ if (input.targetType === 'queue' && input.targetQueue) {
279
+ await assertQueueTargetAuthorizedForActor(ctx, input.targetQueue)
280
+ await assertQueueTargetPayloadAllowed(input.targetQueue, input.targetPayload)
281
+ }
282
+
215
283
  // Create schedule
216
284
  const schedule = em.create(ScheduledJob, {
217
285
  name: input.name,
@@ -228,8 +296,8 @@ const createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }>
228
296
  targetPayload: input.targetPayload ?? null,
229
297
  requireFeature: input.requireFeature ?? null,
230
298
  isEnabled: input.isEnabled ?? true,
231
- sourceType: input.sourceType ?? 'user',
232
- sourceModule: input.sourceModule ?? null,
299
+ sourceType: 'user',
300
+ sourceModule: null,
233
301
  nextRunAt,
234
302
  createdByUserId: resolveCommandActorUserId(ctx),
235
303
  createdAt: new Date(),
@@ -310,10 +378,76 @@ const updateScheduleCommand: CommandHandler<ScheduleUpdateInput, { ok: boolean }
310
378
  if (schedule.organizationId) ensureOrganizationScope(ctx, schedule.organizationId)
311
379
  ensureCanManageSystemScopedJob(ctx, schedule)
312
380
 
381
+ // Module-authored schedules are trusted infrastructure wiring (#5213): their
382
+ // target and payload are owned by the registering module. Submitting them
383
+ // unchanged is a no-op (the edit form always resends every field) — only an
384
+ // actual change to provenance-relevant values is rejected.
385
+ const touchesModuleOwnedTarget =
386
+ schedule.sourceType === 'module'
387
+ && (
388
+ targetValueChanged(input.targetType, schedule.targetType)
389
+ || targetValueChanged(input.targetQueue, schedule.targetQueue)
390
+ || targetValueChanged(input.targetCommand, schedule.targetCommand)
391
+ || targetPayloadChanged(input.targetPayload, schedule.targetPayload)
392
+ )
393
+ if (touchesModuleOwnedTarget) {
394
+ const { translate } = await resolveTranslations()
395
+ throw new CrudHttpError(403, {
396
+ error: translate(
397
+ 'scheduler.errors.module_target_immutable',
398
+ 'Module-authored schedules own their target; only operational fields can be updated here.',
399
+ ),
400
+ })
401
+ }
402
+
313
403
  if (input.isEnabled === true && schedule.isEnabled !== true) {
314
404
  await enforceTenantActiveScheduleLimit(em, schedule.tenantId)
315
405
  }
316
406
 
407
+ const effectiveTargetType = input.targetType ?? schedule.targetType
408
+ const effectiveQueue = input.targetQueue ?? schedule.targetQueue
409
+ const queueChanged =
410
+ effectiveTargetType === 'queue'
411
+ && (
412
+ targetValueChanged(input.targetQueue, schedule.targetQueue)
413
+ || targetValueChanged(input.targetType, schedule.targetType)
414
+ || targetPayloadChanged(input.targetPayload, schedule.targetPayload)
415
+ )
416
+ if (effectiveTargetType === 'queue' && effectiveQueue) {
417
+ const retargetedToUnapprovedQueue =
418
+ input.targetQueue !== undefined
419
+ && input.targetQueue !== schedule.targetQueue
420
+ && !isSchedulerSafeQueue(input.targetQueue)
421
+ // A save that leaves a user-authored row ACTIVE on an unapproved queue
422
+ // restores the invariant the update schema used to enforce; disabling the
423
+ // row stays allowed so admins can remediate legacy targets from the UI.
424
+ // (Dispatch-time re-verification remains the backstop either way.)
425
+ const keepsUnapprovedQueueActive =
426
+ effectiveQueue != null
427
+ && !isSchedulerSafeQueue(effectiveQueue)
428
+ && input.isEnabled !== false
429
+ && schedule.isEnabled !== false
430
+ if (
431
+ (retargetedToUnapprovedQueue || keepsUnapprovedQueueActive)
432
+ && schedule.sourceType !== 'module'
433
+ ) {
434
+ const { translate } = await resolveTranslations()
435
+ throw new CrudHttpError(422, {
436
+ error: translate(
437
+ 'scheduler.errors.queue_not_approved',
438
+ 'Target queue is not an approved scheduler target. Only workers that opted into scheduling can be selected.',
439
+ ),
440
+ })
441
+ }
442
+ if (queueChanged) {
443
+ await assertQueueTargetAuthorizedForActor(ctx, effectiveQueue)
444
+ const effectivePayload = input.targetPayload !== undefined
445
+ ? input.targetPayload
446
+ : schedule.targetPayload
447
+ await assertQueueTargetPayloadAllowed(effectiveQueue, effectivePayload)
448
+ }
449
+ }
450
+
317
451
  const scheduleChanged = input.scheduleType !== undefined || input.scheduleValue !== undefined || input.timezone !== undefined
318
452
  const nextRunAt = scheduleChanged
319
453
  ? calculateNextRunForWrite(
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'
2
+ import {
3
+ scheduleCreateSchema,
4
+ scheduleUpdateSchema,
5
+ } from '../validators'
6
+ import { registerModules } from '@open-mercato/shared/lib/modules/registry'
7
+
8
+ const tenantId = '123e4567-e89b-12d3-a456-426614174000'
9
+ const organizationId = '123e4567-e89b-12d3-a456-426614174001'
10
+ const scheduleId = '123e4567-e89b-12d3-a456-426614174002'
11
+
12
+ const REGISTRY_KEY = '__openMercatoModulesRegistry__'
13
+
14
+ beforeAll(() => {
15
+ registerModules([
16
+ {
17
+ id: 'test_module',
18
+ workers: [
19
+ {
20
+ id: 'test_module:workers:safe',
21
+ queue: 'test-module-safe-queue',
22
+ concurrency: 1,
23
+ schedulerSafe: true,
24
+ handler: async () => {},
25
+ },
26
+ {
27
+ id: 'test_module:workers:internal',
28
+ queue: 'stripe-webhook',
29
+ concurrency: 1,
30
+ handler: async () => {},
31
+ },
32
+ ],
33
+ },
34
+ ] as never)
35
+ })
36
+
37
+ afterAll(() => {
38
+ ;(globalThis as Record<string, unknown>)[REGISTRY_KEY] = null
39
+ })
40
+
41
+ describe('scheduler queue target authorization (#5213)', () => {
42
+ describe('scheduleCreateSchema', () => {
43
+ it('accepts a queue target declared scheduler-safe by its worker', () => {
44
+ const result = scheduleCreateSchema.parse({
45
+ name: 'Safe queue schedule',
46
+ scopeType: 'organization',
47
+ organizationId,
48
+ tenantId,
49
+ scheduleType: 'interval',
50
+ scheduleValue: '15m',
51
+ targetType: 'queue',
52
+ targetQueue: 'test-module-safe-queue',
53
+ })
54
+
55
+ expect(result.targetQueue).toBe('test-module-safe-queue')
56
+ })
57
+
58
+ it('rejects a queue target the worker did not declare scheduler-safe', () => {
59
+ expect(() =>
60
+ scheduleCreateSchema.parse({
61
+ name: 'Internal sink schedule',
62
+ scopeType: 'organization',
63
+ organizationId,
64
+ tenantId,
65
+ scheduleType: 'interval',
66
+ scheduleValue: '15m',
67
+ targetType: 'queue',
68
+ targetQueue: 'stripe-webhook',
69
+ })
70
+ ).toThrow(/not an approved scheduler target/)
71
+ })
72
+
73
+ it('rejects an unknown queue target', () => {
74
+ expect(() =>
75
+ scheduleCreateSchema.parse({
76
+ name: 'Unknown queue schedule',
77
+ scopeType: 'organization',
78
+ organizationId,
79
+ tenantId,
80
+ scheduleType: 'interval',
81
+ scheduleValue: '15m',
82
+ targetType: 'queue',
83
+ targetQueue: 'no-such-queue-anywhere',
84
+ })
85
+ ).toThrow(/not an approved scheduler target/)
86
+ })
87
+ })
88
+
89
+ describe('scheduleUpdateSchema', () => {
90
+ // Queue-safety rules for updates live in the scheduler.jobs.update command
91
+ // (change-detection against the stored row), so unchanged targets resent by
92
+ // the edit form and legacy remediation stay possible (#5213 N1).
93
+ // See commands/__tests__/jobs.target-guard.test.ts for that coverage.
94
+ it('accepts the request shape the edit form sends for an unchanged target', () => {
95
+ const result = scheduleUpdateSchema.parse({
96
+ id: scheduleId,
97
+ targetType: 'queue',
98
+ targetQueue: 'stripe-webhook',
99
+ isEnabled: false,
100
+ })
101
+ expect(result.isEnabled).toBe(false)
102
+ })
103
+
104
+ it('still rejects changing to a queue without providing the queue value', () => {
105
+ expect(() =>
106
+ scheduleUpdateSchema.parse({ id: scheduleId, targetType: 'queue' })
107
+ ).toThrow(/corresponding targetQueue/)
108
+ })
109
+ })
110
+ })