@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
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { getModules } from '@open-mercato/shared/lib/modules/registry'
|
|
2
|
+
import type { ModuleWorker } from '@open-mercato/shared/modules/registry'
|
|
3
|
+
import { z, type ZodTypeAny } from 'zod'
|
|
4
|
+
|
|
5
|
+
export type SchedulerSafeQueueTarget = {
|
|
6
|
+
queue: string
|
|
7
|
+
/**
|
|
8
|
+
* Indicative primary owner — the first registry module found among the
|
|
9
|
+
* queue's consumers. A queue may legitimately have consumers from more than
|
|
10
|
+
* one module; ownership for provenance checks is always resolved per module.
|
|
11
|
+
*/
|
|
12
|
+
moduleId: string
|
|
13
|
+
/** Extra creator features a principal must hold to schedule onto this queue. */
|
|
14
|
+
requiredFeatures: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type TrustedScheduleScope = {
|
|
18
|
+
tenantId: string | null
|
|
19
|
+
organizationId: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Runtime payload schemas for opted-in scheduler targets.
|
|
24
|
+
*
|
|
25
|
+
* Worker metadata cannot carry a Zod schema through static registry generation,
|
|
26
|
+
* so the module that owns a schedulable worker registers its target payload
|
|
27
|
+
* schema here at bootstrap. Targets without a registered schema accept
|
|
28
|
+
* arbitrary JSON payloads (documented escape hatch, mirroring scheduler-safe
|
|
29
|
+
* commands which also carry no payload schema).
|
|
30
|
+
*/
|
|
31
|
+
const registeredPayloadSchemas = new Map<string, ZodTypeAny>()
|
|
32
|
+
|
|
33
|
+
export function registerSchedulerQueuePayloadSchema(queue: string, schema: ZodTypeAny): void {
|
|
34
|
+
registeredPayloadSchemas.set(queue, schema)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getSchedulerQueuePayloadSchema(queue: string): ZodTypeAny | null {
|
|
38
|
+
return registeredPayloadSchemas.get(queue) ?? null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type SchedulerTargetRbacService = {
|
|
42
|
+
userHasAllFeatures?: (
|
|
43
|
+
userId: string,
|
|
44
|
+
required: readonly string[],
|
|
45
|
+
scope: { tenantId: string | null; organizationId: string | null },
|
|
46
|
+
) => Promise<boolean>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creator-side authorization for an opted-in queue target (#5213): a principal
|
|
51
|
+
* must hold every feature the target declares beyond `scheduler.jobs.manage`.
|
|
52
|
+
* Super administrators bypass target-specific checks, mirroring the rest of
|
|
53
|
+
* the scheduler's authorization model. Returns null when authorized,
|
|
54
|
+
* otherwise a short reason string.
|
|
55
|
+
*/
|
|
56
|
+
export async function assertSchedulerQueueTargetAuthorized(params: {
|
|
57
|
+
queue: string
|
|
58
|
+
actorUserId?: string | null
|
|
59
|
+
tenantId?: string | null
|
|
60
|
+
organizationId?: string | null
|
|
61
|
+
isSuperAdmin?: boolean
|
|
62
|
+
rbacService?: SchedulerTargetRbacService | null
|
|
63
|
+
}): Promise<string | null> {
|
|
64
|
+
const requiredFeatures = getSchedulerQueueRequiredFeatures(params.queue)
|
|
65
|
+
if (requiredFeatures.length === 0) return null
|
|
66
|
+
if (params.isSuperAdmin === true) return null
|
|
67
|
+
|
|
68
|
+
const actorUserId = typeof params.actorUserId === 'string' ? params.actorUserId.trim() : ''
|
|
69
|
+
if (!actorUserId) return 'Scheduler queue target requires an authenticated creator'
|
|
70
|
+
if (typeof params.rbacService?.userHasAllFeatures !== 'function') {
|
|
71
|
+
return 'Scheduler queue target authorization is unavailable'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const authorized = await params.rbacService.userHasAllFeatures(actorUserId, requiredFeatures, {
|
|
75
|
+
tenantId: params.tenantId ?? null,
|
|
76
|
+
organizationId: params.organizationId ?? null,
|
|
77
|
+
})
|
|
78
|
+
if (!authorized) {
|
|
79
|
+
return `Scheduler queue target requires features the creator lacks: ${requiredFeatures.join(', ')}`
|
|
80
|
+
}
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validates an author-supplied target payload against the target's registered
|
|
86
|
+
* schema, when one exists. Returns null when the payload is acceptable,
|
|
87
|
+
* otherwise a short reason string.
|
|
88
|
+
*/
|
|
89
|
+
export function validateSchedulerTargetPayload(
|
|
90
|
+
queue: string,
|
|
91
|
+
targetPayload: Record<string, unknown> | null | undefined,
|
|
92
|
+
): string | null {
|
|
93
|
+
const schema = getSchedulerQueuePayloadSchema(queue)
|
|
94
|
+
if (!schema) return null
|
|
95
|
+
const result = schema.safeParse(targetPayload ?? {})
|
|
96
|
+
if (result.success) return null
|
|
97
|
+
const firstIssue = result.error.issues[0]
|
|
98
|
+
return [
|
|
99
|
+
firstIssue?.path?.length ? `${firstIssue.path.join('.')}: ` : '',
|
|
100
|
+
firstIssue?.message ?? 'Invalid payload',
|
|
101
|
+
].join('')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Keys a schedule author may never control on a dispatched target payload:
|
|
106
|
+
* tenant/organization authority context and the reserved `_`-prefixed envelope
|
|
107
|
+
* namespace (idempotency keys, dispatch-origin markers, …).
|
|
108
|
+
*/
|
|
109
|
+
const RESERVED_PAYLOAD_KEYS = new Set(['tenantId', 'organizationId', 'scope'])
|
|
110
|
+
|
|
111
|
+
function isReservedPayloadKey(key: string): boolean {
|
|
112
|
+
return RESERVED_PAYLOAD_KEYS.has(key) || key.startsWith('_')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stripReservedKeys(payload: Record<string, unknown>): Record<string, unknown> {
|
|
116
|
+
const result: Record<string, unknown> = {}
|
|
117
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
118
|
+
if (isReservedPayloadKey(key)) continue
|
|
119
|
+
if (key === 'payload' && value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
120
|
+
result[key] = stripReservedKeys(value as Record<string, unknown>)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
result[key] = value
|
|
124
|
+
}
|
|
125
|
+
return result
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Rebuild the trusted scope for a dispatched schedule payload from the stored
|
|
130
|
+
* schedule row. Callers must never trust scope-shaped data from the schedule
|
|
131
|
+
* author's `targetPayload`.
|
|
132
|
+
*/
|
|
133
|
+
export function buildTrustedScheduleScope(schedule: {
|
|
134
|
+
tenantId?: string | null
|
|
135
|
+
organizationId?: string | null
|
|
136
|
+
}): TrustedScheduleScope {
|
|
137
|
+
return {
|
|
138
|
+
tenantId: schedule.tenantId ?? null,
|
|
139
|
+
organizationId: schedule.organizationId ?? null,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Sanitize an author-supplied schedule target payload before dispatch:
|
|
145
|
+
* deep-copies it, strips reserved authority and `_`-prefixed envelope keys at
|
|
146
|
+
* the payload root and inside the local-strategy `payload` wrapper level, then
|
|
147
|
+
* injects the trusted scope derived from the schedule row.
|
|
148
|
+
*/
|
|
149
|
+
export function sanitizeSchedulerTargetPayload(
|
|
150
|
+
targetPayload: Record<string, unknown> | null | undefined,
|
|
151
|
+
schedule: Parameters<typeof buildTrustedScheduleScope>[0],
|
|
152
|
+
): Record<string, unknown> {
|
|
153
|
+
const source = targetPayload ?? {}
|
|
154
|
+
const cloned = structuredClone(source)
|
|
155
|
+
const stripped = stripReservedKeys(cloned)
|
|
156
|
+
return {
|
|
157
|
+
...stripped,
|
|
158
|
+
scope: buildTrustedScheduleScope(schedule),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const queueConsumerCache = new WeakMap<object, Map<string, ModuleWorker[]>>()
|
|
163
|
+
|
|
164
|
+
function collectQueueConsumers(): Map<string, ModuleWorker[]> {
|
|
165
|
+
let modules: ReturnType<typeof getModules>
|
|
166
|
+
try {
|
|
167
|
+
modules = getModules()
|
|
168
|
+
} catch {
|
|
169
|
+
return new Map()
|
|
170
|
+
}
|
|
171
|
+
// The registry returns the same array instance between registrations, so the
|
|
172
|
+
// grouped map can be memoized per instance instead of rebuilt per lookup.
|
|
173
|
+
const cached = queueConsumerCache.get(modules)
|
|
174
|
+
if (cached) return cached
|
|
175
|
+
|
|
176
|
+
const byQueue = new Map<string, ModuleWorker[]>()
|
|
177
|
+
for (const mod of modules) {
|
|
178
|
+
for (const worker of mod.workers ?? []) {
|
|
179
|
+
if (!worker.queue) continue
|
|
180
|
+
const consumers = byQueue.get(worker.queue) ?? []
|
|
181
|
+
consumers.push(worker)
|
|
182
|
+
byQueue.set(worker.queue, consumers)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
queueConsumerCache.set(modules, byQueue)
|
|
186
|
+
return byQueue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Enumerate queue targets that are safe for user-facing scheduling (#5213):
|
|
191
|
+
* every worker consuming the queue must explicitly opt in — queue consumers
|
|
192
|
+
* compete for the same jobs, so a single non-opted-in consumer would otherwise
|
|
193
|
+
* receive scheduler-controlled payloads it never agreed to accept.
|
|
194
|
+
*/
|
|
195
|
+
export function listSchedulerSafeQueueTargets(): SchedulerSafeQueueTarget[] {
|
|
196
|
+
const targets: SchedulerSafeQueueTarget[] = []
|
|
197
|
+
for (const [queue, consumers] of collectQueueConsumers()) {
|
|
198
|
+
const optedIn = consumers.filter((worker) => worker.schedulerSafe === true)
|
|
199
|
+
if (optedIn.length === 0 || optedIn.length !== consumers.length) continue
|
|
200
|
+
|
|
201
|
+
const requiredFeatures = [...new Set(optedIn.flatMap((worker) => (
|
|
202
|
+
Array.isArray(worker.schedulerRequiredFeatures) ? worker.schedulerRequiredFeatures : []
|
|
203
|
+
)))]
|
|
204
|
+
const ownerModuleIds = [...new Set(consumers.map((worker) => (
|
|
205
|
+
typeof worker.id === 'string' ? (worker.id.split(':')[0] ?? '') : ''
|
|
206
|
+
)))]
|
|
207
|
+
targets.push({ queue, moduleId: ownerModuleIds[0] ?? '', requiredFeatures })
|
|
208
|
+
}
|
|
209
|
+
targets.sort((a, b) => a.queue.localeCompare(b.queue))
|
|
210
|
+
return targets
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function isSchedulerSafeQueue(queue: string | null | undefined): boolean {
|
|
214
|
+
if (!queue) return false
|
|
215
|
+
return listSchedulerSafeQueueTargets().some((target) => target.queue === queue)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Extra creator features required by the target queue beyond
|
|
220
|
+
* `scheduler.jobs.manage` (#5213). Empty when the target declares none.
|
|
221
|
+
*/
|
|
222
|
+
export function getSchedulerQueueRequiredFeatures(queue: string): string[] {
|
|
223
|
+
return listSchedulerSafeQueueTargets().find((target) => target.queue === queue)?.requiredFeatures ?? []
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The module ids that own workers on this queue, from the live registry.
|
|
228
|
+
*/
|
|
229
|
+
function findQueueOwnerModuleIds(queue: string): Set<string> {
|
|
230
|
+
const owners = new Set<string>()
|
|
231
|
+
for (const [candidate, consumers] of collectQueueConsumers()) {
|
|
232
|
+
if (candidate !== queue) continue
|
|
233
|
+
for (const worker of consumers) {
|
|
234
|
+
if (typeof worker.id === 'string') owners.add(worker.id.split(':')[0] ?? '')
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return owners
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Operator-facing audit for persisted module-authored queue rows (#5213).
|
|
242
|
+
*
|
|
243
|
+
* Provenance on pre-upgrade rows is not fully verifiable at rest: a row created
|
|
244
|
+
* by a non-user-bound API key carries no acting-user stamp, making it
|
|
245
|
+
* indistinguishable from a genuine module registration. This helper surfaces
|
|
246
|
+
* every module-authored queue-target row with its current dispatch verdict so
|
|
247
|
+
* operators can review the allowed ones against their known integrations
|
|
248
|
+
* (`mercato scheduler audit-queue-targets`).
|
|
249
|
+
*/
|
|
250
|
+
export type SchedulerModuleQueueRowAudit = {
|
|
251
|
+
scheduleId: string
|
|
252
|
+
name?: string
|
|
253
|
+
sourceModule: string | null
|
|
254
|
+
targetQueue: string | null
|
|
255
|
+
isEnabled: boolean
|
|
256
|
+
/** Whether the dispatch guard currently lets this row fire. */
|
|
257
|
+
dispatchAllowed: boolean
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function auditSchedulerModuleQueueRows(
|
|
261
|
+
rows: ReadonlyArray<{
|
|
262
|
+
id: string
|
|
263
|
+
name?: string | null
|
|
264
|
+
targetQueue?: string | null
|
|
265
|
+
sourceType?: string | null
|
|
266
|
+
sourceModule?: string | null
|
|
267
|
+
createdByUserId?: string | null
|
|
268
|
+
isEnabled?: boolean
|
|
269
|
+
}>,
|
|
270
|
+
): SchedulerModuleQueueRowAudit[] {
|
|
271
|
+
return rows
|
|
272
|
+
.filter((row) => row.sourceType === 'module' && row.targetQueue)
|
|
273
|
+
.map((row) => ({
|
|
274
|
+
scheduleId: row.id,
|
|
275
|
+
name: row.name ?? undefined,
|
|
276
|
+
sourceModule: row.sourceModule ?? null,
|
|
277
|
+
targetQueue: row.targetQueue ?? null,
|
|
278
|
+
isEnabled: row.isEnabled !== false,
|
|
279
|
+
dispatchAllowed: canDispatchScheduleQueueTarget(row),
|
|
280
|
+
}))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Dispatch-time authorization for queue-targeted schedules (#5213).
|
|
285
|
+
*
|
|
286
|
+
* Provenance is server-owned and verified, never trusted from the stored row:
|
|
287
|
+
* - API-authored schedules (`sourceType: 'user'`) may only target queues whose
|
|
288
|
+
* workers all opted into scheduling.
|
|
289
|
+
* - Module-authored schedules are accepted only when the recorded
|
|
290
|
+
* `sourceModule` owns the queue in the live registry AND the row carries no
|
|
291
|
+
* acting-user stamp (`createdByUserId == null`) — `schedulerService.register()`
|
|
292
|
+
* never stamps one while every interactive API write does. Residual limit: a
|
|
293
|
+
* pre-upgrade row authored by a non-user-bound API key also lacks the stamp;
|
|
294
|
+
* surface those with `auditSchedulerModuleQueueRows` /
|
|
295
|
+
* `mercato scheduler audit-queue-targets`.
|
|
296
|
+
*/
|
|
297
|
+
export function canDispatchScheduleQueueTarget(schedule: {
|
|
298
|
+
targetQueue?: string | null
|
|
299
|
+
sourceType?: string | null
|
|
300
|
+
sourceModule?: string | null
|
|
301
|
+
/** Server-stamped actor on API-authored rows; module registration never sets it. */
|
|
302
|
+
createdByUserId?: string | null
|
|
303
|
+
}): boolean {
|
|
304
|
+
if (!schedule.targetQueue) return false
|
|
305
|
+
if (schedule.sourceType === 'module') {
|
|
306
|
+
// Provenance must be server-owned, and both stored fields were client-
|
|
307
|
+
// writable before #5213. A matching sourceModule alone is forgeable public
|
|
308
|
+
// knowledge, so a persisted module row is only trusted when it also carries
|
|
309
|
+
// no acting-user stamp: `schedulerService.register()` never sets one, while
|
|
310
|
+
// every API write does. Forged pre-upgrade rows fail closed here.
|
|
311
|
+
if (!schedule.sourceModule) return false
|
|
312
|
+
if (schedule.createdByUserId) return false
|
|
313
|
+
return findQueueOwnerModuleIds(schedule.targetQueue).has(schedule.sourceModule)
|
|
314
|
+
}
|
|
315
|
+
return isSchedulerSafeQueue(schedule.targetQueue)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Built-in opted-in target owned by this module: the side-effect-free QA echo
|
|
319
|
+
// queue. The strict shape keeps scheduler-controlled payloads predictable and
|
|
320
|
+
// gives deployments a working reference for registering their own schemas.
|
|
321
|
+
registerSchedulerQueuePayloadSchema(
|
|
322
|
+
'scheduler-test',
|
|
323
|
+
z.object({ message: z.string().max(200).optional() }).passthrough(),
|
|
324
|
+
)
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
clearSchedulerSafeCommandsForTests,
|
|
7
7
|
registerSchedulerSafeCommands,
|
|
8
8
|
} from '../../lib/scheduler-safe-commands'
|
|
9
|
+
import { registerModules } from '@open-mercato/shared/lib/modules/registry'
|
|
9
10
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
10
11
|
|
|
11
12
|
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
@@ -61,6 +62,40 @@ describe('LocalSchedulerService', () => {
|
|
|
61
62
|
let mockRbacService: { tenantHasFeature: jest.Mock; userHasAllFeatures: jest.Mock }
|
|
62
63
|
let mockLockStrategy: { runWithLock: jest.Mock }
|
|
63
64
|
|
|
65
|
+
beforeAll(() => {
|
|
66
|
+
// Queue-target dispatch reauthorizes against scheduler-safe queues (#5213);
|
|
67
|
+
// seed one safe queue and one module-owned internal queue for the guards.
|
|
68
|
+
registerModules([
|
|
69
|
+
{
|
|
70
|
+
id: 'test_module',
|
|
71
|
+
workers: [
|
|
72
|
+
{
|
|
73
|
+
id: 'test_module:workers:test-queue',
|
|
74
|
+
queue: 'test-queue',
|
|
75
|
+
concurrency: 1,
|
|
76
|
+
schedulerSafe: true,
|
|
77
|
+
handler: async () => {},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'legacy_module',
|
|
83
|
+
workers: [
|
|
84
|
+
{
|
|
85
|
+
id: 'legacy_module:workers:internal',
|
|
86
|
+
queue: 'legacy-internal',
|
|
87
|
+
concurrency: 1,
|
|
88
|
+
handler: async () => {},
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
] as never)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
afterAll(() => {
|
|
96
|
+
;(globalThis as Record<string, unknown>).__openMercatoModulesRegistry__ = null
|
|
97
|
+
})
|
|
98
|
+
|
|
64
99
|
beforeEach(() => {
|
|
65
100
|
jest.clearAllMocks()
|
|
66
101
|
jest.useFakeTimers()
|
|
@@ -296,8 +331,151 @@ describe('LocalSchedulerService', () => {
|
|
|
296
331
|
tenantId: null,
|
|
297
332
|
organizationId: null,
|
|
298
333
|
_idempotencyKey: expect.stringMatching(/^scheduler-test-1-/),
|
|
334
|
+
_jobOrigin: 'scheduler',
|
|
335
|
+
})
|
|
336
|
+
)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('should sanitize author-supplied scope and envelope keys from the target payload', async () => {
|
|
340
|
+
const schedule = createSchedule({
|
|
341
|
+
tenantId: 'tenant-1',
|
|
342
|
+
organizationId: 'org-1',
|
|
343
|
+
sourceType: 'module',
|
|
344
|
+
sourceModule: 'test_module',
|
|
345
|
+
targetQueue: 'test-queue',
|
|
346
|
+
targetPayload: {
|
|
347
|
+
scope: { tenantId: 'forged-tenant', organizationId: 'forged-org' },
|
|
348
|
+
tenantId: 'forged-tenant',
|
|
349
|
+
_jobOrigin: 'inbound-webhook',
|
|
350
|
+
payload: { nested: 'data', _idempotencyKey: 'forged' },
|
|
351
|
+
legit: 'kept',
|
|
352
|
+
},
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
mockForkedEm.find.mockResolvedValue([schedule])
|
|
356
|
+
mockForkedEm.findOne.mockResolvedValue({ ...schedule })
|
|
357
|
+
mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
|
|
358
|
+
await fn()
|
|
359
|
+
return { acquired: true }
|
|
360
|
+
})
|
|
361
|
+
mockQueue.enqueue.mockResolvedValue(undefined as any)
|
|
362
|
+
|
|
363
|
+
await service.start()
|
|
364
|
+
|
|
365
|
+
expect(mockQueue.enqueue).toHaveBeenCalledWith(
|
|
366
|
+
expect.objectContaining({
|
|
367
|
+
tenantId: 'tenant-1',
|
|
368
|
+
organizationId: 'org-1',
|
|
369
|
+
payload: { nested: 'data' },
|
|
370
|
+
legit: 'kept',
|
|
371
|
+
scope: { tenantId: 'tenant-1', organizationId: 'org-1' },
|
|
372
|
+
_jobOrigin: 'scheduler',
|
|
299
373
|
})
|
|
300
374
|
)
|
|
375
|
+
const enqueued = mockQueue.enqueue.mock.calls[0][0] as Record<string, unknown>
|
|
376
|
+
expect(enqueued.payload).toEqual({ nested: 'data' })
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('should refuse user-authored schedules targeting non-safe internal queues', async () => {
|
|
380
|
+
const schedule = createSchedule({
|
|
381
|
+
targetQueue: 'stripe-webhook',
|
|
382
|
+
sourceType: 'user',
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
mockForkedEm.find.mockResolvedValue([schedule])
|
|
386
|
+
mockForkedEm.findOne.mockResolvedValue({ ...schedule })
|
|
387
|
+
mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
|
|
388
|
+
await fn()
|
|
389
|
+
return { acquired: true }
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
await service.start()
|
|
393
|
+
|
|
394
|
+
expect(mockQueueFactory).not.toHaveBeenCalled()
|
|
395
|
+
expect(loggerError).toHaveBeenCalled()
|
|
396
|
+
expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
|
|
397
|
+
'scheduler.job.failed',
|
|
398
|
+
expect.objectContaining({
|
|
399
|
+
id: 'test-1',
|
|
400
|
+
error: expect.stringContaining('not an approved scheduler target'),
|
|
401
|
+
})
|
|
402
|
+
)
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('should refuse a pre-upgrade module row whose sourceModule MATCHES the owner but carries an actor stamp (#5213 B1)', async () => {
|
|
406
|
+
// forged pre-upgrade row: attacker set sourceType/sourceModule to the real
|
|
407
|
+
// owning module, but API writes always stamp createdByUserId — which
|
|
408
|
+
// schedulerService.register() never does. The row must fail closed.
|
|
409
|
+
const schedule = createSchedule({
|
|
410
|
+
targetQueue: 'legacy-internal',
|
|
411
|
+
sourceType: 'module',
|
|
412
|
+
sourceModule: 'legacy_module',
|
|
413
|
+
createdByUserId: 'attacker-user',
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
mockForkedEm.find.mockResolvedValue([schedule])
|
|
417
|
+
mockForkedEm.findOne.mockResolvedValue({ ...schedule })
|
|
418
|
+
mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
|
|
419
|
+
await fn()
|
|
420
|
+
return { acquired: true }
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
await service.start()
|
|
424
|
+
|
|
425
|
+
expect(mockQueueFactory).not.toHaveBeenCalled()
|
|
426
|
+
expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
|
|
427
|
+
'scheduler.job.failed',
|
|
428
|
+
expect.objectContaining({
|
|
429
|
+
id: 'test-1',
|
|
430
|
+
error: expect.stringContaining('not an approved scheduler target'),
|
|
431
|
+
})
|
|
432
|
+
)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('should refuse a pre-upgrade module row whose sourceModule does not own its internal queue (#5213 B1)', async () => {
|
|
436
|
+
const schedule = createSchedule({
|
|
437
|
+
targetQueue: 'stripe-webhook',
|
|
438
|
+
sourceType: 'module',
|
|
439
|
+
sourceModule: 'legacy_module',
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
mockForkedEm.find.mockResolvedValue([schedule])
|
|
443
|
+
mockForkedEm.findOne.mockResolvedValue({ ...schedule })
|
|
444
|
+
mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
|
|
445
|
+
await fn()
|
|
446
|
+
return { acquired: true }
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
await service.start()
|
|
450
|
+
|
|
451
|
+
expect(mockQueueFactory).not.toHaveBeenCalled()
|
|
452
|
+
expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
|
|
453
|
+
'scheduler.job.failed',
|
|
454
|
+
expect.objectContaining({
|
|
455
|
+
id: 'test-1',
|
|
456
|
+
error: expect.stringContaining('not an approved scheduler target'),
|
|
457
|
+
})
|
|
458
|
+
)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('should allow module-authored schedules to keep targeting queues their own module owns (#5213 B1)', async () => {
|
|
462
|
+
const schedule = createSchedule({
|
|
463
|
+
targetQueue: 'legacy-internal',
|
|
464
|
+
sourceType: 'module',
|
|
465
|
+
sourceModule: 'legacy_module',
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
mockForkedEm.find.mockResolvedValue([schedule])
|
|
469
|
+
mockForkedEm.findOne.mockResolvedValue({ ...schedule })
|
|
470
|
+
mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
|
|
471
|
+
await fn()
|
|
472
|
+
return { acquired: true }
|
|
473
|
+
})
|
|
474
|
+
mockQueue.enqueue.mockResolvedValue(undefined as any)
|
|
475
|
+
|
|
476
|
+
await service.start()
|
|
477
|
+
|
|
478
|
+
expect(mockQueue.enqueue).toHaveBeenCalled()
|
|
301
479
|
})
|
|
302
480
|
|
|
303
481
|
it('should execute command target', async () => {
|
|
@@ -670,6 +848,7 @@ describe('LocalSchedulerService', () => {
|
|
|
670
848
|
tenantId: schedule.tenantId,
|
|
671
849
|
organizationId: schedule.organizationId,
|
|
672
850
|
_idempotencyKey: expect.stringMatching(new RegExp(`^scheduler-${schedule.id}-`)),
|
|
851
|
+
_jobOrigin: 'scheduler',
|
|
673
852
|
})
|
|
674
853
|
)
|
|
675
854
|
const enqueued = mockQueue.enqueue.mock.calls[0][0] as Record<string, unknown>
|
|
@@ -11,6 +11,12 @@ import { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-comm
|
|
|
11
11
|
import { buildScheduledCommandContext } from '../lib/commandContext.js'
|
|
12
12
|
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
|
|
13
13
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
14
|
+
import {
|
|
15
|
+
canDispatchScheduleQueueTarget,
|
|
16
|
+
getSchedulerQueueRequiredFeatures,
|
|
17
|
+
sanitizeSchedulerTargetPayload,
|
|
18
|
+
validateSchedulerTargetPayload,
|
|
19
|
+
} from '../lib/safeQueueTargets'
|
|
14
20
|
|
|
15
21
|
const logger = createLogger('scheduler').child({ component: 'local' })
|
|
16
22
|
|
|
@@ -261,18 +267,45 @@ export class LocalSchedulerService {
|
|
|
261
267
|
throw new Error('Target queue is required for queue target type')
|
|
262
268
|
}
|
|
263
269
|
|
|
270
|
+
// Dispatch-time reauthorization (#5213): provenance is verified against the
|
|
271
|
+
// live registry (module rows must own their queue), API rows must target
|
|
272
|
+
// opted-in queues.
|
|
273
|
+
if (!canDispatchScheduleQueueTarget(schedule)) {
|
|
274
|
+
throw new Error(`Queue is not an approved scheduler target: ${schedule.targetQueue}`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)
|
|
278
|
+
if (payloadIssue) {
|
|
279
|
+
throw new Error(`Invalid payload for scheduler queue ${schedule.targetQueue}: ${payloadIssue}`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (schedule.scopeType !== 'system') {
|
|
283
|
+
for (const feature of getSchedulerQueueRequiredFeatures(schedule.targetQueue)) {
|
|
284
|
+
const hasFeature = await this.checkFeature({ ...schedule, requireFeature: feature } as ScheduledJob)
|
|
285
|
+
if (!hasFeature) {
|
|
286
|
+
throw new Error(`Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
264
291
|
const queue = this.queueFactory(schedule.targetQueue)
|
|
265
292
|
|
|
266
293
|
// Deliver the same flat contract as the asynchronous execute-schedule
|
|
267
294
|
// worker: targetPayload fields on the root, scheduler-owned scope and
|
|
268
295
|
// idempotency fields applied last. Local mode runs each firing exactly
|
|
269
296
|
// once, so the firing timestamp is a valid logical execution key.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
297
|
+
// Author-supplied scope/envelope keys never survive dispatch (#5213).
|
|
298
|
+
const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule)
|
|
299
|
+
|
|
300
|
+
await queue.enqueue({
|
|
301
|
+
...buildQueueTargetPayload({
|
|
302
|
+
targetPayload: sanitizedPayload,
|
|
303
|
+
tenantId: schedule.tenantId,
|
|
304
|
+
organizationId: schedule.organizationId,
|
|
305
|
+
idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),
|
|
306
|
+
}),
|
|
307
|
+
_jobOrigin: 'scheduler' as const,
|
|
308
|
+
})
|
|
276
309
|
|
|
277
310
|
logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })
|
|
278
311
|
}
|
|
@@ -3,8 +3,25 @@ import { ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
|
|
|
3
3
|
import { createQueue } from '@open-mercato/queue'
|
|
4
4
|
import { ScheduledJob } from '../../data/entities'
|
|
5
5
|
import { registerSchedulerSafeCommands } from '../../lib/scheduler-safe-commands'
|
|
6
|
+
import { registerModules } from '@open-mercato/shared/lib/modules/registry'
|
|
6
7
|
import executeScheduleWorker from '../execute-schedule.worker'
|
|
7
8
|
|
|
9
|
+
// Queue-target dispatch verifies module provenance against the live registry
|
|
10
|
+
// (#5213 B1): register the module that owns the fixture queue.
|
|
11
|
+
registerModules([
|
|
12
|
+
{
|
|
13
|
+
id: 'worker_test_module',
|
|
14
|
+
workers: [
|
|
15
|
+
{
|
|
16
|
+
id: 'worker_test_module:workers:example',
|
|
17
|
+
queue: 'example',
|
|
18
|
+
concurrency: 1,
|
|
19
|
+
handler: async () => {},
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
] as never)
|
|
24
|
+
|
|
8
25
|
const mockCommandExecute = jest.fn()
|
|
9
26
|
|
|
10
27
|
jest.mock('@open-mercato/shared/lib/commands', () => ({
|
|
@@ -127,7 +144,11 @@ describe('executeScheduleWorker queue target payload contract', () => {
|
|
|
127
144
|
targetType: 'queue',
|
|
128
145
|
targetQueue: 'example',
|
|
129
146
|
targetCommand: null,
|
|
130
|
-
|
|
147
|
+
sourceType: 'module',
|
|
148
|
+
// module registration never stamps an acting user (#5213 B1)
|
|
149
|
+
createdByUserId: null,
|
|
150
|
+
sourceModule: 'worker_test_module',
|
|
151
|
+
targetPayload: { connectionId: 'connection-id' },
|
|
131
152
|
...overrides,
|
|
132
153
|
})
|
|
133
154
|
}
|
|
@@ -157,7 +178,7 @@ describe('executeScheduleWorker queue target payload contract', () => {
|
|
|
157
178
|
;(createQueue as jest.Mock).mockReturnValue({ enqueue, close })
|
|
158
179
|
})
|
|
159
180
|
|
|
160
|
-
it('delivers the flat targetPayload contract with scheduler-owned fields applied last', async () => {
|
|
181
|
+
it('delivers the flat targetPayload contract with scheduler-owned fields applied last (#5213)', async () => {
|
|
161
182
|
const schedule = buildQueueSchedule({
|
|
162
183
|
targetPayload: {
|
|
163
184
|
connectionId: 'connection-id',
|
|
@@ -171,11 +192,12 @@ describe('executeScheduleWorker queue target payload contract', () => {
|
|
|
171
192
|
|
|
172
193
|
expect(enqueue).toHaveBeenCalledWith({
|
|
173
194
|
connectionId: 'connection-id',
|
|
174
|
-
scope: '
|
|
195
|
+
scope: { tenantId: 'tenant-a', organizationId: 'org-a' },
|
|
175
196
|
payload: { nested: true },
|
|
176
197
|
tenantId: 'tenant-a',
|
|
177
198
|
organizationId: 'org-a',
|
|
178
199
|
_idempotencyKey: `scheduler-${scheduleId}-worker-job-1`,
|
|
200
|
+
_jobOrigin: 'scheduler',
|
|
179
201
|
})
|
|
180
202
|
expect(close).toHaveBeenCalledTimes(1)
|
|
181
203
|
})
|