@open-mercato/scheduler 0.6.8-develop.7100.1.fbf66fca35 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js +2 -2
  3. package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js.map +2 -2
  4. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +4 -9
  5. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +2 -2
  6. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +3 -3
  7. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +2 -2
  8. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js +3 -3
  9. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
  10. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +44 -107
  11. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
  12. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +3 -13
  13. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
  14. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +13 -7
  15. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
  16. package/dist/modules/scheduler/api/targets/route.js +12 -2
  17. package/dist/modules/scheduler/api/targets/route.js.map +2 -2
  18. package/dist/modules/scheduler/api/trigger/route.js +13 -7
  19. package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
  20. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +1 -3
  21. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
  22. package/dist/modules/scheduler/cli.js +1 -32
  23. package/dist/modules/scheduler/cli.js.map +2 -2
  24. package/dist/modules/scheduler/commands/jobs.js +2 -84
  25. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  26. package/dist/modules/scheduler/data/validators.js +3 -27
  27. package/dist/modules/scheduler/data/validators.js.map +2 -2
  28. package/dist/modules/scheduler/i18n/de.json +0 -2
  29. package/dist/modules/scheduler/i18n/en.json +0 -2
  30. package/dist/modules/scheduler/i18n/es.json +0 -2
  31. package/dist/modules/scheduler/i18n/ko.json +0 -2
  32. package/dist/modules/scheduler/i18n/pl.json +0 -2
  33. package/dist/modules/scheduler/services/localSchedulerService.js +6 -31
  34. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  35. package/dist/modules/scheduler/workers/execute-schedule.worker.js +6 -64
  36. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  37. package/package.json +6 -7
  38. package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
  39. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +8 -14
  40. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
  41. package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
  42. package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +66 -154
  43. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +5 -24
  44. package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +19 -9
  45. package/src/modules/scheduler/api/targets/__tests__/route.test.ts +4 -3
  46. package/src/modules/scheduler/api/targets/route.ts +17 -6
  47. package/src/modules/scheduler/api/trigger/route.ts +19 -9
  48. package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +1 -4
  49. package/src/modules/scheduler/cli.ts +1 -44
  50. package/src/modules/scheduler/commands/jobs.ts +3 -137
  51. package/src/modules/scheduler/data/__tests__/validators.test.ts +4 -26
  52. package/src/modules/scheduler/data/validators.ts +2 -37
  53. package/src/modules/scheduler/i18n/de.json +0 -2
  54. package/src/modules/scheduler/i18n/en.json +0 -2
  55. package/src/modules/scheduler/i18n/es.json +0 -2
  56. package/src/modules/scheduler/i18n/ko.json +0 -2
  57. package/src/modules/scheduler/i18n/pl.json +0 -2
  58. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +0 -179
  59. package/src/modules/scheduler/services/localSchedulerService.ts +6 -39
  60. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +3 -25
  61. package/src/modules/scheduler/workers/execute-schedule.worker.ts +7 -76
  62. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +0 -72
  63. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +0 -7
  64. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +0 -66
  65. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +0 -7
  66. package/dist/modules/scheduler/lib/safeQueueTargets.js +0 -157
  67. package/dist/modules/scheduler/lib/safeQueueTargets.js.map +0 -7
  68. package/dist/modules/scheduler/lib/scheduleAccess.js +0 -22
  69. package/dist/modules/scheduler/lib/scheduleAccess.js.map +0 -7
  70. package/dist/modules/scheduler/workers/test-echo-queue.worker.js +0 -22
  71. package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +0 -7
  72. package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +0 -90
  73. package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +0 -94
  74. package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +0 -177
  75. package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +0 -110
  76. package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +0 -311
  77. package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +0 -164
  78. package/src/modules/scheduler/lib/safeQueueTargets.ts +0 -324
  79. package/src/modules/scheduler/lib/scheduleAccess.ts +0 -104
  80. package/src/modules/scheduler/workers/test-echo-queue.worker.ts +0 -25
@@ -1,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
- }