@open-mercato/scheduler 0.6.8-develop.7100.1.fbf66fca35 → 0.7.0

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 +4 -9
  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-CRUDFORM-001.spec.js +3 -3
  9. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
  10. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +44 -107
  11. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
  12. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +3 -13
  13. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
  14. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +13 -7
  15. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
  16. package/dist/modules/scheduler/api/targets/route.js +12 -2
  17. package/dist/modules/scheduler/api/targets/route.js.map +2 -2
  18. package/dist/modules/scheduler/api/trigger/route.js +13 -7
  19. package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
  20. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +1 -3
  21. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
  22. package/dist/modules/scheduler/cli.js +1 -32
  23. package/dist/modules/scheduler/cli.js.map +2 -2
  24. package/dist/modules/scheduler/commands/jobs.js +2 -84
  25. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  26. package/dist/modules/scheduler/data/validators.js +3 -27
  27. package/dist/modules/scheduler/data/validators.js.map +2 -2
  28. package/dist/modules/scheduler/i18n/de.json +0 -2
  29. package/dist/modules/scheduler/i18n/en.json +0 -2
  30. package/dist/modules/scheduler/i18n/es.json +0 -2
  31. package/dist/modules/scheduler/i18n/ko.json +0 -2
  32. package/dist/modules/scheduler/i18n/pl.json +0 -2
  33. package/dist/modules/scheduler/services/localSchedulerService.js +6 -31
  34. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  35. package/dist/modules/scheduler/workers/execute-schedule.worker.js +6 -64
  36. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  37. package/package.json +6 -7
  38. package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
  39. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +8 -14
  40. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
  41. package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
  42. package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +66 -154
  43. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +5 -24
  44. package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +19 -9
  45. package/src/modules/scheduler/api/targets/__tests__/route.test.ts +4 -3
  46. package/src/modules/scheduler/api/targets/route.ts +17 -6
  47. package/src/modules/scheduler/api/trigger/route.ts +19 -9
  48. package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +1 -4
  49. package/src/modules/scheduler/cli.ts +1 -44
  50. package/src/modules/scheduler/commands/jobs.ts +3 -137
  51. package/src/modules/scheduler/data/__tests__/validators.test.ts +4 -26
  52. package/src/modules/scheduler/data/validators.ts +2 -37
  53. package/src/modules/scheduler/i18n/de.json +0 -2
  54. package/src/modules/scheduler/i18n/en.json +0 -2
  55. package/src/modules/scheduler/i18n/es.json +0 -2
  56. package/src/modules/scheduler/i18n/ko.json +0 -2
  57. package/src/modules/scheduler/i18n/pl.json +0 -2
  58. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +0 -179
  59. package/src/modules/scheduler/services/localSchedulerService.ts +6 -39
  60. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +3 -25
  61. package/src/modules/scheduler/workers/execute-schedule.worker.ts +7 -76
  62. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +0 -72
  63. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +0 -7
  64. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +0 -66
  65. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +0 -7
  66. package/dist/modules/scheduler/lib/safeQueueTargets.js +0 -157
  67. package/dist/modules/scheduler/lib/safeQueueTargets.js.map +0 -7
  68. package/dist/modules/scheduler/lib/scheduleAccess.js +0 -22
  69. package/dist/modules/scheduler/lib/scheduleAccess.js.map +0 -7
  70. package/dist/modules/scheduler/workers/test-echo-queue.worker.js +0 -22
  71. package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +0 -7
  72. package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +0 -90
  73. package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +0 -94
  74. package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +0 -177
  75. package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +0 -110
  76. package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +0 -311
  77. package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +0 -164
  78. package/src/modules/scheduler/lib/safeQueueTargets.ts +0 -324
  79. package/src/modules/scheduler/lib/scheduleAccess.ts +0 -104
  80. package/src/modules/scheduler/workers/test-echo-queue.worker.ts +0 -25
@@ -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'
4
5
  import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
5
6
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
6
7
  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,9 +13,7 @@ export const metadata = {
13
13
 
14
14
  /**
15
15
  * GET /api/scheduler/targets
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).
16
+ * Returns available queue names and command IDs for schedule target selection.
19
17
  */
20
18
  export async function GET(req: NextRequest) {
21
19
  const auth = await getAuthFromRequest(req)
@@ -23,8 +21,20 @@ export async function GET(req: NextRequest) {
23
21
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
24
22
  }
25
23
 
26
- const queues = listSchedulerSafeQueueTargets()
27
- .map((target) => ({ value: target.queue, label: target.queue }))
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 }))
28
38
 
29
39
  const commands = listSchedulerSafeCommands()
30
40
  .filter((command) => commandRegistry.has(command.commandId))
@@ -56,6 +66,7 @@ export const openApi: OpenApiRouteDoc = {
56
66
  GET: {
57
67
  operationId: 'listScheduleTargets',
58
68
  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.',
59
70
  responses: [
60
71
  {
61
72
  status: 200,
@@ -7,7 +7,6 @@ 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'
11
10
  import { scheduleTriggerSchema } from '../../data/validators.js'
12
11
  import type { ExecuteSchedulePayload } from '../../workers/execute-schedule.worker.js'
13
12
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
@@ -41,19 +40,30 @@ export async function POST(req: NextRequest) {
41
40
  const body = await req.json()
42
41
  const input = scheduleTriggerSchema.parse(body)
43
42
 
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 })
43
+ // Find the schedule with tenant/org scope filter
44
+ const findFilter: Record<string, unknown> = {
45
+ id: input.id,
46
+ deletedAt: null,
47
+ }
47
48
 
48
- if (!schedule) {
49
- return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
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
50
55
  }
51
56
 
52
- const access = resolveScheduleAccess(schedule, auth)
53
- if (access === 'not_found') {
57
+ const schedule = await em.findOne(ScheduledJob, findFilter)
58
+
59
+ if (!schedule) {
54
60
  return NextResponse.json({ error: translate('scheduler.error.not_found', 'Schedule not found') }, { status: 404 })
55
61
  }
56
- if (access === 'forbidden') {
62
+
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) {
57
67
  return NextResponse.json({ error: translate('scheduler.error.access_denied', 'Access denied') }, { status: 403 })
58
68
  }
59
69
 
@@ -39,7 +39,6 @@ type SchedulesResponse = {
39
39
  total?: number
40
40
  page?: number
41
41
  totalPages?: number
42
- totalIsCapped?: boolean
43
42
  }
44
43
 
45
44
  function mapApiItem(item: Record<string, unknown>): ScheduleRow | null {
@@ -83,7 +82,6 @@ export default function SchedulerPage() {
83
82
  const [pageSize] = React.useState(20)
84
83
  const [total, setTotal] = React.useState(0)
85
84
  const [totalPages, setTotalPages] = React.useState(1)
86
- const [totalIsCapped, setTotalIsCapped] = React.useState(false)
87
85
  const [search, setSearch] = React.useState('')
88
86
  const [isLoading, setIsLoading] = React.useState(false)
89
87
  const scopeVersion = useOrganizationScopeVersion()
@@ -106,7 +104,6 @@ export default function SchedulerPage() {
106
104
  setRows(mapped)
107
105
  setTotal(result?.total ?? 0)
108
106
  setTotalPages(result?.totalPages ?? 1)
109
- setTotalIsCapped(result?.totalIsCapped === true)
110
107
  } catch (error) {
111
108
  flash(t('scheduler.error.fetch_failed', 'Failed to load schedules'), 'error')
112
109
  setRows([])
@@ -285,7 +282,7 @@ export default function SchedulerPage() {
285
282
  createLabel={t('scheduler.action.create', 'New Schedule')}
286
283
  />
287
284
  )}
288
- pagination={{ page, pageSize, total, totalPages, totalIsCapped, onPageChange: setPage }}
285
+ pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
289
286
  isLoading={isLoading}
290
287
  searchValue={search}
291
288
  onSearchChange={(value) => { setSearch(value); setPage(1) }}
@@ -3,7 +3,6 @@ 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'
7
6
 
8
7
  const writeLine = (text = '') => {
9
8
  process.stdout.write(`${text}\n`)
@@ -226,46 +225,4 @@ const startCommand: ModuleCli = {
226
225
  },
227
226
  }
228
227
 
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]
228
+ export default [listCommand, statusCommand, runCommand, startCommand]
@@ -14,12 +14,7 @@ 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
- import {
18
- assertSchedulerQueueTargetAuthorized,
19
- isSchedulerSafeQueue,
20
- validateSchedulerTargetPayload,
21
- } from '../lib/safeQueueTargets.js'
22
- import type { SchedulerTargetRbacService } from '../lib/safeQueueTargets.js'
17
+
23
18
  /**
24
19
  * Snapshot of a schedule for undo/redo
25
20
  */
@@ -88,64 +83,6 @@ function toDate(value: Date | string | null | undefined): Date | null {
88
83
  return value instanceof Date ? value : new Date(value)
89
84
  }
90
85
 
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
-
149
86
  function resolveCommandActorUserId(ctx: CommandRuntimeContext): string | null {
150
87
  const auth = ctx.auth
151
88
  if (!auth) return null
@@ -275,11 +212,6 @@ const createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }>
275
212
  throw new Error('Failed to calculate next run time')
276
213
  }
277
214
 
278
- if (input.targetType === 'queue' && input.targetQueue) {
279
- await assertQueueTargetAuthorizedForActor(ctx, input.targetQueue)
280
- await assertQueueTargetPayloadAllowed(input.targetQueue, input.targetPayload)
281
- }
282
-
283
215
  // Create schedule
284
216
  const schedule = em.create(ScheduledJob, {
285
217
  name: input.name,
@@ -296,8 +228,8 @@ const createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }>
296
228
  targetPayload: input.targetPayload ?? null,
297
229
  requireFeature: input.requireFeature ?? null,
298
230
  isEnabled: input.isEnabled ?? true,
299
- sourceType: 'user',
300
- sourceModule: null,
231
+ sourceType: input.sourceType ?? 'user',
232
+ sourceModule: input.sourceModule ?? null,
301
233
  nextRunAt,
302
234
  createdByUserId: resolveCommandActorUserId(ctx),
303
235
  createdAt: new Date(),
@@ -378,76 +310,10 @@ const updateScheduleCommand: CommandHandler<ScheduleUpdateInput, { ok: boolean }
378
310
  if (schedule.organizationId) ensureOrganizationScope(ctx, schedule.organizationId)
379
311
  ensureCanManageSystemScopedJob(ctx, schedule)
380
312
 
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
-
403
313
  if (input.isEnabled === true && schedule.isEnabled !== true) {
404
314
  await enforceTenantActiveScheduleLimit(em, schedule.tenantId)
405
315
  }
406
316
 
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
-
451
317
  const scheduleChanged = input.scheduleType !== undefined || input.scheduleValue !== undefined || input.timezone !== undefined
452
318
  const nextRunAt = scheduleChanged
453
319
  ? calculateNextRunForWrite(
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeAll, afterAll } from '@jest/globals'
1
+ import { describe, it, expect, beforeAll } from '@jest/globals'
2
2
  import {
3
3
  scheduleCreateSchema,
4
4
  scheduleUpdateSchema,
@@ -13,7 +13,6 @@ import {
13
13
  clearSchedulerSafeCommandsForTests,
14
14
  registerSchedulerSafeCommands,
15
15
  } from '../../lib/scheduler-safe-commands'
16
- import { registerModules } from '@open-mercato/shared/lib/modules/registry'
17
16
 
18
17
  // Register test commands for validation tests
19
18
  const testCommand: CommandHandler<unknown, { ok: boolean }> = {
@@ -40,27 +39,6 @@ beforeAll(() => {
40
39
  requiredFeatures: ['scheduler.jobs.manage'],
41
40
  },
42
41
  ])
43
- // Queue targets are only valid when their worker opted into scheduling
44
- // (`schedulerSafe: true`). Seed one safe queue so schema tests can exercise
45
- // queue-target validation (#5213).
46
- registerModules([
47
- {
48
- id: 'test_module',
49
- workers: [
50
- {
51
- id: 'test_module:workers:safe',
52
- queue: 'test',
53
- concurrency: 1,
54
- schedulerSafe: true,
55
- handler: async () => {},
56
- },
57
- ],
58
- },
59
- ] as never)
60
- })
61
-
62
- afterAll(() => {
63
- ;(globalThis as Record<string, unknown>).__openMercatoModulesRegistry__ = null
64
42
  })
65
43
 
66
44
  const tenantId = '123e4567-e89b-12d3-a456-426614174000'
@@ -76,7 +54,7 @@ describe('scheduleCreateSchema', () => {
76
54
  scheduleValue: '0 0 * * *',
77
55
  timezone: 'UTC',
78
56
  targetType: 'queue',
79
- targetQueue: 'test',
57
+ targetQueue: 'backup-queue',
80
58
  isEnabled: true,
81
59
  })
82
60
 
@@ -84,7 +62,7 @@ describe('scheduleCreateSchema', () => {
84
62
  expect(result.scopeType).toBe('system')
85
63
  expect(result.scheduleType).toBe('cron')
86
64
  expect(result.targetType).toBe('queue')
87
- expect(result.targetQueue).toBe('test')
65
+ expect(result.targetQueue).toBe('backup-queue')
88
66
  })
89
67
 
90
68
  it('should accept valid organization-scoped interval schedule with command target', () => {
@@ -120,7 +98,7 @@ describe('scheduleCreateSchema', () => {
120
98
  scheduleType: 'interval',
121
99
  scheduleValue: '15m',
122
100
  targetType: 'queue',
123
- targetQueue: 'test',
101
+ targetQueue: 'cleanup',
124
102
  })
125
103
 
126
104
  expect(result.scopeType).toBe('tenant')
@@ -4,7 +4,6 @@ import { validateInterval } from '../lib/intervalParser'
4
4
  import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
5
5
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
6
6
  import { isSchedulerSafeCommandId } from '../lib/scheduler-safe-commands'
7
- import { isSchedulerSafeQueue, validateSchedulerTargetPayload } from '../lib/safeQueueTargets'
8
7
 
9
8
  /**
10
9
  * Validate that a command exists in the command registry
@@ -17,15 +16,6 @@ function validateCommandIsSchedulable(commandId: string): boolean {
17
16
  return isSchedulerSafeCommandId(commandId)
18
17
  }
19
18
 
20
- /**
21
- * Validate that a queue was declared scheduler-safe by its owning worker.
22
- * Internal and system-only workers (webhook processors, indexers, …) stay
23
- * undiscoverable as schedule targets — see issue #5213.
24
- */
25
- function validateSchedulerSafeQueue(queue: string | null | undefined): boolean {
26
- return isSchedulerSafeQueue(queue)
27
- }
28
-
29
19
  /**
30
20
  * Base schedule fields
31
21
  */
@@ -49,6 +39,8 @@ const scheduleBaseSchema = z.object({
49
39
  requireFeature: z.string().optional().nullable(),
50
40
 
51
41
  isEnabled: z.boolean().default(true),
42
+ sourceType: z.enum(['user', 'module']).default('user'),
43
+ sourceModule: z.string().optional().nullable(),
52
44
  })
53
45
 
54
46
  /**
@@ -128,30 +120,6 @@ export const scheduleCreateSchema = scheduleBaseSchema
128
120
  path: ['targetCommand'],
129
121
  }
130
122
  )
131
- .refine(
132
- (data) => {
133
- if (data.targetType === 'queue' && data.targetQueue) {
134
- return validateSchedulerSafeQueue(data.targetQueue)
135
- }
136
- return true
137
- },
138
- {
139
- message: 'Target queue is not an approved scheduler target. Only workers that opted into scheduling can be selected.',
140
- path: ['targetQueue'],
141
- }
142
- )
143
- .refine(
144
- (data) => {
145
- if (data.targetType === 'queue' && data.targetQueue) {
146
- return validateSchedulerTargetPayload(data.targetQueue, data.targetPayload) === null
147
- }
148
- return true
149
- },
150
- {
151
- message: 'Invalid target payload for the selected scheduler queue.',
152
- path: ['targetPayload'],
153
- }
154
- )
155
123
 
156
124
  /**
157
125
  * Update schedule schema (all fields optional except id)
@@ -246,9 +214,6 @@ export const scheduleUpdateSchema = z.object({
246
214
  path: ['targetCommand'],
247
215
  }
248
216
  )
249
- // Queue-target safety and payload-shape rules for updates are enforced in the
250
- // scheduler.jobs.update command against the stored row, so unchanged targets
251
- // (always resent by the edit form) and legacy remediation stays possible (#5213).
252
217
 
253
218
  /**
254
219
  * Delete schedule schema
@@ -48,8 +48,6 @@
48
48
  "scheduler.error.unauthorized": "Nicht autorisiert",
49
49
  "scheduler.error.update_failed": "Fehler beim Aktualisieren des Zeitplans",
50
50
  "scheduler.errors.id_required": "Zeitplankennung ist erforderlich.",
51
- "scheduler.errors.module_target_immutable": "Von Modulen erstellte Zeitpläne besitzen ihr Ziel; nur operative Felder können hier geändert werden.",
52
- "scheduler.errors.queue_not_approved": "Ziel-Queue ist kein genehmigtes Scheduler-Ziel. Nur Worker, die sich angemeldet haben, können ausgewählt werden.",
53
51
  "scheduler.execution.actions": "Aktionen",
54
52
  "scheduler.execution.completed_message": "Diese Ausführung wurde erfolgreich ohne Fehler abgeschlossen.",
55
53
  "scheduler.execution.details_title": "Ausführungsdetails",
@@ -48,8 +48,6 @@
48
48
  "scheduler.error.unauthorized": "Unauthorized",
49
49
  "scheduler.error.update_failed": "Failed to update schedule",
50
50
  "scheduler.errors.id_required": "Schedule identifier is required.",
51
- "scheduler.errors.module_target_immutable": "Module-authored schedules own their target; only operational fields can be updated here.",
52
- "scheduler.errors.queue_not_approved": "Target queue is not an approved scheduler target. Only workers that opted into scheduling can be selected.",
53
51
  "scheduler.execution.actions": "Actions",
54
52
  "scheduler.execution.completed_message": "This execution completed successfully without errors.",
55
53
  "scheduler.execution.details_title": "Execution Details",
@@ -48,8 +48,6 @@
48
48
  "scheduler.error.unauthorized": "No autorizado",
49
49
  "scheduler.error.update_failed": "Error al actualizar la programación",
50
50
  "scheduler.errors.id_required": "El identificador de programación es obligatorio.",
51
- "scheduler.errors.module_target_immutable": "Los programados creados por módulos poseen su destino; solo los campos operativos pueden modificarse aquí.",
52
- "scheduler.errors.queue_not_approved": "La cola de destino no es un destino aprobado del planificador. Solo se pueden seleccionar workers que hayan optado.",
53
51
  "scheduler.execution.actions": "Acciones",
54
52
  "scheduler.execution.completed_message": "Esta ejecución se completó con éxito sin errores.",
55
53
  "scheduler.execution.details_title": "Detalles de ejecución",
@@ -48,8 +48,6 @@
48
48
  "scheduler.error.unauthorized": "권한이 없습니다",
49
49
  "scheduler.error.update_failed": "일정 수정에 실패했습니다",
50
50
  "scheduler.errors.id_required": "일정 식별자는 필수입니다.",
51
- "scheduler.errors.module_target_immutable": "모듈이 생성한 일정은 대상을 소유하므로 여기서는 운영 필드만 수정할 수 있습니다.",
52
- "scheduler.errors.queue_not_approved": "대상 큐는 승인된 스케줄러 대상이 아닙니다. 옵트인한 워커만 선택할 수 있습니다.",
53
51
  "scheduler.execution.actions": "작업",
54
52
  "scheduler.execution.completed_message": "이 실행은 오류 없이 성공적으로 완료되었습니다.",
55
53
  "scheduler.execution.details_title": "실행 상세 정보",
@@ -48,8 +48,6 @@
48
48
  "scheduler.error.unauthorized": "Brak autoryzacji",
49
49
  "scheduler.error.update_failed": "Nie udało się zaktualizować harmonogramu",
50
50
  "scheduler.errors.id_required": "Identyfikator harmonogramu jest wymagany.",
51
- "scheduler.errors.module_target_immutable": "Harmonogramy utworzone przez moduły są właścicielami swojego celu; tutaj można zmieniać wyłącznie pola operacyjne.",
52
- "scheduler.errors.queue_not_approved": "Kolejka docelowa nie jest zatwierdzonym celem harmonogramu. Można wybrać tylko workery, które się zadeklarowały.",
53
51
  "scheduler.execution.actions": "Akcje",
54
52
  "scheduler.execution.completed_message": "To wykonanie zakończyło się pomyślnie bez błędów.",
55
53
  "scheduler.execution.details_title": "Szczegóły wykonania",