@open-mercato/scheduler 0.7.0 → 0.7.1-develop.7103.1.41ff100d93

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 +9 -4
  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-008-safe-targets.spec.js +72 -0
  9. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +7 -0
  10. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +66 -0
  11. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +7 -0
  12. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js +3 -3
  13. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
  14. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +107 -44
  15. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
  16. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +13 -3
  17. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
  18. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +7 -13
  19. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
  20. package/dist/modules/scheduler/api/targets/route.js +2 -12
  21. package/dist/modules/scheduler/api/targets/route.js.map +2 -2
  22. package/dist/modules/scheduler/api/trigger/route.js +7 -13
  23. package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
  24. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +3 -1
  25. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
  26. package/dist/modules/scheduler/cli.js +32 -1
  27. package/dist/modules/scheduler/cli.js.map +2 -2
  28. package/dist/modules/scheduler/commands/jobs.js +84 -2
  29. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  30. package/dist/modules/scheduler/data/validators.js +27 -3
  31. package/dist/modules/scheduler/data/validators.js.map +2 -2
  32. package/dist/modules/scheduler/i18n/de.json +2 -0
  33. package/dist/modules/scheduler/i18n/en.json +2 -0
  34. package/dist/modules/scheduler/i18n/es.json +2 -0
  35. package/dist/modules/scheduler/i18n/ko.json +2 -0
  36. package/dist/modules/scheduler/i18n/pl.json +2 -0
  37. package/dist/modules/scheduler/lib/safeQueueTargets.js +157 -0
  38. package/dist/modules/scheduler/lib/safeQueueTargets.js.map +7 -0
  39. package/dist/modules/scheduler/lib/scheduleAccess.js +22 -0
  40. package/dist/modules/scheduler/lib/scheduleAccess.js.map +7 -0
  41. package/dist/modules/scheduler/services/localSchedulerService.js +31 -6
  42. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  43. package/dist/modules/scheduler/workers/execute-schedule.worker.js +64 -6
  44. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  45. package/dist/modules/scheduler/workers/test-echo-queue.worker.js +22 -0
  46. package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +7 -0
  47. package/package.json +7 -6
  48. package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
  49. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +14 -8
  50. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
  51. package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +90 -0
  52. package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +94 -0
  53. package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
  54. package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +154 -66
  55. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +24 -5
  56. package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +9 -19
  57. package/src/modules/scheduler/api/targets/__tests__/route.test.ts +3 -4
  58. package/src/modules/scheduler/api/targets/route.ts +6 -17
  59. package/src/modules/scheduler/api/trigger/route.ts +9 -19
  60. package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +4 -1
  61. package/src/modules/scheduler/cli.ts +44 -1
  62. package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +177 -0
  63. package/src/modules/scheduler/commands/jobs.ts +137 -3
  64. package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +110 -0
  65. package/src/modules/scheduler/data/__tests__/validators.test.ts +26 -4
  66. package/src/modules/scheduler/data/validators.ts +37 -2
  67. package/src/modules/scheduler/i18n/de.json +2 -0
  68. package/src/modules/scheduler/i18n/en.json +2 -0
  69. package/src/modules/scheduler/i18n/es.json +2 -0
  70. package/src/modules/scheduler/i18n/ko.json +2 -0
  71. package/src/modules/scheduler/i18n/pl.json +2 -0
  72. package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +311 -0
  73. package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +164 -0
  74. package/src/modules/scheduler/lib/safeQueueTargets.ts +324 -0
  75. package/src/modules/scheduler/lib/scheduleAccess.ts +104 -0
  76. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +179 -0
  77. package/src/modules/scheduler/services/localSchedulerService.ts +39 -6
  78. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +25 -3
  79. package/src/modules/scheduler/workers/execute-schedule.worker.ts +76 -7
  80. package/src/modules/scheduler/workers/test-echo-queue.worker.ts +25 -0
@@ -0,0 +1,157 @@
1
+ import { getModules } from "@open-mercato/shared/lib/modules/registry";
2
+ import { z } from "zod";
3
+ const registeredPayloadSchemas = /* @__PURE__ */ new Map();
4
+ function registerSchedulerQueuePayloadSchema(queue, schema) {
5
+ registeredPayloadSchemas.set(queue, schema);
6
+ }
7
+ function getSchedulerQueuePayloadSchema(queue) {
8
+ return registeredPayloadSchemas.get(queue) ?? null;
9
+ }
10
+ async function assertSchedulerQueueTargetAuthorized(params) {
11
+ const requiredFeatures = getSchedulerQueueRequiredFeatures(params.queue);
12
+ if (requiredFeatures.length === 0) return null;
13
+ if (params.isSuperAdmin === true) return null;
14
+ const actorUserId = typeof params.actorUserId === "string" ? params.actorUserId.trim() : "";
15
+ if (!actorUserId) return "Scheduler queue target requires an authenticated creator";
16
+ if (typeof params.rbacService?.userHasAllFeatures !== "function") {
17
+ return "Scheduler queue target authorization is unavailable";
18
+ }
19
+ const authorized = await params.rbacService.userHasAllFeatures(actorUserId, requiredFeatures, {
20
+ tenantId: params.tenantId ?? null,
21
+ organizationId: params.organizationId ?? null
22
+ });
23
+ if (!authorized) {
24
+ return `Scheduler queue target requires features the creator lacks: ${requiredFeatures.join(", ")}`;
25
+ }
26
+ return null;
27
+ }
28
+ function validateSchedulerTargetPayload(queue, targetPayload) {
29
+ const schema = getSchedulerQueuePayloadSchema(queue);
30
+ if (!schema) return null;
31
+ const result = schema.safeParse(targetPayload ?? {});
32
+ if (result.success) return null;
33
+ const firstIssue = result.error.issues[0];
34
+ return [
35
+ firstIssue?.path?.length ? `${firstIssue.path.join(".")}: ` : "",
36
+ firstIssue?.message ?? "Invalid payload"
37
+ ].join("");
38
+ }
39
+ const RESERVED_PAYLOAD_KEYS = /* @__PURE__ */ new Set(["tenantId", "organizationId", "scope"]);
40
+ function isReservedPayloadKey(key) {
41
+ return RESERVED_PAYLOAD_KEYS.has(key) || key.startsWith("_");
42
+ }
43
+ function stripReservedKeys(payload) {
44
+ const result = {};
45
+ for (const [key, value] of Object.entries(payload)) {
46
+ if (isReservedPayloadKey(key)) continue;
47
+ if (key === "payload" && value !== null && typeof value === "object" && !Array.isArray(value)) {
48
+ result[key] = stripReservedKeys(value);
49
+ continue;
50
+ }
51
+ result[key] = value;
52
+ }
53
+ return result;
54
+ }
55
+ function buildTrustedScheduleScope(schedule) {
56
+ return {
57
+ tenantId: schedule.tenantId ?? null,
58
+ organizationId: schedule.organizationId ?? null
59
+ };
60
+ }
61
+ function sanitizeSchedulerTargetPayload(targetPayload, schedule) {
62
+ const source = targetPayload ?? {};
63
+ const cloned = structuredClone(source);
64
+ const stripped = stripReservedKeys(cloned);
65
+ return {
66
+ ...stripped,
67
+ scope: buildTrustedScheduleScope(schedule)
68
+ };
69
+ }
70
+ const queueConsumerCache = /* @__PURE__ */ new WeakMap();
71
+ function collectQueueConsumers() {
72
+ let modules;
73
+ try {
74
+ modules = getModules();
75
+ } catch {
76
+ return /* @__PURE__ */ new Map();
77
+ }
78
+ const cached = queueConsumerCache.get(modules);
79
+ if (cached) return cached;
80
+ const byQueue = /* @__PURE__ */ new Map();
81
+ for (const mod of modules) {
82
+ for (const worker of mod.workers ?? []) {
83
+ if (!worker.queue) continue;
84
+ const consumers = byQueue.get(worker.queue) ?? [];
85
+ consumers.push(worker);
86
+ byQueue.set(worker.queue, consumers);
87
+ }
88
+ }
89
+ queueConsumerCache.set(modules, byQueue);
90
+ return byQueue;
91
+ }
92
+ function listSchedulerSafeQueueTargets() {
93
+ const targets = [];
94
+ for (const [queue, consumers] of collectQueueConsumers()) {
95
+ const optedIn = consumers.filter((worker) => worker.schedulerSafe === true);
96
+ if (optedIn.length === 0 || optedIn.length !== consumers.length) continue;
97
+ const requiredFeatures = [...new Set(optedIn.flatMap((worker) => Array.isArray(worker.schedulerRequiredFeatures) ? worker.schedulerRequiredFeatures : []))];
98
+ const ownerModuleIds = [...new Set(consumers.map((worker) => typeof worker.id === "string" ? worker.id.split(":")[0] ?? "" : ""))];
99
+ targets.push({ queue, moduleId: ownerModuleIds[0] ?? "", requiredFeatures });
100
+ }
101
+ targets.sort((a, b) => a.queue.localeCompare(b.queue));
102
+ return targets;
103
+ }
104
+ function isSchedulerSafeQueue(queue) {
105
+ if (!queue) return false;
106
+ return listSchedulerSafeQueueTargets().some((target) => target.queue === queue);
107
+ }
108
+ function getSchedulerQueueRequiredFeatures(queue) {
109
+ return listSchedulerSafeQueueTargets().find((target) => target.queue === queue)?.requiredFeatures ?? [];
110
+ }
111
+ function findQueueOwnerModuleIds(queue) {
112
+ const owners = /* @__PURE__ */ new Set();
113
+ for (const [candidate, consumers] of collectQueueConsumers()) {
114
+ if (candidate !== queue) continue;
115
+ for (const worker of consumers) {
116
+ if (typeof worker.id === "string") owners.add(worker.id.split(":")[0] ?? "");
117
+ }
118
+ }
119
+ return owners;
120
+ }
121
+ function auditSchedulerModuleQueueRows(rows) {
122
+ return rows.filter((row) => row.sourceType === "module" && row.targetQueue).map((row) => ({
123
+ scheduleId: row.id,
124
+ name: row.name ?? void 0,
125
+ sourceModule: row.sourceModule ?? null,
126
+ targetQueue: row.targetQueue ?? null,
127
+ isEnabled: row.isEnabled !== false,
128
+ dispatchAllowed: canDispatchScheduleQueueTarget(row)
129
+ }));
130
+ }
131
+ function canDispatchScheduleQueueTarget(schedule) {
132
+ if (!schedule.targetQueue) return false;
133
+ if (schedule.sourceType === "module") {
134
+ if (!schedule.sourceModule) return false;
135
+ if (schedule.createdByUserId) return false;
136
+ return findQueueOwnerModuleIds(schedule.targetQueue).has(schedule.sourceModule);
137
+ }
138
+ return isSchedulerSafeQueue(schedule.targetQueue);
139
+ }
140
+ registerSchedulerQueuePayloadSchema(
141
+ "scheduler-test",
142
+ z.object({ message: z.string().max(200).optional() }).passthrough()
143
+ );
144
+ export {
145
+ assertSchedulerQueueTargetAuthorized,
146
+ auditSchedulerModuleQueueRows,
147
+ buildTrustedScheduleScope,
148
+ canDispatchScheduleQueueTarget,
149
+ getSchedulerQueuePayloadSchema,
150
+ getSchedulerQueueRequiredFeatures,
151
+ isSchedulerSafeQueue,
152
+ listSchedulerSafeQueueTargets,
153
+ registerSchedulerQueuePayloadSchema,
154
+ sanitizeSchedulerTargetPayload,
155
+ validateSchedulerTargetPayload
156
+ };
157
+ //# sourceMappingURL=safeQueueTargets.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/scheduler/lib/safeQueueTargets.ts"],
4
+ "sourcesContent": ["import { getModules } from '@open-mercato/shared/lib/modules/registry'\nimport type { ModuleWorker } from '@open-mercato/shared/modules/registry'\nimport { z, type ZodTypeAny } from 'zod'\n\nexport type SchedulerSafeQueueTarget = {\n queue: string\n /**\n * Indicative primary owner \u2014 the first registry module found among the\n * queue's consumers. A queue may legitimately have consumers from more than\n * one module; ownership for provenance checks is always resolved per module.\n */\n moduleId: string\n /** Extra creator features a principal must hold to schedule onto this queue. */\n requiredFeatures: string[]\n}\n\nexport type TrustedScheduleScope = {\n tenantId: string | null\n organizationId: string | null\n}\n\n/**\n * Runtime payload schemas for opted-in scheduler targets.\n *\n * Worker metadata cannot carry a Zod schema through static registry generation,\n * so the module that owns a schedulable worker registers its target payload\n * schema here at bootstrap. Targets without a registered schema accept\n * arbitrary JSON payloads (documented escape hatch, mirroring scheduler-safe\n * commands which also carry no payload schema).\n */\nconst registeredPayloadSchemas = new Map<string, ZodTypeAny>()\n\nexport function registerSchedulerQueuePayloadSchema(queue: string, schema: ZodTypeAny): void {\n registeredPayloadSchemas.set(queue, schema)\n}\n\nexport function getSchedulerQueuePayloadSchema(queue: string): ZodTypeAny | null {\n return registeredPayloadSchemas.get(queue) ?? null\n}\n\nexport type SchedulerTargetRbacService = {\n userHasAllFeatures?: (\n userId: string,\n required: readonly string[],\n scope: { tenantId: string | null; organizationId: string | null },\n ) => Promise<boolean>\n}\n\n/**\n * Creator-side authorization for an opted-in queue target (#5213): a principal\n * must hold every feature the target declares beyond `scheduler.jobs.manage`.\n * Super administrators bypass target-specific checks, mirroring the rest of\n * the scheduler's authorization model. Returns null when authorized,\n * otherwise a short reason string.\n */\nexport async function assertSchedulerQueueTargetAuthorized(params: {\n queue: string\n actorUserId?: string | null\n tenantId?: string | null\n organizationId?: string | null\n isSuperAdmin?: boolean\n rbacService?: SchedulerTargetRbacService | null\n}): Promise<string | null> {\n const requiredFeatures = getSchedulerQueueRequiredFeatures(params.queue)\n if (requiredFeatures.length === 0) return null\n if (params.isSuperAdmin === true) return null\n\n const actorUserId = typeof params.actorUserId === 'string' ? params.actorUserId.trim() : ''\n if (!actorUserId) return 'Scheduler queue target requires an authenticated creator'\n if (typeof params.rbacService?.userHasAllFeatures !== 'function') {\n return 'Scheduler queue target authorization is unavailable'\n }\n\n const authorized = await params.rbacService.userHasAllFeatures(actorUserId, requiredFeatures, {\n tenantId: params.tenantId ?? null,\n organizationId: params.organizationId ?? null,\n })\n if (!authorized) {\n return `Scheduler queue target requires features the creator lacks: ${requiredFeatures.join(', ')}`\n }\n return null\n}\n\n/**\n * Validates an author-supplied target payload against the target's registered\n * schema, when one exists. Returns null when the payload is acceptable,\n * otherwise a short reason string.\n */\nexport function validateSchedulerTargetPayload(\n queue: string,\n targetPayload: Record<string, unknown> | null | undefined,\n): string | null {\n const schema = getSchedulerQueuePayloadSchema(queue)\n if (!schema) return null\n const result = schema.safeParse(targetPayload ?? {})\n if (result.success) return null\n const firstIssue = result.error.issues[0]\n return [\n firstIssue?.path?.length ? `${firstIssue.path.join('.')}: ` : '',\n firstIssue?.message ?? 'Invalid payload',\n ].join('')\n}\n\n/**\n * Keys a schedule author may never control on a dispatched target payload:\n * tenant/organization authority context and the reserved `_`-prefixed envelope\n * namespace (idempotency keys, dispatch-origin markers, \u2026).\n */\nconst RESERVED_PAYLOAD_KEYS = new Set(['tenantId', 'organizationId', 'scope'])\n\nfunction isReservedPayloadKey(key: string): boolean {\n return RESERVED_PAYLOAD_KEYS.has(key) || key.startsWith('_')\n}\n\nfunction stripReservedKeys(payload: Record<string, unknown>): Record<string, unknown> {\n const result: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(payload)) {\n if (isReservedPayloadKey(key)) continue\n if (key === 'payload' && value !== null && typeof value === 'object' && !Array.isArray(value)) {\n result[key] = stripReservedKeys(value as Record<string, unknown>)\n continue\n }\n result[key] = value\n }\n return result\n}\n\n/**\n * Rebuild the trusted scope for a dispatched schedule payload from the stored\n * schedule row. Callers must never trust scope-shaped data from the schedule\n * author's `targetPayload`.\n */\nexport function buildTrustedScheduleScope(schedule: {\n tenantId?: string | null\n organizationId?: string | null\n}): TrustedScheduleScope {\n return {\n tenantId: schedule.tenantId ?? null,\n organizationId: schedule.organizationId ?? null,\n }\n}\n\n/**\n * Sanitize an author-supplied schedule target payload before dispatch:\n * deep-copies it, strips reserved authority and `_`-prefixed envelope keys at\n * the payload root and inside the local-strategy `payload` wrapper level, then\n * injects the trusted scope derived from the schedule row.\n */\nexport function sanitizeSchedulerTargetPayload(\n targetPayload: Record<string, unknown> | null | undefined,\n schedule: Parameters<typeof buildTrustedScheduleScope>[0],\n): Record<string, unknown> {\n const source = targetPayload ?? {}\n const cloned = structuredClone(source)\n const stripped = stripReservedKeys(cloned)\n return {\n ...stripped,\n scope: buildTrustedScheduleScope(schedule),\n }\n}\n\nconst queueConsumerCache = new WeakMap<object, Map<string, ModuleWorker[]>>()\n\nfunction collectQueueConsumers(): Map<string, ModuleWorker[]> {\n let modules: ReturnType<typeof getModules>\n try {\n modules = getModules()\n } catch {\n return new Map()\n }\n // The registry returns the same array instance between registrations, so the\n // grouped map can be memoized per instance instead of rebuilt per lookup.\n const cached = queueConsumerCache.get(modules)\n if (cached) return cached\n\n const byQueue = new Map<string, ModuleWorker[]>()\n for (const mod of modules) {\n for (const worker of mod.workers ?? []) {\n if (!worker.queue) continue\n const consumers = byQueue.get(worker.queue) ?? []\n consumers.push(worker)\n byQueue.set(worker.queue, consumers)\n }\n }\n queueConsumerCache.set(modules, byQueue)\n return byQueue\n}\n\n/**\n * Enumerate queue targets that are safe for user-facing scheduling (#5213):\n * every worker consuming the queue must explicitly opt in \u2014 queue consumers\n * compete for the same jobs, so a single non-opted-in consumer would otherwise\n * receive scheduler-controlled payloads it never agreed to accept.\n */\nexport function listSchedulerSafeQueueTargets(): SchedulerSafeQueueTarget[] {\n const targets: SchedulerSafeQueueTarget[] = []\n for (const [queue, consumers] of collectQueueConsumers()) {\n const optedIn = consumers.filter((worker) => worker.schedulerSafe === true)\n if (optedIn.length === 0 || optedIn.length !== consumers.length) continue\n\n const requiredFeatures = [...new Set(optedIn.flatMap((worker) => (\n Array.isArray(worker.schedulerRequiredFeatures) ? worker.schedulerRequiredFeatures : []\n )))]\n const ownerModuleIds = [...new Set(consumers.map((worker) => (\n typeof worker.id === 'string' ? (worker.id.split(':')[0] ?? '') : ''\n )))]\n targets.push({ queue, moduleId: ownerModuleIds[0] ?? '', requiredFeatures })\n }\n targets.sort((a, b) => a.queue.localeCompare(b.queue))\n return targets\n}\n\nexport function isSchedulerSafeQueue(queue: string | null | undefined): boolean {\n if (!queue) return false\n return listSchedulerSafeQueueTargets().some((target) => target.queue === queue)\n}\n\n/**\n * Extra creator features required by the target queue beyond\n * `scheduler.jobs.manage` (#5213). Empty when the target declares none.\n */\nexport function getSchedulerQueueRequiredFeatures(queue: string): string[] {\n return listSchedulerSafeQueueTargets().find((target) => target.queue === queue)?.requiredFeatures ?? []\n}\n\n/**\n * The module ids that own workers on this queue, from the live registry.\n */\nfunction findQueueOwnerModuleIds(queue: string): Set<string> {\n const owners = new Set<string>()\n for (const [candidate, consumers] of collectQueueConsumers()) {\n if (candidate !== queue) continue\n for (const worker of consumers) {\n if (typeof worker.id === 'string') owners.add(worker.id.split(':')[0] ?? '')\n }\n }\n return owners\n}\n\n/**\n * Operator-facing audit for persisted module-authored queue rows (#5213).\n *\n * Provenance on pre-upgrade rows is not fully verifiable at rest: a row created\n * by a non-user-bound API key carries no acting-user stamp, making it\n * indistinguishable from a genuine module registration. This helper surfaces\n * every module-authored queue-target row with its current dispatch verdict so\n * operators can review the allowed ones against their known integrations\n * (`mercato scheduler audit-queue-targets`).\n */\nexport type SchedulerModuleQueueRowAudit = {\n scheduleId: string\n name?: string\n sourceModule: string | null\n targetQueue: string | null\n isEnabled: boolean\n /** Whether the dispatch guard currently lets this row fire. */\n dispatchAllowed: boolean\n}\n\nexport function auditSchedulerModuleQueueRows(\n rows: ReadonlyArray<{\n id: string\n name?: string | null\n targetQueue?: string | null\n sourceType?: string | null\n sourceModule?: string | null\n createdByUserId?: string | null\n isEnabled?: boolean\n }>,\n): SchedulerModuleQueueRowAudit[] {\n return rows\n .filter((row) => row.sourceType === 'module' && row.targetQueue)\n .map((row) => ({\n scheduleId: row.id,\n name: row.name ?? undefined,\n sourceModule: row.sourceModule ?? null,\n targetQueue: row.targetQueue ?? null,\n isEnabled: row.isEnabled !== false,\n dispatchAllowed: canDispatchScheduleQueueTarget(row),\n }))\n}\n\n/**\n * Dispatch-time authorization for queue-targeted schedules (#5213).\n *\n * Provenance is server-owned and verified, never trusted from the stored row:\n * - API-authored schedules (`sourceType: 'user'`) may only target queues whose\n * workers all opted into scheduling.\n * - Module-authored schedules are accepted only when the recorded\n * `sourceModule` owns the queue in the live registry AND the row carries no\n * acting-user stamp (`createdByUserId == null`) \u2014 `schedulerService.register()`\n * never stamps one while every interactive API write does. Residual limit: a\n * pre-upgrade row authored by a non-user-bound API key also lacks the stamp;\n * surface those with `auditSchedulerModuleQueueRows` /\n * `mercato scheduler audit-queue-targets`.\n */\nexport function canDispatchScheduleQueueTarget(schedule: {\n targetQueue?: string | null\n sourceType?: string | null\n sourceModule?: string | null\n /** Server-stamped actor on API-authored rows; module registration never sets it. */\n createdByUserId?: string | null\n}): boolean {\n if (!schedule.targetQueue) return false\n if (schedule.sourceType === 'module') {\n // Provenance must be server-owned, and both stored fields were client-\n // writable before #5213. A matching sourceModule alone is forgeable public\n // knowledge, so a persisted module row is only trusted when it also carries\n // no acting-user stamp: `schedulerService.register()` never sets one, while\n // every API write does. Forged pre-upgrade rows fail closed here.\n if (!schedule.sourceModule) return false\n if (schedule.createdByUserId) return false\n return findQueueOwnerModuleIds(schedule.targetQueue).has(schedule.sourceModule)\n }\n return isSchedulerSafeQueue(schedule.targetQueue)\n}\n\n// Built-in opted-in target owned by this module: the side-effect-free QA echo\n// queue. The strict shape keeps scheduler-controlled payloads predictable and\n// gives deployments a working reference for registering their own schemas.\nregisterSchedulerQueuePayloadSchema(\n 'scheduler-test',\n z.object({ message: z.string().max(200).optional() }).passthrough(),\n)\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,22 @@
1
+ function resolveScheduleAccess(schedule, actor) {
2
+ const isSuperAdmin = actor?.isSuperAdmin === true;
3
+ const scheduleTenantId = schedule.tenantId ?? null;
4
+ const actorTenantId = actor?.tenantId ?? null;
5
+ if (schedule.scopeType === "system" || scheduleTenantId === null) {
6
+ if (isSuperAdmin) return "allowed";
7
+ if (scheduleTenantId !== null && scheduleTenantId !== actorTenantId) return "not_found";
8
+ return "forbidden";
9
+ }
10
+ if (actorTenantId === null) return "not_found";
11
+ if (scheduleTenantId !== actorTenantId) return "not_found";
12
+ const scheduleOrganizationId = schedule.organizationId ?? null;
13
+ const actorOrganizationId = actor?.orgId ?? null;
14
+ if (scheduleOrganizationId !== null && actorOrganizationId !== null && scheduleOrganizationId !== actorOrganizationId) {
15
+ return "not_found";
16
+ }
17
+ return "allowed";
18
+ }
19
+ export {
20
+ resolveScheduleAccess
21
+ };
22
+ //# sourceMappingURL=scheduleAccess.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/scheduler/lib/scheduleAccess.ts"],
4
+ "sourcesContent": ["export type ScheduleScopeActor = {\n tenantId?: string | null\n orgId?: string | null\n isSuperAdmin?: boolean\n}\n\nexport type ScheduleScopeType = 'system' | 'organization' | 'tenant'\n\n/**\n * The scope columns of a loaded `ScheduledJob` row. Every field is optional because the\n * entity declares them that way (`tenantId?: string | null`), so a required shape here\n * could not accept the row the routes actually load.\n *\n * `undefined` is therefore normalized to `null` on read. That is fail-closed where it\n * matters: an absent `tenantId` classifies the row as system-scoped, which restricts it to\n * super admins rather than widening it. An absent `organizationId` reads as \"not bound to an\n * organization\", the same as an explicit null, which is what the list endpoint's tenant\n * branch already treats as tenant-wide.\n */\nexport type ScheduleScopeSubject = {\n scopeType?: ScheduleScopeType | null\n tenantId?: string | null\n organizationId?: string | null\n}\n\nexport type ScheduleAccessDecision = 'allowed' | 'not_found' | 'forbidden'\n\n/**\n * Decides whether an actor may act on a single schedule that was loaded by id alone.\n *\n * Isolation belongs here, on the loaded row, and never in the `where` clause: a\n * system-scoped schedule carries a null `tenantId`, so folding the actor's tenant into the\n * lookup makes that row unmatchable and turns every system-scope check below into dead code.\n * `commands/jobs.ts` (update/delete) and `api/jobs/buildFilters.ts` (list) already model\n * visibility this way.\n *\n * System scope is classified exactly as `ensureCanManageSystemScopedJob` classifies it \u2014\n * `scopeType === 'system' || tenantId == null` \u2014 so update, delete, trigger and executions\n * cannot disagree about what a system-scoped row is. `organizationId` deliberately plays no\n * part in that test: a row with a null tenant is system-scoped whether or not an\n * organization is set, which is how the create validator's scope refinement and the two\n * command guards already read it.\n *\n * `not_found` vs `forbidden` is deliberate. Another tenant's or another organization's\n * schedule answers `not_found`, because a 403 would confirm that the id exists. Only a\n * system-scoped schedule answers `forbidden` \u2014 its existence is a property of the\n * deployment, not of a tenant.\n *\n * An unresolved actor tenant fails closed for everyone, super admins included, matching\n * `buildSchedulerJobsFilters`, which returns an unmatchable filter when the tenant is\n * falsy. Such an actor cannot see a tenant-bound schedule in the list either.\n *\n * Super-admin status reads the immutable `isSuperAdmin` flag derived from RoleAcl/UserAcl at\n * session resolution. Never compare role names, which are tenant-mutable and spoofable.\n *\n * Organization isolation compares the actor's single `orgId`, where the list endpoint uses\n * the resolved organization scope (`filterIds`: the selected organization plus its\n * descendants). The two diverge in **both** directions, and the second is the permissive one:\n *\n * - Narrower: an actor whose scope spans several organizations sees those rows in the list\n * but is answered `not_found` here.\n * - Wider: an actor carrying no `orgId` at all reaches any org-bound row inside its tenant,\n * because the organization comparison below short-circuits \u2014 while the list hides those\n * rows entirely, emitting its organization branch only when at least one id is known.\n *\n * Both directions are inherited from the lookup this helper replaces, which compared the same\n * single id and omitted the clause entirely when it was absent. They are preserved rather\n * than corrected here, because widening or narrowing silently would change access the\n * previous lookup granted; reconciling the two paths is tracked separately.\n */\nexport function resolveScheduleAccess(\n schedule: ScheduleScopeSubject,\n actor: ScheduleScopeActor | null | undefined,\n): ScheduleAccessDecision {\n const isSuperAdmin = actor?.isSuperAdmin === true\n const scheduleTenantId = schedule.tenantId ?? null\n\n const actorTenantId = actor?.tenantId ?? null\n\n if (schedule.scopeType === 'system' || scheduleTenantId === null) {\n if (isSuperAdmin) return 'allowed'\n // A row mislabelled `system` while still bound to a tenant must not confirm its\n // existence across a tenant boundary \u2014 answering 403 there would tell an actor in one\n // tenant that the id exists in another, which is the disclosure the `not_found`\n // decisions below exist to prevent. Only a genuinely tenant-less row answers 403.\n if (scheduleTenantId !== null && scheduleTenantId !== actorTenantId) return 'not_found'\n return 'forbidden'\n }\n\n if (actorTenantId === null) return 'not_found'\n if (scheduleTenantId !== actorTenantId) return 'not_found'\n\n // Unlike the list endpoint's equivalent branch, which pins `scope_type = 'tenant'`\n // (buildFilters.ts), an org-null row is reachable here whatever its `scopeType`. Same\n // scope-inconsistent-row class as above and equally not producible by the create path;\n // left loose deliberately rather than diverging from the pre-existing lookup.\n const scheduleOrganizationId = schedule.organizationId ?? null\n const actorOrganizationId = actor?.orgId ?? null\n if (scheduleOrganizationId !== null && actorOrganizationId !== null && scheduleOrganizationId !== actorOrganizationId) {\n return 'not_found'\n }\n\n return 'allowed'\n}\n"],
5
+ "mappings": "AAsEO,SAAS,sBACd,UACA,OACwB;AACxB,QAAM,eAAe,OAAO,iBAAiB;AAC7C,QAAM,mBAAmB,SAAS,YAAY;AAE9C,QAAM,gBAAgB,OAAO,YAAY;AAEzC,MAAI,SAAS,cAAc,YAAY,qBAAqB,MAAM;AAChE,QAAI,aAAc,QAAO;AAKzB,QAAI,qBAAqB,QAAQ,qBAAqB,cAAe,QAAO;AAC5E,WAAO;AAAA,EACT;AAEA,MAAI,kBAAkB,KAAM,QAAO;AACnC,MAAI,qBAAqB,cAAe,QAAO;AAM/C,QAAM,yBAAyB,SAAS,kBAAkB;AAC1D,QAAM,sBAAsB,OAAO,SAAS;AAC5C,MAAI,2BAA2B,QAAQ,wBAAwB,QAAQ,2BAA2B,qBAAqB;AACrH,WAAO;AAAA,EACT;AAEA,SAAO;AACT;",
6
+ "names": []
7
+ }
@@ -8,6 +8,12 @@ import { assertSchedulerSafeCommandAuthorized } from "../lib/scheduler-safe-comm
8
8
  import { buildScheduledCommandContext } from "../lib/commandContext.js";
9
9
  import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from "../lib/queueTargetPayload.js";
10
10
  import { createLogger } from "@open-mercato/shared/lib/logger";
11
+ import {
12
+ canDispatchScheduleQueueTarget,
13
+ getSchedulerQueueRequiredFeatures,
14
+ sanitizeSchedulerTargetPayload,
15
+ validateSchedulerTargetPayload
16
+ } from "../lib/safeQueueTargets.js";
11
17
  const logger = createLogger("scheduler").child({ component: "local" });
12
18
  class LocalSchedulerService {
13
19
  constructor(em, queueFactory, rbacService, config = { pollIntervalMs: 3e4 }) {
@@ -171,13 +177,32 @@ class LocalSchedulerService {
171
177
  if (!schedule.targetQueue) {
172
178
  throw new Error("Target queue is required for queue target type");
173
179
  }
180
+ if (!canDispatchScheduleQueueTarget(schedule)) {
181
+ throw new Error(`Queue is not an approved scheduler target: ${schedule.targetQueue}`);
182
+ }
183
+ const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload);
184
+ if (payloadIssue) {
185
+ throw new Error(`Invalid payload for scheduler queue ${schedule.targetQueue}: ${payloadIssue}`);
186
+ }
187
+ if (schedule.scopeType !== "system") {
188
+ for (const feature of getSchedulerQueueRequiredFeatures(schedule.targetQueue)) {
189
+ const hasFeature = await this.checkFeature({ ...schedule, requireFeature: feature });
190
+ if (!hasFeature) {
191
+ throw new Error(`Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`);
192
+ }
193
+ }
194
+ }
174
195
  const queue = this.queueFactory(schedule.targetQueue);
175
- await queue.enqueue(buildQueueTargetPayload({
176
- targetPayload: schedule.targetPayload,
177
- tenantId: schedule.tenantId,
178
- organizationId: schedule.organizationId,
179
- idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now())
180
- }));
196
+ const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule);
197
+ await queue.enqueue({
198
+ ...buildQueueTargetPayload({
199
+ targetPayload: sanitizedPayload,
200
+ tenantId: schedule.tenantId,
201
+ organizationId: schedule.organizationId,
202
+ idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now())
203
+ }),
204
+ _jobOrigin: "scheduler"
205
+ });
181
206
  logger.info("Enqueued job to target queue", { scheduleId: schedule.id, targetQueue: schedule.targetQueue });
182
207
  }
183
208
  /**
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/services/localSchedulerService.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport type { Queue } from '@open-mercato/queue'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { ScheduledJob } from '../data/entities.js'\nimport { LocalLockStrategy } from '../lib/localLockStrategy'\nimport { recalculateNextRun } from '../lib/nextRunCalculator'\nimport { emitSchedulerEvent } from '../events.js'\nimport { getGlobalEventBus } from '@open-mercato/shared/modules/events'\nimport { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-commands.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'local' })\n\nexport interface RbacServiceLike {\n tenantHasFeature(tenantId: string | null | undefined, feature: string, opts?: { organizationId?: string | null }): Promise<boolean>\n userHasAllFeatures(userId: string, required: readonly string[], scope: { tenantId: string | null; organizationId: string | null }): Promise<boolean>\n}\n\nexport interface LocalSchedulerConfig {\n pollIntervalMs: number\n}\n\n/**\n * Local scheduler service for development\n * \n * This service polls the database for due schedules and executes them locally\n * without requiring Redis. Perfect for local development.\n * \n * Features:\n * - PostgreSQL polling (configurable interval, default 30s)\n * - In-process duplicate prevention across overlapping poll ticks\n * - Supports both queue and command targets\n * - Emits the same events as async strategy\n * - Updates nextRunAt after execution\n *\n * Limitations:\n * - Polling delay (up to configured interval)\n * - Single instance only (no distributed locking across instances). Duplicate\n * prevention is in-process only: the advisory lock serialises the claim, not\n * the execution, and nextRunAt is advanced only after the target has run, so\n * two processes polling the same database will both execute a due schedule.\n * Run exactly one process with QUEUE_STRATEGY=local, or use the async\n * strategy, which locks across instances.\n * - Higher database load than async strategy\n * \n * Set QUEUE_STRATEGY=local to use this service.\n */\nexport class LocalSchedulerService {\n private isRunning = false\n private pollTimer?: NodeJS.Timeout\n private lockStrategy: LocalLockStrategy\n\n constructor(\n private em: () => EntityManager,\n private queueFactory: (name: string) => Queue,\n private rbacService: RbacServiceLike,\n private config: LocalSchedulerConfig = { pollIntervalMs: 30000 },\n ) {\n this.lockStrategy = new LocalLockStrategy(em)\n }\n\n /**\n * Start the local scheduler polling engine\n */\n async start(): Promise<void> {\n if (this.isRunning) {\n logger.warn('Polling engine already running')\n return\n }\n\n this.isRunning = true\n logger.info('Starting polling engine', { pollIntervalMs: this.config.pollIntervalMs })\n logger.warn(\n 'Local scheduler strategy provides no cross-process protection: duplicate prevention is in-process only, so a second process polling the same database will execute the same due schedule. Run exactly one process with QUEUE_STRATEGY=local, or use the async strategy for distributed locking.',\n )\n\n // Run initial poll immediately\n await this.poll()\n\n // Schedule recurring polls\n this.pollTimer = setInterval(() => {\n this.poll().catch((error) => {\n logger.error('Poll error', { err: error })\n })\n }, this.config.pollIntervalMs)\n\n logger.info('Polling engine started')\n }\n\n /**\n * Stop the local scheduler\n */\n async stop(): Promise<void> {\n logger.info('Stopping polling engine')\n this.isRunning = false\n \n if (this.pollTimer) {\n clearInterval(this.pollTimer)\n this.pollTimer = undefined\n }\n\n logger.info('Polling engine stopped')\n }\n\n /**\n * Poll for due schedules and execute them\n */\n private async poll(): Promise<void> {\n if (!this.isRunning) {\n return\n }\n\n const em = this.em().fork()\n\n try {\n // Find enabled schedules that are due (limited to avoid spikes after outages)\n const dueSchedules = await em.find(ScheduledJob, {\n isEnabled: true,\n deletedAt: null,\n nextRunAt: { $lte: new Date() },\n }, {\n limit: 100,\n orderBy: { nextRunAt: 'ASC' },\n })\n\n if (dueSchedules.length === 0) {\n logger.debug('No due schedules')\n return\n }\n\n logger.info('Found due schedules', { count: dueSchedules.length })\n\n // Execute each schedule\n for (const schedule of dueSchedules) {\n await this.executeSchedule(schedule)\n }\n } catch (error: unknown) {\n logger.error('Poll failed', { err: error })\n }\n }\n\n /**\n * Execute a single schedule\n */\n private async executeSchedule(schedule: ScheduledJob): Promise<void> {\n const lockKey = `schedule:${schedule.id}`\n const scheduleLogger = logger.child({ scheduleId: schedule.id, scheduleName: schedule.name })\n\n const { acquired } = await this.lockStrategy.runWithLock(lockKey, async () => {\n scheduleLogger.info('Executing schedule')\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n triggerType: 'scheduled',\n startedAt: new Date(),\n })\n\n try {\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await this.checkFeature(schedule)\n \n if (!hasFeature) {\n scheduleLogger.info('Schedule skipped: missing required feature', { requireFeature: schedule.requireFeature })\n \n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n reason: `Missing required feature: ${schedule.requireFeature}`,\n skippedAt: new Date(),\n })\n\n // Still update next run time\n await this.updateNextRun(schedule)\n return\n }\n }\n\n // Execute based on target type\n if (schedule.targetType === 'queue') {\n await this.executeQueueTarget(schedule)\n } else if (schedule.targetType === 'command') {\n await this.executeCommandTarget(schedule)\n } else {\n throw new Error(`Unknown target type: ${schedule.targetType}`)\n }\n\n // Update last run and next run times\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n freshSchedule.lastRunAt = new Date()\n \n // Calculate next run time\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n }\n \n await em.flush()\n }\n\n scheduleLogger.info('Schedule completed')\n\n // Emit completed event\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n completedAt: new Date(),\n })\n } catch (error: unknown) {\n scheduleLogger.error('Schedule failed', { err: error })\n\n // Emit failed event\n await emitSchedulerEvent('scheduler.job.failed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n error: error instanceof Error ? error.message : String(error),\n failedAt: new Date(),\n })\n\n // Still update next run time even on failure\n await this.updateNextRun(schedule)\n }\n })\n\n if (!acquired) {\n scheduleLogger.debug('Schedule already locked, skipping')\n return\n }\n }\n\n /**\n * Execute a queue-based target\n */\n private async executeQueueTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetQueue) {\n throw new Error('Target queue is required for queue target type')\n }\n\n const queue = this.queueFactory(schedule.targetQueue)\n\n // Deliver the same flat contract as the asynchronous execute-schedule\n // worker: targetPayload fields on the root, scheduler-owned scope and\n // idempotency fields applied last. Local mode runs each firing exactly\n // once, so the firing timestamp is a valid logical execution key.\n await queue.enqueue(buildQueueTargetPayload({\n targetPayload: schedule.targetPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),\n }))\n\n logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })\n }\n\n /**\n * Execute a command-based target\n */\n private async executeCommandTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetCommand) {\n throw new Error('Target command is required for command target type')\n }\n\n const commandBus = new CommandBus()\n const actorUserId = typeof schedule.createdByUserId === 'string' ? schedule.createdByUserId.trim() : ''\n await assertSchedulerSafeCommandAuthorized({\n commandId: schedule.targetCommand,\n actorUserId,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n rbacService: this.rbacService,\n })\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n // Build the schedule-scoped command context after the allowlist/RBAC gate.\n const commandCtx = buildScheduledCommandContext(\n schedule,\n {\n resolve: (name: string) => {\n // Simple resolver that forwards to our dependencies\n if (name === 'em') return this.em()\n if (name === 'eventBus') return getGlobalEventBus()\n if (name === 'rbacService') return this.rbacService\n throw new Error(`Service not available in scheduler context: ${name}`)\n },\n } as AppContainer,\n )\n\n await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n\n logger.info('Executed command', { scheduleId: schedule.id, targetCommand: schedule.targetCommand })\n }\n\n /**\n * Check if user/tenant has required feature\n */\n private async checkFeature(schedule: ScheduledJob): Promise<boolean> {\n if (!schedule.requireFeature) {\n return true\n }\n\n try {\n // For system-scoped schedules, no RBAC check needed\n if (schedule.scopeType === 'system') {\n return true\n }\n\n // For tenant/org scoped schedules, check if ANY admin user has the feature\n // This is a simplified check - in reality, scheduled jobs run without a user context\n // so we're just checking if the feature is enabled for the tenant\n const hasFeature = await this.rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n {\n organizationId: schedule.organizationId,\n }\n )\n\n return hasFeature\n } catch (error: unknown) {\n logger.error('Feature check failed', { scheduleId: schedule.id, err: error })\n return false\n }\n }\n\n /**\n * Update next run time for a schedule\n */\n private async updateNextRun(schedule: ScheduledJob): Promise<void> {\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n await em.flush()\n }\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport type { Queue } from '@open-mercato/queue'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { ScheduledJob } from '../data/entities.js'\nimport { LocalLockStrategy } from '../lib/localLockStrategy'\nimport { recalculateNextRun } from '../lib/nextRunCalculator'\nimport { emitSchedulerEvent } from '../events.js'\nimport { getGlobalEventBus } from '@open-mercato/shared/modules/events'\nimport { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-commands.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n canDispatchScheduleQueueTarget,\n getSchedulerQueueRequiredFeatures,\n sanitizeSchedulerTargetPayload,\n validateSchedulerTargetPayload,\n} from '../lib/safeQueueTargets'\n\nconst logger = createLogger('scheduler').child({ component: 'local' })\n\nexport interface RbacServiceLike {\n tenantHasFeature(tenantId: string | null | undefined, feature: string, opts?: { organizationId?: string | null }): Promise<boolean>\n userHasAllFeatures(userId: string, required: readonly string[], scope: { tenantId: string | null; organizationId: string | null }): Promise<boolean>\n}\n\nexport interface LocalSchedulerConfig {\n pollIntervalMs: number\n}\n\n/**\n * Local scheduler service for development\n * \n * This service polls the database for due schedules and executes them locally\n * without requiring Redis. Perfect for local development.\n * \n * Features:\n * - PostgreSQL polling (configurable interval, default 30s)\n * - In-process duplicate prevention across overlapping poll ticks\n * - Supports both queue and command targets\n * - Emits the same events as async strategy\n * - Updates nextRunAt after execution\n *\n * Limitations:\n * - Polling delay (up to configured interval)\n * - Single instance only (no distributed locking across instances). Duplicate\n * prevention is in-process only: the advisory lock serialises the claim, not\n * the execution, and nextRunAt is advanced only after the target has run, so\n * two processes polling the same database will both execute a due schedule.\n * Run exactly one process with QUEUE_STRATEGY=local, or use the async\n * strategy, which locks across instances.\n * - Higher database load than async strategy\n * \n * Set QUEUE_STRATEGY=local to use this service.\n */\nexport class LocalSchedulerService {\n private isRunning = false\n private pollTimer?: NodeJS.Timeout\n private lockStrategy: LocalLockStrategy\n\n constructor(\n private em: () => EntityManager,\n private queueFactory: (name: string) => Queue,\n private rbacService: RbacServiceLike,\n private config: LocalSchedulerConfig = { pollIntervalMs: 30000 },\n ) {\n this.lockStrategy = new LocalLockStrategy(em)\n }\n\n /**\n * Start the local scheduler polling engine\n */\n async start(): Promise<void> {\n if (this.isRunning) {\n logger.warn('Polling engine already running')\n return\n }\n\n this.isRunning = true\n logger.info('Starting polling engine', { pollIntervalMs: this.config.pollIntervalMs })\n logger.warn(\n 'Local scheduler strategy provides no cross-process protection: duplicate prevention is in-process only, so a second process polling the same database will execute the same due schedule. Run exactly one process with QUEUE_STRATEGY=local, or use the async strategy for distributed locking.',\n )\n\n // Run initial poll immediately\n await this.poll()\n\n // Schedule recurring polls\n this.pollTimer = setInterval(() => {\n this.poll().catch((error) => {\n logger.error('Poll error', { err: error })\n })\n }, this.config.pollIntervalMs)\n\n logger.info('Polling engine started')\n }\n\n /**\n * Stop the local scheduler\n */\n async stop(): Promise<void> {\n logger.info('Stopping polling engine')\n this.isRunning = false\n \n if (this.pollTimer) {\n clearInterval(this.pollTimer)\n this.pollTimer = undefined\n }\n\n logger.info('Polling engine stopped')\n }\n\n /**\n * Poll for due schedules and execute them\n */\n private async poll(): Promise<void> {\n if (!this.isRunning) {\n return\n }\n\n const em = this.em().fork()\n\n try {\n // Find enabled schedules that are due (limited to avoid spikes after outages)\n const dueSchedules = await em.find(ScheduledJob, {\n isEnabled: true,\n deletedAt: null,\n nextRunAt: { $lte: new Date() },\n }, {\n limit: 100,\n orderBy: { nextRunAt: 'ASC' },\n })\n\n if (dueSchedules.length === 0) {\n logger.debug('No due schedules')\n return\n }\n\n logger.info('Found due schedules', { count: dueSchedules.length })\n\n // Execute each schedule\n for (const schedule of dueSchedules) {\n await this.executeSchedule(schedule)\n }\n } catch (error: unknown) {\n logger.error('Poll failed', { err: error })\n }\n }\n\n /**\n * Execute a single schedule\n */\n private async executeSchedule(schedule: ScheduledJob): Promise<void> {\n const lockKey = `schedule:${schedule.id}`\n const scheduleLogger = logger.child({ scheduleId: schedule.id, scheduleName: schedule.name })\n\n const { acquired } = await this.lockStrategy.runWithLock(lockKey, async () => {\n scheduleLogger.info('Executing schedule')\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n triggerType: 'scheduled',\n startedAt: new Date(),\n })\n\n try {\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await this.checkFeature(schedule)\n \n if (!hasFeature) {\n scheduleLogger.info('Schedule skipped: missing required feature', { requireFeature: schedule.requireFeature })\n \n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n reason: `Missing required feature: ${schedule.requireFeature}`,\n skippedAt: new Date(),\n })\n\n // Still update next run time\n await this.updateNextRun(schedule)\n return\n }\n }\n\n // Execute based on target type\n if (schedule.targetType === 'queue') {\n await this.executeQueueTarget(schedule)\n } else if (schedule.targetType === 'command') {\n await this.executeCommandTarget(schedule)\n } else {\n throw new Error(`Unknown target type: ${schedule.targetType}`)\n }\n\n // Update last run and next run times\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n freshSchedule.lastRunAt = new Date()\n \n // Calculate next run time\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n }\n \n await em.flush()\n }\n\n scheduleLogger.info('Schedule completed')\n\n // Emit completed event\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n completedAt: new Date(),\n })\n } catch (error: unknown) {\n scheduleLogger.error('Schedule failed', { err: error })\n\n // Emit failed event\n await emitSchedulerEvent('scheduler.job.failed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n error: error instanceof Error ? error.message : String(error),\n failedAt: new Date(),\n })\n\n // Still update next run time even on failure\n await this.updateNextRun(schedule)\n }\n })\n\n if (!acquired) {\n scheduleLogger.debug('Schedule already locked, skipping')\n return\n }\n }\n\n /**\n * Execute a queue-based target\n */\n private async executeQueueTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetQueue) {\n throw new Error('Target queue is required for queue target type')\n }\n\n // Dispatch-time reauthorization (#5213): provenance is verified against the\n // live registry (module rows must own their queue), API rows must target\n // opted-in queues.\n if (!canDispatchScheduleQueueTarget(schedule)) {\n throw new Error(`Queue is not an approved scheduler target: ${schedule.targetQueue}`)\n }\n\n const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)\n if (payloadIssue) {\n throw new Error(`Invalid payload for scheduler queue ${schedule.targetQueue}: ${payloadIssue}`)\n }\n\n if (schedule.scopeType !== 'system') {\n for (const feature of getSchedulerQueueRequiredFeatures(schedule.targetQueue)) {\n const hasFeature = await this.checkFeature({ ...schedule, requireFeature: feature } as ScheduledJob)\n if (!hasFeature) {\n throw new Error(`Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`)\n }\n }\n }\n\n const queue = this.queueFactory(schedule.targetQueue)\n\n // Deliver the same flat contract as the asynchronous execute-schedule\n // worker: targetPayload fields on the root, scheduler-owned scope and\n // idempotency fields applied last. Local mode runs each firing exactly\n // once, so the firing timestamp is a valid logical execution key.\n // Author-supplied scope/envelope keys never survive dispatch (#5213).\n const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule)\n\n await queue.enqueue({\n ...buildQueueTargetPayload({\n targetPayload: sanitizedPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),\n }),\n _jobOrigin: 'scheduler' as const,\n })\n\n logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })\n }\n\n /**\n * Execute a command-based target\n */\n private async executeCommandTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetCommand) {\n throw new Error('Target command is required for command target type')\n }\n\n const commandBus = new CommandBus()\n const actorUserId = typeof schedule.createdByUserId === 'string' ? schedule.createdByUserId.trim() : ''\n await assertSchedulerSafeCommandAuthorized({\n commandId: schedule.targetCommand,\n actorUserId,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n rbacService: this.rbacService,\n })\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n // Build the schedule-scoped command context after the allowlist/RBAC gate.\n const commandCtx = buildScheduledCommandContext(\n schedule,\n {\n resolve: (name: string) => {\n // Simple resolver that forwards to our dependencies\n if (name === 'em') return this.em()\n if (name === 'eventBus') return getGlobalEventBus()\n if (name === 'rbacService') return this.rbacService\n throw new Error(`Service not available in scheduler context: ${name}`)\n },\n } as AppContainer,\n )\n\n await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n\n logger.info('Executed command', { scheduleId: schedule.id, targetCommand: schedule.targetCommand })\n }\n\n /**\n * Check if user/tenant has required feature\n */\n private async checkFeature(schedule: ScheduledJob): Promise<boolean> {\n if (!schedule.requireFeature) {\n return true\n }\n\n try {\n // For system-scoped schedules, no RBAC check needed\n if (schedule.scopeType === 'system') {\n return true\n }\n\n // For tenant/org scoped schedules, check if ANY admin user has the feature\n // This is a simplified check - in reality, scheduled jobs run without a user context\n // so we're just checking if the feature is enabled for the tenant\n const hasFeature = await this.rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n {\n organizationId: schedule.organizationId,\n }\n )\n\n return hasFeature\n } catch (error: unknown) {\n logger.error('Feature check failed', { scheduleId: schedule.id, err: error })\n return false\n }\n }\n\n /**\n * Update next run time for a schedule\n */\n private async updateNextRun(schedule: ScheduledJob): Promise<void> {\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n await em.flush()\n }\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -7,6 +7,12 @@ import { assertSchedulerSafeCommandAuthorized } from "../lib/scheduler-safe-comm
7
7
  import { buildScheduledCommandContext } from "../lib/commandContext.js";
8
8
  import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from "../lib/queueTargetPayload.js";
9
9
  import { createLogger } from "@open-mercato/shared/lib/logger";
10
+ import {
11
+ canDispatchScheduleQueueTarget,
12
+ getSchedulerQueueRequiredFeatures,
13
+ sanitizeSchedulerTargetPayload,
14
+ validateSchedulerTargetPayload
15
+ } from "../lib/safeQueueTargets.js";
10
16
  const logger = createLogger("scheduler").child({ component: "worker" });
11
17
  const metadata = {
12
18
  queue: "scheduler-execution",
@@ -93,6 +99,54 @@ async function executeScheduleWorker(job, ctx) {
93
99
  }
94
100
  }
95
101
  if (schedule.targetType === "queue" && schedule.targetQueue) {
102
+ if (!canDispatchScheduleQueueTarget(schedule)) {
103
+ logger.error("Refusing non-safe queue target for schedule", {
104
+ scheduleId,
105
+ targetQueue: schedule.targetQueue,
106
+ sourceType: schedule.sourceType,
107
+ sourceModule: schedule.sourceType === "module" ? schedule.sourceModule : void 0
108
+ });
109
+ await emitSchedulerEvent("scheduler.job.skipped", {
110
+ id: schedule.id,
111
+ tenantId: schedule.tenantId,
112
+ organizationId: schedule.organizationId,
113
+ reason: `Queue is not an approved scheduler target: ${schedule.targetQueue}`
114
+ });
115
+ return;
116
+ }
117
+ const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload);
118
+ if (payloadIssue) {
119
+ logger.error("Refusing schedule with invalid payload for queue target", {
120
+ scheduleId,
121
+ targetQueue: schedule.targetQueue,
122
+ issue: payloadIssue
123
+ });
124
+ await emitSchedulerEvent("scheduler.job.skipped", {
125
+ id: schedule.id,
126
+ tenantId: schedule.tenantId,
127
+ organizationId: schedule.organizationId,
128
+ reason: `Invalid payload for scheduler queue ${schedule.targetQueue}`
129
+ });
130
+ return;
131
+ }
132
+ const requiredQueueFeatures = getSchedulerQueueRequiredFeatures(schedule.targetQueue);
133
+ if (schedule.scopeType !== "system" && requiredQueueFeatures.length > 0) {
134
+ for (const feature of requiredQueueFeatures) {
135
+ if (await rbacService.tenantHasFeature(schedule.tenantId, feature, { organizationId: schedule.organizationId })) continue;
136
+ logger.error("Refusing schedule whose tenant lacks a required feature of the queue target", {
137
+ scheduleId,
138
+ targetQueue: schedule.targetQueue,
139
+ feature
140
+ });
141
+ await emitSchedulerEvent("scheduler.job.skipped", {
142
+ id: schedule.id,
143
+ tenantId: schedule.tenantId,
144
+ organizationId: schedule.organizationId,
145
+ reason: `Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`
146
+ });
147
+ return;
148
+ }
149
+ }
96
150
  const queueStrategy = process.env.QUEUE_STRATEGY || "local";
97
151
  const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {
98
152
  connection: { url: getRedisUrlOrThrow("QUEUE") }
@@ -100,12 +154,16 @@ async function executeScheduleWorker(job, ctx) {
100
154
  let targetJobId;
101
155
  try {
102
156
  const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now());
103
- targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({
104
- targetPayload: schedule.targetPayload,
105
- tenantId: schedule.tenantId,
106
- organizationId: schedule.organizationId,
107
- idempotencyKey
108
- }));
157
+ const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule);
158
+ targetJobId = await targetQueue.enqueue({
159
+ ...buildQueueTargetPayload({
160
+ targetPayload: sanitizedPayload,
161
+ tenantId: schedule.tenantId,
162
+ organizationId: schedule.organizationId,
163
+ idempotencyKey
164
+ }),
165
+ _jobOrigin: "scheduler"
166
+ });
109
167
  } finally {
110
168
  await targetQueue.close();
111
169
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/workers/execute-schedule.worker.ts"],
4
- "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createQueue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { emitSchedulerEvent } from '../events.js'\nimport { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-commands.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'worker' })\n\n// Worker metadata for auto-discovery\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-execution',\n concurrency: 5, // Process up to 5 schedules concurrently\n}\n\nexport type ExecuteSchedulePayload = {\n scheduleId: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n triggerType?: 'scheduled' | 'manual'\n triggeredByUserId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\ntype RbacServiceLike = {\n tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean>\n userHasAllFeatures(\n userId: string,\n required: readonly string[],\n scope: { tenantId: string | null; organizationId: string | null },\n ): Promise<boolean>\n}\n\n/**\n * Worker that executes scheduled jobs.\n * \n * This worker is triggered by BullMQ repeatable jobs at the scheduled times.\n * It loads the fresh schedule configuration from the database, validates\n * conditions, and enqueues the target job or executes the command.\n * \n * BullMQ handles:\n * - Timing (exact cron/interval execution)\n * - Distributed locking (prevents duplicate execution)\n * - Retries (if worker fails)\n * - Execution history (via job state, logs, timestamps)\n * \n * This worker handles:\n * - Loading fresh schedule config\n * - Checking feature flags and conditions\n * - Enqueuing target job or executing command\n * - Updating last run time\n */\nexport default async function executeScheduleWorker(\n job: QueuedJob<ExecuteSchedulePayload>,\n ctx: JobContext & HandlerContext,\n): Promise<void> {\n logger.debug('Processing job', {\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n })\n \n // Defensive: handle both data and payload for BullMQ compatibility\n const payload = (job.payload || (job as unknown as { data?: ExecuteSchedulePayload }).data) as ExecuteSchedulePayload | undefined\n \n if (!payload || !payload.scheduleId) {\n logger.error('Invalid job payload: scheduleId missing', { jobId: ctx.jobId })\n throw new Error('scheduleId is required in job payload')\n }\n\n const { scheduleId } = payload\n\n const em = ctx.resolve<EntityManager>('em')\n const rbacService = ctx.resolve<RbacServiceLike>('rbacService')\n\n // Load fresh schedule from database\n const schedule = await em.findOne(ScheduledJob, { \n id: scheduleId,\n deletedAt: null,\n })\n\n if (!schedule) {\n logger.info('Schedule not found or deleted', { scheduleId })\n return\n }\n\n // CRITICAL: Verify scope integrity - ensure payload scope matches database\n // This prevents scope tampering and ensures proper multi-tenant isolation\n if (payload.scopeType !== schedule.scopeType) {\n logger.error('Scope type mismatch for schedule', {\n scheduleId,\n payloadScope: payload.scopeType,\n dbScope: schedule.scopeType,\n })\n throw new Error('Schedule scope type mismatch - potential security issue')\n }\n\n if (payload.tenantId !== schedule.tenantId) {\n logger.error('Tenant ID mismatch for schedule', {\n scheduleId,\n payloadTenant: payload.tenantId,\n dbTenant: schedule.tenantId,\n })\n throw new Error('Schedule tenant ID mismatch - potential security issue')\n }\n\n if (payload.organizationId !== schedule.organizationId) {\n logger.error('Organization ID mismatch for schedule', {\n scheduleId,\n payloadOrg: payload.organizationId,\n dbOrg: schedule.organizationId,\n })\n throw new Error('Schedule organization ID mismatch - potential security issue')\n }\n\n // Check if schedule is still enabled\n if (!schedule.isEnabled) {\n logger.debug('Schedule is disabled', { scheduleId })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: 'Schedule is disabled',\n })\n return\n }\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n attemptNumber: ctx.attemptNumber || 1,\n })\n\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n { organizationId: schedule.organizationId },\n )\n \n if (!hasFeature) {\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Feature not enabled: ${schedule.requireFeature}`,\n })\n\n logger.debug('Schedule skipped: feature not enabled', { scheduleId, requireFeature: schedule.requireFeature })\n return\n }\n }\n\n // Enqueue target job or execute command\n if (schedule.targetType === 'queue' && schedule.targetQueue) {\n // Determine queue strategy from environment\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n \n let targetJobId: string | undefined\n try {\n // The execute-schedule job id is stable across BullMQ retries, so if\n // this worker crashes between enqueue and DB flush the retried attempt\n // reuses the same idempotency key and downstream workers can dedupe.\n const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())\n\n targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({\n targetPayload: schedule.targetPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey,\n }))\n } finally {\n // Always close the queue instance to free Redis connections\n await targetQueue.close()\n }\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n queueJobId: targetJobId,\n queueName: schedule.targetQueue,\n })\n\n logger.debug('Successfully enqueued job', {\n scheduleId: schedule.id,\n targetQueue: schedule.targetQueue,\n queueJobId: targetJobId,\n })\n\n } else if (schedule.targetType === 'command' && schedule.targetCommand) {\n const commandBus = new CommandBus()\n const actorUserId = typeof schedule.createdByUserId === 'string' ? schedule.createdByUserId.trim() : ''\n await assertSchedulerSafeCommandAuthorized({\n commandId: schedule.targetCommand,\n actorUserId,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n rbacService,\n })\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n // Build the schedule-scoped command context after the allowlist/RBAC gate.\n const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer)\n \n const commandResult = await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n \n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n commandId: schedule.targetCommand,\n commandResult: commandResult.result,\n })\n \n logger.debug('Successfully executed command', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n })\n\n } else {\n throw new Error('Invalid target configuration')\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createQueue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { emitSchedulerEvent } from '../events.js'\nimport { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-commands.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n canDispatchScheduleQueueTarget,\n getSchedulerQueueRequiredFeatures,\n sanitizeSchedulerTargetPayload,\n validateSchedulerTargetPayload,\n} from '../lib/safeQueueTargets'\n\nconst logger = createLogger('scheduler').child({ component: 'worker' })\n\n// Worker metadata for auto-discovery\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-execution',\n concurrency: 5, // Process up to 5 schedules concurrently\n}\n\nexport type ExecuteSchedulePayload = {\n scheduleId: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n triggerType?: 'scheduled' | 'manual'\n triggeredByUserId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\ntype RbacServiceLike = {\n tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean>\n userHasAllFeatures(\n userId: string,\n required: readonly string[],\n scope: { tenantId: string | null; organizationId: string | null },\n ): Promise<boolean>\n}\n\n/**\n * Worker that executes scheduled jobs.\n * \n * This worker is triggered by BullMQ repeatable jobs at the scheduled times.\n * It loads the fresh schedule configuration from the database, validates\n * conditions, and enqueues the target job or executes the command.\n * \n * BullMQ handles:\n * - Timing (exact cron/interval execution)\n * - Distributed locking (prevents duplicate execution)\n * - Retries (if worker fails)\n * - Execution history (via job state, logs, timestamps)\n * \n * This worker handles:\n * - Loading fresh schedule config\n * - Checking feature flags and conditions\n * - Enqueuing target job or executing command\n * - Updating last run time\n */\nexport default async function executeScheduleWorker(\n job: QueuedJob<ExecuteSchedulePayload>,\n ctx: JobContext & HandlerContext,\n): Promise<void> {\n logger.debug('Processing job', {\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n })\n \n // Defensive: handle both data and payload for BullMQ compatibility\n const payload = (job.payload || (job as unknown as { data?: ExecuteSchedulePayload }).data) as ExecuteSchedulePayload | undefined\n \n if (!payload || !payload.scheduleId) {\n logger.error('Invalid job payload: scheduleId missing', { jobId: ctx.jobId })\n throw new Error('scheduleId is required in job payload')\n }\n\n const { scheduleId } = payload\n\n const em = ctx.resolve<EntityManager>('em')\n const rbacService = ctx.resolve<RbacServiceLike>('rbacService')\n\n // Load fresh schedule from database\n const schedule = await em.findOne(ScheduledJob, { \n id: scheduleId,\n deletedAt: null,\n })\n\n if (!schedule) {\n logger.info('Schedule not found or deleted', { scheduleId })\n return\n }\n\n // CRITICAL: Verify scope integrity - ensure payload scope matches database\n // This prevents scope tampering and ensures proper multi-tenant isolation\n if (payload.scopeType !== schedule.scopeType) {\n logger.error('Scope type mismatch for schedule', {\n scheduleId,\n payloadScope: payload.scopeType,\n dbScope: schedule.scopeType,\n })\n throw new Error('Schedule scope type mismatch - potential security issue')\n }\n\n if (payload.tenantId !== schedule.tenantId) {\n logger.error('Tenant ID mismatch for schedule', {\n scheduleId,\n payloadTenant: payload.tenantId,\n dbTenant: schedule.tenantId,\n })\n throw new Error('Schedule tenant ID mismatch - potential security issue')\n }\n\n if (payload.organizationId !== schedule.organizationId) {\n logger.error('Organization ID mismatch for schedule', {\n scheduleId,\n payloadOrg: payload.organizationId,\n dbOrg: schedule.organizationId,\n })\n throw new Error('Schedule organization ID mismatch - potential security issue')\n }\n\n // Check if schedule is still enabled\n if (!schedule.isEnabled) {\n logger.debug('Schedule is disabled', { scheduleId })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: 'Schedule is disabled',\n })\n return\n }\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n attemptNumber: ctx.attemptNumber || 1,\n })\n\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n { organizationId: schedule.organizationId },\n )\n \n if (!hasFeature) {\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Feature not enabled: ${schedule.requireFeature}`,\n })\n\n logger.debug('Schedule skipped: feature not enabled', { scheduleId, requireFeature: schedule.requireFeature })\n return\n }\n }\n\n // Enqueue target job or execute command\n if (schedule.targetType === 'queue' && schedule.targetQueue) {\n // Dispatch-time reauthorization (#5213): provenance is verified, never\n // trusted \u2014 module-authored rows must still be owned by their recorded\n // sourceModule and carry no acting-user stamp; API-authored rows may only\n // target workers that opted into scheduling.\n if (!canDispatchScheduleQueueTarget(schedule)) {\n logger.error('Refusing non-safe queue target for schedule', {\n scheduleId,\n targetQueue: schedule.targetQueue,\n sourceType: schedule.sourceType,\n sourceModule: schedule.sourceType === 'module' ? schedule.sourceModule : undefined,\n })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Queue is not an approved scheduler target: ${schedule.targetQueue}`,\n })\n return\n }\n\n // Re-validate the stored payload against the target's registered schema and\n // the tenant-level features the target requires (#5213, defense in depth).\n const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)\n if (payloadIssue) {\n logger.error('Refusing schedule with invalid payload for queue target', {\n scheduleId,\n targetQueue: schedule.targetQueue,\n issue: payloadIssue,\n })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Invalid payload for scheduler queue ${schedule.targetQueue}`,\n })\n return\n }\n\n const requiredQueueFeatures = getSchedulerQueueRequiredFeatures(schedule.targetQueue)\n if (schedule.scopeType !== 'system' && requiredQueueFeatures.length > 0) {\n for (const feature of requiredQueueFeatures) {\n if (await rbacService.tenantHasFeature(schedule.tenantId, feature, { organizationId: schedule.organizationId })) continue\n logger.error('Refusing schedule whose tenant lacks a required feature of the queue target', {\n scheduleId,\n targetQueue: schedule.targetQueue,\n feature,\n })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`,\n })\n return\n }\n }\n\n // Determine queue strategy from environment\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n\n let targetJobId: string | undefined\n try {\n // The execute-schedule job id is stable across BullMQ retries, so if\n // this worker crashes between enqueue and DB flush the retried attempt\n // reuses the same idempotency key and downstream workers can dedupe.\n const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())\n\n // Rebuild tenant/org authority context and the trusted dispatch origin\n // server-side; author-supplied scope/envelope keys never survive (#5213).\n const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule)\n targetJobId = await targetQueue.enqueue({\n ...buildQueueTargetPayload({\n targetPayload: sanitizedPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey,\n }),\n _jobOrigin: 'scheduler' as const,\n })\n } finally {\n // Always close the queue instance to free Redis connections\n await targetQueue.close()\n }\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n queueJobId: targetJobId,\n queueName: schedule.targetQueue,\n })\n\n logger.debug('Successfully enqueued job', {\n scheduleId: schedule.id,\n targetQueue: schedule.targetQueue,\n queueJobId: targetJobId,\n })\n\n } else if (schedule.targetType === 'command' && schedule.targetCommand) {\n const commandBus = new CommandBus()\n const actorUserId = typeof schedule.createdByUserId === 'string' ? schedule.createdByUserId.trim() : ''\n await assertSchedulerSafeCommandAuthorized({\n commandId: schedule.targetCommand,\n actorUserId,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n rbacService,\n })\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n // Build the schedule-scoped command context after the allowlist/RBAC gate.\n const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer)\n \n const commandResult = await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n \n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n commandId: schedule.targetCommand,\n commandResult: commandResult.result,\n })\n \n logger.debug('Successfully executed command', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n })\n\n } else {\n throw new Error('Invalid target configuration')\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,22 @@
1
+ import { createLogger } from "@open-mercato/shared/lib/logger";
2
+ const logger = createLogger("scheduler").child({ component: "test-echo-queue" });
3
+ const metadata = {
4
+ queue: "scheduler-test",
5
+ id: "scheduler:test-echo-queue",
6
+ concurrency: 1,
7
+ /**
8
+ * Deliberate scheduler-safe target for QA fixtures and manual experiments,
9
+ * mirroring the `scheduler.test.echo` command. The handler is side-effect
10
+ * free: it only logs that a delivery arrived.
11
+ */
12
+ schedulerSafe: true
13
+ };
14
+ async function handle(job, _ctx) {
15
+ const message = typeof job.payload?.message === "string" ? job.payload.message : "";
16
+ logger.info("Test queue target received a scheduled delivery", { messageId: message.slice(0, 80) });
17
+ }
18
+ export {
19
+ handle as default,
20
+ metadata
21
+ };
22
+ //# sourceMappingURL=test-echo-queue.worker.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/scheduler/workers/test-echo-queue.worker.ts"],
4
+ "sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'test-echo-queue' })\n\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-test',\n id: 'scheduler:test-echo-queue',\n concurrency: 1,\n /**\n * Deliberate scheduler-safe target for QA fixtures and manual experiments,\n * mirroring the `scheduler.test.echo` command. The handler is side-effect\n * free: it only logs that a delivery arrived.\n */\n schedulerSafe: true,\n}\n\ntype TestEchoPayload = {\n message?: unknown\n}\n\nexport default async function handle(job: QueuedJob<TestEchoPayload>, _ctx: JobContext): Promise<void> {\n const message = typeof job.payload?.message === 'string' ? job.payload.message : ''\n logger.info('Test queue target received a scheduled delivery', { messageId: message.slice(0, 80) })\n}\n"],
5
+ "mappings": "AACA,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,WAAW,EAAE,MAAM,EAAE,WAAW,kBAAkB,CAAC;AAExE,MAAM,WAAuB;AAAA,EAClC,OAAO;AAAA,EACP,IAAI;AAAA,EACJ,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMb,eAAe;AACjB;AAMA,eAAO,OAA8B,KAAiC,MAAiC;AACrG,QAAM,UAAU,OAAO,IAAI,SAAS,YAAY,WAAW,IAAI,QAAQ,UAAU;AACjF,SAAO,KAAK,mDAAmD,EAAE,WAAW,QAAQ,MAAM,GAAG,EAAE,EAAE,CAAC;AACpG;",
6
+ "names": []
7
+ }