@open-mercato/shared 0.6.7 → 0.6.8-develop.6874.1.982d6097d8

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 (195) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +57 -6
  3. package/build.mjs +2 -4
  4. package/dist/lib/ai/safety-identifier.js +32 -0
  5. package/dist/lib/ai/safety-identifier.js.map +7 -0
  6. package/dist/lib/auth/organizationScope.js +34 -0
  7. package/dist/lib/auth/organizationScope.js.map +7 -0
  8. package/dist/lib/auth/server.js +15 -2
  9. package/dist/lib/auth/server.js.map +2 -2
  10. package/dist/lib/bootstrap/clientOnlyModules.js +55 -0
  11. package/dist/lib/bootstrap/clientOnlyModules.js.map +7 -0
  12. package/dist/lib/bootstrap/dynamicLoader.js +117 -43
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/commands/command-bus.js +5 -1
  15. package/dist/lib/commands/command-bus.js.map +2 -2
  16. package/dist/lib/commands/command-interceptor-runner.js +2 -2
  17. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  18. package/dist/lib/crud/enricher-runner.js +2 -11
  19. package/dist/lib/crud/enricher-runner.js.map +2 -2
  20. package/dist/lib/crud/factory.js +72 -9
  21. package/dist/lib/crud/factory.js.map +2 -2
  22. package/dist/lib/crud/interceptor-runner.js +2 -2
  23. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  24. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  25. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  26. package/dist/lib/crud/types.js +4 -0
  27. package/dist/lib/crud/types.js.map +3 -3
  28. package/dist/lib/data/consistency.js +19 -0
  29. package/dist/lib/data/consistency.js.map +7 -0
  30. package/dist/lib/data/engine.js +37 -11
  31. package/dist/lib/data/engine.js.map +2 -2
  32. package/dist/lib/db/entityMetadata.js +26 -0
  33. package/dist/lib/db/entityMetadata.js.map +7 -0
  34. package/dist/lib/db/pg-errors.js +42 -0
  35. package/dist/lib/db/pg-errors.js.map +2 -2
  36. package/dist/lib/di/container.js +48 -6
  37. package/dist/lib/di/container.js.map +2 -2
  38. package/dist/lib/email/send.js +56 -0
  39. package/dist/lib/email/send.js.map +2 -2
  40. package/dist/lib/encryption/subscriber.js +2 -10
  41. package/dist/lib/encryption/subscriber.js.map +2 -2
  42. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  43. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  44. package/dist/lib/i18n/config.js +1 -1
  45. package/dist/lib/i18n/config.js.map +2 -2
  46. package/dist/lib/i18n/server.js +4 -4
  47. package/dist/lib/i18n/server.js.map +3 -3
  48. package/dist/lib/logger/extension.js +29 -0
  49. package/dist/lib/logger/extension.js.map +7 -0
  50. package/dist/lib/logger/index.js +47 -3
  51. package/dist/lib/logger/index.js.map +3 -3
  52. package/dist/lib/modules/registry.js +5 -1
  53. package/dist/lib/modules/registry.js.map +2 -2
  54. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  55. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  56. package/dist/lib/query/ciphertext-search-warning.js +45 -0
  57. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  58. package/dist/lib/query/engine.js +54 -40
  59. package/dist/lib/query/engine.js.map +2 -2
  60. package/dist/lib/query/types.js.map +1 -1
  61. package/dist/lib/redis/connection.js +7 -2
  62. package/dist/lib/redis/connection.js.map +2 -2
  63. package/dist/lib/search/auto-indexing.js +14 -0
  64. package/dist/lib/search/auto-indexing.js.map +7 -0
  65. package/dist/lib/search/availability.js +107 -0
  66. package/dist/lib/search/availability.js.map +7 -0
  67. package/dist/lib/search/config.js +61 -2
  68. package/dist/lib/search/config.js.map +2 -2
  69. package/dist/lib/search/tokenLookup.js +46 -0
  70. package/dist/lib/search/tokenLookup.js.map +7 -0
  71. package/dist/lib/search/tokenize.js +23 -11
  72. package/dist/lib/search/tokenize.js.map +2 -2
  73. package/dist/lib/telemetry/runtime.js +35 -0
  74. package/dist/lib/telemetry/runtime.js.map +7 -0
  75. package/dist/lib/version.js +1 -1
  76. package/dist/lib/version.js.map +1 -1
  77. package/dist/lib/webhooks/body.js +57 -0
  78. package/dist/lib/webhooks/body.js.map +7 -0
  79. package/dist/lib/webhooks/inbound-types.js +1 -0
  80. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  81. package/dist/lib/webhooks/index.js +13 -1
  82. package/dist/lib/webhooks/index.js.map +2 -2
  83. package/dist/lib/webhooks/verify.js +12 -7
  84. package/dist/lib/webhooks/verify.js.map +2 -2
  85. package/dist/modules/analytics.js.map +2 -2
  86. package/dist/modules/overrides.js +50 -1
  87. package/dist/modules/overrides.js.map +2 -2
  88. package/dist/modules/payment_gateways/types.js +8 -1
  89. package/dist/modules/payment_gateways/types.js.map +2 -2
  90. package/dist/modules/registry.js +1 -0
  91. package/dist/modules/registry.js.map +2 -2
  92. package/dist/modules/widgets/extension-points.js +96 -0
  93. package/dist/modules/widgets/extension-points.js.map +7 -0
  94. package/dist/security/enabledModulesRegistry.js +56 -11
  95. package/dist/security/enabledModulesRegistry.js.map +2 -2
  96. package/dist/security/featurePolicy.js +62 -0
  97. package/dist/security/featurePolicy.js.map +7 -0
  98. package/jest.config.cjs +2 -1
  99. package/package.json +19 -9
  100. package/scripts/versionSource.cjs +65 -0
  101. package/src/lib/__tests__/versionSource.test.ts +46 -0
  102. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  103. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  104. package/src/lib/ai/llm-provider.ts +33 -0
  105. package/src/lib/ai/safety-identifier.ts +73 -0
  106. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  107. package/src/lib/auth/__tests__/server.test.ts +23 -0
  108. package/src/lib/auth/organizationScope.ts +65 -0
  109. package/src/lib/auth/server.ts +23 -2
  110. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  111. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  112. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  113. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  114. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  115. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  116. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  117. package/src/lib/bootstrap/dynamicLoader.ts +213 -65
  118. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +28 -0
  119. package/src/lib/commands/command-bus.ts +5 -1
  120. package/src/lib/commands/command-interceptor-runner.ts +2 -2
  121. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  122. package/src/lib/crud/__tests__/crud-factory.test.ts +351 -0
  123. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  124. package/src/lib/crud/enricher-runner.ts +2 -11
  125. package/src/lib/crud/factory.ts +82 -6
  126. package/src/lib/crud/interceptor-runner.ts +2 -2
  127. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  128. package/src/lib/crud/types.ts +3 -0
  129. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  130. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  131. package/src/lib/data/consistency.ts +17 -0
  132. package/src/lib/data/engine.ts +50 -16
  133. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  134. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  135. package/src/lib/db/entityMetadata.ts +43 -0
  136. package/src/lib/db/pg-errors.ts +58 -0
  137. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  138. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  139. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  140. package/src/lib/di/container.ts +72 -8
  141. package/src/lib/email/__tests__/send.test.ts +37 -1
  142. package/src/lib/email/send.ts +79 -0
  143. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  144. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  145. package/src/lib/encryption/subscriber.ts +2 -14
  146. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  147. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  148. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  149. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  150. package/src/lib/i18n/config.ts +2 -2
  151. package/src/lib/i18n/server.ts +6 -2
  152. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  153. package/src/lib/logger/extension.ts +59 -0
  154. package/src/lib/logger/index.ts +59 -1
  155. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  156. package/src/lib/modules/registry.ts +10 -0
  157. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  158. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  159. package/src/lib/query/__tests__/engine.test.ts +65 -13
  160. package/src/lib/query/ciphertext-search-warning.ts +95 -0
  161. package/src/lib/query/engine.ts +75 -58
  162. package/src/lib/query/types.ts +4 -5
  163. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  164. package/src/lib/redis/connection.ts +10 -7
  165. package/src/lib/search/__tests__/availability.test.ts +211 -0
  166. package/src/lib/search/__tests__/config.test.ts +177 -0
  167. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  168. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  169. package/src/lib/search/auto-indexing.ts +22 -0
  170. package/src/lib/search/availability.ts +232 -0
  171. package/src/lib/search/config.ts +106 -8
  172. package/src/lib/search/tokenLookup.ts +133 -0
  173. package/src/lib/search/tokenize.ts +33 -11
  174. package/src/lib/telemetry/runtime.ts +72 -0
  175. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  176. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  177. package/src/lib/webhooks/body.ts +70 -0
  178. package/src/lib/webhooks/inbound-types.ts +125 -0
  179. package/src/lib/webhooks/index.ts +19 -1
  180. package/src/lib/webhooks/verify.ts +17 -9
  181. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  182. package/src/modules/__tests__/registry.test.ts +10 -2
  183. package/src/modules/analytics.ts +7 -0
  184. package/src/modules/customer-auth.ts +1 -0
  185. package/src/modules/encryption.ts +3 -0
  186. package/src/modules/navigation/backendChrome.ts +15 -0
  187. package/src/modules/overrides.ts +103 -0
  188. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  189. package/src/modules/payment_gateways/types.ts +41 -0
  190. package/src/modules/registry.ts +3 -0
  191. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  192. package/src/modules/widgets/extension-points.ts +524 -0
  193. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  194. package/src/security/enabledModulesRegistry.ts +64 -12
  195. package/src/security/featurePolicy.ts +89 -0
@@ -10,6 +10,7 @@ import {
10
10
  registerOptimisticLockReaders,
11
11
  } from '@open-mercato/shared/lib/crud/optimistic-lock-store'
12
12
  import { loadCustomFieldDefinitionIndex } from '@open-mercato/shared/lib/crud/custom-fields'
13
+ import { registerMutationGuards } from '@open-mercato/shared/lib/crud/mutation-guard-store'
13
14
  import { z } from 'zod'
14
15
 
15
16
  // Keep the real custom-field helpers but spy on the definition loader so we can
@@ -201,6 +202,7 @@ describe('CRUD Factory', () => {
201
202
  }
202
203
  crudMutationGuardService = null
203
204
  registerApiInterceptors([])
205
+ registerMutationGuards([])
204
206
  })
205
207
 
206
208
  const querySchema = z.object({
@@ -537,12 +539,66 @@ describe('CRUD Factory', () => {
537
539
  })
538
540
  })
539
541
 
542
+ describe('export loop termination', () => {
543
+ const EXPORT_PAGE_SIZE = 1000
544
+
545
+ afterEach(() => {
546
+ queryEngine.query.mockImplementation(async (_entityId: any, _q: any) => ({ items: [{ id: 'id-1', title: 'A', is_done: false, organization_id: defaultOrganizationId, tenant_id: defaultTenantId }], total: 1 }))
547
+ })
548
+
549
+ const makeItems = (count: number, offset = 0) =>
550
+ Array.from({ length: count }, (_, index) => ({
551
+ id: `id-${offset + index}`,
552
+ title: `Todo ${offset + index}`,
553
+ is_done: false,
554
+ organization_id: defaultOrganizationId,
555
+ tenant_id: defaultTenantId,
556
+ }))
557
+
558
+ const queuePages = (pages: Array<Array<Record<string, unknown>>>, total: number) => {
559
+ queryEngine.query.mockImplementation(async (_entityId: any, q: any) => {
560
+ const page = q?.page?.page ?? 1
561
+ return { items: pages[page - 1] ?? [], total }
562
+ })
563
+ }
564
+
565
+ it('GET export enumerates every page even when total under-reports the result set', async () => {
566
+ queuePages(
567
+ [makeItems(EXPORT_PAGE_SIZE), makeItems(EXPORT_PAGE_SIZE, EXPORT_PAGE_SIZE), makeItems(5, EXPORT_PAGE_SIZE * 2)],
568
+ 3,
569
+ )
570
+ const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
571
+ expect(res.status).toBe(200)
572
+ const parsed = JSON.parse(await res.text())
573
+ expect(parsed).toHaveLength(EXPORT_PAGE_SIZE * 2 + 5)
574
+ expect(queryEngine.query).toHaveBeenCalledTimes(3)
575
+ })
576
+
577
+ it('GET export terminates on a short final page instead of trusting an inflated total', async () => {
578
+ queuePages([makeItems(4)], 10_000)
579
+ const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
580
+ expect(res.status).toBe(200)
581
+ const parsed = JSON.parse(await res.text())
582
+ expect(parsed).toHaveLength(4)
583
+ expect(queryEngine.query).toHaveBeenCalledTimes(1)
584
+ })
585
+
586
+ it('GET export fails closed at the page ceiling rather than serializing a partial export', async () => {
587
+ const fullPage = makeItems(EXPORT_PAGE_SIZE)
588
+ queryEngine.query.mockImplementation(async () => ({ items: fullPage, total: EXPORT_PAGE_SIZE }))
589
+ const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
590
+ expect(res.status).toBe(500)
591
+ expect(queryEngine.query).toHaveBeenCalledTimes(1000)
592
+ })
593
+ })
594
+
540
595
  it('POST creates entity, saves custom fields, emits created event', async () => {
541
596
  const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'B', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
542
597
  expect(res.status).toBe(201)
543
598
  const data = await res.json()
544
599
  expect(data.id).toBeDefined()
545
600
  // CF saved
601
+ expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
546
602
  expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ entityId: 'example.todo', values: { priority: 3 } }))
547
603
  // Event + indexer delegated to data engine
548
604
  expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
@@ -569,6 +625,7 @@ describe('CRUD Factory', () => {
569
625
  await em.persist(created).flush()
570
626
  const res = await route.PUT(new Request('http://x/api/example/todos', { method: 'PUT', body: JSON.stringify({ id: created.id, title: 'X2', cf_priority: 5 }), headers: { 'content-type': 'application/json' } }))
571
627
  expect(res.status).toBe(200)
628
+ expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
572
629
  expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ values: { priority: 5 } }))
573
630
  expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
574
631
  const updatedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
@@ -590,6 +647,34 @@ describe('CRUD Factory', () => {
590
647
  expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
591
648
  })
592
649
 
650
+ it('returns a retryable 503 when a handler hits a transient DB connection failure', async () => {
651
+ setRecordCustomFields.mockImplementationOnce(async () => {
652
+ throw Object.assign(new Error('sorry, too many clients already'), { code: '53300' })
653
+ })
654
+ const res = await route.POST(new Request('http://x/api/example/todos', { method: 'POST', body: JSON.stringify({ title: 'Exhausted', is_done: true, cf_priority: 3 }), headers: { 'content-type': 'application/json' } }))
655
+ expect(res.status).toBe(503)
656
+ expect(res.headers.get('Retry-After')).toBe('2')
657
+ // The failed write is still rolled back — no created event/index leaks out.
658
+ expect(Object.values(db)).toHaveLength(0)
659
+ expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
660
+ })
661
+
662
+ it('POST surfaces CRUD side-effect failures after custom field writes', async () => {
663
+ mockDataEngine.emitOrmEntityEvent.mockImplementationOnce(async () => {
664
+ throw new Error('index write failed')
665
+ })
666
+
667
+ const res = await route.POST(new Request('http://x/api/example/todos', {
668
+ method: 'POST',
669
+ body: JSON.stringify({ title: 'Indexed', is_done: true, cf_priority: 3 }),
670
+ headers: { 'content-type': 'application/json' },
671
+ }))
672
+
673
+ expect(res.status).toBe(500)
674
+ expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
675
+ expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
676
+ })
677
+
593
678
  it('PUT rolls back the entity update when the custom field write fails', async () => {
594
679
  const created = em.create(Todo, { title: 'Before', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
595
680
  created.id = '123e4567-e89b-12d3-a456-426614174003'
@@ -721,6 +806,193 @@ describe('CRUD Factory', () => {
721
806
  expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
722
807
  })
723
808
 
809
+ it('POST command route runs mutation guards before executing the command', async () => {
810
+ const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'Blocked by test guard' }))
811
+ registerMutationGuards([{ moduleId: 'example', guards: [{
812
+ id: 'example.block-command-create',
813
+ targetEntity: 'example.todo',
814
+ operations: ['create'],
815
+ validate: guardValidate,
816
+ }] }])
817
+ const commandRoute = makeCrudRoute({
818
+ metadata: { POST: { requireAuth: true } },
819
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
820
+ indexer: { entityType: 'example.todo' },
821
+ actions: {
822
+ create: {
823
+ commandId: 'example.todo.create',
824
+ schema: createSchema,
825
+ response: () => ({ ok: true }),
826
+ },
827
+ },
828
+ })
829
+
830
+ const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
831
+ method: 'POST',
832
+ body: JSON.stringify({ title: 'A' }),
833
+ headers: { 'content-type': 'application/json' },
834
+ }))
835
+
836
+ expect(res.status).toBe(403)
837
+ expect(guardValidate).toHaveBeenCalledWith(expect.objectContaining({
838
+ resourceKind: 'example.todo',
839
+ resourceId: null,
840
+ operation: 'create',
841
+ mutationPayload: expect.objectContaining({ title: 'A' }),
842
+ }))
843
+ expect(commandBus.execute).not.toHaveBeenCalled()
844
+ })
845
+
846
+ it('POST command route merges guard modifiedPayload and runs afterSuccess with the command result id', async () => {
847
+ commandBus.execute.mockResolvedValue({ result: { id: 'cmd-created-1' }, logEntry: { id: 'log-1' } })
848
+ const guardAfterSuccess = jest.fn(async () => {})
849
+ registerMutationGuards([{ moduleId: 'example', guards: [{
850
+ id: 'example.rewrite-command-create',
851
+ targetEntity: 'example.todo',
852
+ operations: ['create'],
853
+ validate: async (_input: any) => ({ ok: true, modifiedPayload: { title: 'FROM-GUARD' }, shouldRunAfterSuccess: true }),
854
+ afterSuccess: guardAfterSuccess,
855
+ }] }])
856
+ const commandRoute = makeCrudRoute({
857
+ metadata: { POST: { requireAuth: true } },
858
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
859
+ indexer: { entityType: 'example.todo' },
860
+ actions: {
861
+ create: {
862
+ commandId: 'example.todo.create',
863
+ schema: createSchema,
864
+ response: () => ({ ok: true }),
865
+ },
866
+ },
867
+ })
868
+
869
+ const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
870
+ method: 'POST',
871
+ body: JSON.stringify({ title: 'A' }),
872
+ headers: { 'content-type': 'application/json' },
873
+ }))
874
+
875
+ expect(res.status).toBe(201)
876
+ expect(commandBus.execute).toHaveBeenCalledWith('example.todo.create', expect.objectContaining({
877
+ input: expect.objectContaining({ title: 'FROM-GUARD' }),
878
+ }))
879
+ expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
880
+ resourceId: 'cmd-created-1',
881
+ operation: 'create',
882
+ }))
883
+ })
884
+
885
+ it('POST command route falls back to the response payload id for guard afterSuccess', async () => {
886
+ commandBus.execute.mockResolvedValue({ result: { lineId: 'line-42' }, logEntry: { id: 'log-1' } })
887
+ const guardAfterSuccess = jest.fn(async () => {})
888
+ registerMutationGuards([{ moduleId: 'example', guards: [{
889
+ id: 'example.after-command-create',
890
+ targetEntity: 'example.todo',
891
+ operations: ['create'],
892
+ validate: async (_input: any) => ({ ok: true, shouldRunAfterSuccess: true }),
893
+ afterSuccess: guardAfterSuccess,
894
+ }] }])
895
+ const commandRoute = makeCrudRoute({
896
+ metadata: { POST: { requireAuth: true } },
897
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
898
+ indexer: { entityType: 'example.todo' },
899
+ actions: {
900
+ create: {
901
+ commandId: 'example.todo.create',
902
+ schema: createSchema,
903
+ response: ({ result }: any) => ({ id: result.lineId }),
904
+ },
905
+ },
906
+ })
907
+
908
+ const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
909
+ method: 'POST',
910
+ body: JSON.stringify({ title: 'A' }),
911
+ headers: { 'content-type': 'application/json' },
912
+ }))
913
+
914
+ expect(res.status).toBe(201)
915
+ expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
916
+ resourceId: 'line-42',
917
+ operation: 'create',
918
+ }))
919
+ })
920
+
921
+ // Commands whose mapInput wraps the payload (e.g. `{ body }`) null the factory
922
+ // candidateId and thereby OPT OUT of row-level mutation guards, leaving the
923
+ // command-level optimistic-lock check as the sole guard — a documented contract
924
+ // (apps/docs/docs/framework/data-integrity/concurrency-locking.mdx) that sales
925
+ // line/adjustment routes rely on.
926
+ it('PUT command route without a top-level id keeps the documented row-level guard opt-out', async () => {
927
+ crudMutationGuardService = {
928
+ validateMutation: jest.fn().mockResolvedValue({ ok: true, shouldRunAfterSuccess: false }),
929
+ afterMutationSuccess: jest.fn().mockResolvedValue(undefined),
930
+ }
931
+ const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 409, message: 'must not run' }))
932
+ registerMutationGuards([{ moduleId: 'example', guards: [{
933
+ id: 'example.idless-update-opt-out',
934
+ targetEntity: 'example.todo',
935
+ operations: ['update'],
936
+ validate: guardValidate,
937
+ }] }])
938
+ const commandRoute = makeCrudRoute({
939
+ metadata: { PUT: { requireAuth: true } },
940
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
941
+ indexer: { entityType: 'example.todo' },
942
+ actions: {
943
+ update: {
944
+ commandId: 'example.todo.update',
945
+ schema: z.object({ title: z.string() }),
946
+ mapInput: ({ parsed }: any) => ({ body: parsed }),
947
+ response: () => ({ ok: true }),
948
+ },
949
+ },
950
+ })
951
+
952
+ const res = await commandRoute.PUT(new Request('http://x/api/example/todos/command', {
953
+ method: 'PUT',
954
+ body: JSON.stringify({ title: 'nested id shape' }),
955
+ headers: { 'content-type': 'application/json' },
956
+ }))
957
+
958
+ expect(res.status).toBe(200)
959
+ expect(guardValidate).not.toHaveBeenCalled()
960
+ expect(crudMutationGuardService.validateMutation).not.toHaveBeenCalled()
961
+ expect(commandBus.execute).toHaveBeenCalledWith('example.todo.update', expect.anything())
962
+ })
963
+
964
+ it('DELETE command route without any id keeps the documented row-level guard opt-out', async () => {
965
+ const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'must not run' }))
966
+ registerMutationGuards([{ moduleId: 'example', guards: [{
967
+ id: 'example.idless-delete-opt-out',
968
+ targetEntity: 'example.todo',
969
+ operations: ['delete'],
970
+ validate: guardValidate,
971
+ }] }])
972
+ const commandRoute = makeCrudRoute({
973
+ metadata: { DELETE: { requireAuth: true } },
974
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
975
+ indexer: { entityType: 'example.todo' },
976
+ actions: {
977
+ delete: {
978
+ commandId: 'example.todo.delete',
979
+ schema: z.any(),
980
+ response: () => ({ ok: true }),
981
+ },
982
+ },
983
+ })
984
+
985
+ const res = await commandRoute.DELETE(new Request('http://x/api/example/todos/command', {
986
+ method: 'DELETE',
987
+ body: JSON.stringify({}),
988
+ headers: { 'content-type': 'application/json' },
989
+ }))
990
+
991
+ expect(res.status).toBe(200)
992
+ expect(guardValidate).not.toHaveBeenCalled()
993
+ expect(commandBus.execute).toHaveBeenCalledWith('example.todo.delete', expect.anything())
994
+ })
995
+
724
996
  it('POST is blocked by interceptor before hook', async () => {
725
997
  registerApiInterceptors([
726
998
  {
@@ -784,6 +1056,85 @@ describe('CRUD Factory', () => {
784
1056
  const body = await res.json()
785
1057
  expect(body._interceptor).toEqual({ ok: true, count: 1 })
786
1058
  })
1059
+
1060
+ // The command DELETE path used to hand before-interceptors the body only, so a
1061
+ // guard reading `?id=` saw nothing to object to and the delete went through
1062
+ // with a 200 (issue #4842).
1063
+ it('DELETE command route passes the query id to interceptor before hooks', async () => {
1064
+ const lockedId = '123e4567-e89b-12d3-a456-426614174010'
1065
+ const seenQueries: Array<Record<string, unknown> | undefined> = []
1066
+ registerApiInterceptors([
1067
+ {
1068
+ moduleId: 'example',
1069
+ interceptors: [
1070
+ {
1071
+ id: 'example.block-locked-delete',
1072
+ targetRoute: 'example/todos/command',
1073
+ methods: ['DELETE'],
1074
+ async before(request) {
1075
+ seenQueries.push(request.query)
1076
+ const queryId = request.query?.id
1077
+ if (typeof queryId === 'string' && queryId === lockedId) {
1078
+ return { ok: false, statusCode: 409, message: 'Record is locked' }
1079
+ }
1080
+ return { ok: true }
1081
+ },
1082
+ },
1083
+ ],
1084
+ },
1085
+ ])
1086
+ const commandRoute = makeCrudRoute({
1087
+ metadata: { DELETE: { requireAuth: true } },
1088
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
1089
+ indexer: { entityType: 'example.todo' },
1090
+ actions: {
1091
+ delete: {
1092
+ commandId: 'example.todo.delete',
1093
+ schema: z.any(),
1094
+ response: () => ({ ok: true }),
1095
+ },
1096
+ },
1097
+ })
1098
+
1099
+ const res = await commandRoute.DELETE(new Request(`http://x/api/example/todos/command?id=${lockedId}`, {
1100
+ method: 'DELETE',
1101
+ body: JSON.stringify({}),
1102
+ headers: { 'content-type': 'application/json' },
1103
+ }))
1104
+
1105
+ expect(res.status).toBe(409)
1106
+ expect(await res.json()).toMatchObject({ error: 'Record is locked' })
1107
+ expect(seenQueries).toEqual([{ id: lockedId }])
1108
+ expect(commandBus.execute).not.toHaveBeenCalled()
1109
+ })
1110
+
1111
+ it('DELETE route passes the whole query string to interceptor before hooks', async () => {
1112
+ const created = em.create(Todo, { title: 'Z', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
1113
+ created.id = '123e4567-e89b-12d3-a456-426614174011'
1114
+ await em.persist(created).flush()
1115
+ let seenQuery: Record<string, unknown> | undefined
1116
+ registerApiInterceptors([
1117
+ {
1118
+ moduleId: 'example',
1119
+ interceptors: [
1120
+ {
1121
+ id: 'example.capture-delete-query',
1122
+ targetRoute: 'example/todos',
1123
+ methods: ['DELETE'],
1124
+ async before(request) {
1125
+ seenQuery = request.query
1126
+ return { ok: true }
1127
+ },
1128
+ },
1129
+ ],
1130
+ },
1131
+ ])
1132
+
1133
+ const res = await route.DELETE(new Request(`http://x/api/example/todos?id=${created.id}&reason=cleanup`, { method: 'DELETE' }))
1134
+
1135
+ expect(res.status).toBe(200)
1136
+ expect(seenQuery).toEqual({ id: created.id, reason: 'cleanup' })
1137
+ })
787
1138
  })
788
1139
 
789
1140
  describe('CRUD Factory — optimistic-lock auto-registration', () => {
@@ -1,5 +1,9 @@
1
1
  import { matchesEntity, runMutationGuards } from '../mutation-guard-registry'
2
2
  import type { MutationGuard, MutationGuardInput } from '../mutation-guard-registry'
3
+ import {
4
+ applyAclFeatureOverrides,
5
+ resetModuleContractOverridesForTests,
6
+ } from '../../../modules/overrides'
3
7
 
4
8
  describe('matchesEntity', () => {
5
9
  it('matches wildcard "*" against any entity', () => {
@@ -24,6 +28,10 @@ describe('matchesEntity', () => {
24
28
  })
25
29
 
26
30
  describe('runMutationGuards', () => {
31
+ afterEach(() => {
32
+ resetModuleContractOverridesForTests()
33
+ })
34
+
27
35
  const baseInput: MutationGuardInput = {
28
36
  tenantId: 'tenant-1',
29
37
  organizationId: 'org-1',
@@ -159,6 +167,24 @@ describe('runMutationGuards', () => {
159
167
  expect(resultWithWildcard.errorBody).toEqual({ error: 'Blocked by wildcard', guardId: 'g1' })
160
168
  })
161
169
 
170
+ it('does not run a guard gated by a nulled ACL feature', async () => {
171
+ applyAclFeatureOverrides({ 'premium.locks': null })
172
+ const guard = makeGuard({
173
+ id: 'g1',
174
+ features: ['premium.locks'],
175
+ validate: jest.fn().mockResolvedValue({ ok: false }),
176
+ })
177
+
178
+ const result = await runMutationGuards(
179
+ [guard],
180
+ baseInput,
181
+ { userFeatures: ['*', 'premium.locks'] },
182
+ )
183
+
184
+ expect(result.ok).toBe(true)
185
+ expect(guard.validate).not.toHaveBeenCalled()
186
+ })
187
+
162
188
  it('uses custom error body when provided', async () => {
163
189
  const guard = makeGuard({
164
190
  id: 'g1',
@@ -15,6 +15,7 @@ import type {
15
15
  import { getEnrichersForEntity } from './enricher-registry'
16
16
  import { logEnricherTiming } from '../umes/enricher-timing'
17
17
  import { createLogger } from '../logger'
18
+ import { authorizeFeatures } from '../../security/featurePolicy'
18
19
 
19
20
  const logger = createLogger('shared').child({ component: 'umes' })
20
21
 
@@ -35,17 +36,7 @@ function hasRequiredFeatures(
35
36
  ): boolean {
36
37
  if (!enricher.features || enricher.features.length === 0) return true
37
38
  if (!userFeatures) return false
38
- const hasFeature = (required: string): boolean => {
39
- for (const granted of userFeatures) {
40
- if (granted === '*' || granted === required) return true
41
- if (granted.endsWith('.*')) {
42
- const prefix = granted.slice(0, -1)
43
- if (required.startsWith(prefix)) return true
44
- }
45
- }
46
- return false
47
- }
48
- return enricher.features.every((feature) => hasFeature(feature))
39
+ return authorizeFeatures(enricher.features, { grantedFeatures: userFeatures })
49
40
  }
50
41
 
51
42
  function filterByACLAndTenant(
@@ -57,6 +57,7 @@ import {
57
57
  isCrudCacheEnabled,
58
58
  normalizeIdentifierValue,
59
59
  normalizeTagSegment,
60
+ pickFirstIdentifier,
60
61
  resolveCrudCache,
61
62
  } from './cache'
62
63
  import { deriveCrudSegmentTag } from './cache-stats'
@@ -72,6 +73,7 @@ import { parseExtensionHeaders } from '../umes/extension-headers'
72
73
  import { createGenericOptimisticLockReader } from './optimistic-lock'
73
74
  import { registerOptimisticLockReaderIfAbsent } from './optimistic-lock-store'
74
75
  import { createLogger } from '../logger'
76
+ import { isTransientDbError } from '../db/pg-errors'
75
77
 
76
78
  type RbacServiceLike = {
77
79
  getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise<string[]>
@@ -250,6 +252,7 @@ const DEFAULT_EXPORT_FORMATS: CrudExportFormat[] = ['csv', 'json', 'xml', 'markd
250
252
  const DEFAULT_EXPORT_BATCH_SIZE = 1000
251
253
  const MIN_EXPORT_BATCH_SIZE = 100
252
254
  const MAX_EXPORT_BATCH_SIZE = 10000
255
+ const EXPORT_MAX_PAGES = 1000
253
256
 
254
257
  type ColumnResolver = {
255
258
  field: string
@@ -554,6 +557,18 @@ function handleError(err: unknown): Response {
554
557
  if (err instanceof Response) return err
555
558
  if (isCrudHttpError(err)) return json(err.body, { status: err.status })
556
559
  if (err instanceof z.ZodError) return json({ error: 'Invalid input', details: err.issues }, { status: 400 })
560
+ if (isTransientDbError(err)) {
561
+ // Transient DB unavailability (pool exhausted, `max_connections` reached, DB
562
+ // restarting) is retryable — surface a 503 with a Retry-After hint instead of
563
+ // a generic 500 so clients back off and retry once the DB recovers.
564
+ logger.warn('Transient DB failure during CRUD handler', {
565
+ message: err instanceof Error ? err.message : undefined,
566
+ })
567
+ return json(
568
+ { error: 'Service temporarily unavailable' },
569
+ { status: 503, headers: { 'Retry-After': '2' } },
570
+ )
571
+ }
557
572
 
558
573
  const message = err instanceof Error ? err.message : undefined
559
574
  const stack = err instanceof Error ? err.stack : undefined
@@ -915,6 +930,12 @@ function buildCrudCacheKey(
915
930
  `tenant:${normalizeTagSegment(ctx.auth?.tenantId ?? null)}`,
916
931
  `selectedOrg:${normalizeTagSegment(ctx.selectedOrganizationId ?? null)}`,
917
932
  `scope:${scopeSegment}`,
933
+ // List payloads can vary per caller identity beyond tenant/org scope:
934
+ // buildFilters may narrow by ctx.auth (e.g. ?mine=true), before-interceptor
935
+ // query rewrites are feature-gated per user, and afterList/after-interceptor
936
+ // output is embedded in the stored payload — so entries MUST be partitioned
937
+ // per actor (API key or user), never shared across identities.
938
+ `user:${normalizeTagSegment((ctx.auth?.keyId ?? ctx.auth?.sub) ?? null)}`,
918
939
  `query:${serializeSearchParams(url.searchParams)}`,
919
940
  ]
920
941
  // The cached list payload already embeds enricher output (enrichment runs before
@@ -1786,13 +1807,17 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1786
1807
  const initialExportItems = exportFullRequested
1787
1808
  ? rawItems.map(normalizeFullRecordForExport)
1788
1809
  : transformedItems
1789
- let exportItems = [...initialExportItems]
1790
- if (total > exportItems.length) {
1791
- const exportPageSizeNumber = typeof page.pageSize === 'number' ? page.pageSize : exportPageSize
1810
+ const exportItems = [...initialExportItems]
1811
+ const exportPageSizeNumber = typeof page.pageSize === 'number' ? page.pageSize : exportPageSize
1812
+ // Short-page termination: `total` is a display value, not a loop bound — it can
1813
+ // under-report (capped counts) or drift while rows are inserted/deleted mid-export.
1814
+ // Keep fetching while pages come back full; fail closed at the page ceiling rather
1815
+ // than serializing a partial export.
1816
+ if (rawItems.length >= exportPageSizeNumber) {
1792
1817
  const queryBase: any = { ...queryOpts }
1793
1818
  delete queryBase.page
1794
1819
  let nextPage = 2
1795
- while (exportItems.length < total) {
1820
+ for (;;) {
1796
1821
  profiler.mark('export_next_page_request', { page: nextPage })
1797
1822
  const nextRes = await qe.query(opts.list.entityId as any, {
1798
1823
  ...queryBase,
@@ -1807,7 +1832,10 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
1807
1832
  ? nextItemsRaw.map(normalizeFullRecordForExport)
1808
1833
  : nextTransformed
1809
1834
  exportItems.push(...nextExportItems)
1810
- if (nextExportItems.length < exportPageSizeNumber) break
1835
+ if (nextItemsRaw.length < exportPageSizeNumber) break
1836
+ if (nextPage >= EXPORT_MAX_PAGES) {
1837
+ throw new Error(`[internal] export exceeded ${EXPORT_MAX_PAGES} pages; refusing to return a partial export`)
1838
+ }
1811
1839
  nextPage += 1
1812
1840
  }
1813
1841
  }
@@ -2140,6 +2168,31 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2140
2168
  }
2141
2169
  }
2142
2170
 
2171
+ // Mutation guard registry — command path (mirrors the direct create branch)
2172
+ const createCmdUserFeatures = await resolveUserFeatures(ctx)
2173
+ const { allGuards: createCmdAllGuards } = collectAndRunGuards(ctx.container)
2174
+ let createCmdGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
2175
+ if (createCmdAllGuards.length && ctx.auth.tenantId) {
2176
+ const guardResult = await runMutationGuards(createCmdAllGuards, {
2177
+ tenantId: ctx.auth.tenantId,
2178
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
2179
+ userId: ctx.auth.sub,
2180
+ resourceKind,
2181
+ resourceId: null,
2182
+ operation: 'create',
2183
+ requestMethod: request.method,
2184
+ requestHeaders: request.headers,
2185
+ mutationPayload: input && typeof input === 'object' ? (input as Record<string, unknown>) : null,
2186
+ }, { userFeatures: createCmdUserFeatures ?? [] })
2187
+ if (!guardResult.ok) {
2188
+ return json(guardResult.errorBody ?? { error: 'Operation blocked by guard' }, { status: guardResult.errorStatus ?? 422 })
2189
+ }
2190
+ if (guardResult.modifiedPayload && typeof input === 'object' && input) {
2191
+ input = { ...input as Record<string, unknown>, ...guardResult.modifiedPayload }
2192
+ }
2193
+ createCmdGuardAfterCallbacks = guardResult.afterSuccessCallbacks
2194
+ }
2195
+
2143
2196
  const baseMetadata: CommandLogMetadata = {
2144
2197
  tenantId: ctx.auth?.tenantId ?? null,
2145
2198
  organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
@@ -2182,6 +2235,22 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2182
2235
  const status = action.status ?? 201
2183
2236
  const response = json(resolvedPayload, { status })
2184
2237
  attachOperationHeader(response, logEntry)
2238
+ const commandResultId = pickFirstIdentifier(
2239
+ (result as Record<string, unknown> | null | undefined)?.id,
2240
+ (resolvedPayload as Record<string, unknown> | null | undefined)?.id,
2241
+ )
2242
+ if (createCmdGuardAfterCallbacks.length && ctx.auth.tenantId && commandResultId) {
2243
+ await runGuardAfterSuccessCallbacks(createCmdGuardAfterCallbacks, {
2244
+ tenantId: ctx.auth.tenantId,
2245
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId ?? null,
2246
+ userId: ctx.auth.sub,
2247
+ resourceKind,
2248
+ resourceId: commandResultId,
2249
+ operation: 'create',
2250
+ requestMethod: request.method,
2251
+ requestHeaders: request.headers,
2252
+ })
2253
+ }
2185
2254
  // Note: side effects (events + indexing) are already flushed by CommandBus.execute()
2186
2255
  // via flushCrudSideEffects(). Calling markCommandResultForIndexing here would cause
2187
2256
  // duplicate event emissions.
@@ -2285,6 +2354,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2285
2354
  organizationId: targetOrgId,
2286
2355
  tenantId: writeTenantId,
2287
2356
  values,
2357
+ notify: false,
2288
2358
  })
2289
2359
  }
2290
2360
  }
@@ -2422,6 +2492,10 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2422
2492
  const updateUserFeatures = await resolveUserFeatures(ctx)
2423
2493
  const { allGuards: updateAllGuards } = collectAndRunGuards(ctx.container)
2424
2494
  let cmdUpdateGuardAfterCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }> = []
2495
+ // Commands whose mapInput wraps the payload (e.g. `{ body }`) intentionally
2496
+ // null candidateId and OPT OUT of row-level guards, leaving the command-level
2497
+ // optimistic-lock check as the sole guard — a documented contract, see
2498
+ // apps/docs/docs/framework/data-integrity/concurrency-locking.mdx.
2425
2499
  if (updateAllGuards.length && ctx.auth.tenantId && candidateId) {
2426
2500
  const guardResult = await runMutationGuards(updateAllGuards, {
2427
2501
  tenantId: ctx.auth.tenantId,
@@ -2618,6 +2692,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2618
2692
  organizationId: targetOrgId,
2619
2693
  tenantId: writeTenantId,
2620
2694
  values,
2695
+ notify: false,
2621
2696
  })
2622
2697
  }
2623
2698
  }
@@ -2721,6 +2796,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2721
2796
  request,
2722
2797
  method: 'DELETE',
2723
2798
  body: interceptorInput,
2799
+ query: raw.query,
2724
2800
  })
2725
2801
  if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
2726
2802
  interceptorRequestPayload = beforeInterceptors.requestPayload
@@ -2845,7 +2921,7 @@ export function makeCrudRoute<TCreate = any, TUpdate = any, TList = any>(opts: C
2845
2921
  request,
2846
2922
  method: 'DELETE',
2847
2923
  body: idFrom === 'query' ? undefined : ({ id } as Record<string, unknown>),
2848
- query: idFrom === 'query' ? ({ id } as Record<string, unknown>) : undefined,
2924
+ query: idFrom === 'query' ? Object.fromEntries(url.searchParams.entries()) : undefined,
2849
2925
  })
2850
2926
  if (beforeInterceptors.errorResponse) return beforeInterceptors.errorResponse
2851
2927
  interceptorRequestPayload = beforeInterceptors.requestPayload