@open-mercato/scheduler 0.6.8-develop.7066.1.6bfa9ff69f → 0.6.8-develop.7067.1.82e9867ea1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +9 -4
- package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +3 -3
- package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +72 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +7 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js +3 -3
- package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +107 -44
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js +13 -3
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
- package/dist/modules/scheduler/api/targets/route.js +2 -12
- package/dist/modules/scheduler/api/targets/route.js.map +2 -2
- package/dist/modules/scheduler/cli.js +32 -1
- package/dist/modules/scheduler/cli.js.map +2 -2
- package/dist/modules/scheduler/commands/jobs.js +84 -2
- package/dist/modules/scheduler/commands/jobs.js.map +2 -2
- package/dist/modules/scheduler/data/validators.js +27 -3
- package/dist/modules/scheduler/data/validators.js.map +2 -2
- package/dist/modules/scheduler/i18n/de.json +2 -0
- package/dist/modules/scheduler/i18n/en.json +2 -0
- package/dist/modules/scheduler/i18n/es.json +2 -0
- package/dist/modules/scheduler/i18n/ko.json +2 -0
- package/dist/modules/scheduler/i18n/pl.json +2 -0
- package/dist/modules/scheduler/lib/safeQueueTargets.js +157 -0
- package/dist/modules/scheduler/lib/safeQueueTargets.js.map +7 -0
- package/dist/modules/scheduler/services/localSchedulerService.js +31 -6
- package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
- package/dist/modules/scheduler/workers/execute-schedule.worker.js +64 -6
- package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js +22 -0
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +7 -0
- package/package.json +5 -5
- package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
- package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +14 -8
- package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
- package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +90 -0
- package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
- package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +154 -66
- package/src/modules/scheduler/__integration__/helpers/scheduler.ts +24 -5
- package/src/modules/scheduler/api/targets/__tests__/route.test.ts +3 -4
- package/src/modules/scheduler/api/targets/route.ts +6 -17
- package/src/modules/scheduler/cli.ts +44 -1
- package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +177 -0
- package/src/modules/scheduler/commands/jobs.ts +137 -3
- package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +110 -0
- package/src/modules/scheduler/data/__tests__/validators.test.ts +26 -4
- package/src/modules/scheduler/data/validators.ts +37 -2
- package/src/modules/scheduler/i18n/de.json +2 -0
- package/src/modules/scheduler/i18n/en.json +2 -0
- package/src/modules/scheduler/i18n/es.json +2 -0
- package/src/modules/scheduler/i18n/ko.json +2 -0
- package/src/modules/scheduler/i18n/pl.json +2 -0
- package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +311 -0
- package/src/modules/scheduler/lib/safeQueueTargets.ts +324 -0
- package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +179 -0
- package/src/modules/scheduler/services/localSchedulerService.ts +39 -6
- package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +25 -3
- package/src/modules/scheduler/workers/execute-schedule.worker.ts +76 -7
- package/src/modules/scheduler/workers/test-echo-queue.worker.ts +25 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/scheduler/data/validators.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport { validateCron } from '../lib/cronParser'\nimport { validateInterval } from '../lib/intervalParser'\nimport { commandRegistry } from '@open-mercato/shared/lib/commands/registry'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { isSchedulerSafeCommandId } from '../lib/scheduler-safe-commands'\n\n/**\n * Validate that a command exists in the command registry\n */\nfunction validateCommandExists(commandId: string): boolean {\n return commandRegistry.has(commandId)\n}\n\nfunction validateCommandIsSchedulable(commandId: string): boolean {\n return isSchedulerSafeCommandId(commandId)\n}\n\n/**\n * Base schedule fields\n */\nconst scheduleBaseSchema = z.object({\n name: z.string().min(1, 'Name is required').max(200),\n description: z.string().max(500).optional().nullable(),\n \n scopeType: z.enum(['system', 'organization', 'tenant']),\n organizationId: z.uuid().optional().nullable(),\n tenantId: z.uuid().optional().nullable(),\n \n scheduleType: z.enum(['cron', 'interval']),\n scheduleValue: z.string().min(1, 'Schedule value is required'),\n timezone: z.string().default('UTC'),\n \n targetType: z.enum(['queue', 'command']),\n targetQueue: z.string().optional().nullable(),\n targetCommand: z.string().optional().nullable(),\n targetPayload: z.record(z.string(), z.unknown()).optional().nullable(),\n \n requireFeature: z.string().optional().nullable(),\n \n isEnabled: z.boolean().default(true),\n sourceType: z.enum(['user', 'module']).default('user'),\n sourceModule: z.string().optional().nullable(),\n})\n\n/**\n * Create schedule schema\n */\nexport const scheduleCreateSchema = scheduleBaseSchema\n .refine(\n (data) => {\n if (data.scopeType === 'system') {\n return !data.organizationId && !data.tenantId\n }\n if (data.scopeType === 'organization') {\n return !!data.organizationId && !!data.tenantId\n }\n if (data.scopeType === 'tenant') {\n return !data.organizationId && !!data.tenantId\n }\n return false\n },\n {\n message: 'Invalid scope configuration',\n path: ['scopeType'],\n }\n )\n .refine(\n (data) => {\n if (data.targetType === 'queue') {\n return !!data.targetQueue\n }\n if (data.targetType === 'command') {\n return !!data.targetCommand\n }\n return false\n },\n {\n message: 'Target queue or command is required based on target type',\n path: ['targetType'],\n }\n )\n .refine(\n (data) => {\n if (data.scheduleType === 'cron') {\n return validateCron(data.scheduleValue)\n }\n if (data.scheduleType === 'interval') {\n return validateInterval(data.scheduleValue)\n }\n return false\n },\n {\n message: 'Invalid schedule value. For cron: use valid cron expression (e.g., \"0 0 * * *\"). For interval: use <number><unit> format (e.g., \"15m\", \"2h\", \"1d\")',\n path: ['scheduleValue'],\n }\n )\n .refine(\n (data) => {\n // Validate that command exists if targetType is 'command'\n if (data.targetType === 'command' && data.targetCommand) {\n return validateCommandExists(data.targetCommand)\n }\n return true\n },\n {\n message: 'Command does not exist. Please ensure the command is registered before creating a schedule.',\n path: ['targetCommand'],\n }\n )\n .refine(\n (data) => {\n if (data.targetType === 'command' && data.targetCommand) {\n return validateCommandIsSchedulable(data.targetCommand)\n }\n return true\n },\n {\n message: 'Command is not schedulable. Only scheduler-safe commands can be used as schedule targets.',\n path: ['targetCommand'],\n }\n )\n\n/**\n * Update schedule schema (all fields optional except id)\n */\nexport const scheduleUpdateSchema = z.object({\n id: z.uuid(),\n name: z.string().min(1).max(200).optional(),\n description: z.string().max(500).optional().nullable(),\n \n scheduleType: z.enum(['cron', 'interval']).optional(),\n scheduleValue: z.string().min(1).optional(),\n timezone: z.string().optional(),\n \n targetType: z.enum(['queue', 'command']).optional(),\n targetQueue: z.string().optional().nullable(),\n targetCommand: z.string().optional().nullable(),\n targetPayload: z.record(z.string(), z.unknown()).optional().nullable(),\n requireFeature: z.string().optional().nullable(),\n \n isEnabled: z.boolean().optional(),\n})\n .refine(\n (data) => {\n // If scheduleValue is provided, scheduleType must also be provided\n // This ensures we can validate the value against its type\n if (data.scheduleValue !== undefined && data.scheduleType === undefined) {\n return false\n }\n return true\n },\n {\n message: 'scheduleType is required when updating scheduleValue',\n path: ['scheduleType'],\n }\n )\n .refine(\n (data) => {\n // If scheduleValue is provided, validate it based on scheduleType\n if (data.scheduleValue && data.scheduleType) {\n if (data.scheduleType === 'cron') {\n return validateCron(data.scheduleValue)\n }\n if (data.scheduleType === 'interval') {\n return validateInterval(data.scheduleValue)\n }\n }\n return true\n },\n {\n message: 'Invalid schedule value. For cron: use valid cron expression (e.g., \"0 0 * * *\"). For interval: use <number><unit> format (e.g., \"15m\", \"2h\", \"1d\")',\n path: ['scheduleValue'],\n }\n )\n .refine(\n (data) => {\n // If targetType is provided, ensure appropriate target is set\n if (data.targetType === 'queue') {\n return data.targetQueue !== undefined\n }\n if (data.targetType === 'command') {\n return data.targetCommand !== undefined\n }\n return true\n },\n {\n message: 'When changing target type, you must provide the corresponding targetQueue or targetCommand',\n path: ['targetType'],\n }\n )\n .refine(\n (data) => {\n // Validate that command exists if targetCommand is provided\n if (data.targetCommand) {\n return validateCommandExists(data.targetCommand)\n }\n return true\n },\n {\n message: 'Command does not exist. Please ensure the command is registered before updating.',\n path: ['targetCommand'],\n }\n )\n .refine(\n (data) => {\n if (data.targetCommand) {\n return validateCommandIsSchedulable(data.targetCommand)\n }\n return true\n },\n {\n message: 'Command is not schedulable. Only scheduler-safe commands can be used as schedule targets.',\n path: ['targetCommand'],\n }\n )\n\n/**\n * Delete schedule schema\n */\nexport const scheduleDeleteSchema = z.object({\n id: z.uuid(),\n})\n\n/**\n * List schedules query schema\n */\nexport const scheduleListQuerySchema = z.object({\n page: z.coerce.number().int().min(1).default(1),\n pageSize: z.coerce.number().int().min(1).max(100).default(20),\n id: z.string().uuid().optional(),\n search: z.string().optional(),\n scopeType: z.enum(['system', 'organization', 'tenant']).optional(),\n isEnabled: z.string().optional().transform((val) => {\n if (val === undefined) return undefined\n return parseBooleanToken(val) ?? undefined\n }),\n sourceType: z.enum(['user', 'module']).optional(),\n sourceModule: z.string().optional(),\n sort: z.string().optional(),\n order: z.enum(['asc', 'desc']).optional(),\n})\n\n/**\n * Trigger schedule schema (manual execution)\n */\nexport const scheduleTriggerSchema = z.object({\n id: z.uuid(),\n})\n\n/**\n * Get schedule runs query schema\n */\nexport const scheduleRunsQuerySchema = z.object({\n page: z.coerce.number().int().min(1).default(1),\n pageSize: z.coerce.number().int().min(1).max(100).default(20),\n scheduledJobId: z.uuid().optional(),\n status: z.enum(['running', 'completed', 'failed', 'skipped']).optional(),\n triggerType: z.enum(['scheduled', 'manual']).optional(),\n fromDate: z.string().datetime().optional(),\n toDate: z.string().datetime().optional(),\n sort: z.string().optional(),\n order: z.enum(['asc', 'desc']).optional(),\n})\n\n/**\n * Type exports\n */\nexport type ScheduleCreateInput = z.infer<typeof scheduleCreateSchema>\nexport type ScheduleUpdateInput = z.infer<typeof scheduleUpdateSchema>\nexport type ScheduleDeleteInput = z.infer<typeof scheduleDeleteSchema>\nexport type ScheduleListQuery = z.infer<typeof scheduleListQuerySchema>\nexport type ScheduleTriggerInput = z.infer<typeof scheduleTriggerSchema>\nexport type ScheduleRunsQuery = z.infer<typeof scheduleRunsQuerySchema>\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,SAAS;AAClB,SAAS,oBAAoB;AAC7B,SAAS,wBAAwB;AACjC,SAAS,uBAAuB;AAChC,SAAS,yBAAyB;AAClC,SAAS,gCAAgC;
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport { validateCron } from '../lib/cronParser'\nimport { validateInterval } from '../lib/intervalParser'\nimport { commandRegistry } from '@open-mercato/shared/lib/commands/registry'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { isSchedulerSafeCommandId } from '../lib/scheduler-safe-commands'\nimport { isSchedulerSafeQueue, validateSchedulerTargetPayload } from '../lib/safeQueueTargets'\n\n/**\n * Validate that a command exists in the command registry\n */\nfunction validateCommandExists(commandId: string): boolean {\n return commandRegistry.has(commandId)\n}\n\nfunction validateCommandIsSchedulable(commandId: string): boolean {\n return isSchedulerSafeCommandId(commandId)\n}\n\n/**\n * Validate that a queue was declared scheduler-safe by its owning worker.\n * Internal and system-only workers (webhook processors, indexers, \u2026) stay\n * undiscoverable as schedule targets \u2014 see issue #5213.\n */\nfunction validateSchedulerSafeQueue(queue: string | null | undefined): boolean {\n return isSchedulerSafeQueue(queue)\n}\n\n/**\n * Base schedule fields\n */\nconst scheduleBaseSchema = z.object({\n name: z.string().min(1, 'Name is required').max(200),\n description: z.string().max(500).optional().nullable(),\n \n scopeType: z.enum(['system', 'organization', 'tenant']),\n organizationId: z.uuid().optional().nullable(),\n tenantId: z.uuid().optional().nullable(),\n \n scheduleType: z.enum(['cron', 'interval']),\n scheduleValue: z.string().min(1, 'Schedule value is required'),\n timezone: z.string().default('UTC'),\n \n targetType: z.enum(['queue', 'command']),\n targetQueue: z.string().optional().nullable(),\n targetCommand: z.string().optional().nullable(),\n targetPayload: z.record(z.string(), z.unknown()).optional().nullable(),\n \n requireFeature: z.string().optional().nullable(),\n \n isEnabled: z.boolean().default(true),\n})\n\n/**\n * Create schedule schema\n */\nexport const scheduleCreateSchema = scheduleBaseSchema\n .refine(\n (data) => {\n if (data.scopeType === 'system') {\n return !data.organizationId && !data.tenantId\n }\n if (data.scopeType === 'organization') {\n return !!data.organizationId && !!data.tenantId\n }\n if (data.scopeType === 'tenant') {\n return !data.organizationId && !!data.tenantId\n }\n return false\n },\n {\n message: 'Invalid scope configuration',\n path: ['scopeType'],\n }\n )\n .refine(\n (data) => {\n if (data.targetType === 'queue') {\n return !!data.targetQueue\n }\n if (data.targetType === 'command') {\n return !!data.targetCommand\n }\n return false\n },\n {\n message: 'Target queue or command is required based on target type',\n path: ['targetType'],\n }\n )\n .refine(\n (data) => {\n if (data.scheduleType === 'cron') {\n return validateCron(data.scheduleValue)\n }\n if (data.scheduleType === 'interval') {\n return validateInterval(data.scheduleValue)\n }\n return false\n },\n {\n message: 'Invalid schedule value. For cron: use valid cron expression (e.g., \"0 0 * * *\"). For interval: use <number><unit> format (e.g., \"15m\", \"2h\", \"1d\")',\n path: ['scheduleValue'],\n }\n )\n .refine(\n (data) => {\n // Validate that command exists if targetType is 'command'\n if (data.targetType === 'command' && data.targetCommand) {\n return validateCommandExists(data.targetCommand)\n }\n return true\n },\n {\n message: 'Command does not exist. Please ensure the command is registered before creating a schedule.',\n path: ['targetCommand'],\n }\n )\n .refine(\n (data) => {\n if (data.targetType === 'command' && data.targetCommand) {\n return validateCommandIsSchedulable(data.targetCommand)\n }\n return true\n },\n {\n message: 'Command is not schedulable. Only scheduler-safe commands can be used as schedule targets.',\n path: ['targetCommand'],\n }\n )\n .refine(\n (data) => {\n if (data.targetType === 'queue' && data.targetQueue) {\n return validateSchedulerSafeQueue(data.targetQueue)\n }\n return true\n },\n {\n message: 'Target queue is not an approved scheduler target. Only workers that opted into scheduling can be selected.',\n path: ['targetQueue'],\n }\n )\n .refine(\n (data) => {\n if (data.targetType === 'queue' && data.targetQueue) {\n return validateSchedulerTargetPayload(data.targetQueue, data.targetPayload) === null\n }\n return true\n },\n {\n message: 'Invalid target payload for the selected scheduler queue.',\n path: ['targetPayload'],\n }\n )\n\n/**\n * Update schedule schema (all fields optional except id)\n */\nexport const scheduleUpdateSchema = z.object({\n id: z.uuid(),\n name: z.string().min(1).max(200).optional(),\n description: z.string().max(500).optional().nullable(),\n \n scheduleType: z.enum(['cron', 'interval']).optional(),\n scheduleValue: z.string().min(1).optional(),\n timezone: z.string().optional(),\n \n targetType: z.enum(['queue', 'command']).optional(),\n targetQueue: z.string().optional().nullable(),\n targetCommand: z.string().optional().nullable(),\n targetPayload: z.record(z.string(), z.unknown()).optional().nullable(),\n requireFeature: z.string().optional().nullable(),\n \n isEnabled: z.boolean().optional(),\n})\n .refine(\n (data) => {\n // If scheduleValue is provided, scheduleType must also be provided\n // This ensures we can validate the value against its type\n if (data.scheduleValue !== undefined && data.scheduleType === undefined) {\n return false\n }\n return true\n },\n {\n message: 'scheduleType is required when updating scheduleValue',\n path: ['scheduleType'],\n }\n )\n .refine(\n (data) => {\n // If scheduleValue is provided, validate it based on scheduleType\n if (data.scheduleValue && data.scheduleType) {\n if (data.scheduleType === 'cron') {\n return validateCron(data.scheduleValue)\n }\n if (data.scheduleType === 'interval') {\n return validateInterval(data.scheduleValue)\n }\n }\n return true\n },\n {\n message: 'Invalid schedule value. For cron: use valid cron expression (e.g., \"0 0 * * *\"). For interval: use <number><unit> format (e.g., \"15m\", \"2h\", \"1d\")',\n path: ['scheduleValue'],\n }\n )\n .refine(\n (data) => {\n // If targetType is provided, ensure appropriate target is set\n if (data.targetType === 'queue') {\n return data.targetQueue !== undefined\n }\n if (data.targetType === 'command') {\n return data.targetCommand !== undefined\n }\n return true\n },\n {\n message: 'When changing target type, you must provide the corresponding targetQueue or targetCommand',\n path: ['targetType'],\n }\n )\n .refine(\n (data) => {\n // Validate that command exists if targetCommand is provided\n if (data.targetCommand) {\n return validateCommandExists(data.targetCommand)\n }\n return true\n },\n {\n message: 'Command does not exist. Please ensure the command is registered before updating.',\n path: ['targetCommand'],\n }\n )\n .refine(\n (data) => {\n if (data.targetCommand) {\n return validateCommandIsSchedulable(data.targetCommand)\n }\n return true\n },\n {\n message: 'Command is not schedulable. Only scheduler-safe commands can be used as schedule targets.',\n path: ['targetCommand'],\n }\n )\n // Queue-target safety and payload-shape rules for updates are enforced in the\n // scheduler.jobs.update command against the stored row, so unchanged targets\n // (always resent by the edit form) and legacy remediation stays possible (#5213).\n\n/**\n * Delete schedule schema\n */\nexport const scheduleDeleteSchema = z.object({\n id: z.uuid(),\n})\n\n/**\n * List schedules query schema\n */\nexport const scheduleListQuerySchema = z.object({\n page: z.coerce.number().int().min(1).default(1),\n pageSize: z.coerce.number().int().min(1).max(100).default(20),\n id: z.string().uuid().optional(),\n search: z.string().optional(),\n scopeType: z.enum(['system', 'organization', 'tenant']).optional(),\n isEnabled: z.string().optional().transform((val) => {\n if (val === undefined) return undefined\n return parseBooleanToken(val) ?? undefined\n }),\n sourceType: z.enum(['user', 'module']).optional(),\n sourceModule: z.string().optional(),\n sort: z.string().optional(),\n order: z.enum(['asc', 'desc']).optional(),\n})\n\n/**\n * Trigger schedule schema (manual execution)\n */\nexport const scheduleTriggerSchema = z.object({\n id: z.uuid(),\n})\n\n/**\n * Get schedule runs query schema\n */\nexport const scheduleRunsQuerySchema = z.object({\n page: z.coerce.number().int().min(1).default(1),\n pageSize: z.coerce.number().int().min(1).max(100).default(20),\n scheduledJobId: z.uuid().optional(),\n status: z.enum(['running', 'completed', 'failed', 'skipped']).optional(),\n triggerType: z.enum(['scheduled', 'manual']).optional(),\n fromDate: z.string().datetime().optional(),\n toDate: z.string().datetime().optional(),\n sort: z.string().optional(),\n order: z.enum(['asc', 'desc']).optional(),\n})\n\n/**\n * Type exports\n */\nexport type ScheduleCreateInput = z.infer<typeof scheduleCreateSchema>\nexport type ScheduleUpdateInput = z.infer<typeof scheduleUpdateSchema>\nexport type ScheduleDeleteInput = z.infer<typeof scheduleDeleteSchema>\nexport type ScheduleListQuery = z.infer<typeof scheduleListQuerySchema>\nexport type ScheduleTriggerInput = z.infer<typeof scheduleTriggerSchema>\nexport type ScheduleRunsQuery = z.infer<typeof scheduleRunsQuerySchema>\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -48,6 +48,8 @@
|
|
|
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.",
|
|
51
53
|
"scheduler.execution.actions": "Aktionen",
|
|
52
54
|
"scheduler.execution.completed_message": "Diese Ausführung wurde erfolgreich ohne Fehler abgeschlossen.",
|
|
53
55
|
"scheduler.execution.details_title": "Ausführungsdetails",
|
|
@@ -48,6 +48,8 @@
|
|
|
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.",
|
|
51
53
|
"scheduler.execution.actions": "Actions",
|
|
52
54
|
"scheduler.execution.completed_message": "This execution completed successfully without errors.",
|
|
53
55
|
"scheduler.execution.details_title": "Execution Details",
|
|
@@ -48,6 +48,8 @@
|
|
|
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.",
|
|
51
53
|
"scheduler.execution.actions": "Acciones",
|
|
52
54
|
"scheduler.execution.completed_message": "Esta ejecución se completó con éxito sin errores.",
|
|
53
55
|
"scheduler.execution.details_title": "Detalles de ejecución",
|
|
@@ -48,6 +48,8 @@
|
|
|
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": "대상 큐는 승인된 스케줄러 대상이 아닙니다. 옵트인한 워커만 선택할 수 있습니다.",
|
|
51
53
|
"scheduler.execution.actions": "작업",
|
|
52
54
|
"scheduler.execution.completed_message": "이 실행은 오류 없이 성공적으로 완료되었습니다.",
|
|
53
55
|
"scheduler.execution.details_title": "실행 상세 정보",
|
|
@@ -48,6 +48,8 @@
|
|
|
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.",
|
|
51
53
|
"scheduler.execution.actions": "Akcje",
|
|
52
54
|
"scheduler.execution.completed_message": "To wykonanie zakończyło się pomyślnie bez błędów.",
|
|
53
55
|
"scheduler.execution.details_title": "Szczegóły wykonania",
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { getModules } from "@open-mercato/shared/lib/modules/registry";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const registeredPayloadSchemas = /* @__PURE__ */ new Map();
|
|
4
|
+
function registerSchedulerQueuePayloadSchema(queue, schema) {
|
|
5
|
+
registeredPayloadSchemas.set(queue, schema);
|
|
6
|
+
}
|
|
7
|
+
function getSchedulerQueuePayloadSchema(queue) {
|
|
8
|
+
return registeredPayloadSchemas.get(queue) ?? null;
|
|
9
|
+
}
|
|
10
|
+
async function assertSchedulerQueueTargetAuthorized(params) {
|
|
11
|
+
const requiredFeatures = getSchedulerQueueRequiredFeatures(params.queue);
|
|
12
|
+
if (requiredFeatures.length === 0) return null;
|
|
13
|
+
if (params.isSuperAdmin === true) return null;
|
|
14
|
+
const actorUserId = typeof params.actorUserId === "string" ? params.actorUserId.trim() : "";
|
|
15
|
+
if (!actorUserId) return "Scheduler queue target requires an authenticated creator";
|
|
16
|
+
if (typeof params.rbacService?.userHasAllFeatures !== "function") {
|
|
17
|
+
return "Scheduler queue target authorization is unavailable";
|
|
18
|
+
}
|
|
19
|
+
const authorized = await params.rbacService.userHasAllFeatures(actorUserId, requiredFeatures, {
|
|
20
|
+
tenantId: params.tenantId ?? null,
|
|
21
|
+
organizationId: params.organizationId ?? null
|
|
22
|
+
});
|
|
23
|
+
if (!authorized) {
|
|
24
|
+
return `Scheduler queue target requires features the creator lacks: ${requiredFeatures.join(", ")}`;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
function validateSchedulerTargetPayload(queue, targetPayload) {
|
|
29
|
+
const schema = getSchedulerQueuePayloadSchema(queue);
|
|
30
|
+
if (!schema) return null;
|
|
31
|
+
const result = schema.safeParse(targetPayload ?? {});
|
|
32
|
+
if (result.success) return null;
|
|
33
|
+
const firstIssue = result.error.issues[0];
|
|
34
|
+
return [
|
|
35
|
+
firstIssue?.path?.length ? `${firstIssue.path.join(".")}: ` : "",
|
|
36
|
+
firstIssue?.message ?? "Invalid payload"
|
|
37
|
+
].join("");
|
|
38
|
+
}
|
|
39
|
+
const RESERVED_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["tenantId", "organizationId", "scope"]);
|
|
40
|
+
function isReservedPayloadKey(key) {
|
|
41
|
+
return RESERVED_PAYLOAD_KEYS.has(key) || key.startsWith("_");
|
|
42
|
+
}
|
|
43
|
+
function stripReservedKeys(payload) {
|
|
44
|
+
const result = {};
|
|
45
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
46
|
+
if (isReservedPayloadKey(key)) continue;
|
|
47
|
+
if (key === "payload" && value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
48
|
+
result[key] = stripReservedKeys(value);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
result[key] = value;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
function buildTrustedScheduleScope(schedule) {
|
|
56
|
+
return {
|
|
57
|
+
tenantId: schedule.tenantId ?? null,
|
|
58
|
+
organizationId: schedule.organizationId ?? null
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function sanitizeSchedulerTargetPayload(targetPayload, schedule) {
|
|
62
|
+
const source = targetPayload ?? {};
|
|
63
|
+
const cloned = structuredClone(source);
|
|
64
|
+
const stripped = stripReservedKeys(cloned);
|
|
65
|
+
return {
|
|
66
|
+
...stripped,
|
|
67
|
+
scope: buildTrustedScheduleScope(schedule)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const queueConsumerCache = /* @__PURE__ */ new WeakMap();
|
|
71
|
+
function collectQueueConsumers() {
|
|
72
|
+
let modules;
|
|
73
|
+
try {
|
|
74
|
+
modules = getModules();
|
|
75
|
+
} catch {
|
|
76
|
+
return /* @__PURE__ */ new Map();
|
|
77
|
+
}
|
|
78
|
+
const cached = queueConsumerCache.get(modules);
|
|
79
|
+
if (cached) return cached;
|
|
80
|
+
const byQueue = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const mod of modules) {
|
|
82
|
+
for (const worker of mod.workers ?? []) {
|
|
83
|
+
if (!worker.queue) continue;
|
|
84
|
+
const consumers = byQueue.get(worker.queue) ?? [];
|
|
85
|
+
consumers.push(worker);
|
|
86
|
+
byQueue.set(worker.queue, consumers);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
queueConsumerCache.set(modules, byQueue);
|
|
90
|
+
return byQueue;
|
|
91
|
+
}
|
|
92
|
+
function listSchedulerSafeQueueTargets() {
|
|
93
|
+
const targets = [];
|
|
94
|
+
for (const [queue, consumers] of collectQueueConsumers()) {
|
|
95
|
+
const optedIn = consumers.filter((worker) => worker.schedulerSafe === true);
|
|
96
|
+
if (optedIn.length === 0 || optedIn.length !== consumers.length) continue;
|
|
97
|
+
const requiredFeatures = [...new Set(optedIn.flatMap((worker) => Array.isArray(worker.schedulerRequiredFeatures) ? worker.schedulerRequiredFeatures : []))];
|
|
98
|
+
const ownerModuleIds = [...new Set(consumers.map((worker) => typeof worker.id === "string" ? worker.id.split(":")[0] ?? "" : ""))];
|
|
99
|
+
targets.push({ queue, moduleId: ownerModuleIds[0] ?? "", requiredFeatures });
|
|
100
|
+
}
|
|
101
|
+
targets.sort((a, b) => a.queue.localeCompare(b.queue));
|
|
102
|
+
return targets;
|
|
103
|
+
}
|
|
104
|
+
function isSchedulerSafeQueue(queue) {
|
|
105
|
+
if (!queue) return false;
|
|
106
|
+
return listSchedulerSafeQueueTargets().some((target) => target.queue === queue);
|
|
107
|
+
}
|
|
108
|
+
function getSchedulerQueueRequiredFeatures(queue) {
|
|
109
|
+
return listSchedulerSafeQueueTargets().find((target) => target.queue === queue)?.requiredFeatures ?? [];
|
|
110
|
+
}
|
|
111
|
+
function findQueueOwnerModuleIds(queue) {
|
|
112
|
+
const owners = /* @__PURE__ */ new Set();
|
|
113
|
+
for (const [candidate, consumers] of collectQueueConsumers()) {
|
|
114
|
+
if (candidate !== queue) continue;
|
|
115
|
+
for (const worker of consumers) {
|
|
116
|
+
if (typeof worker.id === "string") owners.add(worker.id.split(":")[0] ?? "");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return owners;
|
|
120
|
+
}
|
|
121
|
+
function auditSchedulerModuleQueueRows(rows) {
|
|
122
|
+
return rows.filter((row) => row.sourceType === "module" && row.targetQueue).map((row) => ({
|
|
123
|
+
scheduleId: row.id,
|
|
124
|
+
name: row.name ?? void 0,
|
|
125
|
+
sourceModule: row.sourceModule ?? null,
|
|
126
|
+
targetQueue: row.targetQueue ?? null,
|
|
127
|
+
isEnabled: row.isEnabled !== false,
|
|
128
|
+
dispatchAllowed: canDispatchScheduleQueueTarget(row)
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
function canDispatchScheduleQueueTarget(schedule) {
|
|
132
|
+
if (!schedule.targetQueue) return false;
|
|
133
|
+
if (schedule.sourceType === "module") {
|
|
134
|
+
if (!schedule.sourceModule) return false;
|
|
135
|
+
if (schedule.createdByUserId) return false;
|
|
136
|
+
return findQueueOwnerModuleIds(schedule.targetQueue).has(schedule.sourceModule);
|
|
137
|
+
}
|
|
138
|
+
return isSchedulerSafeQueue(schedule.targetQueue);
|
|
139
|
+
}
|
|
140
|
+
registerSchedulerQueuePayloadSchema(
|
|
141
|
+
"scheduler-test",
|
|
142
|
+
z.object({ message: z.string().max(200).optional() }).passthrough()
|
|
143
|
+
);
|
|
144
|
+
export {
|
|
145
|
+
assertSchedulerQueueTargetAuthorized,
|
|
146
|
+
auditSchedulerModuleQueueRows,
|
|
147
|
+
buildTrustedScheduleScope,
|
|
148
|
+
canDispatchScheduleQueueTarget,
|
|
149
|
+
getSchedulerQueuePayloadSchema,
|
|
150
|
+
getSchedulerQueueRequiredFeatures,
|
|
151
|
+
isSchedulerSafeQueue,
|
|
152
|
+
listSchedulerSafeQueueTargets,
|
|
153
|
+
registerSchedulerQueuePayloadSchema,
|
|
154
|
+
sanitizeSchedulerTargetPayload,
|
|
155
|
+
validateSchedulerTargetPayload
|
|
156
|
+
};
|
|
157
|
+
//# sourceMappingURL=safeQueueTargets.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/scheduler/lib/safeQueueTargets.ts"],
|
|
4
|
+
"sourcesContent": ["import { getModules } from '@open-mercato/shared/lib/modules/registry'\nimport type { ModuleWorker } from '@open-mercato/shared/modules/registry'\nimport { z, type ZodTypeAny } from 'zod'\n\nexport type SchedulerSafeQueueTarget = {\n queue: string\n /**\n * Indicative primary owner \u2014 the first registry module found among the\n * queue's consumers. A queue may legitimately have consumers from more than\n * one module; ownership for provenance checks is always resolved per module.\n */\n moduleId: string\n /** Extra creator features a principal must hold to schedule onto this queue. */\n requiredFeatures: string[]\n}\n\nexport type TrustedScheduleScope = {\n tenantId: string | null\n organizationId: string | null\n}\n\n/**\n * Runtime payload schemas for opted-in scheduler targets.\n *\n * Worker metadata cannot carry a Zod schema through static registry generation,\n * so the module that owns a schedulable worker registers its target payload\n * schema here at bootstrap. Targets without a registered schema accept\n * arbitrary JSON payloads (documented escape hatch, mirroring scheduler-safe\n * commands which also carry no payload schema).\n */\nconst registeredPayloadSchemas = new Map<string, ZodTypeAny>()\n\nexport function registerSchedulerQueuePayloadSchema(queue: string, schema: ZodTypeAny): void {\n registeredPayloadSchemas.set(queue, schema)\n}\n\nexport function getSchedulerQueuePayloadSchema(queue: string): ZodTypeAny | null {\n return registeredPayloadSchemas.get(queue) ?? null\n}\n\nexport type SchedulerTargetRbacService = {\n userHasAllFeatures?: (\n userId: string,\n required: readonly string[],\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<boolean>\n}\n\n/**\n * Creator-side authorization for an opted-in queue target (#5213): a principal\n * must hold every feature the target declares beyond `scheduler.jobs.manage`.\n * Super administrators bypass target-specific checks, mirroring the rest of\n * the scheduler's authorization model. Returns null when authorized,\n * otherwise a short reason string.\n */\nexport async function assertSchedulerQueueTargetAuthorized(params: {\n queue: string\n actorUserId?: string | null\n tenantId?: string | null\n organizationId?: string | null\n isSuperAdmin?: boolean\n rbacService?: SchedulerTargetRbacService | null\n}): Promise<string | null> {\n const requiredFeatures = getSchedulerQueueRequiredFeatures(params.queue)\n if (requiredFeatures.length === 0) return null\n if (params.isSuperAdmin === true) return null\n\n const actorUserId = typeof params.actorUserId === 'string' ? params.actorUserId.trim() : ''\n if (!actorUserId) return 'Scheduler queue target requires an authenticated creator'\n if (typeof params.rbacService?.userHasAllFeatures !== 'function') {\n return 'Scheduler queue target authorization is unavailable'\n }\n\n const authorized = await params.rbacService.userHasAllFeatures(actorUserId, requiredFeatures, {\n tenantId: params.tenantId ?? null,\n organizationId: params.organizationId ?? null,\n })\n if (!authorized) {\n return `Scheduler queue target requires features the creator lacks: ${requiredFeatures.join(', ')}`\n }\n return null\n}\n\n/**\n * Validates an author-supplied target payload against the target's registered\n * schema, when one exists. Returns null when the payload is acceptable,\n * otherwise a short reason string.\n */\nexport function validateSchedulerTargetPayload(\n queue: string,\n targetPayload: Record<string, unknown> | null | undefined,\n): string | null {\n const schema = getSchedulerQueuePayloadSchema(queue)\n if (!schema) return null\n const result = schema.safeParse(targetPayload ?? {})\n if (result.success) return null\n const firstIssue = result.error.issues[0]\n return [\n firstIssue?.path?.length ? `${firstIssue.path.join('.')}: ` : '',\n firstIssue?.message ?? 'Invalid payload',\n ].join('')\n}\n\n/**\n * Keys a schedule author may never control on a dispatched target payload:\n * tenant/organization authority context and the reserved `_`-prefixed envelope\n * namespace (idempotency keys, dispatch-origin markers, \u2026).\n */\nconst RESERVED_PAYLOAD_KEYS = new Set(['tenantId', 'organizationId', 'scope'])\n\nfunction isReservedPayloadKey(key: string): boolean {\n return RESERVED_PAYLOAD_KEYS.has(key) || key.startsWith('_')\n}\n\nfunction stripReservedKeys(payload: Record<string, unknown>): Record<string, unknown> {\n const result: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(payload)) {\n if (isReservedPayloadKey(key)) continue\n if (key === 'payload' && value !== null && typeof value === 'object' && !Array.isArray(value)) {\n result[key] = stripReservedKeys(value as Record<string, unknown>)\n continue\n }\n result[key] = value\n }\n return result\n}\n\n/**\n * Rebuild the trusted scope for a dispatched schedule payload from the stored\n * schedule row. Callers must never trust scope-shaped data from the schedule\n * author's `targetPayload`.\n */\nexport function buildTrustedScheduleScope(schedule: {\n tenantId?: string | null\n organizationId?: string | null\n}): TrustedScheduleScope {\n return {\n tenantId: schedule.tenantId ?? null,\n organizationId: schedule.organizationId ?? null,\n }\n}\n\n/**\n * Sanitize an author-supplied schedule target payload before dispatch:\n * deep-copies it, strips reserved authority and `_`-prefixed envelope keys at\n * the payload root and inside the local-strategy `payload` wrapper level, then\n * injects the trusted scope derived from the schedule row.\n */\nexport function sanitizeSchedulerTargetPayload(\n targetPayload: Record<string, unknown> | null | undefined,\n schedule: Parameters<typeof buildTrustedScheduleScope>[0],\n): Record<string, unknown> {\n const source = targetPayload ?? {}\n const cloned = structuredClone(source)\n const stripped = stripReservedKeys(cloned)\n return {\n ...stripped,\n scope: buildTrustedScheduleScope(schedule),\n }\n}\n\nconst queueConsumerCache = new WeakMap<object, Map<string, ModuleWorker[]>>()\n\nfunction collectQueueConsumers(): Map<string, ModuleWorker[]> {\n let modules: ReturnType<typeof getModules>\n try {\n modules = getModules()\n } catch {\n return new Map()\n }\n // The registry returns the same array instance between registrations, so the\n // grouped map can be memoized per instance instead of rebuilt per lookup.\n const cached = queueConsumerCache.get(modules)\n if (cached) return cached\n\n const byQueue = new Map<string, ModuleWorker[]>()\n for (const mod of modules) {\n for (const worker of mod.workers ?? []) {\n if (!worker.queue) continue\n const consumers = byQueue.get(worker.queue) ?? []\n consumers.push(worker)\n byQueue.set(worker.queue, consumers)\n }\n }\n queueConsumerCache.set(modules, byQueue)\n return byQueue\n}\n\n/**\n * Enumerate queue targets that are safe for user-facing scheduling (#5213):\n * every worker consuming the queue must explicitly opt in \u2014 queue consumers\n * compete for the same jobs, so a single non-opted-in consumer would otherwise\n * receive scheduler-controlled payloads it never agreed to accept.\n */\nexport function listSchedulerSafeQueueTargets(): SchedulerSafeQueueTarget[] {\n const targets: SchedulerSafeQueueTarget[] = []\n for (const [queue, consumers] of collectQueueConsumers()) {\n const optedIn = consumers.filter((worker) => worker.schedulerSafe === true)\n if (optedIn.length === 0 || optedIn.length !== consumers.length) continue\n\n const requiredFeatures = [...new Set(optedIn.flatMap((worker) => (\n Array.isArray(worker.schedulerRequiredFeatures) ? worker.schedulerRequiredFeatures : []\n )))]\n const ownerModuleIds = [...new Set(consumers.map((worker) => (\n typeof worker.id === 'string' ? (worker.id.split(':')[0] ?? '') : ''\n )))]\n targets.push({ queue, moduleId: ownerModuleIds[0] ?? '', requiredFeatures })\n }\n targets.sort((a, b) => a.queue.localeCompare(b.queue))\n return targets\n}\n\nexport function isSchedulerSafeQueue(queue: string | null | undefined): boolean {\n if (!queue) return false\n return listSchedulerSafeQueueTargets().some((target) => target.queue === queue)\n}\n\n/**\n * Extra creator features required by the target queue beyond\n * `scheduler.jobs.manage` (#5213). Empty when the target declares none.\n */\nexport function getSchedulerQueueRequiredFeatures(queue: string): string[] {\n return listSchedulerSafeQueueTargets().find((target) => target.queue === queue)?.requiredFeatures ?? []\n}\n\n/**\n * The module ids that own workers on this queue, from the live registry.\n */\nfunction findQueueOwnerModuleIds(queue: string): Set<string> {\n const owners = new Set<string>()\n for (const [candidate, consumers] of collectQueueConsumers()) {\n if (candidate !== queue) continue\n for (const worker of consumers) {\n if (typeof worker.id === 'string') owners.add(worker.id.split(':')[0] ?? '')\n }\n }\n return owners\n}\n\n/**\n * Operator-facing audit for persisted module-authored queue rows (#5213).\n *\n * Provenance on pre-upgrade rows is not fully verifiable at rest: a row created\n * by a non-user-bound API key carries no acting-user stamp, making it\n * indistinguishable from a genuine module registration. This helper surfaces\n * every module-authored queue-target row with its current dispatch verdict so\n * operators can review the allowed ones against their known integrations\n * (`mercato scheduler audit-queue-targets`).\n */\nexport type SchedulerModuleQueueRowAudit = {\n scheduleId: string\n name?: string\n sourceModule: string | null\n targetQueue: string | null\n isEnabled: boolean\n /** Whether the dispatch guard currently lets this row fire. */\n dispatchAllowed: boolean\n}\n\nexport function auditSchedulerModuleQueueRows(\n rows: ReadonlyArray<{\n id: string\n name?: string | null\n targetQueue?: string | null\n sourceType?: string | null\n sourceModule?: string | null\n createdByUserId?: string | null\n isEnabled?: boolean\n }>,\n): SchedulerModuleQueueRowAudit[] {\n return rows\n .filter((row) => row.sourceType === 'module' && row.targetQueue)\n .map((row) => ({\n scheduleId: row.id,\n name: row.name ?? undefined,\n sourceModule: row.sourceModule ?? null,\n targetQueue: row.targetQueue ?? null,\n isEnabled: row.isEnabled !== false,\n dispatchAllowed: canDispatchScheduleQueueTarget(row),\n }))\n}\n\n/**\n * Dispatch-time authorization for queue-targeted schedules (#5213).\n *\n * Provenance is server-owned and verified, never trusted from the stored row:\n * - API-authored schedules (`sourceType: 'user'`) may only target queues whose\n * workers all opted into scheduling.\n * - Module-authored schedules are accepted only when the recorded\n * `sourceModule` owns the queue in the live registry AND the row carries no\n * acting-user stamp (`createdByUserId == null`) \u2014 `schedulerService.register()`\n * never stamps one while every interactive API write does. Residual limit: a\n * pre-upgrade row authored by a non-user-bound API key also lacks the stamp;\n * surface those with `auditSchedulerModuleQueueRows` /\n * `mercato scheduler audit-queue-targets`.\n */\nexport function canDispatchScheduleQueueTarget(schedule: {\n targetQueue?: string | null\n sourceType?: string | null\n sourceModule?: string | null\n /** Server-stamped actor on API-authored rows; module registration never sets it. */\n createdByUserId?: string | null\n}): boolean {\n if (!schedule.targetQueue) return false\n if (schedule.sourceType === 'module') {\n // Provenance must be server-owned, and both stored fields were client-\n // writable before #5213. A matching sourceModule alone is forgeable public\n // knowledge, so a persisted module row is only trusted when it also carries\n // no acting-user stamp: `schedulerService.register()` never sets one, while\n // every API write does. Forged pre-upgrade rows fail closed here.\n if (!schedule.sourceModule) return false\n if (schedule.createdByUserId) return false\n return findQueueOwnerModuleIds(schedule.targetQueue).has(schedule.sourceModule)\n }\n return isSchedulerSafeQueue(schedule.targetQueue)\n}\n\n// Built-in opted-in target owned by this module: the side-effect-free QA echo\n// queue. The strict shape keeps scheduler-controlled payloads predictable and\n// gives deployments a working reference for registering their own schemas.\nregisterSchedulerQueuePayloadSchema(\n 'scheduler-test',\n z.object({ message: z.string().max(200).optional() }).passthrough(),\n)\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -8,6 +8,12 @@ import { assertSchedulerSafeCommandAuthorized } from "../lib/scheduler-safe-comm
|
|
|
8
8
|
import { buildScheduledCommandContext } from "../lib/commandContext.js";
|
|
9
9
|
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from "../lib/queueTargetPayload.js";
|
|
10
10
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
11
|
+
import {
|
|
12
|
+
canDispatchScheduleQueueTarget,
|
|
13
|
+
getSchedulerQueueRequiredFeatures,
|
|
14
|
+
sanitizeSchedulerTargetPayload,
|
|
15
|
+
validateSchedulerTargetPayload
|
|
16
|
+
} from "../lib/safeQueueTargets.js";
|
|
11
17
|
const logger = createLogger("scheduler").child({ component: "local" });
|
|
12
18
|
class LocalSchedulerService {
|
|
13
19
|
constructor(em, queueFactory, rbacService, config = { pollIntervalMs: 3e4 }) {
|
|
@@ -171,13 +177,32 @@ class LocalSchedulerService {
|
|
|
171
177
|
if (!schedule.targetQueue) {
|
|
172
178
|
throw new Error("Target queue is required for queue target type");
|
|
173
179
|
}
|
|
180
|
+
if (!canDispatchScheduleQueueTarget(schedule)) {
|
|
181
|
+
throw new Error(`Queue is not an approved scheduler target: ${schedule.targetQueue}`);
|
|
182
|
+
}
|
|
183
|
+
const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload);
|
|
184
|
+
if (payloadIssue) {
|
|
185
|
+
throw new Error(`Invalid payload for scheduler queue ${schedule.targetQueue}: ${payloadIssue}`);
|
|
186
|
+
}
|
|
187
|
+
if (schedule.scopeType !== "system") {
|
|
188
|
+
for (const feature of getSchedulerQueueRequiredFeatures(schedule.targetQueue)) {
|
|
189
|
+
const hasFeature = await this.checkFeature({ ...schedule, requireFeature: feature });
|
|
190
|
+
if (!hasFeature) {
|
|
191
|
+
throw new Error(`Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
174
195
|
const queue = this.queueFactory(schedule.targetQueue);
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
196
|
+
const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule);
|
|
197
|
+
await queue.enqueue({
|
|
198
|
+
...buildQueueTargetPayload({
|
|
199
|
+
targetPayload: sanitizedPayload,
|
|
200
|
+
tenantId: schedule.tenantId,
|
|
201
|
+
organizationId: schedule.organizationId,
|
|
202
|
+
idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now())
|
|
203
|
+
}),
|
|
204
|
+
_jobOrigin: "scheduler"
|
|
205
|
+
});
|
|
181
206
|
logger.info("Enqueued job to target queue", { scheduleId: schedule.id, targetQueue: schedule.targetQueue });
|
|
182
207
|
}
|
|
183
208
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/scheduler/services/localSchedulerService.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport type { Queue } from '@open-mercato/queue'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { ScheduledJob } from '../data/entities.js'\nimport { LocalLockStrategy } from '../lib/localLockStrategy'\nimport { recalculateNextRun } from '../lib/nextRunCalculator'\nimport { emitSchedulerEvent } from '../events.js'\nimport { getGlobalEventBus } from '@open-mercato/shared/modules/events'\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: 'local' })\n\nexport interface RbacServiceLike {\n tenantHasFeature(tenantId: string | null | undefined, feature: string, opts?: { organizationId?: string | null }): Promise<boolean>\n userHasAllFeatures(userId: string, required: readonly string[], scope: { tenantId: string | null; organizationId: string | null }): Promise<boolean>\n}\n\nexport interface LocalSchedulerConfig {\n pollIntervalMs: number\n}\n\n/**\n * Local scheduler service for development\n * \n * This service polls the database for due schedules and executes them locally\n * without requiring Redis. Perfect for local development.\n * \n * Features:\n * - PostgreSQL polling (configurable interval, default 30s)\n * - In-process duplicate prevention across overlapping poll ticks\n * - Supports both queue and command targets\n * - Emits the same events as async strategy\n * - Updates nextRunAt after execution\n *\n * Limitations:\n * - Polling delay (up to configured interval)\n * - Single instance only (no distributed locking across instances). Duplicate\n * prevention is in-process only: the advisory lock serialises the claim, not\n * the execution, and nextRunAt is advanced only after the target has run, so\n * two processes polling the same database will both execute a due schedule.\n * Run exactly one process with QUEUE_STRATEGY=local, or use the async\n * strategy, which locks across instances.\n * - Higher database load than async strategy\n * \n * Set QUEUE_STRATEGY=local to use this service.\n */\nexport class LocalSchedulerService {\n private isRunning = false\n private pollTimer?: NodeJS.Timeout\n private lockStrategy: LocalLockStrategy\n\n constructor(\n private em: () => EntityManager,\n private queueFactory: (name: string) => Queue,\n private rbacService: RbacServiceLike,\n private config: LocalSchedulerConfig = { pollIntervalMs: 30000 },\n ) {\n this.lockStrategy = new LocalLockStrategy(em)\n }\n\n /**\n * Start the local scheduler polling engine\n */\n async start(): Promise<void> {\n if (this.isRunning) {\n logger.warn('Polling engine already running')\n return\n }\n\n this.isRunning = true\n logger.info('Starting polling engine', { pollIntervalMs: this.config.pollIntervalMs })\n logger.warn(\n 'Local scheduler strategy provides no cross-process protection: duplicate prevention is in-process only, so a second process polling the same database will execute the same due schedule. Run exactly one process with QUEUE_STRATEGY=local, or use the async strategy for distributed locking.',\n )\n\n // Run initial poll immediately\n await this.poll()\n\n // Schedule recurring polls\n this.pollTimer = setInterval(() => {\n this.poll().catch((error) => {\n logger.error('Poll error', { err: error })\n })\n }, this.config.pollIntervalMs)\n\n logger.info('Polling engine started')\n }\n\n /**\n * Stop the local scheduler\n */\n async stop(): Promise<void> {\n logger.info('Stopping polling engine')\n this.isRunning = false\n \n if (this.pollTimer) {\n clearInterval(this.pollTimer)\n this.pollTimer = undefined\n }\n\n logger.info('Polling engine stopped')\n }\n\n /**\n * Poll for due schedules and execute them\n */\n private async poll(): Promise<void> {\n if (!this.isRunning) {\n return\n }\n\n const em = this.em().fork()\n\n try {\n // Find enabled schedules that are due (limited to avoid spikes after outages)\n const dueSchedules = await em.find(ScheduledJob, {\n isEnabled: true,\n deletedAt: null,\n nextRunAt: { $lte: new Date() },\n }, {\n limit: 100,\n orderBy: { nextRunAt: 'ASC' },\n })\n\n if (dueSchedules.length === 0) {\n logger.debug('No due schedules')\n return\n }\n\n logger.info('Found due schedules', { count: dueSchedules.length })\n\n // Execute each schedule\n for (const schedule of dueSchedules) {\n await this.executeSchedule(schedule)\n }\n } catch (error: unknown) {\n logger.error('Poll failed', { err: error })\n }\n }\n\n /**\n * Execute a single schedule\n */\n private async executeSchedule(schedule: ScheduledJob): Promise<void> {\n const lockKey = `schedule:${schedule.id}`\n const scheduleLogger = logger.child({ scheduleId: schedule.id, scheduleName: schedule.name })\n\n const { acquired } = await this.lockStrategy.runWithLock(lockKey, async () => {\n scheduleLogger.info('Executing schedule')\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 scopeType: schedule.scopeType,\n triggerType: 'scheduled',\n startedAt: new Date(),\n })\n\n try {\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await this.checkFeature(schedule)\n \n if (!hasFeature) {\n scheduleLogger.info('Schedule skipped: missing required feature', { requireFeature: schedule.requireFeature })\n \n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n reason: `Missing required feature: ${schedule.requireFeature}`,\n skippedAt: new Date(),\n })\n\n // Still update next run time\n await this.updateNextRun(schedule)\n return\n }\n }\n\n // Execute based on target type\n if (schedule.targetType === 'queue') {\n await this.executeQueueTarget(schedule)\n } else if (schedule.targetType === 'command') {\n await this.executeCommandTarget(schedule)\n } else {\n throw new Error(`Unknown target type: ${schedule.targetType}`)\n }\n\n // Update last run and next run times\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n freshSchedule.lastRunAt = new Date()\n \n // Calculate next run time\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n }\n \n await em.flush()\n }\n\n scheduleLogger.info('Schedule completed')\n\n // Emit completed event\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n completedAt: new Date(),\n })\n } catch (error: unknown) {\n scheduleLogger.error('Schedule failed', { err: error })\n\n // Emit failed event\n await emitSchedulerEvent('scheduler.job.failed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n error: error instanceof Error ? error.message : String(error),\n failedAt: new Date(),\n })\n\n // Still update next run time even on failure\n await this.updateNextRun(schedule)\n }\n })\n\n if (!acquired) {\n scheduleLogger.debug('Schedule already locked, skipping')\n return\n }\n }\n\n /**\n * Execute a queue-based target\n */\n private async executeQueueTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetQueue) {\n throw new Error('Target queue is required for queue target type')\n }\n\n const queue = this.queueFactory(schedule.targetQueue)\n\n // Deliver the same flat contract as the asynchronous execute-schedule\n // worker: targetPayload fields on the root, scheduler-owned scope and\n // idempotency fields applied last. Local mode runs each firing exactly\n // once, so the firing timestamp is a valid logical execution key.\n await queue.enqueue(buildQueueTargetPayload({\n targetPayload: schedule.targetPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),\n }))\n\n logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })\n }\n\n /**\n * Execute a command-based target\n */\n private async executeCommandTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetCommand) {\n throw new Error('Target command is required for command target type')\n }\n\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: this.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(\n schedule,\n {\n resolve: (name: string) => {\n // Simple resolver that forwards to our dependencies\n if (name === 'em') return this.em()\n if (name === 'eventBus') return getGlobalEventBus()\n if (name === 'rbacService') return this.rbacService\n throw new Error(`Service not available in scheduler context: ${name}`)\n },\n } as AppContainer,\n )\n\n await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n\n logger.info('Executed command', { scheduleId: schedule.id, targetCommand: schedule.targetCommand })\n }\n\n /**\n * Check if user/tenant has required feature\n */\n private async checkFeature(schedule: ScheduledJob): Promise<boolean> {\n if (!schedule.requireFeature) {\n return true\n }\n\n try {\n // For system-scoped schedules, no RBAC check needed\n if (schedule.scopeType === 'system') {\n return true\n }\n\n // For tenant/org scoped schedules, check if ANY admin user has the feature\n // This is a simplified check - in reality, scheduled jobs run without a user context\n // so we're just checking if the feature is enabled for the tenant\n const hasFeature = await this.rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n {\n organizationId: schedule.organizationId,\n }\n )\n\n return hasFeature\n } catch (error: unknown) {\n logger.error('Feature check failed', { scheduleId: schedule.id, err: error })\n return false\n }\n }\n\n /**\n * Update next run time for a schedule\n */\n private async updateNextRun(schedule: ScheduledJob): Promise<void> {\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n await em.flush()\n }\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "AAEA,SAAS,kBAAkB;AAE3B,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,0BAA0B;AACnC,SAAS,yBAAyB;AAClC,SAAS,4CAA4C;AACrD,SAAS,oCAAoC;AAC7C,SAAS,yBAAyB,oCAAoC;AACtE,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport type { Queue } from '@open-mercato/queue'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { ScheduledJob } from '../data/entities.js'\nimport { LocalLockStrategy } from '../lib/localLockStrategy'\nimport { recalculateNextRun } from '../lib/nextRunCalculator'\nimport { emitSchedulerEvent } from '../events.js'\nimport { getGlobalEventBus } from '@open-mercato/shared/modules/events'\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: 'local' })\n\nexport interface RbacServiceLike {\n tenantHasFeature(tenantId: string | null | undefined, feature: string, opts?: { organizationId?: string | null }): Promise<boolean>\n userHasAllFeatures(userId: string, required: readonly string[], scope: { tenantId: string | null; organizationId: string | null }): Promise<boolean>\n}\n\nexport interface LocalSchedulerConfig {\n pollIntervalMs: number\n}\n\n/**\n * Local scheduler service for development\n * \n * This service polls the database for due schedules and executes them locally\n * without requiring Redis. Perfect for local development.\n * \n * Features:\n * - PostgreSQL polling (configurable interval, default 30s)\n * - In-process duplicate prevention across overlapping poll ticks\n * - Supports both queue and command targets\n * - Emits the same events as async strategy\n * - Updates nextRunAt after execution\n *\n * Limitations:\n * - Polling delay (up to configured interval)\n * - Single instance only (no distributed locking across instances). Duplicate\n * prevention is in-process only: the advisory lock serialises the claim, not\n * the execution, and nextRunAt is advanced only after the target has run, so\n * two processes polling the same database will both execute a due schedule.\n * Run exactly one process with QUEUE_STRATEGY=local, or use the async\n * strategy, which locks across instances.\n * - Higher database load than async strategy\n * \n * Set QUEUE_STRATEGY=local to use this service.\n */\nexport class LocalSchedulerService {\n private isRunning = false\n private pollTimer?: NodeJS.Timeout\n private lockStrategy: LocalLockStrategy\n\n constructor(\n private em: () => EntityManager,\n private queueFactory: (name: string) => Queue,\n private rbacService: RbacServiceLike,\n private config: LocalSchedulerConfig = { pollIntervalMs: 30000 },\n ) {\n this.lockStrategy = new LocalLockStrategy(em)\n }\n\n /**\n * Start the local scheduler polling engine\n */\n async start(): Promise<void> {\n if (this.isRunning) {\n logger.warn('Polling engine already running')\n return\n }\n\n this.isRunning = true\n logger.info('Starting polling engine', { pollIntervalMs: this.config.pollIntervalMs })\n logger.warn(\n 'Local scheduler strategy provides no cross-process protection: duplicate prevention is in-process only, so a second process polling the same database will execute the same due schedule. Run exactly one process with QUEUE_STRATEGY=local, or use the async strategy for distributed locking.',\n )\n\n // Run initial poll immediately\n await this.poll()\n\n // Schedule recurring polls\n this.pollTimer = setInterval(() => {\n this.poll().catch((error) => {\n logger.error('Poll error', { err: error })\n })\n }, this.config.pollIntervalMs)\n\n logger.info('Polling engine started')\n }\n\n /**\n * Stop the local scheduler\n */\n async stop(): Promise<void> {\n logger.info('Stopping polling engine')\n this.isRunning = false\n \n if (this.pollTimer) {\n clearInterval(this.pollTimer)\n this.pollTimer = undefined\n }\n\n logger.info('Polling engine stopped')\n }\n\n /**\n * Poll for due schedules and execute them\n */\n private async poll(): Promise<void> {\n if (!this.isRunning) {\n return\n }\n\n const em = this.em().fork()\n\n try {\n // Find enabled schedules that are due (limited to avoid spikes after outages)\n const dueSchedules = await em.find(ScheduledJob, {\n isEnabled: true,\n deletedAt: null,\n nextRunAt: { $lte: new Date() },\n }, {\n limit: 100,\n orderBy: { nextRunAt: 'ASC' },\n })\n\n if (dueSchedules.length === 0) {\n logger.debug('No due schedules')\n return\n }\n\n logger.info('Found due schedules', { count: dueSchedules.length })\n\n // Execute each schedule\n for (const schedule of dueSchedules) {\n await this.executeSchedule(schedule)\n }\n } catch (error: unknown) {\n logger.error('Poll failed', { err: error })\n }\n }\n\n /**\n * Execute a single schedule\n */\n private async executeSchedule(schedule: ScheduledJob): Promise<void> {\n const lockKey = `schedule:${schedule.id}`\n const scheduleLogger = logger.child({ scheduleId: schedule.id, scheduleName: schedule.name })\n\n const { acquired } = await this.lockStrategy.runWithLock(lockKey, async () => {\n scheduleLogger.info('Executing schedule')\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 scopeType: schedule.scopeType,\n triggerType: 'scheduled',\n startedAt: new Date(),\n })\n\n try {\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await this.checkFeature(schedule)\n \n if (!hasFeature) {\n scheduleLogger.info('Schedule skipped: missing required feature', { requireFeature: schedule.requireFeature })\n \n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n reason: `Missing required feature: ${schedule.requireFeature}`,\n skippedAt: new Date(),\n })\n\n // Still update next run time\n await this.updateNextRun(schedule)\n return\n }\n }\n\n // Execute based on target type\n if (schedule.targetType === 'queue') {\n await this.executeQueueTarget(schedule)\n } else if (schedule.targetType === 'command') {\n await this.executeCommandTarget(schedule)\n } else {\n throw new Error(`Unknown target type: ${schedule.targetType}`)\n }\n\n // Update last run and next run times\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n freshSchedule.lastRunAt = new Date()\n \n // Calculate next run time\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n }\n \n await em.flush()\n }\n\n scheduleLogger.info('Schedule completed')\n\n // Emit completed event\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n completedAt: new Date(),\n })\n } catch (error: unknown) {\n scheduleLogger.error('Schedule failed', { err: error })\n\n // Emit failed event\n await emitSchedulerEvent('scheduler.job.failed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n error: error instanceof Error ? error.message : String(error),\n failedAt: new Date(),\n })\n\n // Still update next run time even on failure\n await this.updateNextRun(schedule)\n }\n })\n\n if (!acquired) {\n scheduleLogger.debug('Schedule already locked, skipping')\n return\n }\n }\n\n /**\n * Execute a queue-based target\n */\n private async executeQueueTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetQueue) {\n throw new Error('Target queue is required for queue target type')\n }\n\n // Dispatch-time reauthorization (#5213): provenance is verified against the\n // live registry (module rows must own their queue), API rows must target\n // opted-in queues.\n if (!canDispatchScheduleQueueTarget(schedule)) {\n throw new Error(`Queue is not an approved scheduler target: ${schedule.targetQueue}`)\n }\n\n const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)\n if (payloadIssue) {\n throw new Error(`Invalid payload for scheduler queue ${schedule.targetQueue}: ${payloadIssue}`)\n }\n\n if (schedule.scopeType !== 'system') {\n for (const feature of getSchedulerQueueRequiredFeatures(schedule.targetQueue)) {\n const hasFeature = await this.checkFeature({ ...schedule, requireFeature: feature } as ScheduledJob)\n if (!hasFeature) {\n throw new Error(`Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`)\n }\n }\n }\n\n const queue = this.queueFactory(schedule.targetQueue)\n\n // Deliver the same flat contract as the asynchronous execute-schedule\n // worker: targetPayload fields on the root, scheduler-owned scope and\n // idempotency fields applied last. Local mode runs each firing exactly\n // once, so the firing timestamp is a valid logical execution key.\n // Author-supplied scope/envelope keys never survive dispatch (#5213).\n const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule)\n\n await queue.enqueue({\n ...buildQueueTargetPayload({\n targetPayload: sanitizedPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),\n }),\n _jobOrigin: 'scheduler' as const,\n })\n\n logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })\n }\n\n /**\n * Execute a command-based target\n */\n private async executeCommandTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetCommand) {\n throw new Error('Target command is required for command target type')\n }\n\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: this.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(\n schedule,\n {\n resolve: (name: string) => {\n // Simple resolver that forwards to our dependencies\n if (name === 'em') return this.em()\n if (name === 'eventBus') return getGlobalEventBus()\n if (name === 'rbacService') return this.rbacService\n throw new Error(`Service not available in scheduler context: ${name}`)\n },\n } as AppContainer,\n )\n\n await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n\n logger.info('Executed command', { scheduleId: schedule.id, targetCommand: schedule.targetCommand })\n }\n\n /**\n * Check if user/tenant has required feature\n */\n private async checkFeature(schedule: ScheduledJob): Promise<boolean> {\n if (!schedule.requireFeature) {\n return true\n }\n\n try {\n // For system-scoped schedules, no RBAC check needed\n if (schedule.scopeType === 'system') {\n return true\n }\n\n // For tenant/org scoped schedules, check if ANY admin user has the feature\n // This is a simplified check - in reality, scheduled jobs run without a user context\n // so we're just checking if the feature is enabled for the tenant\n const hasFeature = await this.rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n {\n organizationId: schedule.organizationId,\n }\n )\n\n return hasFeature\n } catch (error: unknown) {\n logger.error('Feature check failed', { scheduleId: schedule.id, err: error })\n return false\n }\n }\n\n /**\n * Update next run time for a schedule\n */\n private async updateNextRun(schedule: ScheduledJob): Promise<void> {\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n await em.flush()\n }\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -7,6 +7,12 @@ import { assertSchedulerSafeCommandAuthorized } from "../lib/scheduler-safe-comm
|
|
|
7
7
|
import { buildScheduledCommandContext } from "../lib/commandContext.js";
|
|
8
8
|
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from "../lib/queueTargetPayload.js";
|
|
9
9
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
10
|
+
import {
|
|
11
|
+
canDispatchScheduleQueueTarget,
|
|
12
|
+
getSchedulerQueueRequiredFeatures,
|
|
13
|
+
sanitizeSchedulerTargetPayload,
|
|
14
|
+
validateSchedulerTargetPayload
|
|
15
|
+
} from "../lib/safeQueueTargets.js";
|
|
10
16
|
const logger = createLogger("scheduler").child({ component: "worker" });
|
|
11
17
|
const metadata = {
|
|
12
18
|
queue: "scheduler-execution",
|
|
@@ -93,6 +99,54 @@ async function executeScheduleWorker(job, ctx) {
|
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
101
|
if (schedule.targetType === "queue" && schedule.targetQueue) {
|
|
102
|
+
if (!canDispatchScheduleQueueTarget(schedule)) {
|
|
103
|
+
logger.error("Refusing non-safe queue target for schedule", {
|
|
104
|
+
scheduleId,
|
|
105
|
+
targetQueue: schedule.targetQueue,
|
|
106
|
+
sourceType: schedule.sourceType,
|
|
107
|
+
sourceModule: schedule.sourceType === "module" ? schedule.sourceModule : void 0
|
|
108
|
+
});
|
|
109
|
+
await emitSchedulerEvent("scheduler.job.skipped", {
|
|
110
|
+
id: schedule.id,
|
|
111
|
+
tenantId: schedule.tenantId,
|
|
112
|
+
organizationId: schedule.organizationId,
|
|
113
|
+
reason: `Queue is not an approved scheduler target: ${schedule.targetQueue}`
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload);
|
|
118
|
+
if (payloadIssue) {
|
|
119
|
+
logger.error("Refusing schedule with invalid payload for queue target", {
|
|
120
|
+
scheduleId,
|
|
121
|
+
targetQueue: schedule.targetQueue,
|
|
122
|
+
issue: payloadIssue
|
|
123
|
+
});
|
|
124
|
+
await emitSchedulerEvent("scheduler.job.skipped", {
|
|
125
|
+
id: schedule.id,
|
|
126
|
+
tenantId: schedule.tenantId,
|
|
127
|
+
organizationId: schedule.organizationId,
|
|
128
|
+
reason: `Invalid payload for scheduler queue ${schedule.targetQueue}`
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const requiredQueueFeatures = getSchedulerQueueRequiredFeatures(schedule.targetQueue);
|
|
133
|
+
if (schedule.scopeType !== "system" && requiredQueueFeatures.length > 0) {
|
|
134
|
+
for (const feature of requiredQueueFeatures) {
|
|
135
|
+
if (await rbacService.tenantHasFeature(schedule.tenantId, feature, { organizationId: schedule.organizationId })) continue;
|
|
136
|
+
logger.error("Refusing schedule whose tenant lacks a required feature of the queue target", {
|
|
137
|
+
scheduleId,
|
|
138
|
+
targetQueue: schedule.targetQueue,
|
|
139
|
+
feature
|
|
140
|
+
});
|
|
141
|
+
await emitSchedulerEvent("scheduler.job.skipped", {
|
|
142
|
+
id: schedule.id,
|
|
143
|
+
tenantId: schedule.tenantId,
|
|
144
|
+
organizationId: schedule.organizationId,
|
|
145
|
+
reason: `Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
96
150
|
const queueStrategy = process.env.QUEUE_STRATEGY || "local";
|
|
97
151
|
const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {
|
|
98
152
|
connection: { url: getRedisUrlOrThrow("QUEUE") }
|
|
@@ -100,12 +154,16 @@ async function executeScheduleWorker(job, ctx) {
|
|
|
100
154
|
let targetJobId;
|
|
101
155
|
try {
|
|
102
156
|
const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now());
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
157
|
+
const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule);
|
|
158
|
+
targetJobId = await targetQueue.enqueue({
|
|
159
|
+
...buildQueueTargetPayload({
|
|
160
|
+
targetPayload: sanitizedPayload,
|
|
161
|
+
tenantId: schedule.tenantId,
|
|
162
|
+
organizationId: schedule.organizationId,
|
|
163
|
+
idempotencyKey
|
|
164
|
+
}),
|
|
165
|
+
_jobOrigin: "scheduler"
|
|
166
|
+
});
|
|
109
167
|
} finally {
|
|
110
168
|
await targetQueue.close();
|
|
111
169
|
}
|