@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.
- 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 +4 -9
- 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-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 +44 -107
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js +3 -13
- package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
- package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +13 -7
- package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
- package/dist/modules/scheduler/api/targets/route.js +12 -2
- package/dist/modules/scheduler/api/targets/route.js.map +2 -2
- package/dist/modules/scheduler/api/trigger/route.js +13 -7
- package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
- package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +1 -3
- package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
- package/dist/modules/scheduler/cli.js +1 -32
- package/dist/modules/scheduler/cli.js.map +2 -2
- package/dist/modules/scheduler/commands/jobs.js +2 -84
- package/dist/modules/scheduler/commands/jobs.js.map +2 -2
- package/dist/modules/scheduler/data/validators.js +3 -27
- package/dist/modules/scheduler/data/validators.js.map +2 -2
- package/dist/modules/scheduler/i18n/de.json +0 -2
- package/dist/modules/scheduler/i18n/en.json +0 -2
- package/dist/modules/scheduler/i18n/es.json +0 -2
- package/dist/modules/scheduler/i18n/ko.json +0 -2
- package/dist/modules/scheduler/i18n/pl.json +0 -2
- package/dist/modules/scheduler/services/localSchedulerService.js +6 -31
- package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
- package/dist/modules/scheduler/workers/execute-schedule.worker.js +6 -64
- package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
- package/package.json +6 -7
- package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
- package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +8 -14
- package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
- 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 +66 -154
- package/src/modules/scheduler/__integration__/helpers/scheduler.ts +5 -24
- package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +19 -9
- package/src/modules/scheduler/api/targets/__tests__/route.test.ts +4 -3
- package/src/modules/scheduler/api/targets/route.ts +17 -6
- package/src/modules/scheduler/api/trigger/route.ts +19 -9
- package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +1 -4
- package/src/modules/scheduler/cli.ts +1 -44
- package/src/modules/scheduler/commands/jobs.ts +3 -137
- package/src/modules/scheduler/data/__tests__/validators.test.ts +4 -26
- package/src/modules/scheduler/data/validators.ts +2 -37
- package/src/modules/scheduler/i18n/de.json +0 -2
- package/src/modules/scheduler/i18n/en.json +0 -2
- package/src/modules/scheduler/i18n/es.json +0 -2
- package/src/modules/scheduler/i18n/ko.json +0 -2
- package/src/modules/scheduler/i18n/pl.json +0 -2
- package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +0 -179
- package/src/modules/scheduler/services/localSchedulerService.ts +6 -39
- package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +3 -25
- package/src/modules/scheduler/workers/execute-schedule.worker.ts +7 -76
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +0 -72
- package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +0 -7
- package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +0 -66
- package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +0 -7
- package/dist/modules/scheduler/lib/safeQueueTargets.js +0 -157
- package/dist/modules/scheduler/lib/safeQueueTargets.js.map +0 -7
- package/dist/modules/scheduler/lib/scheduleAccess.js +0 -22
- package/dist/modules/scheduler/lib/scheduleAccess.js.map +0 -7
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js +0 -22
- package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +0 -7
- package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +0 -90
- package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +0 -94
- package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +0 -177
- package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +0 -110
- package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +0 -311
- package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +0 -164
- package/src/modules/scheduler/lib/safeQueueTargets.ts +0 -324
- package/src/modules/scheduler/lib/scheduleAccess.ts +0 -104
- package/src/modules/scheduler/workers/test-echo-queue.worker.ts +0 -25
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { getModules } from '@open-mercato/shared/lib/modules/registry'
|
|
4
5
|
import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
|
|
5
6
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
6
7
|
import { listSchedulerSafeCommands } from '../../lib/scheduler-safe-commands'
|
|
7
|
-
import { listSchedulerSafeQueueTargets } from '../../lib/safeQueueTargets'
|
|
8
8
|
|
|
9
9
|
export const metadata = {
|
|
10
10
|
requireAuth: true,
|
|
@@ -13,9 +13,7 @@ export const metadata = {
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* GET /api/scheduler/targets
|
|
16
|
-
* Returns
|
|
17
|
-
* Only queues whose workers opted into scheduling (`schedulerSafe: true`) are
|
|
18
|
-
* advertised; internal and system-only workers stay undiscoverable (#5213).
|
|
16
|
+
* Returns available queue names and command IDs for schedule target selection.
|
|
19
17
|
*/
|
|
20
18
|
export async function GET(req: NextRequest) {
|
|
21
19
|
const auth = await getAuthFromRequest(req)
|
|
@@ -23,8 +21,20 @@ export async function GET(req: NextRequest) {
|
|
|
23
21
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
24
22
|
}
|
|
25
23
|
|
|
26
|
-
const
|
|
27
|
-
|
|
24
|
+
const modules = getModules()
|
|
25
|
+
|
|
26
|
+
const queueSet = new Set<string>()
|
|
27
|
+
for (const mod of modules) {
|
|
28
|
+
if (mod.workers) {
|
|
29
|
+
for (const worker of mod.workers) {
|
|
30
|
+
queueSet.add(worker.queue)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const queues = Array.from(queueSet)
|
|
36
|
+
.sort((a, b) => a.localeCompare(b))
|
|
37
|
+
.map((queue) => ({ value: queue, label: queue }))
|
|
28
38
|
|
|
29
39
|
const commands = listSchedulerSafeCommands()
|
|
30
40
|
.filter((command) => commandRegistry.has(command.commandId))
|
|
@@ -56,6 +66,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
56
66
|
GET: {
|
|
57
67
|
operationId: 'listScheduleTargets',
|
|
58
68
|
summary: 'List available queues and commands',
|
|
69
|
+
description: 'Returns all registered queue names (from module workers) and explicitly scheduler-safe command IDs that can be used as schedule targets.',
|
|
59
70
|
responses: [
|
|
60
71
|
{
|
|
61
72
|
status: 200,
|
|
@@ -7,7 +7,6 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
|
7
7
|
import { createQueue } from '@open-mercato/queue'
|
|
8
8
|
import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
|
|
9
9
|
import { ScheduledJob } from '../../data/entities.js'
|
|
10
|
-
import { resolveScheduleAccess } from '../../lib/scheduleAccess.js'
|
|
11
10
|
import { scheduleTriggerSchema } from '../../data/validators.js'
|
|
12
11
|
import type { ExecuteSchedulePayload } from '../../workers/execute-schedule.worker.js'
|
|
13
12
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
@@ -41,19 +40,30 @@ export async function POST(req: NextRequest) {
|
|
|
41
40
|
const body = await req.json()
|
|
42
41
|
const input = scheduleTriggerSchema.parse(body)
|
|
43
42
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// Find the schedule with tenant/org scope filter
|
|
44
|
+
const findFilter: Record<string, unknown> = {
|
|
45
|
+
id: input.id,
|
|
46
|
+
deletedAt: null,
|
|
47
|
+
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
53
|
-
|
|
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
|
-
|
|
62
|
+
|
|
63
|
+
// System-scoped schedules (no tenantId/orgId) require super-admin. Use the
|
|
64
|
+
// immutable `isSuperAdmin` flag — never compare mutable/spoofable role names.
|
|
65
|
+
const isSuperAdmin = auth.isSuperAdmin === true
|
|
66
|
+
if (!schedule.tenantId && !schedule.organizationId && !isSuperAdmin) {
|
|
57
67
|
return NextResponse.json({ error: translate('scheduler.error.access_denied', 'Access denied') }, { status: 403 })
|
|
58
68
|
}
|
|
59
69
|
|
|
@@ -39,7 +39,6 @@ type SchedulesResponse = {
|
|
|
39
39
|
total?: number
|
|
40
40
|
page?: number
|
|
41
41
|
totalPages?: number
|
|
42
|
-
totalIsCapped?: boolean
|
|
43
42
|
}
|
|
44
43
|
|
|
45
44
|
function mapApiItem(item: Record<string, unknown>): ScheduleRow | null {
|
|
@@ -83,7 +82,6 @@ export default function SchedulerPage() {
|
|
|
83
82
|
const [pageSize] = React.useState(20)
|
|
84
83
|
const [total, setTotal] = React.useState(0)
|
|
85
84
|
const [totalPages, setTotalPages] = React.useState(1)
|
|
86
|
-
const [totalIsCapped, setTotalIsCapped] = React.useState(false)
|
|
87
85
|
const [search, setSearch] = React.useState('')
|
|
88
86
|
const [isLoading, setIsLoading] = React.useState(false)
|
|
89
87
|
const scopeVersion = useOrganizationScopeVersion()
|
|
@@ -106,7 +104,6 @@ export default function SchedulerPage() {
|
|
|
106
104
|
setRows(mapped)
|
|
107
105
|
setTotal(result?.total ?? 0)
|
|
108
106
|
setTotalPages(result?.totalPages ?? 1)
|
|
109
|
-
setTotalIsCapped(result?.totalIsCapped === true)
|
|
110
107
|
} catch (error) {
|
|
111
108
|
flash(t('scheduler.error.fetch_failed', 'Failed to load schedules'), 'error')
|
|
112
109
|
setRows([])
|
|
@@ -285,7 +282,7 @@ export default function SchedulerPage() {
|
|
|
285
282
|
createLabel={t('scheduler.action.create', 'New Schedule')}
|
|
286
283
|
/>
|
|
287
284
|
)}
|
|
288
|
-
pagination={{ page, pageSize, total, totalPages,
|
|
285
|
+
pagination={{ page, pageSize, total, totalPages, onPageChange: setPage }}
|
|
289
286
|
isLoading={isLoading}
|
|
290
287
|
searchValue={search}
|
|
291
288
|
onSearchChange={(value) => { setSearch(value); setPage(1) }}
|
|
@@ -3,7 +3,6 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
3
3
|
import type { EntityManager } from '@mikro-orm/core'
|
|
4
4
|
import { ScheduledJob } from './data/entities.js'
|
|
5
5
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
6
|
-
import { auditSchedulerModuleQueueRows } from './lib/safeQueueTargets'
|
|
7
6
|
|
|
8
7
|
const writeLine = (text = '') => {
|
|
9
8
|
process.stdout.write(`${text}\n`)
|
|
@@ -226,46 +225,4 @@ const startCommand: ModuleCli = {
|
|
|
226
225
|
},
|
|
227
226
|
}
|
|
228
227
|
|
|
229
|
-
|
|
230
|
-
// dispatch verdict (#5213). Rows authored before the hardening by a
|
|
231
|
-
// non-user-bound API key are indistinguishable from genuine registrations at
|
|
232
|
-
// rest, so operators review the ALLOWED entries against their known integrations.
|
|
233
|
-
const auditQueueTargetsCommand: ModuleCli = {
|
|
234
|
-
command: 'audit-queue-targets',
|
|
235
|
-
async run(rest) {
|
|
236
|
-
const args = parseArgs(rest)
|
|
237
|
-
const { resolve } = await createRequestContainer()
|
|
238
|
-
const em = resolve('em') as EntityManager
|
|
239
|
-
|
|
240
|
-
const where: Record<string, unknown> = { deletedAt: null, sourceType: 'module' }
|
|
241
|
-
if (args.tenant || args.tenantId) where.tenantId = args.tenant || args.tenantId
|
|
242
|
-
|
|
243
|
-
const rows = await em.find(ScheduledJob, where, {
|
|
244
|
-
orderBy: { name: 'ASC' },
|
|
245
|
-
})
|
|
246
|
-
|
|
247
|
-
const audits = auditSchedulerModuleQueueRows(rows)
|
|
248
|
-
|
|
249
|
-
writeLine(`Module-authored queue-target schedules: ${audits.length}`)
|
|
250
|
-
let allowedCount = 0
|
|
251
|
-
for (const audit of audits) {
|
|
252
|
-
const verdict = audit.dispatchAllowed ? 'ALLOWED' : 'blocked'
|
|
253
|
-
if (audit.dispatchAllowed) allowedCount += 1
|
|
254
|
-
writeLine(
|
|
255
|
-
`[${verdict}] ${audit.scheduleId}${audit.name ? ` — ${audit.name}` : ''}` +
|
|
256
|
-
` module=${audit.sourceModule ?? '(none)'} queue=${audit.targetQueue ?? '(none)'}` +
|
|
257
|
-
` enabled=${audit.isEnabled ? 'yes' : 'no'}`,
|
|
258
|
-
)
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
if (allowedCount > 0) {
|
|
262
|
-
writeLine('')
|
|
263
|
-
writeLine(`Review the ${allowedCount} ALLOWED row(s) above against your known module integrations.`)
|
|
264
|
-
writeLine('Pre-upgrade rows created with a non-user-bound API key cannot be distinguished')
|
|
265
|
-
writeLine('from genuine registrations at rest; disable anything unrecognized:')
|
|
266
|
-
writeLine(' yarn mercato scheduler list --tenant <tenantId>')
|
|
267
|
-
}
|
|
268
|
-
},
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export default [listCommand, statusCommand, runCommand, startCommand, auditQueueTargetsCommand]
|
|
228
|
+
export default [listCommand, statusCommand, runCommand, startCommand]
|
|
@@ -14,12 +14,7 @@ import type {
|
|
|
14
14
|
} from '../data/validators.js'
|
|
15
15
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
16
16
|
import type { BullMQSchedulerService } from '../services/bullmqSchedulerService.js'
|
|
17
|
-
|
|
18
|
-
assertSchedulerQueueTargetAuthorized,
|
|
19
|
-
isSchedulerSafeQueue,
|
|
20
|
-
validateSchedulerTargetPayload,
|
|
21
|
-
} from '../lib/safeQueueTargets.js'
|
|
22
|
-
import type { SchedulerTargetRbacService } from '../lib/safeQueueTargets.js'
|
|
17
|
+
|
|
23
18
|
/**
|
|
24
19
|
* Snapshot of a schedule for undo/redo
|
|
25
20
|
*/
|
|
@@ -88,64 +83,6 @@ function toDate(value: Date | string | null | undefined): Date | null {
|
|
|
88
83
|
return value instanceof Date ? value : new Date(value)
|
|
89
84
|
}
|
|
90
85
|
|
|
91
|
-
function canonicalJson(value: unknown): string {
|
|
92
|
-
if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null)
|
|
93
|
-
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
94
|
-
const entries = Object.entries(value as Record<string, unknown>)
|
|
95
|
-
.filter(([, v]) => v !== undefined)
|
|
96
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
97
|
-
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(',')}}`
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function normalizeTargetPayloadValue(value: unknown): unknown {
|
|
101
|
-
// `schedulerService.register()` persists `{}` as-is while the edit form sends
|
|
102
|
-
// `null` for an empty payload — treat the three empty shapes as equal (#5213).
|
|
103
|
-
if (value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
104
|
-
&& Object.keys(value as Record<string, unknown>).length === 0) return null
|
|
105
|
-
return value
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function targetPayloadChanged(inputValue: unknown, storedValue: unknown): boolean {
|
|
109
|
-
return targetValueChanged(
|
|
110
|
-
normalizeTargetPayloadValue(inputValue),
|
|
111
|
-
normalizeTargetPayloadValue(storedValue),
|
|
112
|
-
)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function targetValueChanged(inputValue: unknown, storedValue: unknown): boolean {
|
|
116
|
-
if (inputValue === undefined) return false
|
|
117
|
-
return canonicalJson(inputValue ?? null) !== canonicalJson(storedValue ?? null)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
type QueueTargetAuthorizationContext = CommandRuntimeContext
|
|
121
|
-
|
|
122
|
-
async function assertQueueTargetAuthorizedForActor(
|
|
123
|
-
ctx: QueueTargetAuthorizationContext,
|
|
124
|
-
queue: string,
|
|
125
|
-
): Promise<void> {
|
|
126
|
-
const rbacService = (() => {
|
|
127
|
-
try {
|
|
128
|
-
return ctx.container?.resolve<SchedulerTargetRbacService>('rbacService') ?? null
|
|
129
|
-
} catch {
|
|
130
|
-
return null
|
|
131
|
-
}
|
|
132
|
-
})()
|
|
133
|
-
const reason = await assertSchedulerQueueTargetAuthorized({
|
|
134
|
-
queue,
|
|
135
|
-
actorUserId: resolveCommandActorUserId(ctx),
|
|
136
|
-
tenantId: ctx.auth?.tenantId ?? null,
|
|
137
|
-
organizationId: ctx.auth?.orgId ?? null,
|
|
138
|
-
isSuperAdmin: ctx.auth?.isSuperAdmin === true,
|
|
139
|
-
rbacService,
|
|
140
|
-
})
|
|
141
|
-
if (reason) throw new CrudHttpError(403, { error: reason })
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function assertQueueTargetPayloadAllowed(queue: string, targetPayload: Record<string, unknown> | null | undefined): Promise<void> {
|
|
145
|
-
const reason = validateSchedulerTargetPayload(queue, targetPayload)
|
|
146
|
-
if (reason) throw new CrudHttpError(422, { error: `Invalid target payload for queue ${queue}: ${reason}` })
|
|
147
|
-
}
|
|
148
|
-
|
|
149
86
|
function resolveCommandActorUserId(ctx: CommandRuntimeContext): string | null {
|
|
150
87
|
const auth = ctx.auth
|
|
151
88
|
if (!auth) return null
|
|
@@ -275,11 +212,6 @@ const createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }>
|
|
|
275
212
|
throw new Error('Failed to calculate next run time')
|
|
276
213
|
}
|
|
277
214
|
|
|
278
|
-
if (input.targetType === 'queue' && input.targetQueue) {
|
|
279
|
-
await assertQueueTargetAuthorizedForActor(ctx, input.targetQueue)
|
|
280
|
-
await assertQueueTargetPayloadAllowed(input.targetQueue, input.targetPayload)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
215
|
// Create schedule
|
|
284
216
|
const schedule = em.create(ScheduledJob, {
|
|
285
217
|
name: input.name,
|
|
@@ -296,8 +228,8 @@ const createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }>
|
|
|
296
228
|
targetPayload: input.targetPayload ?? null,
|
|
297
229
|
requireFeature: input.requireFeature ?? null,
|
|
298
230
|
isEnabled: input.isEnabled ?? true,
|
|
299
|
-
sourceType: 'user',
|
|
300
|
-
sourceModule: null,
|
|
231
|
+
sourceType: input.sourceType ?? 'user',
|
|
232
|
+
sourceModule: input.sourceModule ?? null,
|
|
301
233
|
nextRunAt,
|
|
302
234
|
createdByUserId: resolveCommandActorUserId(ctx),
|
|
303
235
|
createdAt: new Date(),
|
|
@@ -378,76 +310,10 @@ const updateScheduleCommand: CommandHandler<ScheduleUpdateInput, { ok: boolean }
|
|
|
378
310
|
if (schedule.organizationId) ensureOrganizationScope(ctx, schedule.organizationId)
|
|
379
311
|
ensureCanManageSystemScopedJob(ctx, schedule)
|
|
380
312
|
|
|
381
|
-
// Module-authored schedules are trusted infrastructure wiring (#5213): their
|
|
382
|
-
// target and payload are owned by the registering module. Submitting them
|
|
383
|
-
// unchanged is a no-op (the edit form always resends every field) — only an
|
|
384
|
-
// actual change to provenance-relevant values is rejected.
|
|
385
|
-
const touchesModuleOwnedTarget =
|
|
386
|
-
schedule.sourceType === 'module'
|
|
387
|
-
&& (
|
|
388
|
-
targetValueChanged(input.targetType, schedule.targetType)
|
|
389
|
-
|| targetValueChanged(input.targetQueue, schedule.targetQueue)
|
|
390
|
-
|| targetValueChanged(input.targetCommand, schedule.targetCommand)
|
|
391
|
-
|| targetPayloadChanged(input.targetPayload, schedule.targetPayload)
|
|
392
|
-
)
|
|
393
|
-
if (touchesModuleOwnedTarget) {
|
|
394
|
-
const { translate } = await resolveTranslations()
|
|
395
|
-
throw new CrudHttpError(403, {
|
|
396
|
-
error: translate(
|
|
397
|
-
'scheduler.errors.module_target_immutable',
|
|
398
|
-
'Module-authored schedules own their target; only operational fields can be updated here.',
|
|
399
|
-
),
|
|
400
|
-
})
|
|
401
|
-
}
|
|
402
|
-
|
|
403
313
|
if (input.isEnabled === true && schedule.isEnabled !== true) {
|
|
404
314
|
await enforceTenantActiveScheduleLimit(em, schedule.tenantId)
|
|
405
315
|
}
|
|
406
316
|
|
|
407
|
-
const effectiveTargetType = input.targetType ?? schedule.targetType
|
|
408
|
-
const effectiveQueue = input.targetQueue ?? schedule.targetQueue
|
|
409
|
-
const queueChanged =
|
|
410
|
-
effectiveTargetType === 'queue'
|
|
411
|
-
&& (
|
|
412
|
-
targetValueChanged(input.targetQueue, schedule.targetQueue)
|
|
413
|
-
|| targetValueChanged(input.targetType, schedule.targetType)
|
|
414
|
-
|| targetPayloadChanged(input.targetPayload, schedule.targetPayload)
|
|
415
|
-
)
|
|
416
|
-
if (effectiveTargetType === 'queue' && effectiveQueue) {
|
|
417
|
-
const retargetedToUnapprovedQueue =
|
|
418
|
-
input.targetQueue !== undefined
|
|
419
|
-
&& input.targetQueue !== schedule.targetQueue
|
|
420
|
-
&& !isSchedulerSafeQueue(input.targetQueue)
|
|
421
|
-
// A save that leaves a user-authored row ACTIVE on an unapproved queue
|
|
422
|
-
// restores the invariant the update schema used to enforce; disabling the
|
|
423
|
-
// row stays allowed so admins can remediate legacy targets from the UI.
|
|
424
|
-
// (Dispatch-time re-verification remains the backstop either way.)
|
|
425
|
-
const keepsUnapprovedQueueActive =
|
|
426
|
-
effectiveQueue != null
|
|
427
|
-
&& !isSchedulerSafeQueue(effectiveQueue)
|
|
428
|
-
&& input.isEnabled !== false
|
|
429
|
-
&& schedule.isEnabled !== false
|
|
430
|
-
if (
|
|
431
|
-
(retargetedToUnapprovedQueue || keepsUnapprovedQueueActive)
|
|
432
|
-
&& schedule.sourceType !== 'module'
|
|
433
|
-
) {
|
|
434
|
-
const { translate } = await resolveTranslations()
|
|
435
|
-
throw new CrudHttpError(422, {
|
|
436
|
-
error: translate(
|
|
437
|
-
'scheduler.errors.queue_not_approved',
|
|
438
|
-
'Target queue is not an approved scheduler target. Only workers that opted into scheduling can be selected.',
|
|
439
|
-
),
|
|
440
|
-
})
|
|
441
|
-
}
|
|
442
|
-
if (queueChanged) {
|
|
443
|
-
await assertQueueTargetAuthorizedForActor(ctx, effectiveQueue)
|
|
444
|
-
const effectivePayload = input.targetPayload !== undefined
|
|
445
|
-
? input.targetPayload
|
|
446
|
-
: schedule.targetPayload
|
|
447
|
-
await assertQueueTargetPayloadAllowed(effectiveQueue, effectivePayload)
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
317
|
const scheduleChanged = input.scheduleType !== undefined || input.scheduleValue !== undefined || input.timezone !== undefined
|
|
452
318
|
const nextRunAt = scheduleChanged
|
|
453
319
|
? calculateNextRunForWrite(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll
|
|
1
|
+
import { describe, it, expect, beforeAll } from '@jest/globals'
|
|
2
2
|
import {
|
|
3
3
|
scheduleCreateSchema,
|
|
4
4
|
scheduleUpdateSchema,
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
clearSchedulerSafeCommandsForTests,
|
|
14
14
|
registerSchedulerSafeCommands,
|
|
15
15
|
} from '../../lib/scheduler-safe-commands'
|
|
16
|
-
import { registerModules } from '@open-mercato/shared/lib/modules/registry'
|
|
17
16
|
|
|
18
17
|
// Register test commands for validation tests
|
|
19
18
|
const testCommand: CommandHandler<unknown, { ok: boolean }> = {
|
|
@@ -40,27 +39,6 @@ beforeAll(() => {
|
|
|
40
39
|
requiredFeatures: ['scheduler.jobs.manage'],
|
|
41
40
|
},
|
|
42
41
|
])
|
|
43
|
-
// Queue targets are only valid when their worker opted into scheduling
|
|
44
|
-
// (`schedulerSafe: true`). Seed one safe queue so schema tests can exercise
|
|
45
|
-
// queue-target validation (#5213).
|
|
46
|
-
registerModules([
|
|
47
|
-
{
|
|
48
|
-
id: 'test_module',
|
|
49
|
-
workers: [
|
|
50
|
-
{
|
|
51
|
-
id: 'test_module:workers:safe',
|
|
52
|
-
queue: 'test',
|
|
53
|
-
concurrency: 1,
|
|
54
|
-
schedulerSafe: true,
|
|
55
|
-
handler: async () => {},
|
|
56
|
-
},
|
|
57
|
-
],
|
|
58
|
-
},
|
|
59
|
-
] as never)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
afterAll(() => {
|
|
63
|
-
;(globalThis as Record<string, unknown>).__openMercatoModulesRegistry__ = null
|
|
64
42
|
})
|
|
65
43
|
|
|
66
44
|
const tenantId = '123e4567-e89b-12d3-a456-426614174000'
|
|
@@ -76,7 +54,7 @@ describe('scheduleCreateSchema', () => {
|
|
|
76
54
|
scheduleValue: '0 0 * * *',
|
|
77
55
|
timezone: 'UTC',
|
|
78
56
|
targetType: 'queue',
|
|
79
|
-
targetQueue: '
|
|
57
|
+
targetQueue: 'backup-queue',
|
|
80
58
|
isEnabled: true,
|
|
81
59
|
})
|
|
82
60
|
|
|
@@ -84,7 +62,7 @@ describe('scheduleCreateSchema', () => {
|
|
|
84
62
|
expect(result.scopeType).toBe('system')
|
|
85
63
|
expect(result.scheduleType).toBe('cron')
|
|
86
64
|
expect(result.targetType).toBe('queue')
|
|
87
|
-
expect(result.targetQueue).toBe('
|
|
65
|
+
expect(result.targetQueue).toBe('backup-queue')
|
|
88
66
|
})
|
|
89
67
|
|
|
90
68
|
it('should accept valid organization-scoped interval schedule with command target', () => {
|
|
@@ -120,7 +98,7 @@ describe('scheduleCreateSchema', () => {
|
|
|
120
98
|
scheduleType: 'interval',
|
|
121
99
|
scheduleValue: '15m',
|
|
122
100
|
targetType: 'queue',
|
|
123
|
-
targetQueue: '
|
|
101
|
+
targetQueue: 'cleanup',
|
|
124
102
|
})
|
|
125
103
|
|
|
126
104
|
expect(result.scopeType).toBe('tenant')
|
|
@@ -4,7 +4,6 @@ import { validateInterval } from '../lib/intervalParser'
|
|
|
4
4
|
import { commandRegistry } from '@open-mercato/shared/lib/commands/registry'
|
|
5
5
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
6
6
|
import { isSchedulerSafeCommandId } from '../lib/scheduler-safe-commands'
|
|
7
|
-
import { isSchedulerSafeQueue, validateSchedulerTargetPayload } from '../lib/safeQueueTargets'
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Validate that a command exists in the command registry
|
|
@@ -17,15 +16,6 @@ function validateCommandIsSchedulable(commandId: string): boolean {
|
|
|
17
16
|
return isSchedulerSafeCommandId(commandId)
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
/**
|
|
21
|
-
* Validate that a queue was declared scheduler-safe by its owning worker.
|
|
22
|
-
* Internal and system-only workers (webhook processors, indexers, …) stay
|
|
23
|
-
* undiscoverable as schedule targets — see issue #5213.
|
|
24
|
-
*/
|
|
25
|
-
function validateSchedulerSafeQueue(queue: string | null | undefined): boolean {
|
|
26
|
-
return isSchedulerSafeQueue(queue)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
19
|
/**
|
|
30
20
|
* Base schedule fields
|
|
31
21
|
*/
|
|
@@ -49,6 +39,8 @@ const scheduleBaseSchema = z.object({
|
|
|
49
39
|
requireFeature: z.string().optional().nullable(),
|
|
50
40
|
|
|
51
41
|
isEnabled: z.boolean().default(true),
|
|
42
|
+
sourceType: z.enum(['user', 'module']).default('user'),
|
|
43
|
+
sourceModule: z.string().optional().nullable(),
|
|
52
44
|
})
|
|
53
45
|
|
|
54
46
|
/**
|
|
@@ -128,30 +120,6 @@ export const scheduleCreateSchema = scheduleBaseSchema
|
|
|
128
120
|
path: ['targetCommand'],
|
|
129
121
|
}
|
|
130
122
|
)
|
|
131
|
-
.refine(
|
|
132
|
-
(data) => {
|
|
133
|
-
if (data.targetType === 'queue' && data.targetQueue) {
|
|
134
|
-
return validateSchedulerSafeQueue(data.targetQueue)
|
|
135
|
-
}
|
|
136
|
-
return true
|
|
137
|
-
},
|
|
138
|
-
{
|
|
139
|
-
message: 'Target queue is not an approved scheduler target. Only workers that opted into scheduling can be selected.',
|
|
140
|
-
path: ['targetQueue'],
|
|
141
|
-
}
|
|
142
|
-
)
|
|
143
|
-
.refine(
|
|
144
|
-
(data) => {
|
|
145
|
-
if (data.targetType === 'queue' && data.targetQueue) {
|
|
146
|
-
return validateSchedulerTargetPayload(data.targetQueue, data.targetPayload) === null
|
|
147
|
-
}
|
|
148
|
-
return true
|
|
149
|
-
},
|
|
150
|
-
{
|
|
151
|
-
message: 'Invalid target payload for the selected scheduler queue.',
|
|
152
|
-
path: ['targetPayload'],
|
|
153
|
-
}
|
|
154
|
-
)
|
|
155
123
|
|
|
156
124
|
/**
|
|
157
125
|
* Update schedule schema (all fields optional except id)
|
|
@@ -246,9 +214,6 @@ export const scheduleUpdateSchema = z.object({
|
|
|
246
214
|
path: ['targetCommand'],
|
|
247
215
|
}
|
|
248
216
|
)
|
|
249
|
-
// Queue-target safety and payload-shape rules for updates are enforced in the
|
|
250
|
-
// scheduler.jobs.update command against the stored row, so unchanged targets
|
|
251
|
-
// (always resent by the edit form) and legacy remediation stays possible (#5213).
|
|
252
217
|
|
|
253
218
|
/**
|
|
254
219
|
* Delete schedule schema
|
|
@@ -48,8 +48,6 @@
|
|
|
48
48
|
"scheduler.error.unauthorized": "Nicht autorisiert",
|
|
49
49
|
"scheduler.error.update_failed": "Fehler beim Aktualisieren des Zeitplans",
|
|
50
50
|
"scheduler.errors.id_required": "Zeitplankennung ist erforderlich.",
|
|
51
|
-
"scheduler.errors.module_target_immutable": "Von Modulen erstellte Zeitpläne besitzen ihr Ziel; nur operative Felder können hier geändert werden.",
|
|
52
|
-
"scheduler.errors.queue_not_approved": "Ziel-Queue ist kein genehmigtes Scheduler-Ziel. Nur Worker, die sich angemeldet haben, können ausgewählt werden.",
|
|
53
51
|
"scheduler.execution.actions": "Aktionen",
|
|
54
52
|
"scheduler.execution.completed_message": "Diese Ausführung wurde erfolgreich ohne Fehler abgeschlossen.",
|
|
55
53
|
"scheduler.execution.details_title": "Ausführungsdetails",
|
|
@@ -48,8 +48,6 @@
|
|
|
48
48
|
"scheduler.error.unauthorized": "Unauthorized",
|
|
49
49
|
"scheduler.error.update_failed": "Failed to update schedule",
|
|
50
50
|
"scheduler.errors.id_required": "Schedule identifier is required.",
|
|
51
|
-
"scheduler.errors.module_target_immutable": "Module-authored schedules own their target; only operational fields can be updated here.",
|
|
52
|
-
"scheduler.errors.queue_not_approved": "Target queue is not an approved scheduler target. Only workers that opted into scheduling can be selected.",
|
|
53
51
|
"scheduler.execution.actions": "Actions",
|
|
54
52
|
"scheduler.execution.completed_message": "This execution completed successfully without errors.",
|
|
55
53
|
"scheduler.execution.details_title": "Execution Details",
|
|
@@ -48,8 +48,6 @@
|
|
|
48
48
|
"scheduler.error.unauthorized": "No autorizado",
|
|
49
49
|
"scheduler.error.update_failed": "Error al actualizar la programación",
|
|
50
50
|
"scheduler.errors.id_required": "El identificador de programación es obligatorio.",
|
|
51
|
-
"scheduler.errors.module_target_immutable": "Los programados creados por módulos poseen su destino; solo los campos operativos pueden modificarse aquí.",
|
|
52
|
-
"scheduler.errors.queue_not_approved": "La cola de destino no es un destino aprobado del planificador. Solo se pueden seleccionar workers que hayan optado.",
|
|
53
51
|
"scheduler.execution.actions": "Acciones",
|
|
54
52
|
"scheduler.execution.completed_message": "Esta ejecución se completó con éxito sin errores.",
|
|
55
53
|
"scheduler.execution.details_title": "Detalles de ejecución",
|
|
@@ -48,8 +48,6 @@
|
|
|
48
48
|
"scheduler.error.unauthorized": "권한이 없습니다",
|
|
49
49
|
"scheduler.error.update_failed": "일정 수정에 실패했습니다",
|
|
50
50
|
"scheduler.errors.id_required": "일정 식별자는 필수입니다.",
|
|
51
|
-
"scheduler.errors.module_target_immutable": "모듈이 생성한 일정은 대상을 소유하므로 여기서는 운영 필드만 수정할 수 있습니다.",
|
|
52
|
-
"scheduler.errors.queue_not_approved": "대상 큐는 승인된 스케줄러 대상이 아닙니다. 옵트인한 워커만 선택할 수 있습니다.",
|
|
53
51
|
"scheduler.execution.actions": "작업",
|
|
54
52
|
"scheduler.execution.completed_message": "이 실행은 오류 없이 성공적으로 완료되었습니다.",
|
|
55
53
|
"scheduler.execution.details_title": "실행 상세 정보",
|
|
@@ -48,8 +48,6 @@
|
|
|
48
48
|
"scheduler.error.unauthorized": "Brak autoryzacji",
|
|
49
49
|
"scheduler.error.update_failed": "Nie udało się zaktualizować harmonogramu",
|
|
50
50
|
"scheduler.errors.id_required": "Identyfikator harmonogramu jest wymagany.",
|
|
51
|
-
"scheduler.errors.module_target_immutable": "Harmonogramy utworzone przez moduły są właścicielami swojego celu; tutaj można zmieniać wyłącznie pola operacyjne.",
|
|
52
|
-
"scheduler.errors.queue_not_approved": "Kolejka docelowa nie jest zatwierdzonym celem harmonogramu. Można wybrać tylko workery, które się zadeklarowały.",
|
|
53
51
|
"scheduler.execution.actions": "Akcje",
|
|
54
52
|
"scheduler.execution.completed_message": "To wykonanie zakończyło się pomyślnie bez błędów.",
|
|
55
53
|
"scheduler.execution.details_title": "Szczegóły wykonania",
|