@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
@@ -5,6 +5,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
5
  import type { EntityManager } from '@mikro-orm/core'
6
6
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
7
  import { ScheduledJob } from '../../../../data/entities.js'
8
+ import { resolveScheduleAccess } from '../../../../lib/scheduleAccess.js'
8
9
  import { getRedisUrlOrThrow, parseRedisUrl } from '@open-mercato/shared/lib/redis/connection'
9
10
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
10
11
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -40,30 +41,19 @@ export async function GET(
40
41
  const scheduleId = params.id
41
42
 
42
43
  try {
43
- // Verify schedule exists and user has access (with tenant/org scope filter)
44
- const findFilter: Record<string, unknown> = {
45
- id: scheduleId,
46
- deletedAt: null,
47
- }
48
-
49
- // Apply tenant isolation: scope the query to the user's tenant/org
50
- if (auth.tenantId) {
51
- findFilter.tenantId = auth.tenantId
52
- }
53
- if (auth.orgId) {
54
- findFilter.organizationId = auth.orgId
55
- }
56
-
57
- const schedule = await em.findOne(ScheduledJob, findFilter)
44
+ // Load by id alone, then apply isolation to the loaded row. Folding the actor's
45
+ // tenant/org into the lookup would make every system-scoped schedule unmatchable.
46
+ const schedule = await em.findOne(ScheduledJob, { id: scheduleId, deletedAt: null })
58
47
 
59
48
  if (!schedule) {
60
49
  return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
61
50
  }
62
51
 
63
- // System-scoped schedules (no tenantId/orgId) require super-admin. Use the
64
- // immutable `isSuperAdmin` flag — never compare mutable/spoofable role names.
65
- const isSuperAdmin = auth.isSuperAdmin === true
66
- if (!schedule.tenantId && !schedule.organizationId && !isSuperAdmin) {
52
+ const access = resolveScheduleAccess(schedule, auth)
53
+ if (access === 'not_found') {
54
+ return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
55
+ }
56
+ if (access === 'forbidden') {
67
57
  return NextResponse.json({ error: translate('scheduler.error.access_denied', 'Access denied') }, { status: 403 })
68
58
  }
69
59
 
@@ -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,
@@ -7,6 +7,7 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
7
7
  import { createQueue } from '@open-mercato/queue'
8
8
  import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
9
9
  import { ScheduledJob } from '../../data/entities.js'
10
+ import { resolveScheduleAccess } from '../../lib/scheduleAccess.js'
10
11
  import { scheduleTriggerSchema } from '../../data/validators.js'
11
12
  import type { ExecuteSchedulePayload } from '../../workers/execute-schedule.worker.js'
12
13
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
@@ -40,30 +41,19 @@ export async function POST(req: NextRequest) {
40
41
  const body = await req.json()
41
42
  const input = scheduleTriggerSchema.parse(body)
42
43
 
43
- // Find the schedule with tenant/org scope filter
44
- const findFilter: Record<string, unknown> = {
45
- id: input.id,
46
- deletedAt: null,
47
- }
48
-
49
- // Apply tenant isolation: scope the query to the user's tenant/org
50
- if (auth.tenantId) {
51
- findFilter.tenantId = auth.tenantId
52
- }
53
- if (auth.orgId) {
54
- findFilter.organizationId = auth.orgId
55
- }
56
-
57
- const schedule = await em.findOne(ScheduledJob, findFilter)
44
+ // Load by id alone, then apply isolation to the loaded row. Folding the actor's
45
+ // tenant/org into the lookup would make every system-scoped schedule unmatchable.
46
+ const schedule = await em.findOne(ScheduledJob, { id: input.id, deletedAt: null })
58
47
 
59
48
  if (!schedule) {
60
49
  return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
61
50
  }
62
51
 
63
- // System-scoped schedules (no tenantId/orgId) require super-admin. Use the
64
- // immutable `isSuperAdmin` flag — never compare mutable/spoofable role names.
65
- const isSuperAdmin = auth.isSuperAdmin === true
66
- if (!schedule.tenantId && !schedule.organizationId && !isSuperAdmin) {
52
+ const access = resolveScheduleAccess(schedule, auth)
53
+ if (access === 'not_found') {
54
+ return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
55
+ }
56
+ if (access === 'forbidden') {
67
57
  return NextResponse.json({ error: translate('scheduler.error.access_denied', 'Access denied') }, { status: 403 })
68
58
  }
69
59
 
@@ -39,6 +39,7 @@ type SchedulesResponse = {
39
39
  total?: number
40
40
  page?: number
41
41
  totalPages?: number
42
+ totalIsCapped?: boolean
42
43
  }
43
44
 
44
45
  function mapApiItem(item: Record<string, unknown>): ScheduleRow | null {
@@ -82,6 +83,7 @@ export default function SchedulerPage() {
82
83
  const [pageSize] = React.useState(20)
83
84
  const [total, setTotal] = React.useState(0)
84
85
  const [totalPages, setTotalPages] = React.useState(1)
86
+ const [totalIsCapped, setTotalIsCapped] = React.useState(false)
85
87
  const [search, setSearch] = React.useState('')
86
88
  const [isLoading, setIsLoading] = React.useState(false)
87
89
  const scopeVersion = useOrganizationScopeVersion()
@@ -104,6 +106,7 @@ export default function SchedulerPage() {
104
106
  setRows(mapped)
105
107
  setTotal(result?.total ?? 0)
106
108
  setTotalPages(result?.totalPages ?? 1)
109
+ setTotalIsCapped(result?.totalIsCapped === true)
107
110
  } catch (error) {
108
111
  flash(t('scheduler.error.fetch_failed', 'Failed to load schedules'), 'error')
109
112
  setRows([])
@@ -282,7 +285,7 @@ export default function SchedulerPage() {
282
285
  createLabel={t('scheduler.action.create', 'New Schedule')}
283
286
  />
284
287
  )}
285
- pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
288
+ pagination={{ page, pageSize, total, totalPages, totalIsCapped, onPageChange: setPage }}
286
289
  isLoading={isLoading}
287
290
  searchValue={search}
288
291
  onSearchChange={(value) => { setSearch(value); setPage(1) }}
@@ -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(