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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js +2 -2
  3. package/dist/modules/scheduler/__integration__/TC-SCHED-001.spec.js.map +2 -2
  4. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js +4 -9
  5. package/dist/modules/scheduler/__integration__/TC-SCHED-004.spec.js.map +2 -2
  6. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js +3 -3
  7. package/dist/modules/scheduler/__integration__/TC-SCHED-007.spec.js.map +2 -2
  8. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js +3 -3
  9. package/dist/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.js.map +1 -1
  10. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +44 -107
  11. package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +2 -2
  12. package/dist/modules/scheduler/__integration__/helpers/scheduler.js +3 -13
  13. package/dist/modules/scheduler/__integration__/helpers/scheduler.js.map +2 -2
  14. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js +13 -7
  15. package/dist/modules/scheduler/api/jobs/[id]/executions/route.js.map +2 -2
  16. package/dist/modules/scheduler/api/targets/route.js +12 -2
  17. package/dist/modules/scheduler/api/targets/route.js.map +2 -2
  18. package/dist/modules/scheduler/api/trigger/route.js +13 -7
  19. package/dist/modules/scheduler/api/trigger/route.js.map +2 -2
  20. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js +1 -3
  21. package/dist/modules/scheduler/backend/config/scheduled-jobs/page.js.map +2 -2
  22. package/dist/modules/scheduler/cli.js +1 -32
  23. package/dist/modules/scheduler/cli.js.map +2 -2
  24. package/dist/modules/scheduler/commands/jobs.js +2 -84
  25. package/dist/modules/scheduler/commands/jobs.js.map +2 -2
  26. package/dist/modules/scheduler/data/validators.js +3 -27
  27. package/dist/modules/scheduler/data/validators.js.map +2 -2
  28. package/dist/modules/scheduler/i18n/de.json +0 -2
  29. package/dist/modules/scheduler/i18n/en.json +0 -2
  30. package/dist/modules/scheduler/i18n/es.json +0 -2
  31. package/dist/modules/scheduler/i18n/ko.json +0 -2
  32. package/dist/modules/scheduler/i18n/pl.json +0 -2
  33. package/dist/modules/scheduler/services/localSchedulerService.js +6 -31
  34. package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
  35. package/dist/modules/scheduler/workers/execute-schedule.worker.js +6 -64
  36. package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
  37. package/package.json +6 -7
  38. package/src/modules/scheduler/__integration__/TC-SCHED-001.spec.ts +2 -2
  39. package/src/modules/scheduler/__integration__/TC-SCHED-004.spec.ts +8 -14
  40. package/src/modules/scheduler/__integration__/TC-SCHED-007.spec.ts +3 -3
  41. package/src/modules/scheduler/__integration__/TC-SCHED-CRUDFORM-001.spec.ts +3 -3
  42. package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +66 -154
  43. package/src/modules/scheduler/__integration__/helpers/scheduler.ts +5 -24
  44. package/src/modules/scheduler/api/jobs/[id]/executions/route.ts +19 -9
  45. package/src/modules/scheduler/api/targets/__tests__/route.test.ts +4 -3
  46. package/src/modules/scheduler/api/targets/route.ts +17 -6
  47. package/src/modules/scheduler/api/trigger/route.ts +19 -9
  48. package/src/modules/scheduler/backend/config/scheduled-jobs/page.tsx +1 -4
  49. package/src/modules/scheduler/cli.ts +1 -44
  50. package/src/modules/scheduler/commands/jobs.ts +3 -137
  51. package/src/modules/scheduler/data/__tests__/validators.test.ts +4 -26
  52. package/src/modules/scheduler/data/validators.ts +2 -37
  53. package/src/modules/scheduler/i18n/de.json +0 -2
  54. package/src/modules/scheduler/i18n/en.json +0 -2
  55. package/src/modules/scheduler/i18n/es.json +0 -2
  56. package/src/modules/scheduler/i18n/ko.json +0 -2
  57. package/src/modules/scheduler/i18n/pl.json +0 -2
  58. package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +0 -179
  59. package/src/modules/scheduler/services/localSchedulerService.ts +6 -39
  60. package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +3 -25
  61. package/src/modules/scheduler/workers/execute-schedule.worker.ts +7 -76
  62. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js +0 -72
  63. package/dist/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.js.map +0 -7
  64. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js +0 -66
  65. package/dist/modules/scheduler/__integration__/TC-SCHED-008.spec.js.map +0 -7
  66. package/dist/modules/scheduler/lib/safeQueueTargets.js +0 -157
  67. package/dist/modules/scheduler/lib/safeQueueTargets.js.map +0 -7
  68. package/dist/modules/scheduler/lib/scheduleAccess.js +0 -22
  69. package/dist/modules/scheduler/lib/scheduleAccess.js.map +0 -7
  70. package/dist/modules/scheduler/workers/test-echo-queue.worker.js +0 -22
  71. package/dist/modules/scheduler/workers/test-echo-queue.worker.js.map +0 -7
  72. package/src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts +0 -90
  73. package/src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts +0 -94
  74. package/src/modules/scheduler/commands/__tests__/jobs.target-guard.test.ts +0 -177
  75. package/src/modules/scheduler/data/__tests__/safe-targets.test.ts +0 -110
  76. package/src/modules/scheduler/lib/__tests__/safeQueueTargets.test.ts +0 -311
  77. package/src/modules/scheduler/lib/__tests__/scheduleAccess.test.ts +0 -164
  78. package/src/modules/scheduler/lib/safeQueueTargets.ts +0 -324
  79. package/src/modules/scheduler/lib/scheduleAccess.ts +0 -104
  80. package/src/modules/scheduler/workers/test-echo-queue.worker.ts +0 -25
@@ -6,7 +6,6 @@ import {
6
6
  clearSchedulerSafeCommandsForTests,
7
7
  registerSchedulerSafeCommands,
8
8
  } from '../../lib/scheduler-safe-commands'
9
- import { registerModules } from '@open-mercato/shared/lib/modules/registry'
10
9
  import { createLogger } from '@open-mercato/shared/lib/logger'
11
10
 
12
11
  jest.mock('@open-mercato/shared/lib/logger', () => {
@@ -62,40 +61,6 @@ describe('LocalSchedulerService', () => {
62
61
  let mockRbacService: { tenantHasFeature: jest.Mock; userHasAllFeatures: jest.Mock }
63
62
  let mockLockStrategy: { runWithLock: jest.Mock }
64
63
 
65
- beforeAll(() => {
66
- // Queue-target dispatch reauthorizes against scheduler-safe queues (#5213);
67
- // seed one safe queue and one module-owned internal queue for the guards.
68
- registerModules([
69
- {
70
- id: 'test_module',
71
- workers: [
72
- {
73
- id: 'test_module:workers:test-queue',
74
- queue: 'test-queue',
75
- concurrency: 1,
76
- schedulerSafe: true,
77
- handler: async () => {},
78
- },
79
- ],
80
- },
81
- {
82
- id: 'legacy_module',
83
- workers: [
84
- {
85
- id: 'legacy_module:workers:internal',
86
- queue: 'legacy-internal',
87
- concurrency: 1,
88
- handler: async () => {},
89
- },
90
- ],
91
- },
92
- ] as never)
93
- })
94
-
95
- afterAll(() => {
96
- ;(globalThis as Record<string, unknown>).__openMercatoModulesRegistry__ = null
97
- })
98
-
99
64
  beforeEach(() => {
100
65
  jest.clearAllMocks()
101
66
  jest.useFakeTimers()
@@ -331,151 +296,8 @@ describe('LocalSchedulerService', () => {
331
296
  tenantId: null,
332
297
  organizationId: null,
333
298
  _idempotencyKey: expect.stringMatching(/^scheduler-test-1-/),
334
- _jobOrigin: 'scheduler',
335
- })
336
- )
337
- })
338
-
339
- it('should sanitize author-supplied scope and envelope keys from the target payload', async () => {
340
- const schedule = createSchedule({
341
- tenantId: 'tenant-1',
342
- organizationId: 'org-1',
343
- sourceType: 'module',
344
- sourceModule: 'test_module',
345
- targetQueue: 'test-queue',
346
- targetPayload: {
347
- scope: { tenantId: 'forged-tenant', organizationId: 'forged-org' },
348
- tenantId: 'forged-tenant',
349
- _jobOrigin: 'inbound-webhook',
350
- payload: { nested: 'data', _idempotencyKey: 'forged' },
351
- legit: 'kept',
352
- },
353
- })
354
-
355
- mockForkedEm.find.mockResolvedValue([schedule])
356
- mockForkedEm.findOne.mockResolvedValue({ ...schedule })
357
- mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
358
- await fn()
359
- return { acquired: true }
360
- })
361
- mockQueue.enqueue.mockResolvedValue(undefined as any)
362
-
363
- await service.start()
364
-
365
- expect(mockQueue.enqueue).toHaveBeenCalledWith(
366
- expect.objectContaining({
367
- tenantId: 'tenant-1',
368
- organizationId: 'org-1',
369
- payload: { nested: 'data' },
370
- legit: 'kept',
371
- scope: { tenantId: 'tenant-1', organizationId: 'org-1' },
372
- _jobOrigin: 'scheduler',
373
299
  })
374
300
  )
375
- const enqueued = mockQueue.enqueue.mock.calls[0][0] as Record<string, unknown>
376
- expect(enqueued.payload).toEqual({ nested: 'data' })
377
- })
378
-
379
- it('should refuse user-authored schedules targeting non-safe internal queues', async () => {
380
- const schedule = createSchedule({
381
- targetQueue: 'stripe-webhook',
382
- sourceType: 'user',
383
- })
384
-
385
- mockForkedEm.find.mockResolvedValue([schedule])
386
- mockForkedEm.findOne.mockResolvedValue({ ...schedule })
387
- mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
388
- await fn()
389
- return { acquired: true }
390
- })
391
-
392
- await service.start()
393
-
394
- expect(mockQueueFactory).not.toHaveBeenCalled()
395
- expect(loggerError).toHaveBeenCalled()
396
- expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
397
- 'scheduler.job.failed',
398
- expect.objectContaining({
399
- id: 'test-1',
400
- error: expect.stringContaining('not an approved scheduler target'),
401
- })
402
- )
403
- })
404
-
405
- it('should refuse a pre-upgrade module row whose sourceModule MATCHES the owner but carries an actor stamp (#5213 B1)', async () => {
406
- // forged pre-upgrade row: attacker set sourceType/sourceModule to the real
407
- // owning module, but API writes always stamp createdByUserId — which
408
- // schedulerService.register() never does. The row must fail closed.
409
- const schedule = createSchedule({
410
- targetQueue: 'legacy-internal',
411
- sourceType: 'module',
412
- sourceModule: 'legacy_module',
413
- createdByUserId: 'attacker-user',
414
- })
415
-
416
- mockForkedEm.find.mockResolvedValue([schedule])
417
- mockForkedEm.findOne.mockResolvedValue({ ...schedule })
418
- mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
419
- await fn()
420
- return { acquired: true }
421
- })
422
-
423
- await service.start()
424
-
425
- expect(mockQueueFactory).not.toHaveBeenCalled()
426
- expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
427
- 'scheduler.job.failed',
428
- expect.objectContaining({
429
- id: 'test-1',
430
- error: expect.stringContaining('not an approved scheduler target'),
431
- })
432
- )
433
- })
434
-
435
- it('should refuse a pre-upgrade module row whose sourceModule does not own its internal queue (#5213 B1)', async () => {
436
- const schedule = createSchedule({
437
- targetQueue: 'stripe-webhook',
438
- sourceType: 'module',
439
- sourceModule: 'legacy_module',
440
- })
441
-
442
- mockForkedEm.find.mockResolvedValue([schedule])
443
- mockForkedEm.findOne.mockResolvedValue({ ...schedule })
444
- mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
445
- await fn()
446
- return { acquired: true }
447
- })
448
-
449
- await service.start()
450
-
451
- expect(mockQueueFactory).not.toHaveBeenCalled()
452
- expect(mockEmitSchedulerEvent).toHaveBeenCalledWith(
453
- 'scheduler.job.failed',
454
- expect.objectContaining({
455
- id: 'test-1',
456
- error: expect.stringContaining('not an approved scheduler target'),
457
- })
458
- )
459
- })
460
-
461
- it('should allow module-authored schedules to keep targeting queues their own module owns (#5213 B1)', async () => {
462
- const schedule = createSchedule({
463
- targetQueue: 'legacy-internal',
464
- sourceType: 'module',
465
- sourceModule: 'legacy_module',
466
- })
467
-
468
- mockForkedEm.find.mockResolvedValue([schedule])
469
- mockForkedEm.findOne.mockResolvedValue({ ...schedule })
470
- mockLockStrategy.runWithLock.mockImplementation(async (_key: string, fn: () => Promise<unknown>) => {
471
- await fn()
472
- return { acquired: true }
473
- })
474
- mockQueue.enqueue.mockResolvedValue(undefined as any)
475
-
476
- await service.start()
477
-
478
- expect(mockQueue.enqueue).toHaveBeenCalled()
479
301
  })
480
302
 
481
303
  it('should execute command target', async () => {
@@ -848,7 +670,6 @@ describe('LocalSchedulerService', () => {
848
670
  tenantId: schedule.tenantId,
849
671
  organizationId: schedule.organizationId,
850
672
  _idempotencyKey: expect.stringMatching(new RegExp(`^scheduler-${schedule.id}-`)),
851
- _jobOrigin: 'scheduler',
852
673
  })
853
674
  )
854
675
  const enqueued = mockQueue.enqueue.mock.calls[0][0] as Record<string, unknown>
@@ -11,12 +11,6 @@ import { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-comm
11
11
  import { buildScheduledCommandContext } from '../lib/commandContext.js'
12
12
  import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
13
13
  import { createLogger } from '@open-mercato/shared/lib/logger'
14
- import {
15
- canDispatchScheduleQueueTarget,
16
- getSchedulerQueueRequiredFeatures,
17
- sanitizeSchedulerTargetPayload,
18
- validateSchedulerTargetPayload,
19
- } from '../lib/safeQueueTargets'
20
14
 
21
15
  const logger = createLogger('scheduler').child({ component: 'local' })
22
16
 
@@ -267,45 +261,18 @@ export class LocalSchedulerService {
267
261
  throw new Error('Target queue is required for queue target type')
268
262
  }
269
263
 
270
- // Dispatch-time reauthorization (#5213): provenance is verified against the
271
- // live registry (module rows must own their queue), API rows must target
272
- // opted-in queues.
273
- if (!canDispatchScheduleQueueTarget(schedule)) {
274
- throw new Error(`Queue is not an approved scheduler target: ${schedule.targetQueue}`)
275
- }
276
-
277
- const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)
278
- if (payloadIssue) {
279
- throw new Error(`Invalid payload for scheduler queue ${schedule.targetQueue}: ${payloadIssue}`)
280
- }
281
-
282
- if (schedule.scopeType !== 'system') {
283
- for (const feature of getSchedulerQueueRequiredFeatures(schedule.targetQueue)) {
284
- const hasFeature = await this.checkFeature({ ...schedule, requireFeature: feature } as ScheduledJob)
285
- if (!hasFeature) {
286
- throw new Error(`Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`)
287
- }
288
- }
289
- }
290
-
291
264
  const queue = this.queueFactory(schedule.targetQueue)
292
265
 
293
266
  // Deliver the same flat contract as the asynchronous execute-schedule
294
267
  // worker: targetPayload fields on the root, scheduler-owned scope and
295
268
  // idempotency fields applied last. Local mode runs each firing exactly
296
269
  // once, so the firing timestamp is a valid logical execution key.
297
- // Author-supplied scope/envelope keys never survive dispatch (#5213).
298
- const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule)
299
-
300
- await queue.enqueue({
301
- ...buildQueueTargetPayload({
302
- targetPayload: sanitizedPayload,
303
- tenantId: schedule.tenantId,
304
- organizationId: schedule.organizationId,
305
- idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),
306
- }),
307
- _jobOrigin: 'scheduler' as const,
308
- })
270
+ await queue.enqueue(buildQueueTargetPayload({
271
+ targetPayload: schedule.targetPayload,
272
+ tenantId: schedule.tenantId,
273
+ organizationId: schedule.organizationId,
274
+ idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),
275
+ }))
309
276
 
310
277
  logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })
311
278
  }
@@ -3,25 +3,8 @@ import { ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
3
3
  import { createQueue } from '@open-mercato/queue'
4
4
  import { ScheduledJob } from '../../data/entities'
5
5
  import { registerSchedulerSafeCommands } from '../../lib/scheduler-safe-commands'
6
- import { registerModules } from '@open-mercato/shared/lib/modules/registry'
7
6
  import executeScheduleWorker from '../execute-schedule.worker'
8
7
 
9
- // Queue-target dispatch verifies module provenance against the live registry
10
- // (#5213 B1): register the module that owns the fixture queue.
11
- registerModules([
12
- {
13
- id: 'worker_test_module',
14
- workers: [
15
- {
16
- id: 'worker_test_module:workers:example',
17
- queue: 'example',
18
- concurrency: 1,
19
- handler: async () => {},
20
- },
21
- ],
22
- },
23
- ] as never)
24
-
25
8
  const mockCommandExecute = jest.fn()
26
9
 
27
10
  jest.mock('@open-mercato/shared/lib/commands', () => ({
@@ -144,11 +127,7 @@ describe('executeScheduleWorker queue target payload contract', () => {
144
127
  targetType: 'queue',
145
128
  targetQueue: 'example',
146
129
  targetCommand: null,
147
- sourceType: 'module',
148
- // module registration never stamps an acting user (#5213 B1)
149
- createdByUserId: null,
150
- sourceModule: 'worker_test_module',
151
- targetPayload: { connectionId: 'connection-id' },
130
+ targetPayload: { connectionId: 'connection-id', scope: 'organization' },
152
131
  ...overrides,
153
132
  })
154
133
  }
@@ -178,7 +157,7 @@ describe('executeScheduleWorker queue target payload contract', () => {
178
157
  ;(createQueue as jest.Mock).mockReturnValue({ enqueue, close })
179
158
  })
180
159
 
181
- it('delivers the flat targetPayload contract with scheduler-owned fields applied last (#5213)', async () => {
160
+ it('delivers the flat targetPayload contract with scheduler-owned fields applied last', async () => {
182
161
  const schedule = buildQueueSchedule({
183
162
  targetPayload: {
184
163
  connectionId: 'connection-id',
@@ -192,12 +171,11 @@ describe('executeScheduleWorker queue target payload contract', () => {
192
171
 
193
172
  expect(enqueue).toHaveBeenCalledWith({
194
173
  connectionId: 'connection-id',
195
- scope: { tenantId: 'tenant-a', organizationId: 'org-a' },
174
+ scope: 'organization',
196
175
  payload: { nested: true },
197
176
  tenantId: 'tenant-a',
198
177
  organizationId: 'org-a',
199
178
  _idempotencyKey: `scheduler-${scheduleId}-worker-job-1`,
200
- _jobOrigin: 'scheduler',
201
179
  })
202
180
  expect(close).toHaveBeenCalledTimes(1)
203
181
  })
@@ -10,12 +10,6 @@ import { assertSchedulerSafeCommandAuthorized } from '../lib/scheduler-safe-comm
10
10
  import { buildScheduledCommandContext } from '../lib/commandContext.js'
11
11
  import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
12
12
  import { createLogger } from '@open-mercato/shared/lib/logger'
13
- import {
14
- canDispatchScheduleQueueTarget,
15
- getSchedulerQueueRequiredFeatures,
16
- sanitizeSchedulerTargetPayload,
17
- validateSchedulerTargetPayload,
18
- } from '../lib/safeQueueTargets'
19
13
 
20
14
  const logger = createLogger('scheduler').child({ component: 'worker' })
21
15
 
@@ -173,69 +167,12 @@ export default async function executeScheduleWorker(
173
167
 
174
168
  // Enqueue target job or execute command
175
169
  if (schedule.targetType === 'queue' && schedule.targetQueue) {
176
- // Dispatch-time reauthorization (#5213): provenance is verified, never
177
- // trusted — module-authored rows must still be owned by their recorded
178
- // sourceModule and carry no acting-user stamp; API-authored rows may only
179
- // target workers that opted into scheduling.
180
- if (!canDispatchScheduleQueueTarget(schedule)) {
181
- logger.error('Refusing non-safe queue target for schedule', {
182
- scheduleId,
183
- targetQueue: schedule.targetQueue,
184
- sourceType: schedule.sourceType,
185
- sourceModule: schedule.sourceType === 'module' ? schedule.sourceModule : undefined,
186
- })
187
- await emitSchedulerEvent('scheduler.job.skipped', {
188
- id: schedule.id,
189
- tenantId: schedule.tenantId,
190
- organizationId: schedule.organizationId,
191
- reason: `Queue is not an approved scheduler target: ${schedule.targetQueue}`,
192
- })
193
- return
194
- }
195
-
196
- // Re-validate the stored payload against the target's registered schema and
197
- // the tenant-level features the target requires (#5213, defense in depth).
198
- const payloadIssue = validateSchedulerTargetPayload(schedule.targetQueue, schedule.targetPayload)
199
- if (payloadIssue) {
200
- logger.error('Refusing schedule with invalid payload for queue target', {
201
- scheduleId,
202
- targetQueue: schedule.targetQueue,
203
- issue: payloadIssue,
204
- })
205
- await emitSchedulerEvent('scheduler.job.skipped', {
206
- id: schedule.id,
207
- tenantId: schedule.tenantId,
208
- organizationId: schedule.organizationId,
209
- reason: `Invalid payload for scheduler queue ${schedule.targetQueue}`,
210
- })
211
- return
212
- }
213
-
214
- const requiredQueueFeatures = getSchedulerQueueRequiredFeatures(schedule.targetQueue)
215
- if (schedule.scopeType !== 'system' && requiredQueueFeatures.length > 0) {
216
- for (const feature of requiredQueueFeatures) {
217
- if (await rbacService.tenantHasFeature(schedule.tenantId, feature, { organizationId: schedule.organizationId })) continue
218
- logger.error('Refusing schedule whose tenant lacks a required feature of the queue target', {
219
- scheduleId,
220
- targetQueue: schedule.targetQueue,
221
- feature,
222
- })
223
- await emitSchedulerEvent('scheduler.job.skipped', {
224
- id: schedule.id,
225
- tenantId: schedule.tenantId,
226
- organizationId: schedule.organizationId,
227
- reason: `Tenant lacks feature required by scheduler queue ${schedule.targetQueue}: ${feature}`,
228
- })
229
- return
230
- }
231
- }
232
-
233
170
  // Determine queue strategy from environment
234
171
  const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'
235
172
  const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {
236
173
  connection: { url: getRedisUrlOrThrow('QUEUE') },
237
174
  })
238
-
175
+
239
176
  let targetJobId: string | undefined
240
177
  try {
241
178
  // The execute-schedule job id is stable across BullMQ retries, so if
@@ -243,18 +180,12 @@ export default async function executeScheduleWorker(
243
180
  // reuses the same idempotency key and downstream workers can dedupe.
244
181
  const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())
245
182
 
246
- // Rebuild tenant/org authority context and the trusted dispatch origin
247
- // server-side; author-supplied scope/envelope keys never survive (#5213).
248
- const sanitizedPayload = sanitizeSchedulerTargetPayload(schedule.targetPayload, schedule)
249
- targetJobId = await targetQueue.enqueue({
250
- ...buildQueueTargetPayload({
251
- targetPayload: sanitizedPayload,
252
- tenantId: schedule.tenantId,
253
- organizationId: schedule.organizationId,
254
- idempotencyKey,
255
- }),
256
- _jobOrigin: 'scheduler' as const,
257
- })
183
+ targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({
184
+ targetPayload: schedule.targetPayload,
185
+ tenantId: schedule.tenantId,
186
+ organizationId: schedule.organizationId,
187
+ idempotencyKey,
188
+ }))
258
189
  } finally {
259
190
  // Always close the queue instance to free Redis connections
260
191
  await targetQueue.close()
@@ -1,72 +0,0 @@
1
- import { expect, test } from "@playwright/test";
2
- import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
- import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
- import {
5
- SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,
6
- SCHEDULER_JOBS_PATH,
7
- SCHEDULER_TARGETS_PATH,
8
- createScheduleJob,
9
- deleteScheduleJob,
10
- getScheduleJobById,
11
- uniqueScheduleName
12
- } from "./helpers/scheduler.js";
13
- test.describe("TC-SCHED-008: scheduler queue targets restricted to safe workers", () => {
14
- for (const forbiddenQueue of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
15
- test(`rejects creating a schedule targeting the ${forbiddenQueue} worker`, async ({ request }) => {
16
- const token = await getAuthToken(request, "admin");
17
- const response = await apiRequest(request, "POST", SCHEDULER_JOBS_PATH, {
18
- token,
19
- data: {
20
- name: uniqueScheduleName("Forbidden Queue Target"),
21
- scopeType: "organization",
22
- scheduleType: "interval",
23
- scheduleValue: "15m",
24
- timezone: "UTC",
25
- targetType: "queue",
26
- targetQueue: forbiddenQueue,
27
- targetPayload: { scope: { tenantId: "forged", organizationId: "forged" } },
28
- isEnabled: true,
29
- sourceType: "user"
30
- }
31
- });
32
- expect(response.status(), `${forbiddenQueue} must not be schedulable`).toBe(400);
33
- const body = await readJsonSafe(response);
34
- const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join("."));
35
- expect(issuePaths).toContain("targetQueue");
36
- });
37
- }
38
- test("rejects retargeting an existing schedule to an internal worker queue", async ({ request }) => {
39
- const token = await getAuthToken(request, "admin");
40
- let scheduleId = null;
41
- try {
42
- scheduleId = await createScheduleJob(request, token, { name: uniqueScheduleName("Retarget Guard") });
43
- const response = await apiRequest(request, "PUT", SCHEDULER_JOBS_PATH, {
44
- token,
45
- data: {
46
- id: scheduleId,
47
- targetType: "queue",
48
- targetQueue: "stripe-webhook"
49
- }
50
- });
51
- expect(response.status()).toBe(422);
52
- const body = await readJsonSafe(response);
53
- expect(body?.error ?? "").toMatch(/not an approved scheduler target/i);
54
- const unchanged = await getScheduleJobById(request, token, scheduleId);
55
- expect(unchanged).not.toBeNull();
56
- expect(unchanged.targetType).toBe("command");
57
- } finally {
58
- await deleteScheduleJob(request, token, scheduleId);
59
- }
60
- });
61
- test("targets endpoint keeps internal worker queues undiscoverable", async ({ request }) => {
62
- const token = await getAuthToken(request, "admin");
63
- const response = await apiRequest(request, "GET", SCHEDULER_TARGETS_PATH, { token });
64
- expect(response.status()).toBe(200);
65
- const body = await readJsonSafe(response);
66
- const values = (body?.queues ?? []).map((queue) => queue.value);
67
- for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {
68
- expect(values).not.toContain(forbidden);
69
- }
70
- });
71
- });
72
- //# sourceMappingURL=TC-SCHED-008-safe-targets.spec.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-008-safe-targets.spec.ts"],
4
- "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n SCHEDULER_FORBIDDEN_INTERNAL_QUEUES,\n SCHEDULER_JOBS_PATH,\n SCHEDULER_TARGETS_PATH,\n createScheduleJob,\n deleteScheduleJob,\n getScheduleJobById,\n uniqueScheduleName,\n} from './helpers/scheduler'\n\n/**\n * TC-SCHED-008: Scheduler queue targets are restricted to authorized safe\n * workers (#5213). A principal holding only `scheduler.jobs.manage` must not be\n * able to aim a schedule at an internal/system worker queue with an arbitrary\n * payload \u2014 neither at creation, nor by retargeting later.\n */\ntest.describe('TC-SCHED-008: scheduler queue targets restricted to safe workers', () => {\n for (const forbiddenQueue of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {\n test(`rejects creating a schedule targeting the ${forbiddenQueue} worker`, async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'POST', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n name: uniqueScheduleName('Forbidden Queue Target'),\n scopeType: 'organization',\n scheduleType: 'interval',\n scheduleValue: '15m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: forbiddenQueue,\n targetPayload: { scope: { tenantId: 'forged', organizationId: 'forged' } },\n isEnabled: true,\n sourceType: 'user',\n },\n })\n\n expect(response.status(), `${forbiddenQueue} must not be schedulable`).toBe(400)\n const body = await readJsonSafe<{ error?: string; details?: Array<{ path?: Array<string | number> }> }>(response)\n const issuePaths = (body?.details ?? []).map((issue) => (issue.path ?? []).join('.'))\n expect(issuePaths).toContain('targetQueue')\n })\n }\n\n test('rejects retargeting an existing schedule to an internal worker queue', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n scheduleId = await createScheduleJob(request, token, { name: uniqueScheduleName('Retarget Guard') })\n\n const response = await apiRequest(request, 'PUT', SCHEDULER_JOBS_PATH, {\n token,\n data: {\n id: scheduleId,\n targetType: 'queue',\n targetQueue: 'stripe-webhook',\n },\n })\n\n // Enforcement lives in the update command (change-detection vs the stored\n // row), so an actual retarget lands as 422 with the approval error.\n expect(response.status()).toBe(422)\n const body = await readJsonSafe<{ error?: string }>(response)\n expect(body?.error ?? '').toMatch(/not an approved scheduler target/i)\n\n const unchanged = await getScheduleJobById(request, token, scheduleId)\n expect(unchanged).not.toBeNull()\n expect(unchanged!.targetType).toBe('command')\n } finally {\n await deleteScheduleJob(request, token, scheduleId)\n }\n })\n\n test('targets endpoint keeps internal worker queues undiscoverable', async ({ request }) => {\n const token = await getAuthToken(request, 'admin')\n\n const response = await apiRequest(request, 'GET', SCHEDULER_TARGETS_PATH, { token })\n expect(response.status()).toBe(200)\n\n const body = await readJsonSafe<{ queues?: Array<{ value: string }> }>(response)\n const values = (body?.queues ?? []).map((queue) => queue.value)\n for (const forbidden of SCHEDULER_FORBIDDEN_INTERNAL_QUEUES) {\n expect(values).not.toContain(forbidden)\n }\n })\n})\n"],
5
- "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAQP,KAAK,SAAS,oEAAoE,MAAM;AACtF,aAAW,kBAAkB,qCAAqC;AAChE,SAAK,6CAA6C,cAAc,WAAW,OAAO,EAAE,QAAQ,MAAM;AAChG,YAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,YAAM,WAAW,MAAM,WAAW,SAAS,QAAQ,qBAAqB;AAAA,QACtE;AAAA,QACA,MAAM;AAAA,UACJ,MAAM,mBAAmB,wBAAwB;AAAA,UACjD,WAAW;AAAA,UACX,cAAc;AAAA,UACd,eAAe;AAAA,UACf,UAAU;AAAA,UACV,YAAY;AAAA,UACZ,aAAa;AAAA,UACb,eAAe,EAAE,OAAO,EAAE,UAAU,UAAU,gBAAgB,SAAS,EAAE;AAAA,UACzE,WAAW;AAAA,UACX,YAAY;AAAA,QACd;AAAA,MACF,CAAC;AAED,aAAO,SAAS,OAAO,GAAG,GAAG,cAAc,0BAA0B,EAAE,KAAK,GAAG;AAC/E,YAAM,OAAO,MAAM,aAAqF,QAAQ;AAChH,YAAM,cAAc,MAAM,WAAW,CAAC,GAAG,IAAI,CAAC,WAAW,MAAM,QAAQ,CAAC,GAAG,KAAK,GAAG,CAAC;AACpF,aAAO,UAAU,EAAE,UAAU,aAAa;AAAA,IAC5C,CAAC;AAAA,EACH;AAEA,OAAK,wEAAwE,OAAO,EAAE,QAAQ,MAAM;AAClG,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AACjD,QAAI,aAA4B;AAEhC,QAAI;AACF,mBAAa,MAAM,kBAAkB,SAAS,OAAO,EAAE,MAAM,mBAAmB,gBAAgB,EAAE,CAAC;AAEnG,YAAM,WAAW,MAAM,WAAW,SAAS,OAAO,qBAAqB;AAAA,QACrE;AAAA,QACA,MAAM;AAAA,UACJ,IAAI;AAAA,UACJ,YAAY;AAAA,UACZ,aAAa;AAAA,QACf;AAAA,MACF,CAAC;AAID,aAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAClC,YAAM,OAAO,MAAM,aAAiC,QAAQ;AAC5D,aAAO,MAAM,SAAS,EAAE,EAAE,QAAQ,mCAAmC;AAErE,YAAM,YAAY,MAAM,mBAAmB,SAAS,OAAO,UAAU;AACrE,aAAO,SAAS,EAAE,IAAI,SAAS;AAC/B,aAAO,UAAW,UAAU,EAAE,KAAK,SAAS;AAAA,IAC9C,UAAE;AACA,YAAM,kBAAkB,SAAS,OAAO,UAAU;AAAA,IACpD;AAAA,EACF,CAAC;AAED,OAAK,gEAAgE,OAAO,EAAE,QAAQ,MAAM;AAC1F,UAAM,QAAQ,MAAM,aAAa,SAAS,OAAO;AAEjD,UAAM,WAAW,MAAM,WAAW,SAAS,OAAO,wBAAwB,EAAE,MAAM,CAAC;AACnF,WAAO,SAAS,OAAO,CAAC,EAAE,KAAK,GAAG;AAElC,UAAM,OAAO,MAAM,aAAoD,QAAQ;AAC/E,UAAM,UAAU,MAAM,UAAU,CAAC,GAAG,IAAI,CAAC,UAAU,MAAM,KAAK;AAC9D,eAAW,aAAa,qCAAqC;AAC3D,aAAO,MAAM,EAAE,IAAI,UAAU,SAAS;AAAA,IACxC;AAAA,EACF,CAAC;AACH,CAAC;",
6
- "names": []
7
- }
@@ -1,66 +0,0 @@
1
- import { expect, test } from "@playwright/test";
2
- import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
- import { readJsonSafe } from "@open-mercato/core/helpers/integration/generalFixtures";
4
- import {
5
- SCHEDULER_JOBS_PATH,
6
- SCHEDULER_TRIGGER_PATH,
7
- createScheduleJob,
8
- deleteScheduleJob,
9
- uniqueScheduleName
10
- } from "./helpers/scheduler.js";
11
- const isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || "local") === "async";
12
- test.describe("TC-SCHED-008: system-scoped schedule access on trigger and executions", () => {
13
- test("super admin reaches the schedule on both routes; a non-super-admin is forbidden", async ({ request }) => {
14
- const superToken = await getAuthToken(request, "superadmin");
15
- const adminToken = await getAuthToken(request, "admin");
16
- let scheduleId = null;
17
- try {
18
- scheduleId = await createScheduleJob(request, superToken, {
19
- name: uniqueScheduleName("System-Trigger-Test"),
20
- scopeType: "system",
21
- scheduleType: "interval",
22
- scheduleValue: "1h"
23
- });
24
- const superResponse = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
25
- token: superToken,
26
- data: { id: scheduleId }
27
- });
28
- const superBody = await readJsonSafe(superResponse);
29
- expect(
30
- superResponse.status(),
31
- "a super admin must not be told a system-scoped schedule does not exist"
32
- ).not.toBe(404);
33
- if (isAsyncQueueStrategy) {
34
- expect(superResponse.status()).toBe(200);
35
- expect(superBody?.ok).toBe(true);
36
- expect(typeof superBody?.jobId === "string" && superBody.jobId.length > 0).toBe(true);
37
- } else {
38
- expect(superResponse.status()).toBe(400);
39
- expect(superBody?.error ?? "").toMatch(/async/i);
40
- }
41
- const adminResponse = await apiRequest(request, "POST", SCHEDULER_TRIGGER_PATH, {
42
- token: adminToken,
43
- data: { id: scheduleId }
44
- });
45
- expect(
46
- adminResponse.status(),
47
- "a non-super-admin must be forbidden from triggering a system-scoped schedule"
48
- ).toBe(403);
49
- const executionsPath = `${SCHEDULER_JOBS_PATH}/${scheduleId}/executions`;
50
- const superExecutions = await apiRequest(request, "GET", executionsPath, { token: superToken });
51
- expect(
52
- superExecutions.status(),
53
- "a super admin must not be told a system-scoped schedule has no execution history route"
54
- ).not.toBe(404);
55
- expect(superExecutions.status()).toBe(isAsyncQueueStrategy ? 200 : 400);
56
- const adminExecutions = await apiRequest(request, "GET", executionsPath, { token: adminToken });
57
- expect(
58
- adminExecutions.status(),
59
- "a non-super-admin must be forbidden from reading a system-scoped execution history"
60
- ).toBe(403);
61
- } finally {
62
- await deleteScheduleJob(request, superToken, scheduleId);
63
- }
64
- });
65
- });
66
- //# sourceMappingURL=TC-SCHED-008.spec.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/modules/scheduler/__integration__/TC-SCHED-008.spec.ts"],
4
- "sourcesContent": ["import { expect, test } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\nimport { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'\nimport {\n SCHEDULER_JOBS_PATH,\n SCHEDULER_TRIGGER_PATH,\n createScheduleJob,\n deleteScheduleJob,\n uniqueScheduleName,\n} from './helpers/scheduler'\n\nconst isAsyncQueueStrategy = (process.env.QUEUE_STRATEGY || 'local') === 'async'\n\ntype TriggerBody = { ok?: boolean; jobId?: string; error?: string; message?: string }\n\n/**\n * TC-SCHED-008: POST /api/scheduler/trigger and GET /api/scheduler/jobs/[id]/executions\n * resolve the scope of a system-scoped schedule on the loaded row, not in the lookup.\n *\n * A system-scoped schedule has `tenantId === null` and `organizationId === null`. When both\n * routes folded the caller's tenant/org into the `where` clause, no such row could ever match\n * and every caller \u2014 super admins included \u2014 got 404, which also masked each route's own\n * super-admin gate. A super admin must now reach the schedule, and a non-super-admin must be\n * told 403 rather than 404.\n *\n * The super-admin assertions hold under either queue strategy: each route's\n * `QUEUE_STRATEGY=async` check sits after the lookup, so `local` yields the pre-existing\n * 400 \u2014 never 404.\n */\ntest.describe('TC-SCHED-008: system-scoped schedule access on trigger and executions', () => {\n test('super admin reaches the schedule on both routes; a non-super-admin is forbidden', async ({ request }) => {\n const superToken = await getAuthToken(request, 'superadmin')\n const adminToken = await getAuthToken(request, 'admin')\n let scheduleId: string | null = null\n\n try {\n // Creating a system-scoped schedule is itself super-admin only.\n scheduleId = await createScheduleJob(request, superToken, {\n name: uniqueScheduleName('System-Trigger-Test'),\n scopeType: 'system',\n scheduleType: 'interval',\n scheduleValue: '1h',\n })\n\n const superResponse = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {\n token: superToken,\n data: { id: scheduleId },\n })\n const superBody = await readJsonSafe<TriggerBody>(superResponse)\n expect(\n superResponse.status(),\n 'a super admin must not be told a system-scoped schedule does not exist',\n ).not.toBe(404)\n\n if (isAsyncQueueStrategy) {\n expect(superResponse.status()).toBe(200)\n expect(superBody?.ok).toBe(true)\n expect(typeof superBody?.jobId === 'string' && superBody.jobId.length > 0).toBe(true)\n } else {\n // The strategy check runs after the scope check, so this 400 is the response the\n // 404 used to mask.\n expect(superResponse.status()).toBe(400)\n expect(superBody?.error ?? '').toMatch(/async/i)\n }\n\n // The same schedule is visible enough to be refused, not hidden, from a tenant admin.\n const adminResponse = await apiRequest(request, 'POST', SCHEDULER_TRIGGER_PATH, {\n token: adminToken,\n data: { id: scheduleId },\n })\n expect(\n adminResponse.status(),\n 'a non-super-admin must be forbidden from triggering a system-scoped schedule',\n ).toBe(403)\n\n // The execution-history route carried the same lookup and needs the same guarantees.\n const executionsPath = `${SCHEDULER_JOBS_PATH}/${scheduleId}/executions`\n const superExecutions = await apiRequest(request, 'GET', executionsPath, { token: superToken })\n expect(\n superExecutions.status(),\n 'a super admin must not be told a system-scoped schedule has no execution history route',\n ).not.toBe(404)\n expect(superExecutions.status()).toBe(isAsyncQueueStrategy ? 200 : 400)\n\n const adminExecutions = await apiRequest(request, 'GET', executionsPath, { token: adminToken })\n expect(\n adminExecutions.status(),\n 'a non-super-admin must be forbidden from reading a system-scoped execution history',\n ).toBe(403)\n } finally {\n await deleteScheduleJob(request, superToken, scheduleId)\n }\n })\n})\n"],
5
- "mappings": "AAAA,SAAS,QAAQ,YAAY;AAC7B,SAAS,YAAY,oBAAoB;AACzC,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,MAAM,wBAAwB,QAAQ,IAAI,kBAAkB,aAAa;AAkBzE,KAAK,SAAS,yEAAyE,MAAM;AAC3F,OAAK,mFAAmF,OAAO,EAAE,QAAQ,MAAM;AAC7G,UAAM,aAAa,MAAM,aAAa,SAAS,YAAY;AAC3D,UAAM,aAAa,MAAM,aAAa,SAAS,OAAO;AACtD,QAAI,aAA4B;AAEhC,QAAI;AAEF,mBAAa,MAAM,kBAAkB,SAAS,YAAY;AAAA,QACxD,MAAM,mBAAmB,qBAAqB;AAAA,QAC9C,WAAW;AAAA,QACX,cAAc;AAAA,QACd,eAAe;AAAA,MACjB,CAAC;AAED,YAAM,gBAAgB,MAAM,WAAW,SAAS,QAAQ,wBAAwB;AAAA,QAC9E,OAAO;AAAA,QACP,MAAM,EAAE,IAAI,WAAW;AAAA,MACzB,CAAC;AACD,YAAM,YAAY,MAAM,aAA0B,aAAa;AAC/D;AAAA,QACE,cAAc,OAAO;AAAA,QACrB;AAAA,MACF,EAAE,IAAI,KAAK,GAAG;AAEd,UAAI,sBAAsB;AACxB,eAAO,cAAc,OAAO,CAAC,EAAE,KAAK,GAAG;AACvC,eAAO,WAAW,EAAE,EAAE,KAAK,IAAI;AAC/B,eAAO,OAAO,WAAW,UAAU,YAAY,UAAU,MAAM,SAAS,CAAC,EAAE,KAAK,IAAI;AAAA,MACtF,OAAO;AAGL,eAAO,cAAc,OAAO,CAAC,EAAE,KAAK,GAAG;AACvC,eAAO,WAAW,SAAS,EAAE,EAAE,QAAQ,QAAQ;AAAA,MACjD;AAGA,YAAM,gBAAgB,MAAM,WAAW,SAAS,QAAQ,wBAAwB;AAAA,QAC9E,OAAO;AAAA,QACP,MAAM,EAAE,IAAI,WAAW;AAAA,MACzB,CAAC;AACD;AAAA,QACE,cAAc,OAAO;AAAA,QACrB;AAAA,MACF,EAAE,KAAK,GAAG;AAGV,YAAM,iBAAiB,GAAG,mBAAmB,IAAI,UAAU;AAC3D,YAAM,kBAAkB,MAAM,WAAW,SAAS,OAAO,gBAAgB,EAAE,OAAO,WAAW,CAAC;AAC9F;AAAA,QACE,gBAAgB,OAAO;AAAA,QACvB;AAAA,MACF,EAAE,IAAI,KAAK,GAAG;AACd,aAAO,gBAAgB,OAAO,CAAC,EAAE,KAAK,uBAAuB,MAAM,GAAG;AAEtE,YAAM,kBAAkB,MAAM,WAAW,SAAS,OAAO,gBAAgB,EAAE,OAAO,WAAW,CAAC;AAC9F;AAAA,QACE,gBAAgB,OAAO;AAAA,QACvB;AAAA,MACF,EAAE,KAAK,GAAG;AAAA,IACZ,UAAE;AACA,YAAM,kBAAkB,SAAS,YAAY,UAAU;AAAA,IACzD;AAAA,EACF,CAAC;AACH,CAAC;",
6
- "names": []
7
- }