@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,157 +0,0 @@
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
@@ -1,7 +0,0 @@
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
- }
@@ -1,22 +0,0 @@
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
@@ -1,7 +0,0 @@
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
- }
@@ -1,22 +0,0 @@
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
@@ -1,7 +0,0 @@
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
- }
@@ -1,90 +0,0 @@
1
- import { expect, test } from '@playwright/test'
2
- import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
- import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
- import {
5
- SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
6
- SCHEDULER_JOBS_PATH,
7
- SCHEDULER_TARGETS_PATH,
8
- createScheduleJob,
9
- deleteScheduleJob,
10
- getScheduleJobById,
11
- uniqueScheduleName,
12
- } from './helpers/scheduler'
13
-
14
- /**
15
- * TC-SCHED-008: Scheduler queue targets are restricted to authorized safe
16
- * workers (#5213). A principal holding only `scheduler.jobs.manage` must not be
17
- * able to aim a schedule at an internal/system worker queue with an arbitrary
18
- * payload — neither at creation, nor by retargeting later.
19
- */
20
- test.describe('TC-SCHED-008: scheduler queue targets restricted to safe workers', () => {
21
- for (const forbiddenQueue of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
22
- test(`rejects creating a schedule targeting the ${forbiddenQueue} worker`, async ({ request }) => {
23
- const token = await getAuthToken(request, 'admin')
24
-
25
- const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {
26
- token,
27
- data: {
28
- name: uniqueScheduleName('Forbidden Queue Target'),
29
- scopeType: 'organization',
30
- scheduleType: 'interval',
31
- scheduleValue: '15m',
32
- timezone: 'UTC',
33
- targetType: 'queue',
34
- targetQueue: forbiddenQueue,
35
- targetPayload: { scope: { tenantId: 'forged', organizationId: 'forged' } },
36
- isEnabled: true,
37
- sourceType: 'user',
38
- },
39
- })
40
-
41
- expect(response.status(), `${forbiddenQueue} must not be schedulable`).toBe(400)
42
- const body = await readJsonSafe<{ error?: string; details?: Array<{ path?: Array<string | number> }> }>(response)
43
- const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))
44
- expect(issuePaths).toContain('targetQueue')
45
- })
46
- }
47
-
48
- test('rejects retargeting an existing schedule to an internal worker queue', async ({ request }) => {
49
- const token = await getAuthToken(request, 'admin')
50
- let scheduleId: string | null = null
51
-
52
- try {
53
- scheduleId = await createScheduleJob(request, token, { name: uniqueScheduleName('Retarget Guard') })
54
-
55
- const response = await apiRequest(request, 'PUT', SCHEDULER_JOBS_PATH, {
56
- token,
57
- data: {
58
- id: scheduleId,
59
- targetType: 'queue',
60
- targetQueue: 'stripe-webhook',
61
- },
62
- })
63
-
64
- // Enforcement lives in the update command (change-detection vs the stored
65
- // row), so an actual retarget lands as 422 with the approval error.
66
- expect(response.status()).toBe(422)
67
- const body = await readJsonSafe<{ error?: string }>(response)
68
- expect(body?.error ?? '').toMatch(/not an approved scheduler target/i)
69
-
70
- const unchanged = await getScheduleJobById(request, token, scheduleId)
71
- expect(unchanged).not.toBeNull()
72
- expect(unchanged!.targetType).toBe('command')
73
- } finally {
74
- await deleteScheduleJob(request, token, scheduleId)
75
- }
76
- })
77
-
78
- test('targets endpoint keeps internal worker queues undiscoverable', async ({ request }) => {
79
- const token = await getAuthToken(request, 'admin')
80
-
81
- const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })
82
- expect(response.status()).toBe(200)
83
-
84
- const body = await readJsonSafe<{ queues?: Array<{ value: string }> }>(response)
85
- const values = (body?.queues ?? []).map((queue) => queue.value)
86
- for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
87
- expect(values).not.toContain(forbidden)
88
- }
89
- })
90
- })
@@ -1,94 +0,0 @@
1
- import { expect, test } from '@playwright/test'
2
- import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
- import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
4
- import {
5
- SCHEDULER_JOBS_PATH,
6
- SCHEDULER_TRIGGER_PATH,
7
- createScheduleJob,
8
- deleteScheduleJob,
9
- uniqueScheduleName,
10
- } from './helpers/scheduler'
11
-
12
- const isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'
13
-
14
- type TriggerBody = { ok?: boolean; jobId?: string; error?: string; message?: string }
15
-
16
- /**
17
- * TC-SCHED-008: POST /api/scheduler/trigger and GET /api/scheduler/jobs/[id]/executions
18
- * resolve the scope of a system-scoped schedule on the loaded row, not in the lookup.
19
- *
20
- * A system-scoped schedule has `tenantId === null` and `organizationId === null`. When both
21
- * routes folded the caller's tenant/org into the `where` clause, no such row could ever match
22
- * and every caller — super admins included — got 404, which also masked each route's own
23
- * super-admin gate. A super admin must now reach the schedule, and a non-super-admin must be
24
- * told 403 rather than 404.
25
- *
26
- * The super-admin assertions hold under either queue strategy: each route's
27
- * `QUEUE_STRATEGY=async` check sits after the lookup, so `local` yields the pre-existing
28
- * 400 — never 404.
29
- */
30
- test.describe('TC-SCHED-008: system-scoped schedule access on trigger and executions', () => {
31
- test('super admin reaches the schedule on both routes; a non-super-admin is forbidden', async ({ request }) => {
32
- const superToken = await getAuthToken(request, 'superadmin')
33
- const adminToken = await getAuthToken(request, 'admin')
34
- let scheduleId: string | null = null
35
-
36
- try {
37
- // Creating a system-scoped schedule is itself super-admin only.
38
- scheduleId = await createScheduleJob(request, superToken, {
39
- name: uniqueScheduleName('System-Trigger-Test'),
40
- scopeType: 'system',
41
- scheduleType: 'interval',
42
- scheduleValue: '1h',
43
- })
44
-
45
- const superResponse = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {
46
- token: superToken,
47
- data: { id: scheduleId },
48
- })
49
- const superBody = await readJsonSafe<TriggerBody>(superResponse)
50
- expect(
51
- superResponse.status(),
52
- 'a super admin must not be told a system-scoped schedule does not exist',
53
- ).not.toBe(404)
54
-
55
- if (isAsyncQueueStrategy) {
56
- expect(superResponse.status()).toBe(200)
57
- expect(superBody?.ok).toBe(true)
58
- expect(typeof superBody?.jobId === 'string' && superBody.jobId.length > 0).toBe(true)
59
- } else {
60
- // The strategy check runs after the scope check, so this 400 is the response the
61
- // 404 used to mask.
62
- expect(superResponse.status()).toBe(400)
63
- expect(superBody?.error ?? '').toMatch(/async/i)
64
- }
65
-
66
- // The same schedule is visible enough to be refused, not hidden, from a tenant admin.
67
- const adminResponse = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {
68
- token: adminToken,
69
- data: { id: scheduleId },
70
- })
71
- expect(
72
- adminResponse.status(),
73
- 'a non-super-admin must be forbidden from triggering a system-scoped schedule',
74
- ).toBe(403)
75
-
76
- // The execution-history route carried the same lookup and needs the same guarantees.
77
- const executionsPath = `${SCHEDULER_JOBS_PATH}/${scheduleId}/executions`
78
- const superExecutions = await apiRequest(request, 'GET', executionsPath, { token: superToken })
79
- expect(
80
- superExecutions.status(),
81
- 'a super admin must not be told a system-scoped schedule has no execution history route',
82
- ).not.toBe(404)
83
- expect(superExecutions.status()).toBe(isAsyncQueueStrategy ? 200 : 400)
84
-
85
- const adminExecutions = await apiRequest(request, 'GET', executionsPath, { token: adminToken })
86
- expect(
87
- adminExecutions.status(),
88
- 'a non-super-admin must be forbidden from reading a system-scoped execution history',
89
- ).toBe(403)
90
- } finally {
91
- await deleteScheduleJob(request, superToken, scheduleId)
92
- }
93
- })
94
- })
@@ -1,177 +0,0 @@
1
- export {}
2
-
3
- const registerCommand = jest.fn()
4
-
5
- jest.mock('@open-mercato/shared/lib/commands', () => ({
6
- registerCommand,
7
- }))
8
-
9
- jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
10
- resolveTranslations: jest.fn().mockResolvedValue({
11
- translate: (_key: string, fallback?: string) => fallback ?? _key,
12
- }),
13
- }))
14
-
15
- function loadCommands() {
16
- let update: any
17
- jest.isolateModules(() => {
18
- require('../jobs')
19
- update = registerCommand.mock.calls.find(([cmd]) => cmd.id === 'scheduler.jobs.update')?.[0]
20
- })
21
- return update
22
- }
23
-
24
- const safeQueueRow = {
25
- id: 'job-1',
26
- name: 'QA queue schedule',
27
- scopeType: 'organization',
28
- tenantId: 'tenant-a',
29
- organizationId: 'org-a',
30
- isEnabled: true,
31
- scheduleType: 'interval',
32
- scheduleValue: '15m',
33
- timezone: 'UTC',
34
- targetType: 'queue',
35
- targetQueue: 'scheduler-test',
36
- targetCommand: null,
37
- // nested payload the UI round-trips verbatim
38
- targetPayload: { source: 'integration-test', retries: { max: 5 } },
39
- sourceType: 'user',
40
- sourceModule: null,
41
- }
42
-
43
- const moduleOwnedRow = {
44
- ...safeQueueRow,
45
- id: 'job-module',
46
- name: 'Module-owned schedule',
47
- sourceType: 'module',
48
- sourceModule: 'legacy_module',
49
- createdByUserId: null,
50
- }
51
-
52
- function makeEm(schedule: Record<string, unknown>) {
53
- return {
54
- fork: jest.fn().mockReturnThis(),
55
- findOne: jest.fn().mockResolvedValue(schedule),
56
- persist: jest.fn(),
57
- flush: jest.fn().mockResolvedValue(undefined),
58
- count: jest.fn().mockResolvedValue(0),
59
- }
60
- }
61
-
62
- describe('scheduler.jobs.update target-change detection (#5213 N1)', () => {
63
- beforeEach(() => {
64
- jest.clearAllMocks()
65
- jest.resetModules()
66
- })
67
-
68
- it('allows an operational-only save of a module-authored schedule whose form resends the unchanged target', async () => {
69
- const update = loadCommands()
70
- const em = makeEm(moduleOwnedRow)
71
- const ctx = {
72
- auth: { isSuperAdmin: false, tenantId: 'tenant-a', orgId: 'org-a', sub: 'user-1' },
73
- container: { resolve: jest.fn(() => em) },
74
- }
75
-
76
- const result = await update.execute(
77
- {
78
- id: moduleOwnedRow.id,
79
- // the shipped edit form always resends every field…
80
- targetType: moduleOwnedRow.targetType,
81
- targetQueue: moduleOwnedRow.targetQueue,
82
- targetPayload: moduleOwnedRow.targetPayload,
83
- // …while the operator only changed operational values
84
- name: 'Renamed by ops',
85
- scheduleValue: '30m',
86
- scheduleType: 'interval',
87
- isEnabled: false,
88
- },
89
- ctx,
90
- )
91
-
92
- expect(result).toEqual({ ok: true })
93
- expect(em.flush).toHaveBeenCalled()
94
- })
95
-
96
- it('still blocks an actual target or payload change on a module-authored schedule', async () => {
97
- const update = loadCommands()
98
- const em = makeEm(moduleOwnedRow)
99
- const ctx = {
100
- auth: { isSuperAdmin: false, tenantId: 'tenant-a', orgId: 'org-a', sub: 'user-1' },
101
- container: { resolve: jest.fn(() => em) },
102
- }
103
-
104
- await expect(
105
- update.execute({ id: moduleOwnedRow.id, targetQueue: 'stripe-webhook' }, ctx),
106
- ).rejects.toMatchObject({ status: 403 })
107
- await expect(
108
- update.execute({ id: moduleOwnedRow.id, targetPayload: { forged: true } }, ctx),
109
- ).rejects.toMatchObject({ status: 403 })
110
- expect(em.flush).not.toHaveBeenCalled()
111
- })
112
-
113
- it('lets admins disable a legacy user-authored row whose stored queue is no longer approved (#5213 N1)', async () => {
114
- const update = loadCommands()
115
- const legacyUnsafe = {
116
- ...safeQueueRow,
117
- id: 'job-legacy',
118
- targetQueue: 'stripe-webhook',
119
- targetPayload: null as unknown,
120
- }
121
- const em = makeEm(legacyUnsafe)
122
- const ctx = {
123
- auth: { isSuperAdmin: false, tenantId: 'tenant-a', orgId: 'org-a', sub: 'user-1' },
124
- container: { resolve: jest.fn(() => em) },
125
- }
126
-
127
- // the shipped edit form resends every field — target fields included —
128
- // while only flipping the switch
129
- const result = await update.execute(
130
- {
131
- id: legacyUnsafe.id,
132
- targetType: legacyUnsafe.targetType,
133
- targetQueue: legacyUnsafe.targetQueue,
134
- isEnabled: false,
135
- },
136
- ctx,
137
- )
138
- expect(result).toEqual({ ok: true })
139
-
140
- // retargeting onto another unapproved queue is still rejected
141
- await expect(
142
- update.execute({ id: legacyUnsafe.id, targetQueue: 'payment-gateways-webhook' }, ctx),
143
- ).rejects.toMatchObject({ status: 422 })
144
- })
145
-
146
- it('treats an empty-object stored payload as equal to the null the form submits (#5213 N1-residual)', async () => {
147
- const update = loadCommands()
148
- const emptyPayloadRow = {
149
- ...moduleOwnedRow,
150
- id: 'job-module-empty-payload',
151
- targetPayload: {},
152
- }
153
- const em = makeEm(emptyPayloadRow)
154
- const ctx = {
155
- auth: { isSuperAdmin: true, tenantId: null, orgId: null, sub: 'super-1' },
156
- container: { resolve: jest.fn(() => em) },
157
- }
158
-
159
- // ai_assistant-style registration stores `targetPayload: {}`; the edit form
160
- // normalizes that to `targetPayload: null` on submit. An operational save
161
- // must not trip the module-owned-target guard over the shape difference.
162
- const result = await update.execute(
163
- {
164
- id: emptyPayloadRow.id,
165
- targetType: emptyPayloadRow.targetType,
166
- targetQueue: emptyPayloadRow.targetQueue,
167
- targetPayload: null,
168
- name: 'Renamed by ops',
169
- isEnabled: true,
170
- },
171
- ctx,
172
- )
173
-
174
- expect(result).toEqual({ ok: true })
175
- expect(em.flush).toHaveBeenCalled()
176
- })
177
- })