@open-mercato/scheduler 0.7.1-develop.7113.1.9d83dca10c → 0.7.1-develop.7121.1.734c263bda
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/scheduler/__integration__/TC-SCHED-009.spec.js +61 -0
- package/dist/modules/scheduler/__integration__/TC-SCHED-009.spec.js.map +7 -0
- package/dist/modules/scheduler/api/trigger/route.js +37 -37
- package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
- package/dist/modules/scheduler/commands/jobs.js +3 -0
- package/dist/modules/scheduler/commands/jobs.js.map +2 -2
- package/dist/modules/scheduler/commands/trigger.js +146 -0
- package/dist/modules/scheduler/commands/trigger.js.map +7 -0
- package/dist/modules/scheduler/i18n/de.json +1 -0
- package/dist/modules/scheduler/i18n/en.json +1 -0
- package/dist/modules/scheduler/i18n/es.json +1 -0
- package/dist/modules/scheduler/i18n/ko.json +1 -0
- package/dist/modules/scheduler/i18n/pl.json +1 -0
- package/dist/modules/scheduler/lib/commandContext.js +15 -5
- package/dist/modules/scheduler/lib/commandContext.js.map +2 -2
- package/dist/modules/scheduler/lib/scheduler-safe-commands.js +11 -4
- package/dist/modules/scheduler/lib/scheduler-safe-commands.js.map +2 -2
- package/dist/modules/scheduler/services/localSchedulerService.js +2 -2
- package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
- package/dist/modules/scheduler/workers/execute-schedule.worker.js +38 -11
- package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/scheduler/__integration__/TC-SCHED-009.spec.ts +98 -0
- package/src/modules/scheduler/api/trigger/__tests__/route.test.ts +154 -0
- package/src/modules/scheduler/api/trigger/route.ts +63 -47
- package/src/modules/scheduler/commands/__tests__/trigger.test.ts +411 -0
- package/src/modules/scheduler/commands/jobs.ts +1 -1
- package/src/modules/scheduler/commands/trigger.ts +258 -0
- package/src/modules/scheduler/i18n/de.json +1 -0
- package/src/modules/scheduler/i18n/en.json +1 -0
- package/src/modules/scheduler/i18n/es.json +1 -0
- package/src/modules/scheduler/i18n/ko.json +1 -0
- package/src/modules/scheduler/i18n/pl.json +1 -0
- package/src/modules/scheduler/lib/__tests__/commandContext.test.ts +83 -1
- package/src/modules/scheduler/lib/commandContext.ts +39 -3
- package/src/modules/scheduler/lib/scheduler-safe-commands.ts +25 -4
- package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +1 -1
- package/src/modules/scheduler/services/localSchedulerService.ts +2 -2
- package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +132 -1
- package/src/modules/scheduler/workers/execute-schedule.worker.ts +58 -13
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/scheduler/commands/trigger.ts"],
|
|
4
|
+
"sourcesContent": ["// =============================================================================\n// Manual Schedule Trigger Command \u2014 Undo Policy\n// =============================================================================\n//\n// `scheduler.jobs.trigger` is registered with `isUndoable: false` and defines no\n// `undo`/`redo`. It mutates no domain state itself: it enqueues an execution job,\n// and a worker picks that job up as soon as it is free. By the time an operator\n// could reach for Undo the run has already started, and whatever it did is\n// reversed through the action-log entries the run itself writes \u2014 the target\n// command's own `undo`, or the target queue's domain-level counter-action.\n//\n// Minting an undo token here would therefore offer to reverse an enqueue that no\n// longer exists, while leaving the run's real effects untouched. No `undo`\n// handler also means no `undoToken`, hence no `x-om-operation` header \u2014 which is\n// what `TC-UNDO-001 \u00A74` pins.\n//\n// The command exists for the audit trail: manual triggering is an\n// authorised-looking way to make an automated job run at a moment of the actor's\n// choosing, so every authenticated attempt \u2014 refusals included \u2014 must leave an\n// `ActionLog` row naming the caller. The one exception is bus-wide rather than\n// local: `CommandBus.execute` throws `CommandInterceptorError` before `buildLog`\n// and `persistLog` run, so an attempt a `before` interceptor blocks is refused\n// without a row, exactly as it is for `scheduler.jobs.create`/`.update`/`.delete`.\n// =============================================================================\nimport { registerCommand } from '@open-mercato/shared/lib/commands'\nimport type { CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { createQueue } from '@open-mercato/queue'\nimport type { Queue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { resolveScheduleAccess } from '../lib/scheduleAccess.js'\nimport {\n assertSchedulerSafeCommandAuthorized,\n SchedulerCommandAuthorizationError,\n type SchedulerCommandRbacService,\n} from '../lib/scheduler-safe-commands.js'\nimport { resolveScheduledCommandActorUserId } from '../lib/commandContext.js'\nimport { resolveCommandActorUserId } from './jobs.js'\nimport type { ScheduleTriggerInput } from '../data/validators.js'\nimport type { ExecuteSchedulePayload } from '../workers/execute-schedule.worker.js'\n\nconst logger = createLogger('scheduler').child({ component: 'trigger' })\n\n/**\n * Outcome of a manual trigger attempt.\n *\n * `execute()` returns this for every *expected* refusal instead of throwing.\n * That is a hard requirement, not a style choice: `CommandBus.execute` has no\n * try/catch, so a handler that throws skips `captureAfter`, `buildLog` and\n * `persistLog` entirely and no `ActionLog` row is written \u2014 losing exactly the\n * entries this command exists to create. The route maps `outcome` to HTTP.\n */\nexport type TriggerScheduleResult = {\n scheduleId: string\n scheduleName: string | null\n targetType: 'queue' | 'command' | null\n target: string | null\n outcome: 'enqueued' | 'not_found' | 'forbidden' | 'strategy_unsupported' | 'failed'\n queueJobId: string | null\n error: string | null\n}\n\n/**\n * A refused attempt records the requested id and nothing else about the row.\n *\n * `resolveScheduleAccess` deliberately answers `not_found` rather than 403 for\n * another tenant's schedule, so that a caller cannot confirm an id exists.\n * Echoing that row's name or target into the audit log would hand back exactly\n * the fact the decision withholds, so schedule details are populated only once\n * access has been granted.\n */\nfunction refusedTrigger(\n scheduleId: string,\n outcome: 'not_found' | 'forbidden',\n): TriggerScheduleResult {\n return {\n scheduleId,\n scheduleName: null,\n targetType: null,\n target: null,\n outcome,\n queueJobId: null,\n error: null,\n }\n}\n\nconst triggerScheduleCommand: CommandHandler<ScheduleTriggerInput, TriggerScheduleResult> = {\n id: 'scheduler.jobs.trigger',\n isUndoable: false,\n\n async execute(input, ctx) {\n const em = ctx.container.resolve<EntityManager>('em')\n\n // Load by id alone, then apply isolation to the loaded row. Folding the actor's\n // tenant/org into the lookup would make every system-scoped schedule unmatchable.\n const schedule = await em.findOne(ScheduledJob, { id: input.id, deletedAt: null })\n if (!schedule) return refusedTrigger(input.id, 'not_found')\n\n // Single source of truth for schedule visibility, shared with update, delete,\n // list and executions. Its `not_found` vs `forbidden` split is deliberate.\n const access = resolveScheduleAccess(schedule, ctx.auth)\n if (access === 'not_found') return refusedTrigger(input.id, 'not_found')\n if (access === 'forbidden') return refusedTrigger(input.id, 'forbidden')\n\n const details = {\n scheduleId: schedule.id,\n scheduleName: schedule.name ?? null,\n targetType: schedule.targetType ?? null,\n target:\n (schedule.targetType === 'command' ? schedule.targetCommand : schedule.targetQueue) ?? null,\n }\n\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n if (queueStrategy !== 'async') {\n return { ...details, outcome: 'strategy_unsupported', queueJobId: null, error: null }\n }\n\n // `resolveCommandActorUserId` resolves the bound user whenever the auth\n // context carries one \u2014 including an API key issued on a user's behalf \u2014 and\n // yields null only for a key-only context. That matters now that the worker\n // authorizes and runs a manual command schedule as this identity: a bare key\n // id is not a user id, so it falls back to the schedule's creator rather than\n // failing an RBAC lookup that could never succeed.\n const triggeredByUserId = resolveCommandActorUserId(ctx)\n\n // Decide a command schedule's authorization here, where the audit row is\n // written, rather than leaving it to the worker.\n //\n // The worker gates the run with the same assertion and throws when it refuses,\n // and a throw inside a retrying queue is invisible: the caller has already been\n // told the run was enqueued, the audit row already says `enqueued`, and BullMQ\n // keeps retrying a refusal that no attempt can turn into a success. Answering\n // `forbidden` now makes the refusal the caller's HTTP response and the audit\n // row's outcome. The worker keeps its own gate as the authority \u2014 this decision\n // can go stale between enqueue and execution \u2014 but the common case is decided\n // where it can be seen.\n //\n // Unlike `refusedTrigger`, this refusal carries the schedule's details: access\n // to the row has already been granted, so naming it discloses nothing new.\n if (schedule.targetType === 'command') {\n const actorUserId = resolveScheduledCommandActorUserId(schedule, { triggeredByUserId })\n try {\n await assertSchedulerSafeCommandAuthorized({\n commandId: schedule.targetCommand,\n actorUserId,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n rbacService: ctx.container.resolve<SchedulerCommandRbacService>('rbacService'),\n })\n } catch (error) {\n // Only an authorization decision is a refusal. `userHasAllFeatures` can\n // also fail because its store is down, and answering `403` to an outage\n // would tell the caller they lack access they may well hold, and file an\n // audit row claiming a refusal that was never decided. Those become\n // `failed`, the same outcome an unreachable queue produces \u2014 still audited,\n // because a handler that throws writes no row at all.\n const refused = error instanceof SchedulerCommandAuthorizationError\n const message = error instanceof Error ? error.message : null\n if (refused) {\n logger.info('Manual trigger refused by scheduled-command authorization', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n reason: message,\n })\n } else {\n logger.error('Scheduled-command authorization check failed', {\n err: error,\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n })\n }\n return {\n ...details,\n outcome: refused ? 'forbidden' : 'failed',\n queueJobId: null,\n error: message,\n }\n }\n }\n\n const payload: ExecuteSchedulePayload = {\n scheduleId: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scopeType: schedule.scopeType,\n triggerType: 'manual',\n triggeredByUserId,\n }\n\n let executionQueue: Queue<ExecuteSchedulePayload> | null = null\n try {\n executionQueue = createQueue<ExecuteSchedulePayload>('scheduler-execution', queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n const queueJobId = await executionQueue.enqueue(payload)\n\n logger.info('Manually triggered schedule', {\n scheduleId: schedule.id,\n scheduleName: schedule.name,\n jobId: queueJobId,\n triggeredBy: ctx.auth?.sub ?? null,\n })\n\n return { ...details, outcome: 'enqueued', queueJobId, error: null }\n } catch (error) {\n logger.error('Manual trigger failed', { err: error, scheduleId: schedule.id })\n return {\n ...details,\n outcome: 'failed',\n queueJobId: null,\n error: error instanceof Error ? error.message : null,\n }\n } finally {\n // Cleanup runs on the failure path too \u2014 the previous route-level code closed\n // the queue only after a successful enqueue, leaking a Redis connection\n // otherwise. A failure to close must not turn a completed enqueue into a\n // failed request, so it is reported rather than thrown.\n try {\n await executionQueue?.close()\n } catch (closeError) {\n logger.warn('Failed to close scheduler execution queue', { err: closeError })\n }\n }\n },\n\n async buildLog({ result, ctx }) {\n const { translate } = await resolveTranslations()\n\n return {\n // Keep the label free of create/update/delete/assign verbs: the action-log\n // projection derives `actionType` by string-matching it.\n actionLabel: translate('scheduler.audit.trigger', 'Trigger schedule'),\n resourceKind: 'scheduler.job',\n // The requested id, so that probing for ids that do not exist is traceable.\n resourceId: result.scheduleId,\n // The actor's scope on every outcome, unlike the sibling create/update/delete\n // handlers which log the schedule's. A refusal must never carry the target's\n // tenant: that would leak cross-tenant existence into the caller's trail and,\n // because the action-log reader filters organization_id by strict equality,\n // file the row where the caller's own auditors could never read it.\n tenantId: ctx.auth?.tenantId ?? null,\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n payload: {\n scheduleName: result.scheduleName,\n targetType: result.targetType,\n target: result.target,\n outcome: result.outcome,\n queueJobId: result.queueJobId,\n error: result.error,\n },\n }\n },\n}\n\nregisterCommand(triggerScheduleCommand)\n"],
|
|
5
|
+
"mappings": "AAwBA,SAAS,uBAAuB;AAEhC,SAAS,mBAAmB;AAE5B,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B;AAEpC,SAAS,oBAAoB;AAC7B,SAAS,6BAA6B;AACtC;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AACP,SAAS,0CAA0C;AACnD,SAAS,iCAAiC;AAI1C,MAAM,SAAS,aAAa,WAAW,EAAE,MAAM,EAAE,WAAW,UAAU,CAAC;AA8BvE,SAAS,eACP,YACA,SACuB;AACvB,SAAO;AAAA,IACL;AAAA,IACA,cAAc;AAAA,IACd,YAAY;AAAA,IACZ,QAAQ;AAAA,IACR;AAAA,IACA,YAAY;AAAA,IACZ,OAAO;AAAA,EACT;AACF;AAEA,MAAM,yBAAsF;AAAA,EAC1F,IAAI;AAAA,EACJ,YAAY;AAAA,EAEZ,MAAM,QAAQ,OAAO,KAAK;AACxB,UAAM,KAAK,IAAI,UAAU,QAAuB,IAAI;AAIpD,UAAM,WAAW,MAAM,GAAG,QAAQ,cAAc,EAAE,IAAI,MAAM,IAAI,WAAW,KAAK,CAAC;AACjF,QAAI,CAAC,SAAU,QAAO,eAAe,MAAM,IAAI,WAAW;AAI1D,UAAM,SAAS,sBAAsB,UAAU,IAAI,IAAI;AACvD,QAAI,WAAW,YAAa,QAAO,eAAe,MAAM,IAAI,WAAW;AACvE,QAAI,WAAW,YAAa,QAAO,eAAe,MAAM,IAAI,WAAW;AAEvE,UAAM,UAAU;AAAA,MACd,YAAY,SAAS;AAAA,MACrB,cAAc,SAAS,QAAQ;AAAA,MAC/B,YAAY,SAAS,cAAc;AAAA,MACnC,SACG,SAAS,eAAe,YAAY,SAAS,gBAAgB,SAAS,gBAAgB;AAAA,IAC3F;AAEA,UAAM,gBAAiB,QAAQ,IAAI,kBAAkB;AACrD,QAAI,kBAAkB,SAAS;AAC7B,aAAO,EAAE,GAAG,SAAS,SAAS,wBAAwB,YAAY,MAAM,OAAO,KAAK;AAAA,IACtF;AAQA,UAAM,oBAAoB,0BAA0B,GAAG;AAgBvD,QAAI,SAAS,eAAe,WAAW;AACrC,YAAM,cAAc,mCAAmC,UAAU,EAAE,kBAAkB,CAAC;AACtF,UAAI;AACF,cAAM,qCAAqC;AAAA,UACzC,WAAW,SAAS;AAAA,UACpB;AAAA,UACA,UAAU,SAAS;AAAA,UACnB,gBAAgB,SAAS;AAAA,UACzB,aAAa,IAAI,UAAU,QAAqC,aAAa;AAAA,QAC/E,CAAC;AAAA,MACH,SAAS,OAAO;AAOd,cAAM,UAAU,iBAAiB;AACjC,cAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU;AACzD,YAAI,SAAS;AACX,iBAAO,KAAK,6DAA6D;AAAA,YACvE,YAAY,SAAS;AAAA,YACrB,WAAW,SAAS;AAAA,YACpB,QAAQ;AAAA,UACV,CAAC;AAAA,QACH,OAAO;AACL,iBAAO,MAAM,gDAAgD;AAAA,YAC3D,KAAK;AAAA,YACL,YAAY,SAAS;AAAA,YACrB,WAAW,SAAS;AAAA,UACtB,CAAC;AAAA,QACH;AACA,eAAO;AAAA,UACL,GAAG;AAAA,UACH,SAAS,UAAU,cAAc;AAAA,UACjC,YAAY;AAAA,UACZ,OAAO;AAAA,QACT;AAAA,MACF;AAAA,IACF;AAEA,UAAM,UAAkC;AAAA,MACtC,YAAY,SAAS;AAAA,MACrB,UAAU,SAAS;AAAA,MACnB,gBAAgB,SAAS;AAAA,MACzB,WAAW,SAAS;AAAA,MACpB,aAAa;AAAA,MACb;AAAA,IACF;AAEA,QAAI,iBAAuD;AAC3D,QAAI;AACF,uBAAiB,YAAoC,uBAAuB,eAAe;AAAA,QACzF,YAAY,EAAE,KAAK,mBAAmB,OAAO,EAAE;AAAA,MACjD,CAAC;AACD,YAAM,aAAa,MAAM,eAAe,QAAQ,OAAO;AAEvD,aAAO,KAAK,+BAA+B;AAAA,QACzC,YAAY,SAAS;AAAA,QACrB,cAAc,SAAS;AAAA,QACvB,OAAO;AAAA,QACP,aAAa,IAAI,MAAM,OAAO;AAAA,MAChC,CAAC;AAED,aAAO,EAAE,GAAG,SAAS,SAAS,YAAY,YAAY,OAAO,KAAK;AAAA,IACpE,SAAS,OAAO;AACd,aAAO,MAAM,yBAAyB,EAAE,KAAK,OAAO,YAAY,SAAS,GAAG,CAAC;AAC7E,aAAO;AAAA,QACL,GAAG;AAAA,QACH,SAAS;AAAA,QACT,YAAY;AAAA,QACZ,OAAO,iBAAiB,QAAQ,MAAM,UAAU;AAAA,MAClD;AAAA,IACF,UAAE;AAKA,UAAI;AACF,cAAM,gBAAgB,MAAM;AAAA,MAC9B,SAAS,YAAY;AACnB,eAAO,KAAK,6CAA6C,EAAE,KAAK,WAAW,CAAC;AAAA,MAC9E;AAAA,IACF;AAAA,EACF;AAAA,EAEA,MAAM,SAAS,EAAE,QAAQ,IAAI,GAAG;AAC9B,UAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAEhD,WAAO;AAAA;AAAA;AAAA,MAGL,aAAa,UAAU,2BAA2B,kBAAkB;AAAA,MACpE,cAAc;AAAA;AAAA,MAEd,YAAY,OAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAMnB,UAAU,IAAI,MAAM,YAAY;AAAA,MAChC,gBAAgB,IAAI,0BAA0B,IAAI,MAAM,SAAS;AAAA,MACjE,SAAS;AAAA,QACP,cAAc,OAAO;AAAA,QACrB,YAAY,OAAO;AAAA,QACnB,QAAQ,OAAO;AAAA,QACf,SAAS,OAAO;AAAA,QAChB,YAAY,OAAO;AAAA,QACnB,OAAO,OAAO;AAAA,MAChB;AAAA,IACF;AAAA,EACF;AACF;AAEA,gBAAgB,sBAAsB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"scheduler.action.view": "Anzeigen",
|
|
8
8
|
"scheduler.audit.create": "Zeitplan erstellen",
|
|
9
9
|
"scheduler.audit.delete": "Zeitplan löschen",
|
|
10
|
+
"scheduler.audit.trigger": "Zeitplan ausführen",
|
|
10
11
|
"scheduler.audit.update": "Zeitplan aktualisieren",
|
|
11
12
|
"scheduler.confirm.delete": "Sind Sie sicher, dass Sie diesen Zeitplan löschen möchten?",
|
|
12
13
|
"scheduler.confirm.trigger": "Sind Sie sicher, dass Sie \"{name}\" jetzt ausführen möchten?\n\nDies wird {targetType} sofort ausführen.",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"scheduler.action.view": "View",
|
|
8
8
|
"scheduler.audit.create": "Create schedule",
|
|
9
9
|
"scheduler.audit.delete": "Delete schedule",
|
|
10
|
+
"scheduler.audit.trigger": "Trigger schedule",
|
|
10
11
|
"scheduler.audit.update": "Update schedule",
|
|
11
12
|
"scheduler.confirm.delete": "Are you sure you want to delete this schedule?",
|
|
12
13
|
"scheduler.confirm.trigger": "Are you sure you want to trigger \"{name}\" now?\n\nThis will execute the {targetType} immediately.",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"scheduler.action.view": "Ver",
|
|
8
8
|
"scheduler.audit.create": "Crear programación",
|
|
9
9
|
"scheduler.audit.delete": "Eliminar programación",
|
|
10
|
+
"scheduler.audit.trigger": "Ejecutar programación",
|
|
10
11
|
"scheduler.audit.update": "Actualizar programación",
|
|
11
12
|
"scheduler.confirm.delete": "¿Está seguro de que desea eliminar esta programación?",
|
|
12
13
|
"scheduler.confirm.trigger": "¿Está seguro de que desea ejecutar \"{name}\" ahora?\n\nEsto ejecutará {targetType} inmediatamente.",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"scheduler.action.view": "보기",
|
|
8
8
|
"scheduler.audit.create": "일정 생성",
|
|
9
9
|
"scheduler.audit.delete": "일정 삭제",
|
|
10
|
+
"scheduler.audit.trigger": "일정 실행",
|
|
10
11
|
"scheduler.audit.update": "일정 수정",
|
|
11
12
|
"scheduler.confirm.delete": "이 일정을 삭제하시겠습니까?",
|
|
12
13
|
"scheduler.confirm.trigger": "지금 \"{name}\"을(를) 실행하시겠습니까?\n\n{targetType}이(가) 즉시 실행됩니다.",
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"scheduler.action.view": "Podgląd",
|
|
8
8
|
"scheduler.audit.create": "Utwórz harmonogram",
|
|
9
9
|
"scheduler.audit.delete": "Usuń harmonogram",
|
|
10
|
+
"scheduler.audit.trigger": "Uruchom harmonogram",
|
|
10
11
|
"scheduler.audit.update": "Zaktualizuj harmonogram",
|
|
11
12
|
"scheduler.confirm.delete": "Czy na pewno chcesz usunąć ten harmonogram?",
|
|
12
13
|
"scheduler.confirm.trigger": "Czy na pewno chcesz uruchomić \"{name}\" teraz?\n\nSpowoduje to natychmiastowe wykonanie {targetType}.",
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
const SCHEDULER_SYSTEM_ACTOR_ID = "00000000-0000-0000-0000-000000000000";
|
|
2
|
-
function
|
|
3
|
-
const
|
|
2
|
+
function resolveScheduledCommandActorUserId(schedule, options) {
|
|
3
|
+
const candidates = [options?.triggeredByUserId, schedule.createdByUserId];
|
|
4
|
+
for (const candidate of candidates) {
|
|
5
|
+
if (typeof candidate !== "string") continue;
|
|
6
|
+
const trimmed = candidate.trim();
|
|
7
|
+
if (trimmed.length > 0) return trimmed;
|
|
8
|
+
}
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
function buildScheduledCommandAuth(schedule, options) {
|
|
12
|
+
const actorId = resolveScheduledCommandActorUserId(schedule, options) || SCHEDULER_SYSTEM_ACTOR_ID;
|
|
4
13
|
return {
|
|
5
14
|
sub: actorId,
|
|
6
15
|
userId: actorId,
|
|
@@ -9,13 +18,13 @@ function buildScheduledCommandAuth(schedule) {
|
|
|
9
18
|
isSuperAdmin: false
|
|
10
19
|
};
|
|
11
20
|
}
|
|
12
|
-
function buildScheduledCommandContext(schedule, container) {
|
|
21
|
+
function buildScheduledCommandContext(schedule, container, options) {
|
|
13
22
|
const tenantId = schedule.tenantId ?? null;
|
|
14
23
|
const organizationId = schedule.organizationId ?? null;
|
|
15
24
|
const organizationIds = organizationId ? [organizationId] : null;
|
|
16
25
|
return {
|
|
17
26
|
container,
|
|
18
|
-
auth: buildScheduledCommandAuth(schedule),
|
|
27
|
+
auth: buildScheduledCommandAuth(schedule, options),
|
|
19
28
|
organizationScope: schedule.scopeType === "organization" && organizationId ? {
|
|
20
29
|
selectedId: organizationId,
|
|
21
30
|
filterIds: [organizationId],
|
|
@@ -33,6 +42,7 @@ function buildScheduledCommandContext(schedule, container) {
|
|
|
33
42
|
};
|
|
34
43
|
}
|
|
35
44
|
export {
|
|
36
|
-
buildScheduledCommandContext
|
|
45
|
+
buildScheduledCommandContext,
|
|
46
|
+
resolveScheduledCommandActorUserId
|
|
37
47
|
};
|
|
38
48
|
//# sourceMappingURL=commandContext.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/scheduler/lib/commandContext.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\n\nconst SCHEDULER_SYSTEM_ACTOR_ID = '00000000-0000-0000-0000-000000000000'\n\ntype ScheduledCommandContextSchedule = {\n id: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n createdByUserId?: string | null\n}\n\nfunction buildScheduledCommandAuth(schedule: ScheduledCommandContextSchedule): Exclude<AuthContext, null> {\n const actorId = schedule
|
|
5
|
-
"mappings": "AAIA,MAAM,4BAA4B;
|
|
4
|
+
"sourcesContent": ["import type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\n\nconst SCHEDULER_SYSTEM_ACTOR_ID = '00000000-0000-0000-0000-000000000000'\n\ntype ScheduledCommandContextSchedule = {\n id: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n createdByUserId?: string | null\n}\n\nexport type ScheduledCommandActorOptions = {\n /**\n * The user who manually triggered this run, when there is one. Unattended runs\n * pass null/undefined and keep falling back to the schedule's creator.\n */\n triggeredByUserId?: string | null\n}\n\n/**\n * Resolve the real user a scheduled run acts as: the person who triggered it,\n * else the schedule's creator, else nobody.\n *\n * This is the single definition of that precedence, and it deliberately returns\n * `null` rather than the system actor when neither id is usable. Callers that\n * need an identity for the command context substitute the system actor\n * themselves; the RBAC gate must instead keep rejecting an actor-less schedule\n * with its own clear error, rather than running an RBAC lookup against the\n * all-zeros system id.\n */\nexport function resolveScheduledCommandActorUserId(\n schedule: Pick<ScheduledCommandContextSchedule, 'createdByUserId'>,\n options?: ScheduledCommandActorOptions,\n): string | null {\n const candidates = [options?.triggeredByUserId, schedule.createdByUserId]\n for (const candidate of candidates) {\n if (typeof candidate !== 'string') continue\n const trimmed = candidate.trim()\n if (trimmed.length > 0) return trimmed\n }\n return null\n}\n\nfunction buildScheduledCommandAuth(\n schedule: ScheduledCommandContextSchedule,\n options?: ScheduledCommandActorOptions,\n): Exclude<AuthContext, null> {\n const actorId = resolveScheduledCommandActorUserId(schedule, options) || SCHEDULER_SYSTEM_ACTOR_ID\n return {\n sub: actorId,\n userId: actorId,\n tenantId: schedule.tenantId ?? null,\n orgId: schedule.organizationId ?? null,\n isSuperAdmin: false,\n }\n}\n\nexport function buildScheduledCommandContext(\n schedule: ScheduledCommandContextSchedule,\n container: AppContainer,\n options?: ScheduledCommandActorOptions,\n): CommandRuntimeContext {\n const tenantId = schedule.tenantId ?? null\n const organizationId = schedule.organizationId ?? null\n const organizationIds = organizationId ? [organizationId] : null\n\n return {\n container,\n auth: buildScheduledCommandAuth(schedule, options),\n organizationScope:\n schedule.scopeType === 'organization' && organizationId\n ? {\n selectedId: organizationId,\n filterIds: [organizationId],\n allowedIds: [organizationId],\n tenantId,\n }\n : {\n selectedId: null,\n filterIds: null,\n allowedIds: null,\n tenantId,\n },\n selectedOrganizationId: organizationId,\n organizationIds,\n request: undefined,\n }\n}\n"],
|
|
5
|
+
"mappings": "AAIA,MAAM,4BAA4B;AA6B3B,SAAS,mCACd,UACA,SACe;AACf,QAAM,aAAa,CAAC,SAAS,mBAAmB,SAAS,eAAe;AACxE,aAAW,aAAa,YAAY;AAClC,QAAI,OAAO,cAAc,SAAU;AACnC,UAAM,UAAU,UAAU,KAAK;AAC/B,QAAI,QAAQ,SAAS,EAAG,QAAO;AAAA,EACjC;AACA,SAAO;AACT;AAEA,SAAS,0BACP,UACA,SAC4B;AAC5B,QAAM,UAAU,mCAAmC,UAAU,OAAO,KAAK;AACzE,SAAO;AAAA,IACL,KAAK;AAAA,IACL,QAAQ;AAAA,IACR,UAAU,SAAS,YAAY;AAAA,IAC/B,OAAO,SAAS,kBAAkB;AAAA,IAClC,cAAc;AAAA,EAChB;AACF;AAEO,SAAS,6BACd,UACA,WACA,SACuB;AACvB,QAAM,WAAW,SAAS,YAAY;AACtC,QAAM,iBAAiB,SAAS,kBAAkB;AAClD,QAAM,kBAAkB,iBAAiB,CAAC,cAAc,IAAI;AAE5D,SAAO;AAAA,IACL;AAAA,IACA,MAAM,0BAA0B,UAAU,OAAO;AAAA,IACjD,mBACE,SAAS,cAAc,kBAAkB,iBACrC;AAAA,MACE,YAAY;AAAA,MACZ,WAAW,CAAC,cAAc;AAAA,MAC1B,YAAY,CAAC,cAAc;AAAA,MAC3B;AAAA,IACF,IACA;AAAA,MACE,YAAY;AAAA,MACZ,WAAW;AAAA,MACX,YAAY;AAAA,MACZ;AAAA,IACF;AAAA,IACN,wBAAwB;AAAA,IACxB;AAAA,IACA,SAAS;AAAA,EACX;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
class SchedulerCommandAuthorizationError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "SchedulerCommandAuthorizationError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
1
7
|
const schedulerSafeCommands = /* @__PURE__ */ new Map();
|
|
2
8
|
function normalizeCommandId(commandId) {
|
|
3
9
|
if (typeof commandId !== "string") return null;
|
|
@@ -32,21 +38,21 @@ function listSchedulerSafeCommands() {
|
|
|
32
38
|
async function assertSchedulerSafeCommandAuthorized(params) {
|
|
33
39
|
const command = getSchedulerSafeCommand(params.commandId);
|
|
34
40
|
if (!command) {
|
|
35
|
-
throw new
|
|
41
|
+
throw new SchedulerCommandAuthorizationError("Scheduled command is not allowed");
|
|
36
42
|
}
|
|
37
43
|
const actorUserId = normalizeCommandId(params.actorUserId);
|
|
38
44
|
if (!actorUserId) {
|
|
39
|
-
throw new
|
|
45
|
+
throw new SchedulerCommandAuthorizationError("Scheduled command requires an authenticated actor");
|
|
40
46
|
}
|
|
41
47
|
if (typeof params.rbacService.userHasAllFeatures !== "function") {
|
|
42
|
-
throw new
|
|
48
|
+
throw new SchedulerCommandAuthorizationError("Scheduled command authorization is unavailable");
|
|
43
49
|
}
|
|
44
50
|
const authorized = await params.rbacService.userHasAllFeatures(actorUserId, command.requiredFeatures, {
|
|
45
51
|
tenantId: params.tenantId ?? null,
|
|
46
52
|
organizationId: params.organizationId ?? null
|
|
47
53
|
});
|
|
48
54
|
if (!authorized) {
|
|
49
|
-
throw new
|
|
55
|
+
throw new SchedulerCommandAuthorizationError("Scheduled command actor is not authorized");
|
|
50
56
|
}
|
|
51
57
|
return command;
|
|
52
58
|
}
|
|
@@ -63,6 +69,7 @@ registerSchedulerSafeCommands([
|
|
|
63
69
|
}
|
|
64
70
|
]);
|
|
65
71
|
export {
|
|
72
|
+
SchedulerCommandAuthorizationError,
|
|
66
73
|
assertSchedulerSafeCommandAuthorized,
|
|
67
74
|
clearSchedulerSafeCommandsForTests,
|
|
68
75
|
getSchedulerSafeCommand,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/scheduler/lib/scheduler-safe-commands.ts"],
|
|
4
|
-
"sourcesContent": ["export type SchedulerSafeCommandDefinition = {\n commandId: string\n requiredFeatures: readonly [string, ...string[]]\n}\n\nexport type SchedulerCommandRbacService = {\n userHasAllFeatures?: (\n userId: string,\n required: readonly string[],\n scope: { tenantId: string | null; organizationId: string | null }\n ) => Promise<boolean>\n}\n\nconst schedulerSafeCommands = new Map<string, SchedulerSafeCommandDefinition>()\n\nfunction normalizeCommandId(commandId: unknown): string | null {\n if (typeof commandId !== 'string') return null\n const trimmed = commandId.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\nfunction normalizeFeatures(features: readonly string[]): [string, ...string[]] | null {\n const normalized = features\n .map((feature) => feature.trim())\n .filter((feature) => feature.length > 0)\n return normalized.length > 0 ? (normalized as [string, ...string[]]) : null\n}\n\nexport function registerSchedulerSafeCommands(commands: readonly SchedulerSafeCommandDefinition[]): void {\n for (const command of commands) {\n const commandId = normalizeCommandId(command.commandId)\n const requiredFeatures = normalizeFeatures(command.requiredFeatures)\n if (!commandId || !requiredFeatures) {\n throw new Error('[internal] Scheduler-safe commands require a commandId and requiredFeatures')\n }\n schedulerSafeCommands.set(commandId, { commandId, requiredFeatures })\n }\n}\n\nexport function getSchedulerSafeCommand(commandId: unknown): SchedulerSafeCommandDefinition | null {\n const normalized = normalizeCommandId(commandId)\n if (!normalized) return null\n return schedulerSafeCommands.get(normalized) ?? null\n}\n\nexport function isSchedulerSafeCommandId(commandId: unknown): boolean {\n return getSchedulerSafeCommand(commandId) !== null\n}\n\nexport function listSchedulerSafeCommands(): SchedulerSafeCommandDefinition[] {\n return Array.from(schedulerSafeCommands.values()).sort((a, b) => a.commandId.localeCompare(b.commandId))\n}\n\nexport async function assertSchedulerSafeCommandAuthorized(params: {\n commandId: unknown\n actorUserId: string | null | undefined\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n rbacService: SchedulerCommandRbacService\n}): Promise<SchedulerSafeCommandDefinition> {\n const command = getSchedulerSafeCommand(params.commandId)\n if (!command) {\n throw new
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["export type SchedulerSafeCommandDefinition = {\n commandId: string\n requiredFeatures: readonly [string, ...string[]]\n}\n\nexport type SchedulerCommandRbacService = {\n userHasAllFeatures?: (\n userId: string,\n required: readonly string[],\n scope: { tenantId: string | null; organizationId: string | null }\n ) => Promise<boolean>\n}\n\n/**\n * A scheduled command run was refused, and no retry can change that.\n *\n * Every rejection `assertSchedulerSafeCommandAuthorized` raises is a decision\n * about the actor and the command, not a transient failure: the command is off\n * the allowlist, there is no actor to authorize, the RBAC service is missing, or\n * the actor lacks the required features. A `userHasAllFeatures` implementation\n * whose store is down throws straight through instead, so callers can tell a\n * refusal from an outage.\n *\n * Branch on this type, never on the message text: the wording is diagnostic and\n * changes with the code \u2014 this release alone renamed `creator` to `actor` in two\n * of the four \u2014 which is the fragility this type exists to remove.\n */\nexport class SchedulerCommandAuthorizationError extends Error {\n constructor(message: string) {\n super(message)\n this.name = 'SchedulerCommandAuthorizationError'\n }\n}\n\nconst schedulerSafeCommands = new Map<string, SchedulerSafeCommandDefinition>()\n\nfunction normalizeCommandId(commandId: unknown): string | null {\n if (typeof commandId !== 'string') return null\n const trimmed = commandId.trim()\n return trimmed.length > 0 ? trimmed : null\n}\n\nfunction normalizeFeatures(features: readonly string[]): [string, ...string[]] | null {\n const normalized = features\n .map((feature) => feature.trim())\n .filter((feature) => feature.length > 0)\n return normalized.length > 0 ? (normalized as [string, ...string[]]) : null\n}\n\nexport function registerSchedulerSafeCommands(commands: readonly SchedulerSafeCommandDefinition[]): void {\n for (const command of commands) {\n const commandId = normalizeCommandId(command.commandId)\n const requiredFeatures = normalizeFeatures(command.requiredFeatures)\n if (!commandId || !requiredFeatures) {\n throw new Error('[internal] Scheduler-safe commands require a commandId and requiredFeatures')\n }\n schedulerSafeCommands.set(commandId, { commandId, requiredFeatures })\n }\n}\n\nexport function getSchedulerSafeCommand(commandId: unknown): SchedulerSafeCommandDefinition | null {\n const normalized = normalizeCommandId(commandId)\n if (!normalized) return null\n return schedulerSafeCommands.get(normalized) ?? null\n}\n\nexport function isSchedulerSafeCommandId(commandId: unknown): boolean {\n return getSchedulerSafeCommand(commandId) !== null\n}\n\nexport function listSchedulerSafeCommands(): SchedulerSafeCommandDefinition[] {\n return Array.from(schedulerSafeCommands.values()).sort((a, b) => a.commandId.localeCompare(b.commandId))\n}\n\nexport async function assertSchedulerSafeCommandAuthorized(params: {\n commandId: unknown\n actorUserId: string | null | undefined\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n rbacService: SchedulerCommandRbacService\n}): Promise<SchedulerSafeCommandDefinition> {\n const command = getSchedulerSafeCommand(params.commandId)\n if (!command) {\n throw new SchedulerCommandAuthorizationError('Scheduled command is not allowed')\n }\n\n const actorUserId = normalizeCommandId(params.actorUserId)\n if (!actorUserId) {\n throw new SchedulerCommandAuthorizationError('Scheduled command requires an authenticated actor')\n }\n\n if (typeof params.rbacService.userHasAllFeatures !== 'function') {\n throw new SchedulerCommandAuthorizationError('Scheduled command authorization is unavailable')\n }\n\n const authorized = await params.rbacService.userHasAllFeatures(actorUserId, command.requiredFeatures, {\n tenantId: params.tenantId ?? null,\n organizationId: params.organizationId ?? null,\n })\n if (!authorized) {\n throw new SchedulerCommandAuthorizationError('Scheduled command actor is not authorized')\n }\n\n return command\n}\n\nexport function clearSchedulerSafeCommandsForTests(): void {\n if (process.env.NODE_ENV !== 'test') {\n throw new Error('[internal] clearSchedulerSafeCommandsForTests is test-only')\n }\n schedulerSafeCommands.clear()\n}\n\nregisterSchedulerSafeCommands([\n {\n commandId: 'scheduler.test.echo',\n requiredFeatures: ['scheduler.jobs.manage'],\n },\n])\n"],
|
|
5
|
+
"mappings": "AA2BO,MAAM,2CAA2C,MAAM;AAAA,EAC5D,YAAY,SAAiB;AAC3B,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AACF;AAEA,MAAM,wBAAwB,oBAAI,IAA4C;AAE9E,SAAS,mBAAmB,WAAmC;AAC7D,MAAI,OAAO,cAAc,SAAU,QAAO;AAC1C,QAAM,UAAU,UAAU,KAAK;AAC/B,SAAO,QAAQ,SAAS,IAAI,UAAU;AACxC;AAEA,SAAS,kBAAkB,UAA2D;AACpF,QAAM,aAAa,SAChB,IAAI,CAAC,YAAY,QAAQ,KAAK,CAAC,EAC/B,OAAO,CAAC,YAAY,QAAQ,SAAS,CAAC;AACzC,SAAO,WAAW,SAAS,IAAK,aAAuC;AACzE;AAEO,SAAS,8BAA8B,UAA2D;AACvG,aAAW,WAAW,UAAU;AAC9B,UAAM,YAAY,mBAAmB,QAAQ,SAAS;AACtD,UAAM,mBAAmB,kBAAkB,QAAQ,gBAAgB;AACnE,QAAI,CAAC,aAAa,CAAC,kBAAkB;AACnC,YAAM,IAAI,MAAM,6EAA6E;AAAA,IAC/F;AACA,0BAAsB,IAAI,WAAW,EAAE,WAAW,iBAAiB,CAAC;AAAA,EACtE;AACF;AAEO,SAAS,wBAAwB,WAA2D;AACjG,QAAM,aAAa,mBAAmB,SAAS;AAC/C,MAAI,CAAC,WAAY,QAAO;AACxB,SAAO,sBAAsB,IAAI,UAAU,KAAK;AAClD;AAEO,SAAS,yBAAyB,WAA6B;AACpE,SAAO,wBAAwB,SAAS,MAAM;AAChD;AAEO,SAAS,4BAA8D;AAC5E,SAAO,MAAM,KAAK,sBAAsB,OAAO,CAAC,EAAE,KAAK,CAAC,GAAG,MAAM,EAAE,UAAU,cAAc,EAAE,SAAS,CAAC;AACzG;AAEA,eAAsB,qCAAqC,QAMf;AAC1C,QAAM,UAAU,wBAAwB,OAAO,SAAS;AACxD,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI,mCAAmC,kCAAkC;AAAA,EACjF;AAEA,QAAM,cAAc,mBAAmB,OAAO,WAAW;AACzD,MAAI,CAAC,aAAa;AAChB,UAAM,IAAI,mCAAmC,mDAAmD;AAAA,EAClG;AAEA,MAAI,OAAO,OAAO,YAAY,uBAAuB,YAAY;AAC/D,UAAM,IAAI,mCAAmC,gDAAgD;AAAA,EAC/F;AAEA,QAAM,aAAa,MAAM,OAAO,YAAY,mBAAmB,aAAa,QAAQ,kBAAkB;AAAA,IACpG,UAAU,OAAO,YAAY;AAAA,IAC7B,gBAAgB,OAAO,kBAAkB;AAAA,EAC3C,CAAC;AACD,MAAI,CAAC,YAAY;AACf,UAAM,IAAI,mCAAmC,2CAA2C;AAAA,EAC1F;AAEA,SAAO;AACT;AAEO,SAAS,qCAA2C;AACzD,MAAI,QAAQ,IAAI,aAAa,QAAQ;AACnC,UAAM,IAAI,MAAM,4DAA4D;AAAA,EAC9E;AACA,wBAAsB,MAAM;AAC9B;AAEA,8BAA8B;AAAA,EAC5B;AAAA,IACE,WAAW;AAAA,IACX,kBAAkB,CAAC,uBAAuB;AAAA,EAC5C;AACF,CAAC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -5,7 +5,7 @@ import { recalculateNextRun } from "../lib/nextRunCalculator.js";
|
|
|
5
5
|
import { emitSchedulerEvent } from "../events.js";
|
|
6
6
|
import { getGlobalEventBus } from "@open-mercato/shared/modules/events";
|
|
7
7
|
import { assertSchedulerSafeCommandAuthorized } from "../lib/scheduler-safe-commands.js";
|
|
8
|
-
import { buildScheduledCommandContext } from "../lib/commandContext.js";
|
|
8
|
+
import { buildScheduledCommandContext, resolveScheduledCommandActorUserId } from "../lib/commandContext.js";
|
|
9
9
|
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from "../lib/queueTargetPayload.js";
|
|
10
10
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
11
11
|
import {
|
|
@@ -213,7 +213,7 @@ class LocalSchedulerService {
|
|
|
213
213
|
throw new Error("Target command is required for command target type");
|
|
214
214
|
}
|
|
215
215
|
const commandBus = new CommandBus();
|
|
216
|
-
const actorUserId =
|
|
216
|
+
const actorUserId = resolveScheduledCommandActorUserId(schedule);
|
|
217
217
|
await assertSchedulerSafeCommandAuthorized({
|
|
218
218
|
commandId: schedule.targetCommand,
|
|
219
219
|
actorUserId,
|
|
@@ -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'\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": "AAEA,SAAS,kBAAkB;AAE3B,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,0BAA0B;AACnC,SAAS,yBAAyB;AAClC,SAAS,4CAA4C;AACrD,SAAS,
|
|
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, resolveScheduledCommandActorUserId } 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 = resolveScheduledCommandActorUserId(schedule)\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": "AAEA,SAAS,kBAAkB;AAE3B,SAAS,oBAAoB;AAC7B,SAAS,yBAAyB;AAClC,SAAS,0BAA0B;AACnC,SAAS,0BAA0B;AACnC,SAAS,yBAAyB;AAClC,SAAS,4CAA4C;AACrD,SAAS,8BAA8B,0CAA0C;AACjF,SAAS,yBAAyB,oCAAoC;AACtE,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,MAAM,SAAS,aAAa,WAAW,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;AAoC9D,MAAM,sBAAsB;AAAA,EAKjC,YACU,IACA,cACA,aACA,SAA+B,EAAE,gBAAgB,IAAM,GAC/D;AAJQ;AACA;AACA;AACA;AARV,SAAQ,YAAY;AAUlB,SAAK,eAAe,IAAI,kBAAkB,EAAE;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,QAAuB;AAC3B,QAAI,KAAK,WAAW;AAClB,aAAO,KAAK,gCAAgC;AAC5C;AAAA,IACF;AAEA,SAAK,YAAY;AACjB,WAAO,KAAK,2BAA2B,EAAE,gBAAgB,KAAK,OAAO,eAAe,CAAC;AACrF,WAAO;AAAA,MACL;AAAA,IACF;AAGA,UAAM,KAAK,KAAK;AAGhB,SAAK,YAAY,YAAY,MAAM;AACjC,WAAK,KAAK,EAAE,MAAM,CAAC,UAAU;AAC3B,eAAO,MAAM,cAAc,EAAE,KAAK,MAAM,CAAC;AAAA,MAC3C,CAAC;AAAA,IACH,GAAG,KAAK,OAAO,cAAc;AAE7B,WAAO,KAAK,wBAAwB;AAAA,EACtC;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,OAAsB;AAC1B,WAAO,KAAK,yBAAyB;AACrC,SAAK,YAAY;AAEjB,QAAI,KAAK,WAAW;AAClB,oBAAc,KAAK,SAAS;AAC5B,WAAK,YAAY;AAAA,IACnB;AAEA,WAAO,KAAK,wBAAwB;AAAA,EACtC;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,OAAsB;AAClC,QAAI,CAAC,KAAK,WAAW;AACnB;AAAA,IACF;AAEA,UAAM,KAAK,KAAK,GAAG,EAAE,KAAK;AAE1B,QAAI;AAEF,YAAM,eAAe,MAAM,GAAG,KAAK,cAAc;AAAA,QAC/C,WAAW;AAAA,QACX,WAAW;AAAA,QACX,WAAW,EAAE,MAAM,oBAAI,KAAK,EAAE;AAAA,MAChC,GAAG;AAAA,QACD,OAAO;AAAA,QACP,SAAS,EAAE,WAAW,MAAM;AAAA,MAC9B,CAAC;AAED,UAAI,aAAa,WAAW,GAAG;AAC7B,eAAO,MAAM,kBAAkB;AAC/B;AAAA,MACF;AAEA,aAAO,KAAK,uBAAuB,EAAE,OAAO,aAAa,OAAO,CAAC;AAGjE,iBAAW,YAAY,cAAc;AACnC,cAAM,KAAK,gBAAgB,QAAQ;AAAA,MACrC;AAAA,IACF,SAAS,OAAgB;AACvB,aAAO,MAAM,eAAe,EAAE,KAAK,MAAM,CAAC;AAAA,IAC5C;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,gBAAgB,UAAuC;AACnE,UAAM,UAAU,YAAY,SAAS,EAAE;AACvC,UAAM,iBAAiB,OAAO,MAAM,EAAE,YAAY,SAAS,IAAI,cAAc,SAAS,KAAK,CAAC;AAE5F,UAAM,EAAE,SAAS,IAAI,MAAM,KAAK,aAAa,YAAY,SAAS,YAAY;AAC5E,qBAAe,KAAK,oBAAoB;AAGxC,YAAM,mBAAmB,yBAAyB;AAAA,QAChD,IAAI,SAAS;AAAA,QACb,UAAU,SAAS;AAAA,QACnB,gBAAgB,SAAS;AAAA,QACzB,cAAc,SAAS;AAAA,QACvB,WAAW,SAAS;AAAA,QACpB,aAAa;AAAA,QACb,WAAW,oBAAI,KAAK;AAAA,MACtB,CAAC;AAED,UAAI;AAEF,YAAI,SAAS,gBAAgB;AAC3B,gBAAM,aAAa,MAAM,KAAK,aAAa,QAAQ;AAEnD,cAAI,CAAC,YAAY;AACf,2BAAe,KAAK,8CAA8C,EAAE,gBAAgB,SAAS,eAAe,CAAC;AAE7G,kBAAM,mBAAmB,yBAAyB;AAAA,cAChD,IAAI,SAAS;AAAA,cACb,UAAU,SAAS;AAAA,cACnB,gBAAgB,SAAS;AAAA,cACzB,cAAc,SAAS;AAAA,cACvB,WAAW,SAAS;AAAA,cACpB,QAAQ,6BAA6B,SAAS,cAAc;AAAA,cAC5D,WAAW,oBAAI,KAAK;AAAA,YACtB,CAAC;AAGD,kBAAM,KAAK,cAAc,QAAQ;AACjC;AAAA,UACF;AAAA,QACF;AAGA,YAAI,SAAS,eAAe,SAAS;AACnC,gBAAM,KAAK,mBAAmB,QAAQ;AAAA,QACxC,WAAW,SAAS,eAAe,WAAW;AAC5C,gBAAM,KAAK,qBAAqB,QAAQ;AAAA,QAC1C,OAAO;AACL,gBAAM,IAAI,MAAM,wBAAwB,SAAS,UAAU,EAAE;AAAA,QAC/D;AAGA,cAAM,KAAK,KAAK,GAAG,EAAE,KAAK;AAC1B,cAAM,gBAAgB,MAAM,GAAG,QAAQ,cAAc,EAAE,IAAI,SAAS,GAAG,CAAC;AAExE,YAAI,eAAe;AACjB,wBAAc,YAAY,oBAAI,KAAK;AAGnC,gBAAM,UAAU;AAAA,YACd,cAAc;AAAA,YACd,cAAc;AAAA,YACd,cAAc;AAAA,UAChB;AAEA,cAAI,SAAS;AACX,0BAAc,YAAY;AAAA,UAC5B;AAEA,gBAAM,GAAG,MAAM;AAAA,QACjB;AAEA,uBAAe,KAAK,oBAAoB;AAGxC,cAAM,mBAAmB,2BAA2B;AAAA,UAClD,IAAI,SAAS;AAAA,UACb,UAAU,SAAS;AAAA,UACnB,gBAAgB,SAAS;AAAA,UACzB,cAAc,SAAS;AAAA,UACvB,WAAW,SAAS;AAAA,UACpB,aAAa,oBAAI,KAAK;AAAA,QACxB,CAAC;AAAA,MACH,SAAS,OAAgB;AACvB,uBAAe,MAAM,mBAAmB,EAAE,KAAK,MAAM,CAAC;AAGtD,cAAM,mBAAmB,wBAAwB;AAAA,UAC/C,IAAI,SAAS;AAAA,UACb,UAAU,SAAS;AAAA,UACnB,gBAAgB,SAAS;AAAA,UACzB,cAAc,SAAS;AAAA,UACvB,WAAW,SAAS;AAAA,UACpB,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAAA,UAC5D,UAAU,oBAAI,KAAK;AAAA,QACrB,CAAC;AAGD,cAAM,KAAK,cAAc,QAAQ;AAAA,MACnC;AAAA,IACF,CAAC;AAED,QAAI,CAAC,UAAU;AACb,qBAAe,MAAM,mCAAmC;AACxD;AAAA,IACF;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,mBAAmB,UAAuC;AACtE,QAAI,CAAC,SAAS,aAAa;AACzB,YAAM,IAAI,MAAM,gDAAgD;AAAA,IAClE;AAKA,QAAI,CAAC,+BAA+B,QAAQ,GAAG;AAC7C,YAAM,IAAI,MAAM,8CAA8C,SAAS,WAAW,EAAE;AAAA,IACtF;AAEA,UAAM,eAAe,+BAA+B,SAAS,aAAa,SAAS,aAAa;AAChG,QAAI,cAAc;AAChB,YAAM,IAAI,MAAM,uCAAuC,SAAS,WAAW,KAAK,YAAY,EAAE;AAAA,IAChG;AAEA,QAAI,SAAS,cAAc,UAAU;AACnC,iBAAW,WAAW,kCAAkC,SAAS,WAAW,GAAG;AAC7E,cAAM,aAAa,MAAM,KAAK,aAAa,EAAE,GAAG,UAAU,gBAAgB,QAAQ,CAAiB;AACnG,YAAI,CAAC,YAAY;AACf,gBAAM,IAAI,MAAM,oDAAoD,SAAS,WAAW,KAAK,OAAO,EAAE;AAAA,QACxG;AAAA,MACF;AAAA,IACF;AAEA,UAAM,QAAQ,KAAK,aAAa,SAAS,WAAW;AAOpD,UAAM,mBAAmB,+BAA+B,SAAS,eAAe,QAAQ;AAExF,UAAM,MAAM,QAAQ;AAAA,MAClB,GAAG,wBAAwB;AAAA,QACzB,eAAe;AAAA,QACf,UAAU,SAAS;AAAA,QACnB,gBAAgB,SAAS;AAAA,QACzB,gBAAgB,6BAA6B,SAAS,IAAI,KAAK,IAAI,CAAC;AAAA,MACtE,CAAC;AAAA,MACD,YAAY;AAAA,IACd,CAAC;AAED,WAAO,KAAK,gCAAgC,EAAE,YAAY,SAAS,IAAI,aAAa,SAAS,YAAY,CAAC;AAAA,EAC5G;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,qBAAqB,UAAuC;AACxE,QAAI,CAAC,SAAS,eAAe;AAC3B,YAAM,IAAI,MAAM,oDAAoD;AAAA,IACtE;AAEA,UAAM,aAAa,IAAI,WAAW;AAClC,UAAM,cAAc,mCAAmC,QAAQ;AAC/D,UAAM,qCAAqC;AAAA,MACzC,WAAW,SAAS;AAAA,MACpB;AAAA,MACA,UAAU,SAAS;AAAA,MACnB,gBAAgB,SAAS;AAAA,MACzB,aAAa,KAAK;AAAA,IACpB,CAAC;AAED,UAAM,eAAe;AAAA,MACnB,GAAK,SAAS,iBAA6C,CAAC;AAAA,MAC5D,UAAU,SAAS;AAAA,MACnB,gBAAgB,SAAS;AAAA,IAC3B;AAGA,UAAM,aAAa;AAAA,MACjB;AAAA,MACA;AAAA,QACE,SAAS,CAAC,SAAiB;AAEzB,cAAI,SAAS,KAAM,QAAO,KAAK,GAAG;AAClC,cAAI,SAAS,WAAY,QAAO,kBAAkB;AAClD,cAAI,SAAS,cAAe,QAAO,KAAK;AACxC,gBAAM,IAAI,MAAM,+CAA+C,IAAI,EAAE;AAAA,QACvE;AAAA,MACF;AAAA,IACF;AAEA,UAAM,WAAW,QAAQ,SAAS,eAAe;AAAA,MAC/C,OAAO;AAAA,MACP,KAAK;AAAA,IACP,CAAC;AAED,WAAO,KAAK,oBAAoB,EAAE,YAAY,SAAS,IAAI,eAAe,SAAS,cAAc,CAAC;AAAA,EACpG;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,aAAa,UAA0C;AACnE,QAAI,CAAC,SAAS,gBAAgB;AAC5B,aAAO;AAAA,IACT;AAEA,QAAI;AAEF,UAAI,SAAS,cAAc,UAAU;AACnC,eAAO;AAAA,MACT;AAKA,YAAM,aAAa,MAAM,KAAK,YAAY;AAAA,QACxC,SAAS;AAAA,QACT,SAAS;AAAA,QACT;AAAA,UACE,gBAAgB,SAAS;AAAA,QAC3B;AAAA,MACF;AAEA,aAAO;AAAA,IACT,SAAS,OAAgB;AACvB,aAAO,MAAM,wBAAwB,EAAE,YAAY,SAAS,IAAI,KAAK,MAAM,CAAC;AAC5E,aAAO;AAAA,IACT;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,cAAc,UAAuC;AACjE,UAAM,KAAK,KAAK,GAAG,EAAE,KAAK;AAC1B,UAAM,gBAAgB,MAAM,GAAG,QAAQ,cAAc,EAAE,IAAI,SAAS,GAAG,CAAC;AAExE,QAAI,eAAe;AACjB,YAAM,UAAU;AAAA,QACd,cAAc;AAAA,QACd,cAAc;AAAA,QACd,cAAc;AAAA,MAChB;AAEA,UAAI,SAAS;AACX,sBAAc,YAAY;AAC1B,cAAM,GAAG,MAAM;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -3,8 +3,11 @@ import { getRedisUrlOrThrow } from "@open-mercato/shared/lib/redis/connection";
|
|
|
3
3
|
import { ScheduledJob } from "../data/entities.js";
|
|
4
4
|
import { CommandBus } from "@open-mercato/shared/lib/commands";
|
|
5
5
|
import { emitSchedulerEvent } from "../events.js";
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
assertSchedulerSafeCommandAuthorized,
|
|
8
|
+
SchedulerCommandAuthorizationError
|
|
9
|
+
} from "../lib/scheduler-safe-commands.js";
|
|
10
|
+
import { buildScheduledCommandContext, resolveScheduledCommandActorUserId } from "../lib/commandContext.js";
|
|
8
11
|
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from "../lib/queueTargetPayload.js";
|
|
9
12
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
10
13
|
import {
|
|
@@ -183,20 +186,44 @@ async function executeScheduleWorker(job, ctx) {
|
|
|
183
186
|
});
|
|
184
187
|
} else if (schedule.targetType === "command" && schedule.targetCommand) {
|
|
185
188
|
const commandBus = new CommandBus();
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
189
|
+
const triggeredByUserId = payload.triggerType === "manual" ? payload.triggeredByUserId ?? null : null;
|
|
190
|
+
const actorUserId = resolveScheduledCommandActorUserId(schedule, { triggeredByUserId });
|
|
191
|
+
try {
|
|
192
|
+
await assertSchedulerSafeCommandAuthorized({
|
|
193
|
+
commandId: schedule.targetCommand,
|
|
194
|
+
actorUserId,
|
|
195
|
+
tenantId: schedule.tenantId,
|
|
196
|
+
organizationId: schedule.organizationId,
|
|
197
|
+
rbacService
|
|
198
|
+
});
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (!(error instanceof SchedulerCommandAuthorizationError)) throw error;
|
|
201
|
+
const reason = error.message;
|
|
202
|
+
await emitSchedulerEvent("scheduler.job.failed", {
|
|
203
|
+
id: schedule.id,
|
|
204
|
+
tenantId: schedule.tenantId,
|
|
205
|
+
organizationId: schedule.organizationId,
|
|
206
|
+
scheduleName: schedule.name,
|
|
207
|
+
scopeType: schedule.scopeType,
|
|
208
|
+
error: reason,
|
|
209
|
+
failedAt: /* @__PURE__ */ new Date()
|
|
210
|
+
});
|
|
211
|
+
logger.warn("Schedule refused: scheduled command is not authorized", {
|
|
212
|
+
scheduleId: schedule.id,
|
|
213
|
+
commandId: schedule.targetCommand,
|
|
214
|
+
triggerType: payload.triggerType ?? "scheduled",
|
|
215
|
+
reason
|
|
216
|
+
});
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
194
219
|
const commandInput = {
|
|
195
220
|
...schedule.targetPayload || {},
|
|
196
221
|
tenantId: schedule.tenantId,
|
|
197
222
|
organizationId: schedule.organizationId
|
|
198
223
|
};
|
|
199
|
-
const commandCtx = buildScheduledCommandContext(schedule, ctx
|
|
224
|
+
const commandCtx = buildScheduledCommandContext(schedule, ctx, {
|
|
225
|
+
triggeredByUserId
|
|
226
|
+
});
|
|
200
227
|
const commandResult = await commandBus.execute(schedule.targetCommand, {
|
|
201
228
|
input: commandInput,
|
|
202
229
|
ctx: commandCtx
|
|
@@ -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'\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": "AACA,SAAS,mBAAmB;AAC5B,SAAS,0BAA0B;AAEnC,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAE3B,SAAS,0BAA0B;AACnC,
|
|
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 {\n assertSchedulerSafeCommandAuthorized,\n SchedulerCommandAuthorizationError,\n} from '../lib/scheduler-safe-commands.js'\nimport { buildScheduledCommandContext, resolveScheduledCommandActorUserId } 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 // A manual run acts as whoever pressed the button; an unattended run keeps\n // acting as the schedule's creator. The gate below and the context built\n // afterwards must agree on that identity, so it is resolved once, by the same\n // helper the context itself uses.\n const triggeredByUserId = payload.triggerType === 'manual' ? payload.triggeredByUserId ?? null : null\n const actorUserId = resolveScheduledCommandActorUserId(schedule, { triggeredByUserId })\n try {\n await assertSchedulerSafeCommandAuthorized({\n commandId: schedule.targetCommand,\n actorUserId,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n rbacService,\n })\n } catch (error) {\n // A refusal is permanent, so it must not be thrown: throwing hands BullMQ an\n // error it cannot tell from an outage and it retries a decision that no\n // attempt can change, while the run leaves no trace beyond a log line. The\n // queue branch above already ends its equivalent conditions with an event and\n // a return; this does the same, and records the refusal as a failure rather\n // than a skip because a schedule whose actor may no longer run its command is\n // a state an operator has to act on.\n //\n // Only the authorization decision is swallowed. Anything else \u2014 an RBAC\n // lookup that fails because its store is down \u2014 is genuinely transient and\n // still propagates so BullMQ retries it.\n if (!(error instanceof SchedulerCommandAuthorizationError)) throw error\n\n const reason = error.message\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: reason,\n failedAt: new Date(),\n })\n\n logger.warn('Schedule refused: scheduled command is not authorized', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n triggerType: payload.triggerType ?? 'scheduled',\n reason,\n })\n return\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 // The gate authorized `actorUserId`; this must execute as that same identity.\n const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer, {\n triggeredByUserId,\n })\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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/scheduler",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7121.1.734c263bda",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Database-managed scheduled jobs with admin UI",
|
|
6
6
|
"type": "module",
|
|
@@ -59,13 +59,13 @@
|
|
|
59
59
|
"./*/*/*/*/*/*.json": "./src/*/*/*/*/*/*.json"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@open-mercato/events": "0.7.1-develop.
|
|
63
|
-
"@open-mercato/queue": "0.7.1-develop.
|
|
62
|
+
"@open-mercato/events": "0.7.1-develop.7121.1.734c263bda",
|
|
63
|
+
"@open-mercato/queue": "0.7.1-develop.7121.1.734c263bda",
|
|
64
64
|
"cron-parser": "^5.6.2"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"@mikro-orm/core": "^7.0.14",
|
|
68
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
68
|
+
"@open-mercato/shared": "0.7.1-develop.7121.1.734c263bda",
|
|
69
69
|
"bullmq": "^5.0.0 || ^6.0.0",
|
|
70
70
|
"ioredis": "^5.0.0 || ^6.0.0",
|
|
71
71
|
"zod": ">=3.23.0"
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
82
|
+
"@open-mercato/shared": "0.7.1-develop.7121.1.734c263bda",
|
|
83
83
|
"@types/jest": "^30.0.0",
|
|
84
84
|
"@types/node": "^26.1.2",
|
|
85
85
|
"jest": "^30.4.2",
|