@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,324 +0,0 @@
|
|
|
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
|
-
)
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
export type ScheduleScopeActor = {
|
|
2
|
-
tenantId?: string | null
|
|
3
|
-
orgId?: string | null
|
|
4
|
-
isSuperAdmin?: boolean
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export type ScheduleScopeType = 'system' | 'organization' | 'tenant'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The scope columns of a loaded `ScheduledJob` row. Every field is optional because the
|
|
11
|
-
* entity declares them that way (`tenantId?: string | null`), so a required shape here
|
|
12
|
-
* could not accept the row the routes actually load.
|
|
13
|
-
*
|
|
14
|
-
* `undefined` is therefore normalized to `null` on read. That is fail-closed where it
|
|
15
|
-
* matters: an absent `tenantId` classifies the row as system-scoped, which restricts it to
|
|
16
|
-
* super admins rather than widening it. An absent `organizationId` reads as "not bound to an
|
|
17
|
-
* organization", the same as an explicit null, which is what the list endpoint's tenant
|
|
18
|
-
* branch already treats as tenant-wide.
|
|
19
|
-
*/
|
|
20
|
-
export type ScheduleScopeSubject = {
|
|
21
|
-
scopeType?: ScheduleScopeType | null
|
|
22
|
-
tenantId?: string | null
|
|
23
|
-
organizationId?: string | null
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export type ScheduleAccessDecision = 'allowed' | 'not_found' | 'forbidden'
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Decides whether an actor may act on a single schedule that was loaded by id alone.
|
|
30
|
-
*
|
|
31
|
-
* Isolation belongs here, on the loaded row, and never in the `where` clause: a
|
|
32
|
-
* system-scoped schedule carries a null `tenantId`, so folding the actor's tenant into the
|
|
33
|
-
* lookup makes that row unmatchable and turns every system-scope check below into dead code.
|
|
34
|
-
* `commands/jobs.ts` (update/delete) and `api/jobs/buildFilters.ts` (list) already model
|
|
35
|
-
* visibility this way.
|
|
36
|
-
*
|
|
37
|
-
* System scope is classified exactly as `ensureCanManageSystemScopedJob` classifies it —
|
|
38
|
-
* `scopeType === 'system' || tenantId == null` — so update, delete, trigger and executions
|
|
39
|
-
* cannot disagree about what a system-scoped row is. `organizationId` deliberately plays no
|
|
40
|
-
* part in that test: a row with a null tenant is system-scoped whether or not an
|
|
41
|
-
* organization is set, which is how the create validator's scope refinement and the two
|
|
42
|
-
* command guards already read it.
|
|
43
|
-
*
|
|
44
|
-
* `not_found` vs `forbidden` is deliberate. Another tenant's or another organization's
|
|
45
|
-
* schedule answers `not_found`, because a 403 would confirm that the id exists. Only a
|
|
46
|
-
* system-scoped schedule answers `forbidden` — its existence is a property of the
|
|
47
|
-
* deployment, not of a tenant.
|
|
48
|
-
*
|
|
49
|
-
* An unresolved actor tenant fails closed for everyone, super admins included, matching
|
|
50
|
-
* `buildSchedulerJobsFilters`, which returns an unmatchable filter when the tenant is
|
|
51
|
-
* falsy. Such an actor cannot see a tenant-bound schedule in the list either.
|
|
52
|
-
*
|
|
53
|
-
* Super-admin status reads the immutable `isSuperAdmin` flag derived from RoleAcl/UserAcl at
|
|
54
|
-
* session resolution. Never compare role names, which are tenant-mutable and spoofable.
|
|
55
|
-
*
|
|
56
|
-
* Organization isolation compares the actor's single `orgId`, where the list endpoint uses
|
|
57
|
-
* the resolved organization scope (`filterIds`: the selected organization plus its
|
|
58
|
-
* descendants). The two diverge in **both** directions, and the second is the permissive one:
|
|
59
|
-
*
|
|
60
|
-
* - Narrower: an actor whose scope spans several organizations sees those rows in the list
|
|
61
|
-
* but is answered `not_found` here.
|
|
62
|
-
* - Wider: an actor carrying no `orgId` at all reaches any org-bound row inside its tenant,
|
|
63
|
-
* because the organization comparison below short-circuits — while the list hides those
|
|
64
|
-
* rows entirely, emitting its organization branch only when at least one id is known.
|
|
65
|
-
*
|
|
66
|
-
* Both directions are inherited from the lookup this helper replaces, which compared the same
|
|
67
|
-
* single id and omitted the clause entirely when it was absent. They are preserved rather
|
|
68
|
-
* than corrected here, because widening or narrowing silently would change access the
|
|
69
|
-
* previous lookup granted; reconciling the two paths is tracked separately.
|
|
70
|
-
*/
|
|
71
|
-
export function resolveScheduleAccess(
|
|
72
|
-
schedule: ScheduleScopeSubject,
|
|
73
|
-
actor: ScheduleScopeActor | null | undefined,
|
|
74
|
-
): ScheduleAccessDecision {
|
|
75
|
-
const isSuperAdmin = actor?.isSuperAdmin === true
|
|
76
|
-
const scheduleTenantId = schedule.tenantId ?? null
|
|
77
|
-
|
|
78
|
-
const actorTenantId = actor?.tenantId ?? null
|
|
79
|
-
|
|
80
|
-
if (schedule.scopeType === 'system' || scheduleTenantId === null) {
|
|
81
|
-
if (isSuperAdmin) return 'allowed'
|
|
82
|
-
// A row mislabelled `system` while still bound to a tenant must not confirm its
|
|
83
|
-
// existence across a tenant boundary — answering 403 there would tell an actor in one
|
|
84
|
-
// tenant that the id exists in another, which is the disclosure the `not_found`
|
|
85
|
-
// decisions below exist to prevent. Only a genuinely tenant-less row answers 403.
|
|
86
|
-
if (scheduleTenantId !== null && scheduleTenantId !== actorTenantId) return 'not_found'
|
|
87
|
-
return 'forbidden'
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (actorTenantId === null) return 'not_found'
|
|
91
|
-
if (scheduleTenantId !== actorTenantId) return 'not_found'
|
|
92
|
-
|
|
93
|
-
// Unlike the list endpoint's equivalent branch, which pins `scope_type = 'tenant'`
|
|
94
|
-
// (buildFilters.ts), an org-null row is reachable here whatever its `scopeType`. Same
|
|
95
|
-
// scope-inconsistent-row class as above and equally not producible by the create path;
|
|
96
|
-
// left loose deliberately rather than diverging from the pre-existing lookup.
|
|
97
|
-
const scheduleOrganizationId = schedule.organizationId ?? null
|
|
98
|
-
const actorOrganizationId = actor?.orgId ?? null
|
|
99
|
-
if (scheduleOrganizationId !== null && actorOrganizationId !== null && scheduleOrganizationId !== actorOrganizationId) {
|
|
100
|
-
return 'not_found'
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return 'allowed'
|
|
104
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'
|
|
2
|
-
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
3
|
-
|
|
4
|
-
const logger = createLogger('scheduler').child({ component: 'test-echo-queue' })
|
|
5
|
-
|
|
6
|
-
export const metadata: WorkerMeta = {
|
|
7
|
-
queue: 'scheduler-test',
|
|
8
|
-
id: 'scheduler:test-echo-queue',
|
|
9
|
-
concurrency: 1,
|
|
10
|
-
/**
|
|
11
|
-
* Deliberate scheduler-safe target for QA fixtures and manual experiments,
|
|
12
|
-
* mirroring the `scheduler.test.echo` command. The handler is side-effect
|
|
13
|
-
* free: it only logs that a delivery arrived.
|
|
14
|
-
*/
|
|
15
|
-
schedulerSafe: true,
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
type TestEchoPayload = {
|
|
19
|
-
message?: unknown
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export default async function handle(job: QueuedJob<TestEchoPayload>, _ctx: JobContext): Promise<void> {
|
|
23
|
-
const message = typeof job.payload?.message === 'string' ? job.payload.message : ''
|
|
24
|
-
logger.info('Test queue target received a scheduled delivery', { messageId: message.slice(0, 80) })
|
|
25
|
-
}
|