@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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/scheduler/__integration__/TC-SCHED-009.spec.js +61 -0
  3. package/dist/modules/scheduler/__integration__/TC-SCHED-009.spec.js.map +7 -0
  4. package/dist/modules/scheduler/api/trigger/route.js +37 -37
  5. package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
  6. package/dist/modules/scheduler/commands/jobs.js +3 -0
  7. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  8. package/dist/modules/scheduler/commands/trigger.js +146 -0
  9. package/dist/modules/scheduler/commands/trigger.js.map +7 -0
  10. package/dist/modules/scheduler/i18n/de.json +1 -0
  11. package/dist/modules/scheduler/i18n/en.json +1 -0
  12. package/dist/modules/scheduler/i18n/es.json +1 -0
  13. package/dist/modules/scheduler/i18n/ko.json +1 -0
  14. package/dist/modules/scheduler/i18n/pl.json +1 -0
  15. package/dist/modules/scheduler/lib/commandContext.js +15 -5
  16. package/dist/modules/scheduler/lib/commandContext.js.map +2 -2
  17. package/dist/modules/scheduler/lib/scheduler-safe-commands.js +11 -4
  18. package/dist/modules/scheduler/lib/scheduler-safe-commands.js.map +2 -2
  19. package/dist/modules/scheduler/services/localSchedulerService.js +2 -2
  20. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  21. package/dist/modules/scheduler/workers/execute-schedule.worker.js +38 -11
  22. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  23. package/package.json +5 -5
  24. package/src/modules/scheduler/__integration__/TC-SCHED-009.spec.ts +98 -0
  25. package/src/modules/scheduler/api/trigger/__tests__/route.test.ts +154 -0
  26. package/src/modules/scheduler/api/trigger/route.ts +63 -47
  27. package/src/modules/scheduler/commands/__tests__/trigger.test.ts +411 -0
  28. package/src/modules/scheduler/commands/jobs.ts +1 -1
  29. package/src/modules/scheduler/commands/trigger.ts +258 -0
  30. package/src/modules/scheduler/i18n/de.json +1 -0
  31. package/src/modules/scheduler/i18n/en.json +1 -0
  32. package/src/modules/scheduler/i18n/es.json +1 -0
  33. package/src/modules/scheduler/i18n/ko.json +1 -0
  34. package/src/modules/scheduler/i18n/pl.json +1 -0
  35. package/src/modules/scheduler/lib/__tests__/commandContext.test.ts +83 -1
  36. package/src/modules/scheduler/lib/commandContext.ts +39 -3
  37. package/src/modules/scheduler/lib/scheduler-safe-commands.ts +25 -4
  38. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +1 -1
  39. package/src/modules/scheduler/services/localSchedulerService.ts +2 -2
  40. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +132 -1
  41. package/src/modules/scheduler/workers/execute-schedule.worker.ts +58 -13
@@ -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,4 +1,6 @@
1
- import { buildScheduledCommandContext } from '../commandContext'
1
+ import { buildScheduledCommandContext, resolveScheduledCommandActorUserId } from '../commandContext'
2
+
3
+ const container = {} as Parameters<typeof buildScheduledCommandContext>[1]
2
4
 
3
5
  describe('buildScheduledCommandContext', () => {
4
6
  it('binds organization-scoped scheduled commands to the schedule tenant and organization', () => {
@@ -57,4 +59,84 @@ describe('buildScheduledCommandContext', () => {
57
59
  })
58
60
  expect(ctx.organizationIds).toBeNull()
59
61
  })
62
+
63
+ it('acts as the triggering user when a manual run supplies one', () => {
64
+ const ctx = buildScheduledCommandContext(
65
+ {
66
+ id: 'schedule-3',
67
+ tenantId: 'tenant-a',
68
+ organizationId: 'org-a',
69
+ scopeType: 'organization',
70
+ createdByUserId: 'user-a',
71
+ },
72
+ container,
73
+ { triggeredByUserId: 'user-b' },
74
+ )
75
+
76
+ expect(ctx.auth).toMatchObject({ sub: 'user-b', userId: 'user-b' })
77
+ // Only the acting identity changes: the run stays bound to the schedule's scope.
78
+ expect(ctx.auth).toMatchObject({ tenantId: 'tenant-a', orgId: 'org-a', isSuperAdmin: false })
79
+ expect(ctx.selectedOrganizationId).toBe('org-a')
80
+ })
81
+
82
+ it('falls back to the creator when an unattended run supplies no triggering user', () => {
83
+ for (const options of [undefined, {}, { triggeredByUserId: null }, { triggeredByUserId: ' ' }]) {
84
+ const ctx = buildScheduledCommandContext(
85
+ {
86
+ id: 'schedule-4',
87
+ tenantId: 'tenant-a',
88
+ organizationId: null,
89
+ scopeType: 'tenant',
90
+ createdByUserId: 'user-a',
91
+ },
92
+ container,
93
+ options,
94
+ )
95
+
96
+ expect(ctx.auth).toMatchObject({ sub: 'user-a', userId: 'user-a' })
97
+ }
98
+ })
99
+
100
+ it('falls back to the system actor when neither a triggering user nor a creator is usable', () => {
101
+ const ctx = buildScheduledCommandContext(
102
+ {
103
+ id: 'schedule-5',
104
+ tenantId: 'tenant-a',
105
+ organizationId: null,
106
+ scopeType: 'tenant',
107
+ createdByUserId: null,
108
+ },
109
+ container,
110
+ { triggeredByUserId: null },
111
+ )
112
+
113
+ expect(ctx.auth).toMatchObject({ sub: '00000000-0000-0000-0000-000000000000' })
114
+ })
115
+ })
116
+
117
+ describe('resolveScheduledCommandActorUserId', () => {
118
+ it('prefers the triggering user over the creator', () => {
119
+ expect(
120
+ resolveScheduledCommandActorUserId({ createdByUserId: 'user-a' }, { triggeredByUserId: 'user-b' }),
121
+ ).toBe('user-b')
122
+ })
123
+
124
+ it('falls back to the creator', () => {
125
+ expect(resolveScheduledCommandActorUserId({ createdByUserId: 'user-a' })).toBe('user-a')
126
+ expect(resolveScheduledCommandActorUserId({ createdByUserId: 'user-a' }, { triggeredByUserId: null })).toBe('user-a')
127
+ })
128
+
129
+ it('trims and ignores blank ids', () => {
130
+ expect(resolveScheduledCommandActorUserId({ createdByUserId: ' user-a ' })).toBe('user-a')
131
+ expect(
132
+ resolveScheduledCommandActorUserId({ createdByUserId: 'user-a' }, { triggeredByUserId: ' ' }),
133
+ ).toBe('user-a')
134
+ })
135
+
136
+ it('returns null rather than the system actor when no real user is available', () => {
137
+ // The RBAC gate relies on this: it must reject an actor-less schedule with its
138
+ // own error instead of authorizing the all-zeros system id.
139
+ expect(resolveScheduledCommandActorUserId({ createdByUserId: null })).toBeNull()
140
+ expect(resolveScheduledCommandActorUserId({ createdByUserId: ' ' }, { triggeredByUserId: '' })).toBeNull()
141
+ })
60
142
  })
@@ -12,8 +12,43 @@ type ScheduledCommandContextSchedule = {
12
12
  createdByUserId?: string | null
13
13
  }
14
14
 
15
- function buildScheduledCommandAuth(schedule: ScheduledCommandContextSchedule): Exclude<AuthContext, null> {
16
- const actorId = schedule.createdByUserId || SCHEDULER_SYSTEM_ACTOR_ID
15
+ export type ScheduledCommandActorOptions = {
16
+ /**
17
+ * The user who manually triggered this run, when there is one. Unattended runs
18
+ * pass null/undefined and keep falling back to the schedule's creator.
19
+ */
20
+ triggeredByUserId?: string | null
21
+ }
22
+
23
+ /**
24
+ * Resolve the real user a scheduled run acts as: the person who triggered it,
25
+ * else the schedule's creator, else nobody.
26
+ *
27
+ * This is the single definition of that precedence, and it deliberately returns
28
+ * `null` rather than the system actor when neither id is usable. Callers that
29
+ * need an identity for the command context substitute the system actor
30
+ * themselves; the RBAC gate must instead keep rejecting an actor-less schedule
31
+ * with its own clear error, rather than running an RBAC lookup against the
32
+ * all-zeros system id.
33
+ */
34
+ export function resolveScheduledCommandActorUserId(
35
+ schedule: Pick<ScheduledCommandContextSchedule, 'createdByUserId'>,
36
+ options?: ScheduledCommandActorOptions,
37
+ ): string | null {
38
+ const candidates = [options?.triggeredByUserId, schedule.createdByUserId]
39
+ for (const candidate of candidates) {
40
+ if (typeof candidate !== 'string') continue
41
+ const trimmed = candidate.trim()
42
+ if (trimmed.length > 0) return trimmed
43
+ }
44
+ return null
45
+ }
46
+
47
+ function buildScheduledCommandAuth(
48
+ schedule: ScheduledCommandContextSchedule,
49
+ options?: ScheduledCommandActorOptions,
50
+ ): Exclude<AuthContext, null> {
51
+ const actorId = resolveScheduledCommandActorUserId(schedule, options) || SCHEDULER_SYSTEM_ACTOR_ID
17
52
  return {
18
53
  sub: actorId,
19
54
  userId: actorId,
@@ -26,6 +61,7 @@ function buildScheduledCommandAuth(schedule: ScheduledCommandContextSchedule): E
26
61
  export function buildScheduledCommandContext(
27
62
  schedule: ScheduledCommandContextSchedule,
28
63
  container: AppContainer,
64
+ options?: ScheduledCommandActorOptions,
29
65
  ): CommandRuntimeContext {
30
66
  const tenantId = schedule.tenantId ?? null
31
67
  const organizationId = schedule.organizationId ?? null
@@ -33,7 +69,7 @@ export function buildScheduledCommandContext(
33
69
 
34
70
  return {
35
71
  container,
36
- auth: buildScheduledCommandAuth(schedule),
72
+ auth: buildScheduledCommandAuth(schedule, options),
37
73
  organizationScope:
38
74
  schedule.scopeType === 'organization' && organizationId
39
75
  ? {
@@ -11,6 +11,27 @@ export type SchedulerCommandRbacService = {
11
11
  ) => Promise<boolean>
12
12
  }
13
13
 
14
+ /**
15
+ * A scheduled command run was refused, and no retry can change that.
16
+ *
17
+ * Every rejection `assertSchedulerSafeCommandAuthorized` raises is a decision
18
+ * about the actor and the command, not a transient failure: the command is off
19
+ * the allowlist, there is no actor to authorize, the RBAC service is missing, or
20
+ * the actor lacks the required features. A `userHasAllFeatures` implementation
21
+ * whose store is down throws straight through instead, so callers can tell a
22
+ * refusal from an outage.
23
+ *
24
+ * Branch on this type, never on the message text: the wording is diagnostic and
25
+ * changes with the code — this release alone renamed `creator` to `actor` in two
26
+ * of the four — which is the fragility this type exists to remove.
27
+ */
28
+ export class SchedulerCommandAuthorizationError extends Error {
29
+ constructor(message: string) {
30
+ super(message)
31
+ this.name = 'SchedulerCommandAuthorizationError'
32
+ }
33
+ }
34
+
14
35
  const schedulerSafeCommands = new Map<string, SchedulerSafeCommandDefinition>()
15
36
 
16
37
  function normalizeCommandId(commandId: unknown): string | null {
@@ -60,16 +81,16 @@ export async function assertSchedulerSafeCommandAuthorized(params: {
60
81
  }): Promise<SchedulerSafeCommandDefinition> {
61
82
  const command = getSchedulerSafeCommand(params.commandId)
62
83
  if (!command) {
63
- throw new Error('Scheduled command is not allowed')
84
+ throw new SchedulerCommandAuthorizationError('Scheduled command is not allowed')
64
85
  }
65
86
 
66
87
  const actorUserId = normalizeCommandId(params.actorUserId)
67
88
  if (!actorUserId) {
68
- throw new Error('Scheduled command requires an authenticated creator')
89
+ throw new SchedulerCommandAuthorizationError('Scheduled command requires an authenticated actor')
69
90
  }
70
91
 
71
92
  if (typeof params.rbacService.userHasAllFeatures !== 'function') {
72
- throw new Error('Scheduled command authorization is unavailable')
93
+ throw new SchedulerCommandAuthorizationError('Scheduled command authorization is unavailable')
73
94
  }
74
95
 
75
96
  const authorized = await params.rbacService.userHasAllFeatures(actorUserId, command.requiredFeatures, {
@@ -77,7 +98,7 @@ export async function assertSchedulerSafeCommandAuthorized(params: {
77
98
  organizationId: params.organizationId ?? null,
78
99
  })
79
100
  if (!authorized) {
80
- throw new Error('Scheduled command creator is not authorized')
101
+ throw new SchedulerCommandAuthorizationError('Scheduled command actor is not authorized')
81
102
  }
82
103
 
83
104
  return command
@@ -578,7 +578,7 @@ describe('LocalSchedulerService', () => {
578
578
  'scheduler.job.failed',
579
579
  expect.objectContaining({
580
580
  id: 'test-1',
581
- error: 'Scheduled command creator is not authorized',
581
+ error: 'Scheduled command actor is not authorized',
582
582
  })
583
583
  )
584
584
  consoleErrorSpy.mockRestore()
@@ -8,7 +8,7 @@ import { recalculateNextRun } from '../lib/nextRunCalculator'
8
8
  import { emitSchedulerEvent } from '../events.js'
9
9
  import { getGlobalEventBus } from '@open-mercato/shared/modules/events'
10
10
  import { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-commands.js'
11
- import { buildScheduledCommandContext } from '../lib/commandContext.js'
11
+ import { buildScheduledCommandContext, resolveScheduledCommandActorUserId } from '../lib/commandContext.js'
12
12
  import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
13
13
  import { createLogger } from '@open-mercato/shared/lib/logger'
14
14
  import {
@@ -319,7 +319,7 @@ export class LocalSchedulerService {
319
319
  }
320
320
 
321
321
  const commandBus = new CommandBus()
322
- const actorUserId = typeof schedule.createdByUserId === 'string' ? schedule.createdByUserId.trim() : ''
322
+ const actorUserId = resolveScheduledCommandActorUserId(schedule)
323
323
  await assertSchedulerSafeCommandAuthorized({
324
324
  commandId: schedule.targetCommand,
325
325
  actorUserId,
@@ -38,8 +38,10 @@ jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
38
38
  getRedisUrlOrThrow: jest.fn(() => 'redis://localhost:6379'),
39
39
  }))
40
40
 
41
+ const emitSchedulerEvent = jest.fn(async () => undefined)
42
+
41
43
  jest.mock('../../events', () => ({
42
- emitSchedulerEvent: jest.fn(async () => undefined),
44
+ emitSchedulerEvent: (...args: unknown[]) => emitSchedulerEvent(...(args as [])),
43
45
  }))
44
46
 
45
47
  const scheduleId = '11111111-1111-4111-8111-111111111111'
@@ -135,6 +137,135 @@ describe('executeScheduleWorker command scope', () => {
135
137
  })
136
138
  })
137
139
 
140
+ describe('executeScheduleWorker command actor attribution', () => {
141
+ function runWorker(
142
+ schedule: ScheduledJob,
143
+ payloadOverrides: Record<string, unknown> = {},
144
+ context = buildWorkerContext(schedule),
145
+ ) {
146
+ return {
147
+ context,
148
+ promise: executeScheduleWorker(
149
+ {
150
+ id: 'queued-job-1',
151
+ queue: 'scheduler-execution',
152
+ payload: {
153
+ scheduleId,
154
+ tenantId: schedule.tenantId,
155
+ organizationId: schedule.organizationId,
156
+ scopeType: schedule.scopeType,
157
+ ...payloadOverrides,
158
+ },
159
+ attempts: 0,
160
+ createdAt: Date.now(),
161
+ },
162
+ context.context,
163
+ ),
164
+ }
165
+ }
166
+
167
+ beforeEach(() => {
168
+ mockCommandExecute.mockReset()
169
+ emitSchedulerEvent.mockClear()
170
+ mockCommandExecute.mockResolvedValue({ result: { ok: true }, logEntry: null })
171
+ })
172
+
173
+ it('runs a manual trigger as the user who triggered it, not the creator', async () => {
174
+ const schedule = buildCommandSchedule({ createdByUserId: 'user-a' })
175
+ const { context, promise } = runWorker(schedule, {
176
+ triggerType: 'manual',
177
+ triggeredByUserId: 'user-b',
178
+ })
179
+ await promise
180
+
181
+ // The gate authorizes the identity that will execute...
182
+ expect(context.rbacService.userHasAllFeatures).toHaveBeenCalledWith(
183
+ 'user-b',
184
+ ['scheduler.jobs.manage'],
185
+ { tenantId: 'tenant-a', organizationId: 'org-a' },
186
+ )
187
+ // ...and the command context carries that same identity.
188
+ const [, { ctx }] = mockCommandExecute.mock.calls[0]
189
+ expect(ctx.auth).toMatchObject({ sub: 'user-b', userId: 'user-b' })
190
+ })
191
+
192
+ it('keeps running an unattended run as the schedule creator', async () => {
193
+ const schedule = buildCommandSchedule({ createdByUserId: 'user-a' })
194
+ const { context, promise } = runWorker(schedule)
195
+ await promise
196
+
197
+ expect(context.rbacService.userHasAllFeatures).toHaveBeenCalledWith(
198
+ 'user-a',
199
+ ['scheduler.jobs.manage'],
200
+ { tenantId: 'tenant-a', organizationId: 'org-a' },
201
+ )
202
+ const [, { ctx }] = mockCommandExecute.mock.calls[0]
203
+ expect(ctx.auth).toMatchObject({ sub: 'user-a', userId: 'user-a' })
204
+ })
205
+
206
+ it('ignores a triggering user carried on a scheduled (non-manual) run', async () => {
207
+ const schedule = buildCommandSchedule({ createdByUserId: 'user-a' })
208
+ const { context, promise } = runWorker(schedule, {
209
+ triggerType: 'scheduled',
210
+ triggeredByUserId: 'user-b',
211
+ })
212
+ await promise
213
+
214
+ expect(context.rbacService.userHasAllFeatures).toHaveBeenCalledWith(
215
+ 'user-a',
216
+ expect.anything(),
217
+ expect.anything(),
218
+ )
219
+ })
220
+
221
+ it('refuses a manual trigger whose triggering user lacks the target features, even when the creator holds them', async () => {
222
+ const schedule = buildCommandSchedule({ createdByUserId: 'user-a' })
223
+ const context = buildWorkerContext(schedule)
224
+ context.rbacService.userHasAllFeatures.mockImplementation(async (userId: string) => userId === 'user-a')
225
+
226
+ const { promise } = runWorker(schedule, { triggerType: 'manual', triggeredByUserId: 'user-b' }, context)
227
+
228
+ // The refusal is permanent, so it ends the job rather than throwing: a throw
229
+ // is indistinguishable from an outage and BullMQ would retry a decision no
230
+ // attempt can change, leaving no trace but a log line.
231
+ await expect(promise).resolves.toBeUndefined()
232
+ expect(mockCommandExecute).not.toHaveBeenCalled()
233
+ expect(schedule.lastRunAt).toBeUndefined()
234
+ expect(emitSchedulerEvent).toHaveBeenCalledWith(
235
+ 'scheduler.job.failed',
236
+ expect.objectContaining({ id: scheduleId, error: 'Scheduled command actor is not authorized' }),
237
+ )
238
+ })
239
+
240
+ it('still throws when the authorization lookup itself fails, so a genuine outage is retried', async () => {
241
+ const schedule = buildCommandSchedule({ createdByUserId: 'user-a' })
242
+ const context = buildWorkerContext(schedule)
243
+ context.rbacService.userHasAllFeatures.mockRejectedValue(new Error('rbac store unavailable'))
244
+
245
+ const { promise } = runWorker(schedule, {}, context)
246
+
247
+ await expect(promise).rejects.toThrow('rbac store unavailable')
248
+ expect(emitSchedulerEvent).not.toHaveBeenCalledWith('scheduler.job.failed', expect.anything())
249
+ })
250
+
251
+ it('falls back to the creator when a manual trigger carries no user (API-key caller)', async () => {
252
+ const schedule = buildCommandSchedule({ createdByUserId: 'user-a' })
253
+ const { context, promise } = runWorker(schedule, {
254
+ triggerType: 'manual',
255
+ triggeredByUserId: null,
256
+ })
257
+ await promise
258
+
259
+ expect(context.rbacService.userHasAllFeatures).toHaveBeenCalledWith(
260
+ 'user-a',
261
+ expect.anything(),
262
+ expect.anything(),
263
+ )
264
+ const [, { ctx }] = mockCommandExecute.mock.calls[0]
265
+ expect(ctx.auth).toMatchObject({ sub: 'user-a' })
266
+ })
267
+ })
268
+
138
269
  describe('executeScheduleWorker queue target payload contract', () => {
139
270
  const enqueue = jest.fn(async () => 'target-job-1')
140
271
  const close = jest.fn(async () => undefined)
@@ -6,8 +6,11 @@ import { ScheduledJob } from '../data/entities.js'
6
6
  import { CommandBus } from '@open-mercato/shared/lib/commands'
7
7
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
8
8
  import { emitSchedulerEvent } from '../events.js'
9
- import { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-commands.js'
10
- import { buildScheduledCommandContext } from '../lib/commandContext.js'
9
+ import {
10
+ assertSchedulerSafeCommandAuthorized,
11
+ SchedulerCommandAuthorizationError,
12
+ } from '../lib/scheduler-safe-commands.js'
13
+ import { buildScheduledCommandContext, resolveScheduledCommandActorUserId } from '../lib/commandContext.js'
11
14
  import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
12
15
  import { createLogger } from '@open-mercato/shared/lib/logger'
13
16
  import {
@@ -280,23 +283,65 @@ export default async function executeScheduleWorker(
280
283
 
281
284
  } else if (schedule.targetType === 'command' && schedule.targetCommand) {
282
285
  const commandBus = new CommandBus()
283
- const actorUserId = typeof schedule.createdByUserId === 'string' ? schedule.createdByUserId.trim() : ''
284
- await assertSchedulerSafeCommandAuthorized({
285
- commandId: schedule.targetCommand,
286
- actorUserId,
287
- tenantId: schedule.tenantId,
288
- organizationId: schedule.organizationId,
289
- rbacService,
290
- })
291
-
286
+ // A manual run acts as whoever pressed the button; an unattended run keeps
287
+ // acting as the schedule's creator. The gate below and the context built
288
+ // afterwards must agree on that identity, so it is resolved once, by the same
289
+ // helper the context itself uses.
290
+ const triggeredByUserId = payload.triggerType === 'manual' ? payload.triggeredByUserId ?? null : null
291
+ const actorUserId = resolveScheduledCommandActorUserId(schedule, { triggeredByUserId })
292
+ try {
293
+ await assertSchedulerSafeCommandAuthorized({
294
+ commandId: schedule.targetCommand,
295
+ actorUserId,
296
+ tenantId: schedule.tenantId,
297
+ organizationId: schedule.organizationId,
298
+ rbacService,
299
+ })
300
+ } catch (error) {
301
+ // A refusal is permanent, so it must not be thrown: throwing hands BullMQ an
302
+ // error it cannot tell from an outage and it retries a decision that no
303
+ // attempt can change, while the run leaves no trace beyond a log line. The
304
+ // queue branch above already ends its equivalent conditions with an event and
305
+ // a return; this does the same, and records the refusal as a failure rather
306
+ // than a skip because a schedule whose actor may no longer run its command is
307
+ // a state an operator has to act on.
308
+ //
309
+ // Only the authorization decision is swallowed. Anything else — an RBAC
310
+ // lookup that fails because its store is down — is genuinely transient and
311
+ // still propagates so BullMQ retries it.
312
+ if (!(error instanceof SchedulerCommandAuthorizationError)) throw error
313
+
314
+ const reason = error.message
315
+ await emitSchedulerEvent('scheduler.job.failed', {
316
+ id: schedule.id,
317
+ tenantId: schedule.tenantId,
318
+ organizationId: schedule.organizationId,
319
+ scheduleName: schedule.name,
320
+ scopeType: schedule.scopeType,
321
+ error: reason,
322
+ failedAt: new Date(),
323
+ })
324
+
325
+ logger.warn('Schedule refused: scheduled command is not authorized', {
326
+ scheduleId: schedule.id,
327
+ commandId: schedule.targetCommand,
328
+ triggerType: payload.triggerType ?? 'scheduled',
329
+ reason,
330
+ })
331
+ return
332
+ }
333
+
292
334
  const commandInput = {
293
335
  ...((schedule.targetPayload as Record<string, unknown>) || {}),
294
336
  tenantId: schedule.tenantId,
295
337
  organizationId: schedule.organizationId,
296
338
  }
297
-
339
+
298
340
  // Build the schedule-scoped command context after the allowlist/RBAC gate.
299
- const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer)
341
+ // The gate authorized `actorUserId`; this must execute as that same identity.
342
+ const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer, {
343
+ triggeredByUserId,
344
+ })
300
345
 
301
346
  const commandResult = await commandBus.execute(schedule.targetCommand, {
302
347
  input: commandInput,