@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,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/workers/execute-schedule.worker.ts"],
4
- "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createQueue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { emitSchedulerEvent } from '../events.js'\nimport { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-commands.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n canDispatchScheduleQueueTarget,\n getSchedulerQueueRequiredFeatures,\n sanitizeSchedulerTargetPayload,\n validateSchedulerTargetPayload,\n} from '../lib/safeQueueTargets'\n\nconst logger = createLogger('scheduler').child({ component: 'worker' })\n\n// Worker metadata for auto-discovery\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-execution',\n concurrency: 5, // Process up to 5 schedules concurrently\n}\n\nexport type ExecuteSchedulePayload = {\n scheduleId: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n triggerType?: 'scheduled' | 'manual'\n triggeredByUserId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\ntype RbacServiceLike = {\n tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean>\n userHasAllFeatures(\n userId: string,\n required: readonly string[],\n scope: { tenantId: string | null; organizationId: string | null },\n ): Promise<boolean>\n}\n\n/**\n * Worker that executes scheduled jobs.\n * \n * This worker is triggered by BullMQ repeatable jobs at the scheduled times.\n * It loads the fresh schedule configuration from the database, validates\n * conditions, and enqueues the target job or executes the command.\n * \n * BullMQ handles:\n * - Timing (exact cron/interval execution)\n * - Distributed locking (prevents duplicate execution)\n * - Retries (if worker fails)\n * - Execution history (via job state, logs, timestamps)\n * \n * This worker handles:\n * - Loading fresh schedule config\n * - Checking feature flags and conditions\n * - Enqueuing target job or executing command\n * - Updating last run time\n */\nexport default async function executeScheduleWorker(\n job: QueuedJob<ExecuteSchedulePayload>,\n ctx: JobContext & HandlerContext,\n): Promise<void> {\n logger.debug('Processing job', {\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n })\n \n // Defensive: handle both data and payload for BullMQ compatibility\n const payload = (job.payload || (job as unknown as { data?: ExecuteSchedulePayload }).data) as ExecuteSchedulePayload | undefined\n \n if (!payload || !payload.scheduleId) {\n logger.error('Invalid job payload: scheduleId missing', { jobId: ctx.jobId })\n throw new Error('scheduleId is required in job payload')\n }\n\n const { scheduleId } = payload\n\n const em = ctx.resolve<EntityManager>('em')\n const rbacService = ctx.resolve<RbacServiceLike>('rbacService')\n\n // Load fresh schedule from database\n const schedule = await em.findOne(ScheduledJob, { \n id: scheduleId,\n deletedAt: null,\n })\n\n if (!schedule) {\n logger.info('Schedule not found or deleted', { scheduleId })\n return\n }\n\n // CRITICAL: Verify scope integrity - ensure payload scope matches database\n // This prevents scope tampering and ensures proper multi-tenant isolation\n if (payload.scopeType !== schedule.scopeType) {\n logger.error('Scope type mismatch for schedule', {\n scheduleId,\n payloadScope: payload.scopeType,\n dbScope: schedule.scopeType,\n })\n throw new Error('Schedule scope type mismatch - potential security issue')\n }\n\n if (payload.tenantId !== schedule.tenantId) {\n logger.error('Tenant ID mismatch for schedule', {\n scheduleId,\n payloadTenant: payload.tenantId,\n dbTenant: schedule.tenantId,\n })\n throw new Error('Schedule tenant ID mismatch - potential security issue')\n }\n\n if (payload.organizationId !== schedule.organizationId) {\n logger.error('Organization ID mismatch for schedule', {\n scheduleId,\n payloadOrg: payload.organizationId,\n dbOrg: schedule.organizationId,\n })\n throw new Error('Schedule organization ID mismatch - potential security issue')\n }\n\n // Check if schedule is still enabled\n if (!schedule.isEnabled) {\n logger.debug('Schedule is disabled', { scheduleId })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: 'Schedule is disabled',\n })\n return\n }\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n attemptNumber: ctx.attemptNumber || 1,\n })\n\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n { organizationId: schedule.organizationId },\n )\n \n if (!hasFeature) {\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Feature not enabled: ${schedule.requireFeature}`,\n })\n\n logger.debug('Schedule skipped: feature not enabled', { scheduleId, requireFeature: schedule.requireFeature })\n return\n }\n }\n\n // Enqueue target job or execute command\n if (schedule.targetType === 'queue' && schedule.targetQueue) {\n // Dispatch-time reauthorization (#5213): provenance is verified, never\n // trusted \u2014 module-authored rows must still be owned by their recorded\n // sourceModule and carry no acting-user stamp; API-authored rows may only\n // target workers that opted into scheduling.\n if (!canDispatchScheduleQueueTarget(schedule)) {\n logger.error('Refusing non-safe queue target for schedule', {\n scheduleId,\n targetQueue: schedule.targetQueue,\n sourceType: schedule.sourceType,\n sourceModule: schedule.sourceType === 'module' ? schedule.sourceModule : undefined,\n })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Queue is not an approved scheduler target: ${schedule.targetQueue}`,\n })\n return\n }\n\n // Re-validate the stored payload against the target's registered schema and\n // the tenant-level features the target requires (#5213, defense in depth).\n const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)\n if (payloadIssue) {\n logger.error('Refusing schedule with invalid payload for queue target', {\n scheduleId,\n targetQueue: schedule.targetQueue,\n issue: payloadIssue,\n })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Invalid payload for scheduler queue ${schedule.targetQueue}`,\n })\n return\n }\n\n const requiredQueueFeatures = getSchedulerQueueRequiredFeatures(schedule.targetQueue)\n if (schedule.scopeType !== 'system' && requiredQueueFeatures.length > 0) {\n for (const feature of requiredQueueFeatures) {\n if (await rbacService.tenantHasFeature(schedule.tenantId, feature, { organizationId: schedule.organizationId })) continue\n logger.error('Refusing schedule whose tenant lacks a required feature of the queue target', {\n scheduleId,\n targetQueue: schedule.targetQueue,\n feature,\n })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`,\n })\n return\n }\n }\n\n // Determine queue strategy from environment\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n\n let targetJobId: string | undefined\n try {\n // The execute-schedule job id is stable across BullMQ retries, so if\n // this worker crashes between enqueue and DB flush the retried attempt\n // reuses the same idempotency key and downstream workers can dedupe.\n const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())\n\n // Rebuild tenant/org authority context and the trusted dispatch origin\n // server-side; author-supplied scope/envelope keys never survive (#5213).\n const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule)\n targetJobId = await targetQueue.enqueue({\n ...buildQueueTargetPayload({\n targetPayload: sanitizedPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey,\n }),\n _jobOrigin: 'scheduler' as const,\n })\n } finally {\n // Always close the queue instance to free Redis connections\n await targetQueue.close()\n }\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n queueJobId: targetJobId,\n queueName: schedule.targetQueue,\n })\n\n logger.debug('Successfully enqueued job', {\n scheduleId: schedule.id,\n targetQueue: schedule.targetQueue,\n queueJobId: targetJobId,\n })\n\n } else if (schedule.targetType === 'command' && schedule.targetCommand) {\n const commandBus = new CommandBus()\n const actorUserId = typeof schedule.createdByUserId === 'string' ? schedule.createdByUserId.trim() : ''\n await assertSchedulerSafeCommandAuthorized({\n commandId: schedule.targetCommand,\n actorUserId,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n rbacService,\n })\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n // Build the schedule-scoped command context after the allowlist/RBAC gate.\n const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer)\n \n const commandResult = await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n \n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n commandId: schedule.targetCommand,\n commandResult: commandResult.result,\n })\n \n logger.debug('Successfully executed command', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n })\n\n } else {\n throw new Error('Invalid target configuration')\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createQueue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { emitSchedulerEvent } from '../events.js'\nimport { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-commands.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'worker' })\n\n// Worker metadata for auto-discovery\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-execution',\n concurrency: 5, // Process up to 5 schedules concurrently\n}\n\nexport type ExecuteSchedulePayload = {\n scheduleId: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n triggerType?: 'scheduled' | 'manual'\n triggeredByUserId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\ntype RbacServiceLike = {\n tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean>\n userHasAllFeatures(\n userId: string,\n required: readonly string[],\n scope: { tenantId: string | null; organizationId: string | null },\n ): Promise<boolean>\n}\n\n/**\n * Worker that executes scheduled jobs.\n * \n * This worker is triggered by BullMQ repeatable jobs at the scheduled times.\n * It loads the fresh schedule configuration from the database, validates\n * conditions, and enqueues the target job or executes the command.\n * \n * BullMQ handles:\n * - Timing (exact cron/interval execution)\n * - Distributed locking (prevents duplicate execution)\n * - Retries (if worker fails)\n * - Execution history (via job state, logs, timestamps)\n * \n * This worker handles:\n * - Loading fresh schedule config\n * - Checking feature flags and conditions\n * - Enqueuing target job or executing command\n * - Updating last run time\n */\nexport default async function executeScheduleWorker(\n job: QueuedJob<ExecuteSchedulePayload>,\n ctx: JobContext & HandlerContext,\n): Promise<void> {\n logger.debug('Processing job', {\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n })\n \n // Defensive: handle both data and payload for BullMQ compatibility\n const payload = (job.payload || (job as unknown as { data?: ExecuteSchedulePayload }).data) as ExecuteSchedulePayload | undefined\n \n if (!payload || !payload.scheduleId) {\n logger.error('Invalid job payload: scheduleId missing', { jobId: ctx.jobId })\n throw new Error('scheduleId is required in job payload')\n }\n\n const { scheduleId } = payload\n\n const em = ctx.resolve<EntityManager>('em')\n const rbacService = ctx.resolve<RbacServiceLike>('rbacService')\n\n // Load fresh schedule from database\n const schedule = await em.findOne(ScheduledJob, { \n id: scheduleId,\n deletedAt: null,\n })\n\n if (!schedule) {\n logger.info('Schedule not found or deleted', { scheduleId })\n return\n }\n\n // CRITICAL: Verify scope integrity - ensure payload scope matches database\n // This prevents scope tampering and ensures proper multi-tenant isolation\n if (payload.scopeType !== schedule.scopeType) {\n logger.error('Scope type mismatch for schedule', {\n scheduleId,\n payloadScope: payload.scopeType,\n dbScope: schedule.scopeType,\n })\n throw new Error('Schedule scope type mismatch - potential security issue')\n }\n\n if (payload.tenantId !== schedule.tenantId) {\n logger.error('Tenant ID mismatch for schedule', {\n scheduleId,\n payloadTenant: payload.tenantId,\n dbTenant: schedule.tenantId,\n })\n throw new Error('Schedule tenant ID mismatch - potential security issue')\n }\n\n if (payload.organizationId !== schedule.organizationId) {\n logger.error('Organization ID mismatch for schedule', {\n scheduleId,\n payloadOrg: payload.organizationId,\n dbOrg: schedule.organizationId,\n })\n throw new Error('Schedule organization ID mismatch - potential security issue')\n }\n\n // Check if schedule is still enabled\n if (!schedule.isEnabled) {\n logger.debug('Schedule is disabled', { scheduleId })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: 'Schedule is disabled',\n })\n return\n }\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n attemptNumber: ctx.attemptNumber || 1,\n })\n\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n { organizationId: schedule.organizationId },\n )\n \n if (!hasFeature) {\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Feature not enabled: ${schedule.requireFeature}`,\n })\n\n logger.debug('Schedule skipped: feature not enabled', { scheduleId, requireFeature: schedule.requireFeature })\n return\n }\n }\n\n // Enqueue target job or execute command\n if (schedule.targetType === 'queue' && schedule.targetQueue) {\n // Determine queue strategy from environment\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n \n let targetJobId: string | undefined\n try {\n // The execute-schedule job id is stable across BullMQ retries, so if\n // this worker crashes between enqueue and DB flush the retried attempt\n // reuses the same idempotency key and downstream workers can dedupe.\n const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())\n\n targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({\n targetPayload: schedule.targetPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey,\n }))\n } finally {\n // Always close the queue instance to free Redis connections\n await targetQueue.close()\n }\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n queueJobId: targetJobId,\n queueName: schedule.targetQueue,\n })\n\n logger.debug('Successfully enqueued job', {\n scheduleId: schedule.id,\n targetQueue: schedule.targetQueue,\n queueJobId: targetJobId,\n })\n\n } else if (schedule.targetType === 'command' && schedule.targetCommand) {\n const commandBus = new CommandBus()\n const actorUserId = typeof schedule.createdByUserId === 'string' ? schedule.createdByUserId.trim() : ''\n await assertSchedulerSafeCommandAuthorized({\n commandId: schedule.targetCommand,\n actorUserId,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n rbacService,\n })\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n // Build the schedule-scoped command context after the allowlist/RBAC gate.\n const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer)\n \n const commandResult = await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n \n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n commandId: schedule.targetCommand,\n commandResult: commandResult.result,\n })\n \n logger.debug('Successfully executed command', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n })\n\n } else {\n throw new Error('Invalid target configuration')\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/scheduler",
3
- "version": "0.6.8-develop.7100.1.fbf66fca35",
3
+ "version": "0.7.0",
4
4
  "license": "MIT",
5
5
  "description": "Database-managed scheduled jobs with admin UI",
6
6
  "type": "module",
@@ -59,13 +59,13 @@
59
59
  "./*/*/*/*/*/*.json": "./src/*/*/*/*/*/*.json"
60
60
  },
61
61
  "dependencies": {
62
- "@open-mercato/events": "0.6.8-develop.7100.1.fbf66fca35",
63
- "@open-mercato/queue": "0.6.8-develop.7100.1.fbf66fca35",
62
+ "@open-mercato/events": "0.7.0",
63
+ "@open-mercato/queue": "0.7.0",
64
64
  "cron-parser": "^5.6.2"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "@mikro-orm/core": "^7.0.14",
68
- "@open-mercato/shared": "0.6.8-develop.7100.1.fbf66fca35",
68
+ "@open-mercato/shared": "0.7.0",
69
69
  "bullmq": "^5.0.0 || ^6.0.0",
70
70
  "ioredis": "^5.0.0 || ^6.0.0",
71
71
  "zod": ">=3.23.0"
@@ -79,7 +79,7 @@
79
79
  }
80
80
  },
81
81
  "devDependencies": {
82
- "@open-mercato/shared": "0.6.8-develop.7100.1.fbf66fca35",
82
+ "@open-mercato/shared": "0.7.0",
83
83
  "@types/jest": "^30.0.0",
84
84
  "@types/node": "^26.1.2",
85
85
  "jest": "^30.4.2",
@@ -93,6 +93,5 @@
93
93
  "type": "git",
94
94
  "url": "https://github.com/open-mercato/open-mercato",
95
95
  "directory": "packages/scheduler"
96
- },
97
- "stableVersion": "0.6.7"
96
+ }
98
97
  }
@@ -17,8 +17,8 @@ async function createSchedule(request: import('@playwright/test').APIRequestCont
17
17
  scheduleType: 'interval',
18
18
  scheduleValue: '15m',
19
19
  timezone: 'UTC',
20
- targetType: 'command',
21
- targetCommand: 'scheduler.test.echo',
20
+ targetType: 'queue',
21
+ targetQueue: 'scheduler-execution',
22
22
  targetPayload: { source: 'integration-test' },
23
23
  isEnabled: true,
24
24
  sourceType: 'user',
@@ -1,10 +1,7 @@
1
1
  import { expect, test } from '@playwright/test'
2
2
  import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
3
  import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
- import {
5
- SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
6
- SCHEDULER_TARGETS_PATH,
7
- } from './helpers/scheduler'
4
+ import { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_TARGETS_PATH } from './helpers/scheduler'
8
5
 
9
6
  type TargetOption = { value: string; label: string }
10
7
  type TargetsResponse = { queues?: TargetOption[]; commands?: TargetOption[] }
@@ -24,13 +21,12 @@ function expectOptionShape(options: TargetOption[], label: string) {
24
21
  }
25
22
 
26
23
  /**
27
- * TC-SCHED-004: GET /api/scheduler/targets returns scheduler-safe queue names
28
- * and registered command IDs (from the command registry), both sorted
29
- * alphabetically by value. Internal/system workers must stay undiscoverable.
30
- * Read-only, no fixtures.
24
+ * TC-SCHED-004: GET /api/scheduler/targets returns available queue names
25
+ * (from module workers) and registered command IDs (from the command
26
+ * registry), both sorted alphabetically by value. Read-only, no fixtures.
31
27
  */
32
28
  test.describe('TC-SCHED-004: GET /api/scheduler/targets lists queues and commands', () => {
33
- test('returns only scheduler-safe queues plus a non-empty sorted command list', async ({ request }) => {
29
+ test('returns sorted queues (incl. scheduler-execution) and a non-empty command list', async ({ request }) => {
34
30
  const token = await getAuthToken(request, 'admin')
35
31
 
36
32
  const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })
@@ -40,14 +36,12 @@ test.describe('TC-SCHED-004: GET /api/scheduler/targets lists queues and command
40
36
  const queues = body?.queues ?? []
41
37
  const commands = body?.commands ?? []
42
38
 
43
- // Queues: well-shaped, sorted, and free of internal/system worker queues (#5213).
39
+ // Queues: non-empty, well-shaped, sorted, and include the known scheduler queue.
44
40
  expect(Array.isArray(body?.queues)).toBe(true)
41
+ expect(queues.length).toBeGreaterThan(0)
45
42
  expectOptionShape(queues, 'queues')
43
+ expect(queues.map((queue) => queue.value)).toContain(SCHEDULER_EXECUTION_QUEUE)
46
44
  expectSortedByValue(queues, 'queues')
47
- const queueValues = queues.map((queue) => queue.value)
48
- for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
49
- expect(queueValues, `internal queue ${forbidden} must not be a scheduler target`).not.toContain(forbidden)
50
- }
51
45
 
52
46
  // Commands: non-empty, well-shaped, and sorted.
53
47
  expect(Array.isArray(body?.commands)).toBe(true)
@@ -1,7 +1,7 @@
1
1
  import { expect, test } from '@playwright/test'
2
2
  import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
3
  import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
- import { SCHEDULER_JOBS_PATH, SCHEDULER_TEST_COMMAND, uniqueScheduleName } from './helpers/scheduler'
4
+ import { SCHEDULER_EXECUTION_QUEUE, SCHEDULER_JOBS_PATH, uniqueScheduleName } from './helpers/scheduler'
5
5
 
6
6
  type ValidationError = { error?: string; details?: Array<{ path?: Array<string | number> }> }
7
7
 
@@ -36,8 +36,8 @@ test.describe('TC-SCHED-007: POST /api/scheduler/jobs validates schedule value f
36
36
  scheduleType: testCase.scheduleType,
37
37
  scheduleValue: testCase.scheduleValue,
38
38
  timezone: 'UTC',
39
- targetType: 'command',
40
- targetCommand: SCHEDULER_TEST_COMMAND,
39
+ targetType: 'queue',
40
+ targetQueue: SCHEDULER_EXECUTION_QUEUE,
41
41
  isEnabled: true,
42
42
  sourceType: 'user',
43
43
  },
@@ -90,7 +90,7 @@ test.describe('TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
90
90
  scheduleValue: '15m',
91
91
  timezone: 'UTC',
92
92
  targetType: 'queue',
93
- targetQueue: 'scheduler-test',
93
+ targetQueue: 'scheduler-execution',
94
94
  targetPayload: createPayload,
95
95
  isEnabled: true,
96
96
  sourceType: 'user',
@@ -105,7 +105,7 @@ test.describe('TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
105
105
  scheduleValue: '15m',
106
106
  timezone: 'UTC',
107
107
  targetType: 'queue',
108
- targetQueue: 'scheduler-test',
108
+ targetQueue: 'scheduler-execution',
109
109
  targetCommand: null,
110
110
  targetPayload: createPayload,
111
111
  isEnabled: true,
@@ -133,7 +133,7 @@ test.describe('TC-SCHED-CRUDFORM-001: Scheduled Job CrudForm persists scope, tar
133
133
  scheduleValue: '0 0 * * *',
134
134
  timezone: 'Europe/Warsaw',
135
135
  targetType: 'queue',
136
- targetQueue: 'scheduler-test',
136
+ targetQueue: 'scheduler-execution',
137
137
  targetCommand: null,
138
138
  targetPayload: updatePayload,
139
139
  isEnabled: false,
@@ -1,174 +1,86 @@
1
- import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test'
1
+ import { expect, test } from '@playwright/test'
2
2
  import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+ import {
4
+ extractOperation,
5
+ runCrudUndoRoundTrip,
6
+ skipIfUndoTestsDisabled,
7
+ } from '@open-mercato/core/helpers/integration/undoHarness'
8
+ import {
9
+ SCHEDULER_JOBS_PATH,
10
+ SCHEDULER_TRIGGER_PATH,
11
+ createScheduleJob,
12
+ deleteScheduleJob,
13
+ } from './helpers/scheduler'
3
14
 
4
15
  /**
5
- * TC-UNDO-001: scheduler.jobs undo round-trip (regression for issue #2504).
16
+ * TC-UNDO-001 (#2582, refs #2468) — scheduler.jobs undo/redo correctness.
6
17
  *
7
- * Before the fix, every scheduler.jobs undo handler read `logEntry.payload`
8
- * (always undefined — the command bus persists the undo snapshot under
9
- * `commandPayload`), so undo returned `{ ok: true }` while silently changing
10
- * nothing. This spec exercises the real HTTP path end-to-end:
11
- * - CREATE -> undo => job is soft-deleted (no longer listed)
12
- * - UPDATE -> undo => renamed field is restored to its prior value
13
- * - DELETE -> undo => soft-deleted job is re-materialized
18
+ * Regression lock for #2504: scheduler.jobs undo was a silent no-op because every
19
+ * undo handler read `logEntry.payload` (always undefined — the command bus persists
20
+ * the undo snapshot under `commandPayload`), so undo returned `{ ok: true }` while
21
+ * changing nothing. Fixed in PR #2514 via `extractUndoPayload`. This spec asserts the
22
+ * corrected behavior (full restoration), driving the real command bus through the
23
+ * public API + the audit-log undo/redo endpoints.
14
24
  *
15
- * Endpoints covered:
16
- * - POST /api/scheduler/jobs (create)
17
- * - GET /api/scheduler/jobs?id= (read)
18
- * - PUT /api/scheduler/jobs (update)
19
- * - DELETE /api/scheduler/jobs (delete, cleanup)
20
- * - POST /api/audit_logs/audit-logs/actions/undo (undo)
25
+ * scheduler.jobs is a standard `makeCrudRoute` entity (POST/PUT/DELETE on the
26
+ * collection, `?id=` read-back, camelCase serializer, no custom fields), so the shared
27
+ * `runCrudUndoRoundTrip` harness covers the full round-trip in one pass:
28
+ * - I1 update → undo restores every scalar (and bumps `updatedAt`)
29
+ * - I2 delete → undo re-materializes the soft-deleted job
30
+ * - I3 create → undo soft-deletes (never hard-deletes)
31
+ * - I5 a consumed undo token is rejected on a second undo
32
+ * - I6 redo re-applies the command's after-snapshot
33
+ *
34
+ * I4 (custom fields) is N/A — `scheduled_job` declares no `ce.ts`, so it has no `cf_*`.
21
35
  */
22
-
23
- type ScheduleRow = {
24
- id: string
25
- name: string
26
- description: string | null
27
- scheduleValue: string
28
- }
29
-
30
- // The undo endpoint only honors the *latest* undoable log for a resource, and
31
- // `latestUndoableForResource` orders by `created_at` alone. Audit `created_at` is
32
- // millisecond-precision (`new Date()` at log time), so a create + a follow-up
33
- // mutation issued within the same millisecond tie and the lookup may resolve to
34
- // the wrong log. A short settle guarantees the operation under undo is strictly
35
- // the most recent, keeping the round-trip deterministic.
36
- async function settleAuditClock(): Promise<void> {
37
- await new Promise((resolve) => setTimeout(resolve, 50))
38
- }
39
-
40
- function readUndoToken(res: APIResponse): string {
41
- const header = res.headers()['x-om-operation'] ?? ''
42
- const enc = header.startsWith('omop:') ? header.slice(5) : ''
43
- expect(enc, 'x-om-operation header should carry an omop: payload').not.toBe('')
44
- const payload = JSON.parse(decodeURIComponent(enc)) as { undoToken?: string }
45
- expect(typeof payload.undoToken, 'undoToken present in operation payload').toBe('string')
46
- return payload.undoToken as string
47
- }
48
-
49
- async function getById(
50
- request: APIRequestContext,
51
- token: string,
52
- id: string,
53
- ): Promise<ScheduleRow | undefined> {
54
- const res = await apiRequest(request, 'GET', `/api/scheduler/jobs?id=${encodeURIComponent(id)}`, { token })
55
- expect(res.status()).toBe(200)
56
- const body = (await res.json()) as { items?: ScheduleRow[] }
57
- return (body.items ?? [])[0]
58
- }
59
-
60
- async function createJob(request: APIRequestContext, token: string, name: string): Promise<{ id: string; res: APIResponse }> {
61
- const res = await apiRequest(request, 'POST', '/api/scheduler/jobs', {
62
- token,
63
- data: {
64
- name,
65
- description: 'TC-UNDO-001 probe',
66
- scopeType: 'tenant',
67
- scheduleType: 'cron',
68
- scheduleValue: '0 0 * * *',
69
- timezone: 'UTC',
70
- targetType: 'command',
71
- targetCommand: 'scheduler.test.echo',
72
- isEnabled: true,
73
- sourceType: 'user',
74
- },
75
- })
76
- expect(res.status(), 'create returns 201').toBe(201)
77
- const id = ((await res.json()) as { id: string }).id
78
- expect(id, 'create returns an id').toBeTruthy()
79
- return { id, res }
80
- }
81
-
82
- async function undo(request: APIRequestContext, token: string, undoToken: string): Promise<void> {
83
- const res = await apiRequest(request, 'POST', '/api/audit_logs/audit-logs/actions/undo', {
84
- token,
85
- data: { undoToken },
86
- })
87
- const raw = await res.text()
88
- expect(res.status(), `undo returns 200 (body: ${raw})`).toBe(200)
89
- const body = JSON.parse(raw) as { ok?: boolean }
90
- expect(body.ok, 'undo body is { ok: true }').toBe(true)
91
- }
92
-
93
- async function cleanup(request: APIRequestContext, token: string | null, id: string | null): Promise<void> {
94
- if (!token || !id) return
95
- try {
96
- await apiRequest(request, 'DELETE', '/api/scheduler/jobs', { token, data: { id } })
97
- } catch {
98
- /* ignore */
36
+ function jobCreatePayload(stamp: string): Record<string, unknown> {
37
+ return {
38
+ name: `TC-UNDO-001 Scheduler ${stamp}`,
39
+ description: 'TC-UNDO-001 undo/redo probe',
40
+ scopeType: 'organization',
41
+ scheduleType: 'interval',
42
+ scheduleValue: '15m',
43
+ timezone: 'UTC',
44
+ targetType: 'queue',
45
+ targetQueue: 'scheduler-execution',
46
+ isEnabled: true,
47
+ sourceType: 'user',
99
48
  }
100
49
  }
101
50
 
102
- test.describe('TC-UNDO-001: scheduler.jobs undo actually restores state (#2504)', () => {
103
- test('UPDATE -> undo restores the prior name', async ({ request }) => {
104
- let token: string | null = null
105
- let id: string | null = null
106
- try {
107
- token = await getAuthToken(request, 'admin')
108
- const originalName = `TC-UNDO-001 Update ${Date.now()}`
109
- ;({ id } = await createJob(request, token, originalName))
110
- await settleAuditClock()
111
-
112
- const updateRes = await apiRequest(request, 'PUT', '/api/scheduler/jobs', {
113
- token,
114
- data: { id, name: `${originalName} RENAMED` },
115
- })
116
- expect(updateRes.status(), 'update returns 200').toBe(200)
117
- expect((await getById(request, token, id))?.name).toBe(`${originalName} RENAMED`)
51
+ test.describe('TC-UNDO-001 scheduler.jobs undo/redo (#2504)', () => {
52
+ test.beforeAll(() => {
53
+ skipIfUndoTestsDisabled()
54
+ })
118
55
 
119
- await undo(request, token, readUndoToken(updateRes))
56
+ test('jobs CRUD commands restore scalar state on undo/redo (I1/I2/I3/I5/I6)', async ({ request }) => {
57
+ const token = await getAuthToken(request, 'admin')
120
58
 
121
- const restored = await getById(request, token, id)
122
- expect(restored, 'job still exists after update-undo').toBeTruthy()
123
- expect(restored!.name, 'name restored to original by undo').toBe(originalName)
124
- } finally {
125
- await cleanup(request, token, id)
126
- }
59
+ await runCrudUndoRoundTrip(request, token, {
60
+ label: 'scheduler.jobs',
61
+ collectionPath: SCHEDULER_JOBS_PATH,
62
+ field: 'name',
63
+ createPayload: jobCreatePayload,
64
+ updatePayload: (id, stamp) => ({ id, name: `TC-UNDO-001 Scheduler Renamed ${stamp}` }),
65
+ })
127
66
  })
128
67
 
129
- test('DELETE -> undo re-lists the soft-deleted job', async ({ request }) => {
130
- let token: string | null = null
131
- let id: string | null = null
68
+ // §4 manual trigger is a fire-and-forget enqueue that never goes through the
69
+ // command bus, so it exposes no undo affordance. The assertion holds regardless of
70
+ // QUEUE_STRATEGY: async returns 200 (enqueued), local returns 400 (rejected) — neither
71
+ // issues an `x-om-operation` undo token.
72
+ test('§4 trigger exposes no undo token', async ({ request }) => {
73
+ const token = await getAuthToken(request, 'admin')
74
+ let jobId: string | null = null
132
75
  try {
133
- token = await getAuthToken(request, 'admin')
134
- const name = `TC-UNDO-001 Delete ${Date.now()}`
135
- ;({ id } = await createJob(request, token, name))
136
- await settleAuditClock()
137
-
138
- const deleteRes = await apiRequest(request, 'DELETE', '/api/scheduler/jobs', {
76
+ jobId = await createScheduleJob(request, token)
77
+ const triggerRes = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {
139
78
  token,
140
- data: { id },
79
+ data: { id: jobId },
141
80
  })
142
- expect(deleteRes.status(), 'delete returns 200').toBe(200)
143
- expect(await getById(request, token, id), 'job is gone after delete').toBeFalsy()
144
-
145
- await undo(request, token, readUndoToken(deleteRes))
146
-
147
- const restored = await getById(request, token, id)
148
- expect(restored, 'job re-materialized by delete-undo').toBeTruthy()
149
- expect(restored!.name).toBe(name)
150
- } finally {
151
- await cleanup(request, token, id)
152
- }
153
- })
154
-
155
- test('CREATE -> undo removes the created job', async ({ request }) => {
156
- let token: string | null = null
157
- let id: string | null = null
158
- try {
159
- token = await getAuthToken(request, 'admin')
160
- const name = `TC-UNDO-001 Create ${Date.now()}`
161
- const created = await createJob(request, token, name)
162
- id = created.id
163
-
164
- expect(await getById(request, token, id), 'job exists after create').toBeTruthy()
165
-
166
- await undo(request, token, readUndoToken(created.res))
167
-
168
- expect(await getById(request, token, id), 'job removed by create-undo').toBeFalsy()
169
- id = null // already undone; nothing to clean up
81
+ expect(extractOperation(triggerRes), 'trigger response carries no undo token (§4)').toBeNull()
170
82
  } finally {
171
- await cleanup(request, token, id)
83
+ await deleteScheduleJob(request, token, jobId)
172
84
  }
173
85
  })
174
86
  })
@@ -6,25 +6,9 @@ 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 (internal, not schedulable). */
9
+ /** Queue registered by the scheduler module's execute-schedule worker. */
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
-
28
12
  export type SchedulerJob = {
29
13
  id: string
30
14
  name: string
@@ -67,19 +51,16 @@ export function uniqueScheduleName(prefix: string): string {
67
51
  }
68
52
 
69
53
  /**
70
- * Create an organization-scoped schedule via the API and return its id.
54
+ * Create an organization-scoped queue-target schedule via the API and return its id.
71
55
  * Org/tenant are derived server-side from the caller's auth context, so the caller
72
56
  * 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.
76
57
  */
77
58
  export async function createScheduleJob(
78
59
  request: APIRequestContext,
79
60
  token: string,
80
61
  overrides: CreateScheduleOverrides = {},
81
62
  ): Promise<string> {
82
- const targetType = overrides.targetType ?? 'command'
63
+ const targetType = overrides.targetType ?? 'queue'
83
64
  const data: Record<string, unknown> = {
84
65
  name: overrides.name ?? uniqueScheduleName('Integration Scheduler'),
85
66
  description: overrides.description ?? 'Created by scheduler integration tests',
@@ -93,9 +74,9 @@ export async function createScheduleJob(
93
74
  sourceType: overrides.sourceType ?? 'user',
94
75
  }
95
76
  if (targetType === 'command') {
96
- data.targetCommand = overrides.targetCommand ?? SCHEDULER_TEST_COMMAND
77
+ data.targetCommand = overrides.targetCommand
97
78
  } else {
98
- data.targetQueue = overrides.targetQueue ?? SCHEDULER_TEST_QUEUE
79
+ data.targetQueue = overrides.targetQueue ?? SCHEDULER_EXECUTION_QUEUE
99
80
  }
100
81
 
101
82
  const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, { token, data })
@@ -5,7 +5,6 @@ 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'
9
8
  import { getRedisUrlOrThrow, parseRedisUrl } from '@open-mercato/shared/lib/redis/connection'
10
9
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
10
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -41,19 +40,30 @@ export async function GET(
41
40
  const scheduleId = params.id
42
41
 
43
42
  try {
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 })
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
+ }
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
 
@@ -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' }, { queue: 'internal-queue' }] },
54
- { workers: [{ queue: 'a-queue', schedulerSafe: true }] },
53
+ { workers: [{ queue: 'z-queue' }] },
54
+ { workers: [{ queue: 'a-queue' }] },
55
55
  ])
56
56
  })
57
57
 
@@ -60,13 +60,14 @@ describe('GET /api/scheduler/targets', () => {
60
60
  unregisterCommand(unsafeCommand.id)
61
61
  })
62
62
 
63
- it('lists only scheduler-safe queues and commands', async () => {
63
+ it('lists only scheduler-safe 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' },
70
71
  ])
71
72
  expect(body.commands).toEqual([
72
73
  { value: safeCommand.id, label: safeCommand.id },