@open-mercato/shared 0.10.1-develop.7576.1.21d5b50435 → 0.10.1-develop.7577.1.477f2486e5

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 (125) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +4 -4
  3. package/dist/lib/auth/server.js +1 -1
  4. package/dist/lib/auth/server.js.map +2 -2
  5. package/dist/lib/bootstrap/dynamicLoader.js +46 -6
  6. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  7. package/dist/lib/bootstrap/factory.js +4 -3
  8. package/dist/lib/bootstrap/factory.js.map +2 -2
  9. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js +6 -0
  10. package/dist/lib/bootstrap/next-subpath-resolve-hook-worker.js.map +7 -0
  11. package/dist/lib/bootstrap/next-subpath-resolve-hook.js +62 -0
  12. package/dist/lib/bootstrap/next-subpath-resolve-hook.js.map +7 -0
  13. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js +33 -0
  14. package/dist/lib/catalog-visibility/canonicalizeEffectiveScope.js.map +7 -0
  15. package/dist/lib/catalog-visibility/hashEffectiveScope.js +9 -0
  16. package/dist/lib/catalog-visibility/hashEffectiveScope.js.map +7 -0
  17. package/dist/lib/catalog-visibility/index.js +4 -0
  18. package/dist/lib/catalog-visibility/index.js.map +2 -2
  19. package/dist/lib/commands/command-bus.js +45 -13
  20. package/dist/lib/commands/command-bus.js.map +2 -2
  21. package/dist/lib/commands/types.js.map +2 -2
  22. package/dist/lib/crud/exporters.js +1 -1
  23. package/dist/lib/crud/exporters.js.map +2 -2
  24. package/dist/lib/crud/ids.js +13 -1
  25. package/dist/lib/crud/ids.js.map +2 -2
  26. package/dist/lib/db/advisoryLock.js +172 -0
  27. package/dist/lib/db/advisoryLock.js.map +7 -0
  28. package/dist/lib/db/pg-errors.js +14 -0
  29. package/dist/lib/db/pg-errors.js.map +2 -2
  30. package/dist/lib/localization/index.js +9 -1
  31. package/dist/lib/localization/index.js.map +2 -2
  32. package/dist/lib/localization/translatable-fields.js +26 -0
  33. package/dist/lib/localization/translatable-fields.js.map +2 -2
  34. package/dist/lib/number.js +6 -0
  35. package/dist/lib/number.js.map +2 -2
  36. package/dist/lib/openapi/generator.js +84 -67
  37. package/dist/lib/openapi/generator.js.map +2 -2
  38. package/dist/lib/query/engine.js +45 -10
  39. package/dist/lib/query/engine.js.map +2 -2
  40. package/dist/lib/query/join-utils.js +1 -1
  41. package/dist/lib/query/join-utils.js.map +2 -2
  42. package/dist/lib/query/overlap.js +58 -0
  43. package/dist/lib/query/overlap.js.map +7 -0
  44. package/dist/lib/query/types.js.map +2 -2
  45. package/dist/lib/url.js +24 -0
  46. package/dist/lib/url.js.map +2 -2
  47. package/dist/lib/version.js +1 -1
  48. package/dist/lib/version.js.map +1 -1
  49. package/dist/lib/webhooks/body.js +7 -3
  50. package/dist/lib/webhooks/body.js.map +2 -2
  51. package/dist/lib/webhooks/index.js +2 -0
  52. package/dist/lib/webhooks/index.js.map +2 -2
  53. package/dist/modules/integrations/types.js.map +2 -2
  54. package/dist/modules/notifications/safeHref.js +20 -0
  55. package/dist/modules/notifications/safeHref.js.map +7 -0
  56. package/dist/modules/notifications/types.js +4 -0
  57. package/dist/modules/notifications/types.js.map +3 -3
  58. package/dist/modules/payment_gateways/types.js +14 -1
  59. package/dist/modules/payment_gateways/types.js.map +2 -2
  60. package/dist/modules/registry.js.map +2 -2
  61. package/dist/modules/search.js.map +2 -2
  62. package/dist/modules/widgets/injection-loader.js +14 -3
  63. package/dist/modules/widgets/injection-loader.js.map +2 -2
  64. package/package.json +9 -2
  65. package/src/lib/__tests__/number.test.ts +24 -1
  66. package/src/lib/__tests__/url.cross-site.test.ts +140 -0
  67. package/src/lib/auth/__tests__/server.test.ts +14 -0
  68. package/src/lib/auth/server.ts +1 -1
  69. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +2 -2
  70. package/src/lib/bootstrap/__tests__/dynamicLoader.appModuleOverrides.test.ts +2 -2
  71. package/src/lib/bootstrap/__tests__/dynamicLoader.cacheRecovery.test.ts +2 -2
  72. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +2 -2
  73. package/src/lib/bootstrap/__tests__/dynamicLoader.externalSubpath.test.ts +141 -0
  74. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +2 -2
  75. package/src/lib/bootstrap/__tests__/factory.test.ts +18 -6
  76. package/src/lib/bootstrap/__tests__/next-subpath-resolve-hook.test.ts +174 -0
  77. package/src/lib/bootstrap/dynamicLoader.ts +63 -5
  78. package/src/lib/bootstrap/factory.ts +8 -3
  79. package/src/lib/bootstrap/next-subpath-resolve-hook-worker.ts +3 -0
  80. package/src/lib/bootstrap/next-subpath-resolve-hook.ts +130 -0
  81. package/src/lib/bootstrap/types.ts +5 -1
  82. package/src/lib/catalog-visibility/__tests__/hashEffectiveScope.test.ts +121 -0
  83. package/src/lib/catalog-visibility/canonicalizeEffectiveScope.ts +41 -0
  84. package/src/lib/catalog-visibility/hashEffectiveScope.ts +8 -0
  85. package/src/lib/catalog-visibility/index.ts +2 -0
  86. package/src/lib/commands/__tests__/command-bus.test.ts +100 -1
  87. package/src/lib/commands/command-bus.ts +42 -10
  88. package/src/lib/commands/command-interceptor.ts +4 -0
  89. package/src/lib/commands/types.ts +14 -0
  90. package/src/lib/crud/__tests__/exporters.test.ts +18 -0
  91. package/src/lib/crud/__tests__/ids.test.ts +34 -1
  92. package/src/lib/crud/exporters.ts +1 -1
  93. package/src/lib/crud/ids.ts +24 -0
  94. package/src/lib/db/__tests__/advisoryLock.integration.test.ts +343 -0
  95. package/src/lib/db/__tests__/advisoryLock.test.ts +564 -0
  96. package/src/lib/db/__tests__/pg-errors.test.ts +68 -1
  97. package/src/lib/db/advisoryLock.ts +271 -0
  98. package/src/lib/db/pg-errors.ts +35 -0
  99. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +23 -0
  100. package/src/lib/localization/__tests__/translatable-fields.test.ts +17 -0
  101. package/src/lib/localization/index.ts +8 -1
  102. package/src/lib/localization/translatable-fields.ts +46 -0
  103. package/src/lib/number.ts +13 -0
  104. package/src/lib/openapi/__tests__/optional-path-params.test.ts +102 -0
  105. package/src/lib/openapi/generator.ts +106 -77
  106. package/src/lib/query/__tests__/empty-ids-filter.pg.test.ts +89 -0
  107. package/src/lib/query/__tests__/engine.empty-in-filter.test.ts +79 -0
  108. package/src/lib/query/__tests__/overlap.test.ts +219 -0
  109. package/src/lib/query/engine.ts +58 -8
  110. package/src/lib/query/join-utils.ts +1 -1
  111. package/src/lib/query/overlap.ts +106 -0
  112. package/src/lib/query/types.ts +13 -1
  113. package/src/lib/url.ts +30 -0
  114. package/src/lib/webhooks/__tests__/body.test.ts +48 -0
  115. package/src/lib/webhooks/body.ts +11 -4
  116. package/src/lib/webhooks/index.ts +1 -0
  117. package/src/modules/integrations/types.ts +6 -0
  118. package/src/modules/notifications/safeHref.ts +19 -0
  119. package/src/modules/notifications/types.ts +7 -0
  120. package/src/modules/payment_gateways/__tests__/types.test.ts +44 -0
  121. package/src/modules/payment_gateways/types.ts +52 -6
  122. package/src/modules/registry.ts +5 -0
  123. package/src/modules/search.ts +39 -0
  124. package/src/modules/widgets/__tests__/injection-loader.partitioned-registration.test.ts +78 -0
  125. package/src/modules/widgets/injection-loader.ts +33 -3
@@ -0,0 +1,41 @@
1
+ import type { AssortmentScope, EffectiveAssortmentScope } from './types'
2
+
3
+ const ID_LIST_KEYS = [
4
+ 'categoryIds',
5
+ 'excludeCategoryIds',
6
+ 'excludeProductIds',
7
+ 'excludeTagIds',
8
+ 'tagIds',
9
+ ] as const
10
+
11
+ function compareStrings(left: string, right: string): number {
12
+ if (left < right) return -1
13
+ if (left > right) return 1
14
+ return 0
15
+ }
16
+
17
+ function sortedUnique(values: string[]): string[] {
18
+ return Array.from(new Set(values)).sort(compareStrings)
19
+ }
20
+
21
+ function canonicalizeBranch(scope: AssortmentScope): string {
22
+ const members: string[] = []
23
+ const nested = (scope.allOf ?? []).map(canonicalizeBranch)
24
+ if (nested.length > 0) members.push(`"allOf":[${sortedUnique(nested).join(',')}]`)
25
+ for (const key of ID_LIST_KEYS) {
26
+ const ids = scope[key] ?? []
27
+ if (ids.length > 0) members.push(`${JSON.stringify(key)}:${JSON.stringify(sortedUnique(ids))}`)
28
+ }
29
+ return `{${members.join(',')}}`
30
+ }
31
+
32
+ /**
33
+ * Deterministic serialization of a resolved scope. `null` (unrestricted) serializes as `null`
34
+ * and `[]` (deny-all) as `[]`, so the two never collide. Empty and absent arrays are
35
+ * equivalent, id lists are sorted and deduplicated, and OR-branches and `allOf` entries are
36
+ * ordered by their own canonical form with identical ones collapsed.
37
+ */
38
+ export function canonicalizeEffectiveScope(scope: EffectiveAssortmentScope): string {
39
+ if (scope === null) return 'null'
40
+ return `[${sortedUnique(scope.map(canonicalizeBranch)).join(',')}]`
41
+ }
@@ -0,0 +1,8 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { canonicalizeEffectiveScope } from './canonicalizeEffectiveScope'
3
+ import type { EffectiveAssortmentScope } from './types'
4
+
5
+ /** 16-hex-character sha256 digest of the canonical scope (SPEC-029 §6.1 truncation). Server-only: uses node:crypto. */
6
+ export function hashEffectiveScope(scope: EffectiveAssortmentScope): string {
7
+ return createHash('sha256').update(canonicalizeEffectiveScope(scope)).digest('hex').slice(0, 16)
8
+ }
@@ -3,3 +3,5 @@ export { matchesOne } from './matchesOne'
3
3
  export { matchesScope } from './matchesScope'
4
4
  export { unionScopes } from './unionScopes'
5
5
  export { intersectScopes } from './intersectScopes'
6
+ export { canonicalizeEffectiveScope } from './canonicalizeEffectiveScope'
7
+ export { hashEffectiveScope } from './hashEffectiveScope'
@@ -7,7 +7,7 @@ import {
7
7
  isCommandInterceptorError,
8
8
  } from '@open-mercato/shared/lib/commands'
9
9
  import { registerCommandInterceptors } from '@open-mercato/shared/lib/commands/command-interceptor-store'
10
- import type { CommandInterceptor } from '@open-mercato/shared/lib/commands/command-interceptor'
10
+ import type { CommandInterceptor, CommandInterceptorContext } from '@open-mercato/shared/lib/commands/command-interceptor'
11
11
 
12
12
  describe('CommandBus', () => {
13
13
  afterEach(() => {
@@ -296,6 +296,57 @@ describe('CommandBus', () => {
296
296
  )
297
297
  })
298
298
 
299
+ it('defers opted-in before snapshots until after the transactional write guard', async () => {
300
+ const calls: string[] = []
301
+ const transactionalEm = { id: 'transaction-em' }
302
+ const buildLogMock = jest.fn(() => ({
303
+ actionLabel: 'Transaction snapshot',
304
+ resourceKind: 'test',
305
+ resourceId: 'transactional',
306
+ }))
307
+ const prepare = jest.fn(async (_input, ctx) => {
308
+ calls.push('prepare')
309
+ expect(ctx.transactionalEm).toBe(transactionalEm)
310
+ return { before: { state: 'locked-before' } }
311
+ })
312
+
313
+ registerCommand({
314
+ id: 'test.command.transactional-snapshot',
315
+ prepare,
316
+ prepareSnapshotInsideTransaction: true,
317
+ execute: jest.fn(async (_input, ctx) => {
318
+ calls.push('execute')
319
+ await ctx.beforeTransactionalWrite?.(transactionalEm as never)
320
+ calls.push('mutate')
321
+ return { ok: true }
322
+ }),
323
+ buildLog: buildLogMock,
324
+ })
325
+
326
+ const logMock = jest.fn(async () => ({ id: 'transaction-log' }))
327
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
328
+ container.register({ actionLogService: asValue({ log: logMock }) })
329
+ const bus = new CommandBus()
330
+ const ctx = {
331
+ container,
332
+ auth: { sub: 'user-transaction', tenantId: 'tenant-transaction', orgId: null },
333
+ organizationScope: null,
334
+ selectedOrganizationId: null,
335
+ organizationIds: null,
336
+ beforeTransactionalWrite: jest.fn(async () => {
337
+ calls.push('lock')
338
+ }),
339
+ }
340
+
341
+ await bus.execute('test.command.transactional-snapshot', { input: {}, ctx })
342
+
343
+ expect(calls).toEqual(['execute', 'lock', 'prepare', 'mutate'])
344
+ expect(prepare).toHaveBeenCalledTimes(1)
345
+ expect(buildLogMock).toHaveBeenCalledWith(expect.objectContaining({
346
+ snapshots: { before: { state: 'locked-before' }, after: undefined },
347
+ }))
348
+ })
349
+
299
350
  it('loads a command file lazily before execution', async () => {
300
351
  const execute = jest.fn(async () => ({ ok: true }))
301
352
  registerCommandLoaders([
@@ -538,6 +589,54 @@ describe('CommandBus', () => {
538
589
  })
539
590
 
540
591
  describe('interceptor rejections', () => {
592
+ it('passes the original request to beforeExecute for preflight version checks', async () => {
593
+ registerCommand({ id: 'test.request-context', execute: jest.fn(async () => ({ ok: true })) })
594
+ const beforeExecute = jest.fn(async (_input: unknown, context: { request?: Request | null }) => {
595
+ expect(context.request?.headers.get('x-om-ext-optimistic-lock-expected-updated-at')).toBe('2026-09-29T11:00:00.000Z')
596
+ return { ok: false, status: 422 }
597
+ })
598
+ registerCommandInterceptors([{ moduleId: 'test', interceptors: [{
599
+ id: 'test.request-context-interceptor', targetCommand: 'test.request-context', beforeExecute,
600
+ }] }])
601
+ const request = new Request('http://localhost/test', { headers: { 'x-om-ext-optimistic-lock-expected-updated-at': '2026-09-29T11:00:00.000Z' } })
602
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
603
+ const bus = new CommandBus()
604
+ await expect(bus.execute('test.request-context', { input: {}, ctx: {
605
+ container,
606
+ auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: null },
607
+ organizationScope: null,
608
+ selectedOrganizationId: null,
609
+ organizationIds: null,
610
+ request,
611
+ } })).rejects.toMatchObject({ status: 422 })
612
+ expect(beforeExecute).toHaveBeenCalledTimes(1)
613
+ })
614
+
615
+ it('passes request and organization scope to both execution lifecycle hooks', async () => {
616
+ registerCommand({ id: 'test.lifecycle-context', execute: async () => ({ ok: true }) })
617
+ const request = new Request('http://localhost/test')
618
+ const organizationScope = { selectedId: 'parent', filterIds: ['parent', 'child'], allowedIds: ['parent', 'child'], tenantId: 'tenant-1' }
619
+ const beforeExecute = jest.fn(async (_input: unknown, context: CommandInterceptorContext) => {
620
+ expect(context.request).toBe(request)
621
+ expect(context.organizationScope).toBe(organizationScope)
622
+ return { ok: true }
623
+ })
624
+ const afterExecute = jest.fn(async (_input: unknown, _result: unknown, context: CommandInterceptorContext) => {
625
+ expect(context.request).toBe(request)
626
+ expect(context.organizationScope).toBe(organizationScope)
627
+ })
628
+ registerCommandInterceptors([{ moduleId: 'test', interceptors: [{
629
+ id: 'test.lifecycle-context-interceptor', targetCommand: 'test.lifecycle-context', beforeExecute, afterExecute,
630
+ }] }])
631
+ await new CommandBus().execute('test.lifecycle-context', { input: {}, ctx: {
632
+ container: createContainer({ injectionMode: InjectionMode.CLASSIC }),
633
+ auth: { sub: 'user-1', tenantId: 'tenant-1', orgId: 'parent' },
634
+ organizationScope, selectedOrganizationId: 'parent', organizationIds: ['parent', 'child'], request,
635
+ } })
636
+ expect(beforeExecute).toHaveBeenCalledTimes(1)
637
+ expect(afterExecute).toHaveBeenCalledTimes(1)
638
+ })
639
+
541
640
  const blockingInterceptor = (result: Record<string, unknown>): CommandInterceptor => ({
542
641
  id: 'test.block',
543
642
  targetCommand: 'test.*',
@@ -297,6 +297,8 @@ export class CommandBus {
297
297
  auth: baseOptions.ctx.auth ?? null,
298
298
  selectedOrganizationId: baseOptions.ctx.selectedOrganizationId ?? baseOptions.ctx.auth?.orgId ?? null,
299
299
  container: baseOptions.ctx.container,
300
+ organizationScope: baseOptions.ctx.organizationScope ?? null,
301
+ request: baseOptions.ctx.request ?? null,
300
302
  }
301
303
  const beforeResult = await runCommandInterceptorsBefore(
302
304
  allInterceptors, commandId, baseOptions.input, interceptorCtx, resolvedUserFeatures,
@@ -338,16 +340,40 @@ export class CommandBus {
338
340
  }
339
341
 
340
342
  const executeCore = async (coreOptions: CommandExecutionOptions<TInput>) => {
341
- const sourceLog = coreOptions.redoLogEntry ?? null
342
- const snapshots = await this.prepareSnapshots(handler, coreOptions)
343
+ const snapshots: { before?: unknown } = {}
344
+ let executionOptions = coreOptions
345
+ if (handler.prepareSnapshotInsideTransaction && handler.prepare) {
346
+ const prepare = handler.prepare
347
+ const originalCtx = coreOptions.ctx
348
+ const originalBeforeTransactionalWrite = originalCtx.beforeTransactionalWrite
349
+ let prepared = false
350
+ let transactionalCtx: CommandRuntimeContext
351
+ transactionalCtx = {
352
+ ...originalCtx,
353
+ beforeTransactionalWrite: async (em, input) => {
354
+ await originalBeforeTransactionalWrite?.(em, input)
355
+ if (prepared) return
356
+ Object.assign(
357
+ snapshots,
358
+ (await prepare(coreOptions.input, { ...transactionalCtx, transactionalEm: em })) ?? {},
359
+ )
360
+ prepared = true
361
+ },
362
+ }
363
+ executionOptions = { ...coreOptions, ctx: transactionalCtx }
364
+ } else {
365
+ Object.assign(snapshots, await this.prepareSnapshots(handler, coreOptions))
366
+ }
367
+ effectiveOptions = executionOptions
368
+ const sourceLog = executionOptions.redoLogEntry ?? null
343
369
  const result =
344
370
  sourceLog && typeof handler.redo === 'function'
345
- ? await handler.redo({ input: coreOptions.input, ctx: coreOptions.ctx, logEntry: sourceLog })
346
- : await handler.execute(coreOptions.input, coreOptions.ctx)
347
- const afterSnapshot = await this.captureAfter(handler, coreOptions, result)
371
+ ? await handler.redo({ input: executionOptions.input, ctx: executionOptions.ctx, logEntry: sourceLog })
372
+ : await handler.execute(executionOptions.input, executionOptions.ctx)
373
+ const afterSnapshot = await this.captureAfter(handler, executionOptions, result)
348
374
  const snapshotsWithAfter = { ...snapshots, after: afterSnapshot }
349
- const logMeta = await this.buildLog(handler, coreOptions, result, snapshotsWithAfter)
350
- let mergedMeta = this.mergeMetadata(coreOptions.metadata, logMeta)
375
+ const logMeta = await this.buildLog(handler, executionOptions, result, snapshotsWithAfter)
376
+ let mergedMeta = this.mergeMetadata(executionOptions.metadata, logMeta)
351
377
  // Interceptors opt into audit-log enrichment with a reserved `logContext` key rather
352
378
  // than the generic `context` one, so the metadata an interceptor already passes to its
353
379
  // own afterExecute hook is never silently promoted into audit storage.
@@ -362,7 +388,7 @@ export class CommandBus {
362
388
  ...logContextRecord,
363
389
  }
364
390
  }
365
- const baseContext = asRecord(coreOptions.metadata?.context) ?? {}
391
+ const baseContext = asRecord(executionOptions.metadata?.context) ?? {}
366
392
  const logMetaContext = asRecord(logMeta?.context) ?? {}
367
393
  if (Object.keys(interceptorContextMerged).length > 0 || Object.keys(baseContext).length > 0 || Object.keys(logMetaContext).length > 0) {
368
394
  mergedMeta = mergedMeta ?? {}
@@ -376,7 +402,7 @@ export class CommandBus {
376
402
  if (undoable && mergedMeta?.replayable !== false) {
377
403
  mergedMeta = mergedMeta ?? {}
378
404
  if (!mergedMeta.undoToken) mergedMeta.undoToken = defaultUndoToken()
379
- if (mergedMeta.actorUserId === undefined) mergedMeta.actorUserId = coreOptions.ctx.auth?.sub ?? null
405
+ if (mergedMeta.actorUserId === undefined) mergedMeta.actorUserId = executionOptions.ctx.auth?.sub ?? null
380
406
  } else if (mergedMeta?.replayable === false) {
381
407
  mergedMeta.undoToken = null
382
408
  }
@@ -405,7 +431,7 @@ export class CommandBus {
405
431
  if (inferred) mergedMeta.changes = inferred
406
432
  }
407
433
  }
408
- const logEntry = await this.persistLog(commandId, coreOptions, mergedMeta)
434
+ const logEntry = await this.persistLog(commandId, executionOptions, mergedMeta)
409
435
  return { result, mergedMeta, logEntry }
410
436
  }
411
437
 
@@ -474,6 +500,8 @@ export class CommandBus {
474
500
  auth: effectiveOptions.ctx.auth ?? null,
475
501
  selectedOrganizationId: effectiveOptions.ctx.selectedOrganizationId ?? effectiveOptions.ctx.auth?.orgId ?? null,
476
502
  container: effectiveOptions.ctx.container,
503
+ organizationScope: effectiveOptions.ctx.organizationScope ?? null,
504
+ request: effectiveOptions.ctx.request ?? null,
477
505
  }
478
506
  const afterResult = await runCommandInterceptorsAfter(
479
507
  allInterceptors, commandId, effectiveOptions.input, result, interceptorCtx,
@@ -545,6 +573,8 @@ export class CommandBus {
545
573
  auth: replayCtx.auth ?? null,
546
574
  selectedOrganizationId: replayCtx.selectedOrganizationId ?? replayCtx.auth?.orgId ?? null,
547
575
  container: replayCtx.container,
576
+ organizationScope: replayCtx.organizationScope ?? null,
577
+ request: replayCtx.request ?? null,
548
578
  }
549
579
  const beforeResult = await runCommandInterceptorsBeforeUndo(
550
580
  allInterceptors, log.commandId, undoCtx, interceptorCtx, resolvedUserFeatures,
@@ -659,6 +689,8 @@ export class CommandBus {
659
689
  auth: ctx.auth ?? null,
660
690
  selectedOrganizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
661
691
  container: ctx.container,
692
+ organizationScope: ctx.organizationScope ?? null,
693
+ request: ctx.request ?? null,
662
694
  }
663
695
  await runCommandInterceptorsAfterUndo(
664
696
  allInterceptors, log.commandId, undoCtx, interceptorCtx,
@@ -1,4 +1,5 @@
1
1
  import type { AwilixContainer } from 'awilix'
2
+ import type { OrganizationScope } from '@open-mercato/shared/lib/auth/principal-service'
2
3
  import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
3
4
 
4
5
  export interface CommandInterceptor {
@@ -47,8 +48,11 @@ export interface CommandInterceptorContext {
47
48
  auth: AuthContext | null
48
49
  /** Selected organization ID */
49
50
  selectedOrganizationId: string | null
51
+ organizationScope?: OrganizationScope | null
50
52
  /** DI container (read-only usage recommended) */
51
53
  container: AwilixContainer
54
+ /** Original request, when the command was called through HTTP. */
55
+ request?: Request | null
52
56
  /** Metadata passthrough from beforeExecute to afterExecute (or beforeUndo to afterUndo) */
53
57
  metadata?: Record<string, unknown>
54
58
  }
@@ -67,6 +67,14 @@ export type CommandRuntimeContext = {
67
67
  * surrounding work as a single atomic, single-locked operation.
68
68
  */
69
69
  transactionalEm?: EntityManager
70
+ /**
71
+ * Optional server-side guard that supported command handlers invoke after
72
+ * opening their write transaction and before reading or mutating records.
73
+ * The guard receives that same transaction-bound EntityManager and, when the
74
+ * handler provides it, the final input after command interceptors. The input
75
+ * stays optional so existing handlers and callers remain source-compatible.
76
+ */
77
+ beforeTransactionalWrite?: (em: EntityManager, input?: unknown) => Promise<void>
70
78
  /**
71
79
  * Identifies the owner of an already-active {@link transactionalEm} lifetime.
72
80
  * Atomic replay accepts an ambient EntityManager only when this is the exact
@@ -243,6 +251,12 @@ export interface CommandHandler<TInput = unknown, TResult = unknown> {
243
251
  */
244
252
  readonly outputSchema?: ZodTypeAny
245
253
  prepare?(input: TInput, ctx: CommandRuntimeContext): Promise<{ before?: unknown } | null> | { before?: unknown } | null
254
+ /**
255
+ * Opt in to taking the undo snapshot inside the write transaction. The bus
256
+ * defers `prepare()` into `ctx.beforeTransactionalWrite`, after the handler's
257
+ * transaction guard and before its first read or mutation.
258
+ */
259
+ readonly prepareSnapshotInsideTransaction?: boolean
246
260
  execute(input: TInput, ctx: CommandRuntimeContext): Promise<TResult> | TResult
247
261
  buildLog?(args: CommandLogBuilderArgs<TInput, TResult>): Promise<CommandLogMetadata | null | undefined> | CommandLogMetadata | null | undefined
248
262
  captureAfter?(input: TInput, result: TResult, ctx: CommandRuntimeContext): Promise<unknown> | unknown
@@ -28,4 +28,22 @@ describe('CRUD export serializers', () => {
28
28
  '"\'\t=HYPERLINK(""https://example.invalid"")","\'\r+1+1",\'-7',
29
29
  ])
30
30
  })
31
+
32
+ it('escapes and neutralizes CSV header cells the same way as data cells', () => {
33
+ const serialized = serializeExport({
34
+ columns: [
35
+ { field: 'company', header: 'Name, Inc.' },
36
+ { field: 'formula', header: '=HEADER' },
37
+ { field: 'plain', header: 'Balance' },
38
+ ],
39
+ rows: [
40
+ { company: 'Acme', formula: 'ok', plain: 1 },
41
+ ],
42
+ }, 'csv')
43
+
44
+ expect(serialized.body.split('\n')).toEqual([
45
+ '"Name, Inc.",\'=HEADER,Balance',
46
+ 'Acme,ok,1',
47
+ ])
48
+ })
31
49
  })
@@ -1,4 +1,11 @@
1
- import { MAX_IDS_PER_REQUEST, mergeIdFilter, parseIdsParam, isIdsParamProvided } from '@open-mercato/shared/lib/crud/ids'
1
+ import {
2
+ MAX_IDS_PER_REQUEST,
3
+ mergeIdFilter,
4
+ parseIdsParam,
5
+ isIdsParamProvided,
6
+ isUuidListParam,
7
+ splitIdListParam,
8
+ } from '@open-mercato/shared/lib/crud/ids'
2
9
 
3
10
  describe('crud ids helpers', () => {
4
11
  const idA = '550e8400-e29b-41d4-a716-446655440001'
@@ -168,4 +175,30 @@ describe('crud ids helpers', () => {
168
175
  tenantId: 't1',
169
176
  })
170
177
  })
178
+
179
+ it('splitIdListParam trims entries and drops empty ones', () => {
180
+ expect(splitIdListParam(` ${idA} ,, ${idB} ,`)).toEqual([idA, idB])
181
+ expect(splitIdListParam(undefined)).toEqual([])
182
+ expect(splitIdListParam(['not', 'a', 'string'])).toEqual([])
183
+ })
184
+
185
+ it('isUuidListParam accepts absent, empty, and all-UUID lists', () => {
186
+ expect(isUuidListParam(undefined)).toBe(true)
187
+ expect(isUuidListParam('')).toBe(true)
188
+ expect(isUuidListParam(` ${idA}, ${idB.toUpperCase()} `)).toBe(true)
189
+ expect(isUuidListParam('00000000-0000-0000-0000-000000000000')).toBe(true)
190
+ })
191
+
192
+ it('isUuidListParam rejects malformed and mixed lists', () => {
193
+ expect(isUuidListParam('not-a-uuid')).toBe(false)
194
+ expect(isUuidListParam(`${idA},not-a-uuid`)).toBe(false)
195
+ expect(isUuidListParam(`${idA}0`)).toBe(false)
196
+ expect(isUuidListParam(`{${idA}}`)).toBe(false)
197
+ })
198
+
199
+ it('isUuidListParam fails closed on non-string values', () => {
200
+ expect(isUuidListParam(['not-a-uuid'])).toBe(false)
201
+ expect(isUuidListParam([idA])).toBe(false)
202
+ expect(isUuidListParam(null)).toBe(false)
203
+ })
171
204
  })
@@ -81,7 +81,7 @@ function escapeXmlValue(value: string): string {
81
81
  }
82
82
 
83
83
  function serializeCsv(prepared: PreparedExport): SerializedExport {
84
- const headers = prepared.columns.map((col) => col.header)
84
+ const headers = prepared.columns.map((col) => escapeCsv(neutralizeSpreadsheetFormula(col.header)))
85
85
  const lines = [headers.join(',')]
86
86
  for (const row of prepared.rows) {
87
87
  const values = prepared.columns.map((col) => {
@@ -51,6 +51,30 @@ export function parseIdsParam(raw: unknown, maxIds: number = MAX_IDS_PER_REQUEST
51
51
  return parsed.slice(0, safeMax)
52
52
  }
53
53
 
54
+ const UUID_SHAPE_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
55
+
56
+ /**
57
+ * Splits a comma-separated id list query param into its trimmed, non-empty entries.
58
+ */
59
+ export function splitIdListParam(raw: unknown): string[] {
60
+ if (typeof raw !== 'string') return []
61
+ return raw
62
+ .split(',')
63
+ .map((value) => value.trim())
64
+ .filter((value) => value.length > 0)
65
+ }
66
+
67
+ /**
68
+ * Whether every entry of a comma-separated id list query param is UUID-shaped, so it can be
69
+ * bound to a uuid column without a database error. Use it to reject a malformed or mixed list
70
+ * with a 400 instead of dropping the bad entries and widening the result set (#7265).
71
+ */
72
+ export function isUuidListParam(raw: unknown): boolean {
73
+ if (raw === undefined) return true
74
+ if (typeof raw !== 'string') return false
75
+ return splitIdListParam(raw).every((value) => UUID_SHAPE_REGEX.test(value))
76
+ }
77
+
54
78
  /**
55
79
  * Whether an `?ids=` param was supplied at all (a non-empty string, or repeated
56
80
  * `?ids=` occurrences), regardless of whether any value survived UUID