@open-mercato/shared 0.6.7 → 0.6.8-canary.320.1.bf889f49e4

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 (224) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +62 -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 +190 -70
  13. package/dist/lib/bootstrap/dynamicLoader.js.map +2 -2
  14. package/dist/lib/bootstrap/factory.js +18 -13
  15. package/dist/lib/bootstrap/factory.js.map +2 -2
  16. package/dist/lib/commands/command-bus.js +9 -3
  17. package/dist/lib/commands/command-bus.js.map +2 -2
  18. package/dist/lib/commands/command-interceptor-runner.js +9 -4
  19. package/dist/lib/commands/command-interceptor-runner.js.map +2 -2
  20. package/dist/lib/commands/errors.js +25 -4
  21. package/dist/lib/commands/errors.js.map +2 -2
  22. package/dist/lib/commands/index.js +7 -1
  23. package/dist/lib/commands/index.js.map +2 -2
  24. package/dist/lib/crud/enricher-runner.js +2 -11
  25. package/dist/lib/crud/enricher-runner.js.map +2 -2
  26. package/dist/lib/crud/factory.js +104 -23
  27. package/dist/lib/crud/factory.js.map +2 -2
  28. package/dist/lib/crud/interceptor-runner.js +2 -2
  29. package/dist/lib/crud/interceptor-runner.js.map +2 -2
  30. package/dist/lib/crud/mutation-guard-registry.js +2 -2
  31. package/dist/lib/crud/mutation-guard-registry.js.map +2 -2
  32. package/dist/lib/crud/types.js +4 -0
  33. package/dist/lib/crud/types.js.map +3 -3
  34. package/dist/lib/data/consistency.js +19 -0
  35. package/dist/lib/data/consistency.js.map +7 -0
  36. package/dist/lib/data/engine.js +37 -11
  37. package/dist/lib/data/engine.js.map +2 -2
  38. package/dist/lib/db/entityMetadata.js +26 -0
  39. package/dist/lib/db/entityMetadata.js.map +7 -0
  40. package/dist/lib/db/pg-errors.js +42 -0
  41. package/dist/lib/db/pg-errors.js.map +2 -2
  42. package/dist/lib/di/container.js +48 -6
  43. package/dist/lib/di/container.js.map +2 -2
  44. package/dist/lib/email/send.js +56 -0
  45. package/dist/lib/email/send.js.map +2 -2
  46. package/dist/lib/encryption/find.js +22 -0
  47. package/dist/lib/encryption/find.js.map +2 -2
  48. package/dist/lib/encryption/likeFilterWarning.js +66 -0
  49. package/dist/lib/encryption/likeFilterWarning.js.map +7 -0
  50. package/dist/lib/encryption/subscriber.js +2 -10
  51. package/dist/lib/encryption/subscriber.js.map +2 -2
  52. package/dist/lib/encryption/tenantDataEncryptionService.js +52 -17
  53. package/dist/lib/encryption/tenantDataEncryptionService.js.map +2 -2
  54. package/dist/lib/i18n/config.js +1 -1
  55. package/dist/lib/i18n/config.js.map +2 -2
  56. package/dist/lib/i18n/dictionary-cache.js +5 -0
  57. package/dist/lib/i18n/dictionary-cache.js.map +2 -2
  58. package/dist/lib/i18n/server.js +4 -4
  59. package/dist/lib/i18n/server.js.map +3 -3
  60. package/dist/lib/logger/extension.js +29 -0
  61. package/dist/lib/logger/extension.js.map +7 -0
  62. package/dist/lib/logger/index.js +47 -3
  63. package/dist/lib/logger/index.js.map +3 -3
  64. package/dist/lib/modules/registry.js +36 -6
  65. package/dist/lib/modules/registry.js.map +2 -2
  66. package/dist/lib/modules/surfaceFingerprint.js +47 -0
  67. package/dist/lib/modules/surfaceFingerprint.js.map +7 -0
  68. package/dist/lib/query/ciphertext-search-warning.js +51 -0
  69. package/dist/lib/query/ciphertext-search-warning.js.map +7 -0
  70. package/dist/lib/query/engine.js +60 -43
  71. package/dist/lib/query/engine.js.map +2 -2
  72. package/dist/lib/query/types.js.map +1 -1
  73. package/dist/lib/redis/connection.js +7 -2
  74. package/dist/lib/redis/connection.js.map +2 -2
  75. package/dist/lib/search/auto-indexing.js +14 -0
  76. package/dist/lib/search/auto-indexing.js.map +7 -0
  77. package/dist/lib/search/availability.js +107 -0
  78. package/dist/lib/search/availability.js.map +7 -0
  79. package/dist/lib/search/config.js +61 -2
  80. package/dist/lib/search/config.js.map +2 -2
  81. package/dist/lib/search/tokenLookup.js +46 -0
  82. package/dist/lib/search/tokenLookup.js.map +7 -0
  83. package/dist/lib/search/tokenize.js +23 -11
  84. package/dist/lib/search/tokenize.js.map +2 -2
  85. package/dist/lib/telemetry/runtime.js +35 -0
  86. package/dist/lib/telemetry/runtime.js.map +7 -0
  87. package/dist/lib/version.js +1 -1
  88. package/dist/lib/version.js.map +1 -1
  89. package/dist/lib/webhooks/body.js +57 -0
  90. package/dist/lib/webhooks/body.js.map +7 -0
  91. package/dist/lib/webhooks/inbound-types.js +1 -0
  92. package/dist/lib/webhooks/inbound-types.js.map +7 -0
  93. package/dist/lib/webhooks/index.js +13 -1
  94. package/dist/lib/webhooks/index.js.map +2 -2
  95. package/dist/lib/webhooks/verify.js +12 -7
  96. package/dist/lib/webhooks/verify.js.map +2 -2
  97. package/dist/modules/analytics.js.map +2 -2
  98. package/dist/modules/overrides.js +50 -1
  99. package/dist/modules/overrides.js.map +2 -2
  100. package/dist/modules/payment_gateways/types.js +8 -1
  101. package/dist/modules/payment_gateways/types.js.map +2 -2
  102. package/dist/modules/registry.js +1 -0
  103. package/dist/modules/registry.js.map +2 -2
  104. package/dist/modules/widgets/extension-points.js +96 -0
  105. package/dist/modules/widgets/extension-points.js.map +7 -0
  106. package/dist/security/enabledModulesRegistry.js +56 -11
  107. package/dist/security/enabledModulesRegistry.js.map +2 -2
  108. package/dist/security/featurePolicy.js +62 -0
  109. package/dist/security/featurePolicy.js.map +7 -0
  110. package/jest.config.cjs +20 -10
  111. package/package.json +19 -9
  112. package/scripts/versionSource.cjs +65 -0
  113. package/src/lib/__tests__/versionSource.test.ts +64 -0
  114. package/src/lib/ai/__tests__/llm-provider-contract.test.ts +59 -0
  115. package/src/lib/ai/__tests__/safety-identifier.test.ts +71 -0
  116. package/src/lib/ai/llm-provider.ts +33 -0
  117. package/src/lib/ai/safety-identifier.ts +73 -0
  118. package/src/lib/auth/__tests__/organizationScope.test.ts +107 -0
  119. package/src/lib/auth/__tests__/server.test.ts +23 -0
  120. package/src/lib/auth/organizationScope.ts +65 -0
  121. package/src/lib/auth/server.ts +23 -2
  122. package/src/lib/bootstrap/__tests__/clientOnlyModules.test.ts +189 -0
  123. package/src/lib/bootstrap/__tests__/dynamicLoader.aliasResolver.test.ts +98 -0
  124. package/src/lib/bootstrap/__tests__/dynamicLoader.appDi.test.ts +211 -0
  125. package/src/lib/bootstrap/__tests__/dynamicLoader.commandInterceptors.test.ts +63 -8
  126. package/src/lib/bootstrap/__tests__/dynamicLoader.generatedCacheRecovery.test.ts +256 -0
  127. package/src/lib/bootstrap/__tests__/dynamicLoader.test.ts +126 -0
  128. package/src/lib/bootstrap/__tests__/dynamicLoader.tsconfig.test.ts +21 -1
  129. package/src/lib/bootstrap/__tests__/factory.test.ts +78 -0
  130. package/src/lib/bootstrap/clientOnlyModules.ts +85 -0
  131. package/src/lib/bootstrap/dynamicLoader.ts +346 -96
  132. package/src/lib/bootstrap/factory.ts +21 -15
  133. package/src/lib/bootstrap/types.ts +6 -0
  134. package/src/lib/commands/__tests__/command-bus.test.ts +63 -0
  135. package/src/lib/commands/__tests__/command-interceptor-error.test.ts +91 -1
  136. package/src/lib/commands/__tests__/command-interceptor-runner.test.ts +101 -0
  137. package/src/lib/commands/command-bus.ts +9 -3
  138. package/src/lib/commands/command-interceptor-runner.ts +27 -6
  139. package/src/lib/commands/command-interceptor.ts +8 -0
  140. package/src/lib/commands/errors.ts +67 -2
  141. package/src/lib/commands/index.ts +7 -1
  142. package/src/lib/crud/__tests__/crud-factory.cache-user-scope.test.ts +171 -0
  143. package/src/lib/crud/__tests__/crud-factory.test.ts +564 -0
  144. package/src/lib/crud/__tests__/mutation-guard-registry.test.ts +26 -0
  145. package/src/lib/crud/enricher-runner.ts +2 -11
  146. package/src/lib/crud/factory.ts +157 -22
  147. package/src/lib/crud/interceptor-runner.ts +2 -2
  148. package/src/lib/crud/mutation-guard-registry.ts +2 -2
  149. package/src/lib/crud/types.ts +3 -0
  150. package/src/lib/data/__tests__/consistency.test.ts +38 -0
  151. package/src/lib/data/__tests__/engine.bulk-suppress.test.ts +18 -3
  152. package/src/lib/data/consistency.ts +17 -0
  153. package/src/lib/data/engine.ts +50 -16
  154. package/src/lib/db/__tests__/entityMetadata.test.ts +38 -0
  155. package/src/lib/db/__tests__/pg-errors.test.ts +48 -0
  156. package/src/lib/db/entityMetadata.ts +43 -0
  157. package/src/lib/db/pg-errors.ts +58 -0
  158. package/src/lib/di/__tests__/container-app-di-absent.test.ts +155 -0
  159. package/src/lib/di/__tests__/container-app-di.test.ts +173 -0
  160. package/src/lib/di/__tests__/registrar-error-log.test.ts +154 -0
  161. package/src/lib/di/container.ts +72 -8
  162. package/src/lib/email/__tests__/send.test.ts +37 -1
  163. package/src/lib/email/send.ts +79 -0
  164. package/src/lib/encryption/__tests__/likeFilterWarning.test.ts +167 -0
  165. package/src/lib/encryption/__tests__/subscriber.test.ts +54 -2
  166. package/src/lib/encryption/__tests__/tenantDataEncryptionService.test.ts +58 -0
  167. package/src/lib/encryption/find.ts +22 -0
  168. package/src/lib/encryption/likeFilterWarning.ts +102 -0
  169. package/src/lib/encryption/subscriber.ts +2 -14
  170. package/src/lib/encryption/tenantDataEncryptionService.ts +64 -18
  171. package/src/lib/i18n/__tests__/config.test.ts +8 -0
  172. package/src/lib/i18n/__tests__/dictionary-cache.test.ts +23 -0
  173. package/src/lib/i18n/__tests__/server-dictionary-cache.test.ts +30 -0
  174. package/src/lib/i18n/__tests__/server-unbootstrapped.test.ts +29 -0
  175. package/src/lib/i18n/config.ts +2 -2
  176. package/src/lib/i18n/dictionary-cache.ts +5 -0
  177. package/src/lib/i18n/server.ts +6 -2
  178. package/src/lib/logger/__tests__/logger.test.ts +77 -0
  179. package/src/lib/logger/extension.ts +59 -0
  180. package/src/lib/logger/index.ts +59 -1
  181. package/src/lib/modules/__tests__/registry.test.ts +26 -0
  182. package/src/lib/modules/__tests__/surfaceFingerprint.test.ts +122 -0
  183. package/src/lib/modules/registry.ts +46 -7
  184. package/src/lib/modules/surfaceFingerprint.ts +87 -0
  185. package/src/lib/query/__tests__/ciphertext-search-warning.test.ts +178 -0
  186. package/src/lib/query/__tests__/engine.scope-and-or.test.ts +67 -0
  187. package/src/lib/query/__tests__/engine.test.ts +213 -13
  188. package/src/lib/query/ciphertext-search-warning.ts +106 -0
  189. package/src/lib/query/engine.ts +102 -61
  190. package/src/lib/query/types.ts +4 -5
  191. package/src/lib/redis/__tests__/connection.test.ts +16 -0
  192. package/src/lib/redis/connection.ts +10 -7
  193. package/src/lib/search/__tests__/availability.test.ts +211 -0
  194. package/src/lib/search/__tests__/config.test.ts +177 -0
  195. package/src/lib/search/__tests__/tokenLookup.test.ts +206 -0
  196. package/src/lib/search/__tests__/tokenize.test.ts +49 -0
  197. package/src/lib/search/auto-indexing.ts +22 -0
  198. package/src/lib/search/availability.ts +232 -0
  199. package/src/lib/search/config.ts +106 -8
  200. package/src/lib/search/tokenLookup.ts +133 -0
  201. package/src/lib/search/tokenize.ts +33 -11
  202. package/src/lib/telemetry/runtime.ts +72 -0
  203. package/src/lib/webhooks/__tests__/body.test.ts +95 -0
  204. package/src/lib/webhooks/__tests__/verify.test.ts +20 -1
  205. package/src/lib/webhooks/body.ts +70 -0
  206. package/src/lib/webhooks/inbound-types.ts +125 -0
  207. package/src/lib/webhooks/index.ts +19 -1
  208. package/src/lib/webhooks/verify.ts +17 -9
  209. package/src/modules/__tests__/nav-group-order-override.test.ts +183 -0
  210. package/src/modules/__tests__/registry.test.ts +10 -2
  211. package/src/modules/analytics.ts +7 -0
  212. package/src/modules/customer-auth.ts +1 -0
  213. package/src/modules/encryption.ts +3 -0
  214. package/src/modules/entities.ts +15 -0
  215. package/src/modules/navigation/backendChrome.ts +15 -0
  216. package/src/modules/overrides.ts +103 -0
  217. package/src/modules/payment_gateways/__tests__/types.test.ts +37 -0
  218. package/src/modules/payment_gateways/types.ts +41 -0
  219. package/src/modules/registry.ts +3 -0
  220. package/src/modules/widgets/__tests__/extension-points.test.ts +101 -0
  221. package/src/modules/widgets/extension-points.ts +524 -0
  222. package/src/security/__tests__/featurePolicy.test.ts +166 -0
  223. package/src/security/enabledModulesRegistry.ts +64 -12
  224. package/src/security/featurePolicy.ts +89 -0
@@ -10,6 +10,8 @@ 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'
14
+ import { CommandInterceptorError } from '@open-mercato/shared/lib/commands/errors'
13
15
  import { z } from 'zod'
14
16
 
15
17
  // Keep the real custom-field helpers but spy on the definition loader so we can
@@ -201,6 +203,7 @@ describe('CRUD Factory', () => {
201
203
  }
202
204
  crudMutationGuardService = null
203
205
  registerApiInterceptors([])
206
+ registerMutationGuards([])
204
207
  })
205
208
 
206
209
  const querySchema = z.object({
@@ -371,6 +374,107 @@ describe('CRUD Factory', () => {
371
374
  ])
372
375
  })
373
376
 
377
+ // Routes that delegate the fallback order to `list.defaultSort` leave `sortField`
378
+ // optional; a zod `.default()` would make every request look explicitly sorted.
379
+ const sortableQuerySchema = z.object({
380
+ page: z.coerce.number().default(1),
381
+ pageSize: z.coerce.number().default(50),
382
+ sortField: z.string().optional(),
383
+ sortDir: z.enum(['asc', 'desc']).optional(),
384
+ })
385
+
386
+ const makeSortedRoute = (list?: Partial<Parameters<typeof makeCrudRoute>[0]['list']>) => makeCrudRoute({
387
+ metadata: { GET: { requireAuth: true } },
388
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
389
+ indexer: { entityType: 'example.todo' },
390
+ list: {
391
+ schema: sortableQuerySchema,
392
+ entityId: 'example.todo',
393
+ fields: ['id', 'title'],
394
+ sortFieldMap: { id: 'id', title: 'title', lineNumber: 'line_number' },
395
+ buildFilters: () => ({} as any),
396
+ disableListCache: true,
397
+ ...list,
398
+ } as any,
399
+ })
400
+
401
+ it('GET falls back to sorting by id when no default sort is configured', async () => {
402
+ await makeSortedRoute().GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
403
+
404
+ const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
405
+ expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'asc' }])
406
+ })
407
+
408
+ it('GET applies list.defaultSort through sortFieldMap when the request omits a sort', async () => {
409
+ const sortedRoute = makeSortedRoute({
410
+ defaultSort: { field: 'lineNumber', dir: 'asc' },
411
+ tiebreakSortField: 'id',
412
+ })
413
+
414
+ await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10'))
415
+
416
+ const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
417
+ expect(queryArgs?.sort).toEqual([
418
+ { field: 'line_number', dir: 'asc' },
419
+ { field: 'id', dir: 'asc' },
420
+ ])
421
+ })
422
+
423
+ it('GET honours an explicit sort over list.defaultSort and keeps the tiebreak', async () => {
424
+ const sortedRoute = makeSortedRoute({
425
+ defaultSort: { field: 'lineNumber', dir: 'asc' },
426
+ tiebreakSortField: 'id',
427
+ })
428
+
429
+ await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=title&sortDir=desc'))
430
+
431
+ const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
432
+ expect(queryArgs?.sort).toEqual([
433
+ { field: 'title', dir: 'desc' },
434
+ { field: 'id', dir: 'asc' },
435
+ ])
436
+ })
437
+
438
+ it('GET keeps an explicit sort ascending by default even when list.defaultSort is descending', async () => {
439
+ const sortedRoute = makeSortedRoute({ defaultSort: { field: 'title', dir: 'desc' } })
440
+
441
+ await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id'))
442
+
443
+ const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
444
+ expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'asc' }])
445
+ })
446
+
447
+ it('GET treats a blank sortField as absent and falls back to list.defaultSort', async () => {
448
+ const sortedRoute = makeSortedRoute({
449
+ defaultSort: { field: 'lineNumber', dir: 'asc' },
450
+ tiebreakSortField: 'id',
451
+ })
452
+
453
+ await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField='))
454
+
455
+ const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
456
+ expect(queryArgs?.sort).toEqual([
457
+ { field: 'line_number', dir: 'asc' },
458
+ { field: 'id', dir: 'asc' },
459
+ ])
460
+ })
461
+
462
+ it('GET keeps falling back to id for a blank sortField when no default is configured', async () => {
463
+ await makeSortedRoute().GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=&sortDir=desc'))
464
+
465
+ const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
466
+ expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'desc' }])
467
+ })
468
+
469
+ it('GET does not duplicate the tiebreak when it matches the primary sort', async () => {
470
+ const sortedRoute = makeSortedRoute({ tiebreakSortField: 'id' })
471
+
472
+ await sortedRoute.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=desc'))
473
+
474
+ const queryArgs = queryEngine.query.mock.calls.at(-1)?.[1]
475
+ expect(queryArgs?.sort).toEqual([{ field: 'id', dir: 'desc' }])
476
+ })
477
+
374
478
  it('GET intersects ids with existing buildFilters id constraint', async () => {
375
479
  const routeWithIdFilter = makeCrudRoute({
376
480
  metadata: { GET: { requireAuth: true } },
@@ -481,6 +585,45 @@ describe('CRUD Factory', () => {
481
585
  expect(ids).toContain(otherTenant.id)
482
586
  })
483
587
 
588
+ it('GET resolves function-form CSV headers and rows per request', async () => {
589
+ // Regression coverage for the additive `(query, ctx)` form of `ListConfig.csv`.
590
+ // A route whose export columns depend on per-request discovery (custom-field keys,
591
+ // for example) must resolve them from the request context, never from module-level
592
+ // state that the previous request left behind.
593
+ const perRequestColumns = new WeakMap<object, string>()
594
+ const dynamicCsvRoute = makeCrudRoute({
595
+ metadata: { GET: { requireAuth: true } },
596
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
597
+ indexer: { entityType: 'example.todo' },
598
+ list: {
599
+ schema: querySchema,
600
+ entityId: 'example.todo',
601
+ fields: ['id', 'title', 'is_done'],
602
+ sortFieldMap: { id: 'id' },
603
+ buildFilters: () => ({} as any),
604
+ transformItem: (item: any) => ({ id: item.id, title: item.title }),
605
+ allowCsv: true,
606
+ csv: {
607
+ headers: (_query, ctx) => ['id', perRequestColumns.get(ctx) ?? 'fallback'],
608
+ row: (item: any, ctx) => [item.id, `${perRequestColumns.get(ctx) ?? 'fallback'}:${item.title}`],
609
+ filename: 'dynamic.csv',
610
+ },
611
+ },
612
+ hooks: {
613
+ beforeList: (_query, ctx) => {
614
+ const column = new URL(ctx.request!.url).searchParams.get('column')
615
+ perRequestColumns.set(ctx, column ?? 'fallback')
616
+ },
617
+ },
618
+ })
619
+
620
+ const first = await dynamicCsvRoute.GET(new Request('http://x/api/example/todos?format=csv&column=alpha'))
621
+ const second = await dynamicCsvRoute.GET(new Request('http://x/api/example/todos?format=csv&column=beta'))
622
+
623
+ expect((await first.text()).split('\n')[0]).toBe('id,alpha')
624
+ expect((await second.text()).split('\n')[0]).toBe('id,beta')
625
+ })
626
+
484
627
  it('GET returns CSV when format=csv', async () => {
485
628
  const res = await route.GET(new Request('http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc&format=csv'))
486
629
  expect(res.headers.get('content-type')).toContain('text/csv')
@@ -537,12 +680,66 @@ describe('CRUD Factory', () => {
537
680
  })
538
681
  })
539
682
 
683
+ describe('export loop termination', () => {
684
+ const EXPORT_PAGE_SIZE = 1000
685
+
686
+ afterEach(() => {
687
+ 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 }))
688
+ })
689
+
690
+ const makeItems = (count: number, offset = 0) =>
691
+ Array.from({ length: count }, (_, index) => ({
692
+ id: `id-${offset + index}`,
693
+ title: `Todo ${offset + index}`,
694
+ is_done: false,
695
+ organization_id: defaultOrganizationId,
696
+ tenant_id: defaultTenantId,
697
+ }))
698
+
699
+ const queuePages = (pages: Array<Array<Record<string, unknown>>>, total: number) => {
700
+ queryEngine.query.mockImplementation(async (_entityId: any, q: any) => {
701
+ const page = q?.page?.page ?? 1
702
+ return { items: pages[page - 1] ?? [], total }
703
+ })
704
+ }
705
+
706
+ it('GET export enumerates every page even when total under-reports the result set', async () => {
707
+ queuePages(
708
+ [makeItems(EXPORT_PAGE_SIZE), makeItems(EXPORT_PAGE_SIZE, EXPORT_PAGE_SIZE), makeItems(5, EXPORT_PAGE_SIZE * 2)],
709
+ 3,
710
+ )
711
+ const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
712
+ expect(res.status).toBe(200)
713
+ const parsed = JSON.parse(await res.text())
714
+ expect(parsed).toHaveLength(EXPORT_PAGE_SIZE * 2 + 5)
715
+ expect(queryEngine.query).toHaveBeenCalledTimes(3)
716
+ })
717
+
718
+ it('GET export terminates on a short final page instead of trusting an inflated total', async () => {
719
+ queuePages([makeItems(4)], 10_000)
720
+ const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
721
+ expect(res.status).toBe(200)
722
+ const parsed = JSON.parse(await res.text())
723
+ expect(parsed).toHaveLength(4)
724
+ expect(queryEngine.query).toHaveBeenCalledTimes(1)
725
+ })
726
+
727
+ it('GET export fails closed at the page ceiling rather than serializing a partial export', async () => {
728
+ const fullPage = makeItems(EXPORT_PAGE_SIZE)
729
+ queryEngine.query.mockImplementation(async () => ({ items: fullPage, total: EXPORT_PAGE_SIZE }))
730
+ const res = await route.GET(new Request('http://x/api/example/todos?format=json'))
731
+ expect(res.status).toBe(500)
732
+ expect(queryEngine.query).toHaveBeenCalledTimes(1000)
733
+ })
734
+ })
735
+
540
736
  it('POST creates entity, saves custom fields, emits created event', async () => {
541
737
  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
738
  expect(res.status).toBe(201)
543
739
  const data = await res.json()
544
740
  expect(data.id).toBeDefined()
545
741
  // CF saved
742
+ expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
546
743
  expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ entityId: 'example.todo', values: { priority: 3 } }))
547
744
  // Event + indexer delegated to data engine
548
745
  expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
@@ -569,6 +766,7 @@ describe('CRUD Factory', () => {
569
766
  await em.persist(created).flush()
570
767
  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
768
  expect(res.status).toBe(200)
769
+ expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
572
770
  expect(setRecordCustomFields).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ values: { priority: 5 } }))
573
771
  expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
574
772
  const updatedCall = mockDataEngine.emitOrmEntityEvent.mock.calls.at(0)
@@ -590,6 +788,34 @@ describe('CRUD Factory', () => {
590
788
  expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
591
789
  })
592
790
 
791
+ it('returns a retryable 503 when a handler hits a transient DB connection failure', async () => {
792
+ setRecordCustomFields.mockImplementationOnce(async () => {
793
+ throw Object.assign(new Error('sorry, too many clients already'), { code: '53300' })
794
+ })
795
+ 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' } }))
796
+ expect(res.status).toBe(503)
797
+ expect(res.headers.get('Retry-After')).toBe('2')
798
+ // The failed write is still rolled back — no created event/index leaks out.
799
+ expect(Object.values(db)).toHaveLength(0)
800
+ expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
801
+ })
802
+
803
+ it('POST surfaces CRUD side-effect failures after custom field writes', async () => {
804
+ mockDataEngine.emitOrmEntityEvent.mockImplementationOnce(async () => {
805
+ throw new Error('index write failed')
806
+ })
807
+
808
+ const res = await route.POST(new Request('http://x/api/example/todos', {
809
+ method: 'POST',
810
+ body: JSON.stringify({ title: 'Indexed', is_done: true, cf_priority: 3 }),
811
+ headers: { 'content-type': 'application/json' },
812
+ }))
813
+
814
+ expect(res.status).toBe(500)
815
+ expect(mockDataEngine.setCustomFields).toHaveBeenCalledWith(expect.objectContaining({ notify: false }))
816
+ expect(mockDataEngine.emitOrmEntityEvent).toHaveBeenCalledTimes(1)
817
+ })
818
+
593
819
  it('PUT rolls back the entity update when the custom field write fails', async () => {
594
820
  const created = em.create(Todo, { title: 'Before', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
595
821
  created.id = '123e4567-e89b-12d3-a456-426614174003'
@@ -721,6 +947,265 @@ describe('CRUD Factory', () => {
721
947
  expect(mockDataEngine.emitOrmEntityEvent).not.toHaveBeenCalled()
722
948
  })
723
949
 
950
+ it('POST command route runs mutation guards before executing the command', async () => {
951
+ const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'Blocked by test guard' }))
952
+ registerMutationGuards([{ moduleId: 'example', guards: [{
953
+ id: 'example.block-command-create',
954
+ targetEntity: 'example.todo',
955
+ operations: ['create'],
956
+ validate: guardValidate,
957
+ }] }])
958
+ const commandRoute = makeCrudRoute({
959
+ metadata: { POST: { requireAuth: true } },
960
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
961
+ indexer: { entityType: 'example.todo' },
962
+ actions: {
963
+ create: {
964
+ commandId: 'example.todo.create',
965
+ schema: createSchema,
966
+ response: () => ({ ok: true }),
967
+ },
968
+ },
969
+ })
970
+
971
+ const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
972
+ method: 'POST',
973
+ body: JSON.stringify({ title: 'A' }),
974
+ headers: { 'content-type': 'application/json' },
975
+ }))
976
+
977
+ expect(res.status).toBe(403)
978
+ expect(guardValidate).toHaveBeenCalledWith(expect.objectContaining({
979
+ resourceKind: 'example.todo',
980
+ resourceId: null,
981
+ operation: 'create',
982
+ mutationPayload: expect.objectContaining({ title: 'A' }),
983
+ }))
984
+ expect(commandBus.execute).not.toHaveBeenCalled()
985
+ })
986
+
987
+ it('POST command route merges guard modifiedPayload and runs afterSuccess with the command result id', async () => {
988
+ commandBus.execute.mockResolvedValue({ result: { id: 'cmd-created-1' }, logEntry: { id: 'log-1' } })
989
+ const guardAfterSuccess = jest.fn(async () => {})
990
+ registerMutationGuards([{ moduleId: 'example', guards: [{
991
+ id: 'example.rewrite-command-create',
992
+ targetEntity: 'example.todo',
993
+ operations: ['create'],
994
+ validate: async (_input: any) => ({ ok: true, modifiedPayload: { title: 'FROM-GUARD' }, shouldRunAfterSuccess: true }),
995
+ afterSuccess: guardAfterSuccess,
996
+ }] }])
997
+ const commandRoute = makeCrudRoute({
998
+ metadata: { POST: { requireAuth: true } },
999
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
1000
+ indexer: { entityType: 'example.todo' },
1001
+ actions: {
1002
+ create: {
1003
+ commandId: 'example.todo.create',
1004
+ schema: createSchema,
1005
+ response: () => ({ ok: true }),
1006
+ },
1007
+ },
1008
+ })
1009
+
1010
+ const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
1011
+ method: 'POST',
1012
+ body: JSON.stringify({ title: 'A' }),
1013
+ headers: { 'content-type': 'application/json' },
1014
+ }))
1015
+
1016
+ expect(res.status).toBe(201)
1017
+ expect(commandBus.execute).toHaveBeenCalledWith('example.todo.create', expect.objectContaining({
1018
+ input: expect.objectContaining({ title: 'FROM-GUARD' }),
1019
+ }))
1020
+ expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
1021
+ resourceId: 'cmd-created-1',
1022
+ operation: 'create',
1023
+ }))
1024
+ })
1025
+
1026
+ // Issue #5045 — a deliberate interceptor rejection must not be laundered into a generic 500.
1027
+ const interceptorErrorRoute = () => makeCrudRoute({
1028
+ metadata: { POST: { requireAuth: true } },
1029
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
1030
+ indexer: { entityType: 'example.todo' },
1031
+ actions: {
1032
+ create: {
1033
+ commandId: 'example.todo.create',
1034
+ schema: createSchema,
1035
+ response: () => ({ ok: true }),
1036
+ },
1037
+ },
1038
+ })
1039
+
1040
+ const postInterceptorErrorRequest = (route: ReturnType<typeof interceptorErrorRoute>) => route.POST(
1041
+ new Request('http://x/api/example/todos/command', {
1042
+ method: 'POST',
1043
+ body: JSON.stringify({ title: 'A' }),
1044
+ headers: { 'content-type': 'application/json' },
1045
+ }),
1046
+ )
1047
+
1048
+ it('POST command route keeps the generic 500 when an interceptor blocks without a status', async () => {
1049
+ commandBus.execute.mockRejectedValue(new CommandInterceptorError('Missing required fields: VAT id'))
1050
+
1051
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1052
+
1053
+ expect(res.status).toBe(500)
1054
+ await expect(res.json()).resolves.toEqual({
1055
+ error: 'Internal server error',
1056
+ message: 'Something went wrong. Please try again later.',
1057
+ })
1058
+ })
1059
+
1060
+ it('POST command route surfaces the interceptor status and message when the block carries a status', async () => {
1061
+ commandBus.execute.mockRejectedValue(
1062
+ new CommandInterceptorError('Missing required fields: VAT id', { status: 422 }),
1063
+ )
1064
+
1065
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1066
+
1067
+ expect(res.status).toBe(422)
1068
+ await expect(res.json()).resolves.toEqual({ error: 'Missing required fields: VAT id' })
1069
+ })
1070
+
1071
+ it('POST command route surfaces the interceptor body verbatim when one is supplied', async () => {
1072
+ commandBus.execute.mockRejectedValue(
1073
+ new CommandInterceptorError('Blocked', { status: 422, body: { error: 'Blocked', missingFields: ['vatId'] } }),
1074
+ )
1075
+
1076
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1077
+
1078
+ expect(res.status).toBe(422)
1079
+ await expect(res.json()).resolves.toEqual({ error: 'Blocked', missingFields: ['vatId'] })
1080
+ })
1081
+
1082
+ it('POST command route keeps the generic 500 when the interceptor status is outside 4xx/5xx', async () => {
1083
+ // A status the Response constructor would reject (or that would report a block as success)
1084
+ // must not escape handleError as a RangeError — it falls back to the generic 500 instead.
1085
+ commandBus.execute.mockRejectedValue(
1086
+ Object.assign(new CommandInterceptorError('Blocked'), { status: 600, body: { error: 'Blocked' } }),
1087
+ )
1088
+
1089
+ const res = await postInterceptorErrorRequest(interceptorErrorRoute())
1090
+
1091
+ expect(res.status).toBe(500)
1092
+ await expect(res.json()).resolves.toEqual({
1093
+ error: 'Internal server error',
1094
+ message: 'Something went wrong. Please try again later.',
1095
+ })
1096
+ })
1097
+
1098
+ it('POST command route falls back to the response payload id for guard afterSuccess', async () => {
1099
+ commandBus.execute.mockResolvedValue({ result: { lineId: 'line-42' }, logEntry: { id: 'log-1' } })
1100
+ const guardAfterSuccess = jest.fn(async () => {})
1101
+ registerMutationGuards([{ moduleId: 'example', guards: [{
1102
+ id: 'example.after-command-create',
1103
+ targetEntity: 'example.todo',
1104
+ operations: ['create'],
1105
+ validate: async (_input: any) => ({ ok: true, shouldRunAfterSuccess: true }),
1106
+ afterSuccess: guardAfterSuccess,
1107
+ }] }])
1108
+ const commandRoute = makeCrudRoute({
1109
+ metadata: { POST: { requireAuth: true } },
1110
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
1111
+ indexer: { entityType: 'example.todo' },
1112
+ actions: {
1113
+ create: {
1114
+ commandId: 'example.todo.create',
1115
+ schema: createSchema,
1116
+ response: ({ result }: any) => ({ id: result.lineId }),
1117
+ },
1118
+ },
1119
+ })
1120
+
1121
+ const res = await commandRoute.POST(new Request('http://x/api/example/todos/command', {
1122
+ method: 'POST',
1123
+ body: JSON.stringify({ title: 'A' }),
1124
+ headers: { 'content-type': 'application/json' },
1125
+ }))
1126
+
1127
+ expect(res.status).toBe(201)
1128
+ expect(guardAfterSuccess).toHaveBeenCalledWith(expect.objectContaining({
1129
+ resourceId: 'line-42',
1130
+ operation: 'create',
1131
+ }))
1132
+ })
1133
+
1134
+ // Commands whose mapInput wraps the payload (e.g. `{ body }`) null the factory
1135
+ // candidateId and thereby OPT OUT of row-level mutation guards, leaving the
1136
+ // command-level optimistic-lock check as the sole guard — a documented contract
1137
+ // (apps/docs/docs/framework/data-integrity/concurrency-locking.mdx) that sales
1138
+ // line/adjustment routes rely on.
1139
+ it('PUT command route without a top-level id keeps the documented row-level guard opt-out', async () => {
1140
+ crudMutationGuardService = {
1141
+ validateMutation: jest.fn().mockResolvedValue({ ok: true, shouldRunAfterSuccess: false }),
1142
+ afterMutationSuccess: jest.fn().mockResolvedValue(undefined),
1143
+ }
1144
+ const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 409, message: 'must not run' }))
1145
+ registerMutationGuards([{ moduleId: 'example', guards: [{
1146
+ id: 'example.idless-update-opt-out',
1147
+ targetEntity: 'example.todo',
1148
+ operations: ['update'],
1149
+ validate: guardValidate,
1150
+ }] }])
1151
+ const commandRoute = makeCrudRoute({
1152
+ metadata: { PUT: { requireAuth: true } },
1153
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
1154
+ indexer: { entityType: 'example.todo' },
1155
+ actions: {
1156
+ update: {
1157
+ commandId: 'example.todo.update',
1158
+ schema: z.object({ title: z.string() }),
1159
+ mapInput: ({ parsed }: any) => ({ body: parsed }),
1160
+ response: () => ({ ok: true }),
1161
+ },
1162
+ },
1163
+ })
1164
+
1165
+ const res = await commandRoute.PUT(new Request('http://x/api/example/todos/command', {
1166
+ method: 'PUT',
1167
+ body: JSON.stringify({ title: 'nested id shape' }),
1168
+ headers: { 'content-type': 'application/json' },
1169
+ }))
1170
+
1171
+ expect(res.status).toBe(200)
1172
+ expect(guardValidate).not.toHaveBeenCalled()
1173
+ expect(crudMutationGuardService.validateMutation).not.toHaveBeenCalled()
1174
+ expect(commandBus.execute).toHaveBeenCalledWith('example.todo.update', expect.anything())
1175
+ })
1176
+
1177
+ it('DELETE command route without any id keeps the documented row-level guard opt-out', async () => {
1178
+ const guardValidate = jest.fn(async (_input: any) => ({ ok: false, status: 403, message: 'must not run' }))
1179
+ registerMutationGuards([{ moduleId: 'example', guards: [{
1180
+ id: 'example.idless-delete-opt-out',
1181
+ targetEntity: 'example.todo',
1182
+ operations: ['delete'],
1183
+ validate: guardValidate,
1184
+ }] }])
1185
+ const commandRoute = makeCrudRoute({
1186
+ metadata: { DELETE: { requireAuth: true } },
1187
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
1188
+ indexer: { entityType: 'example.todo' },
1189
+ actions: {
1190
+ delete: {
1191
+ commandId: 'example.todo.delete',
1192
+ schema: z.any(),
1193
+ response: () => ({ ok: true }),
1194
+ },
1195
+ },
1196
+ })
1197
+
1198
+ const res = await commandRoute.DELETE(new Request('http://x/api/example/todos/command', {
1199
+ method: 'DELETE',
1200
+ body: JSON.stringify({}),
1201
+ headers: { 'content-type': 'application/json' },
1202
+ }))
1203
+
1204
+ expect(res.status).toBe(200)
1205
+ expect(guardValidate).not.toHaveBeenCalled()
1206
+ expect(commandBus.execute).toHaveBeenCalledWith('example.todo.delete', expect.anything())
1207
+ })
1208
+
724
1209
  it('POST is blocked by interceptor before hook', async () => {
725
1210
  registerApiInterceptors([
726
1211
  {
@@ -784,6 +1269,85 @@ describe('CRUD Factory', () => {
784
1269
  const body = await res.json()
785
1270
  expect(body._interceptor).toEqual({ ok: true, count: 1 })
786
1271
  })
1272
+
1273
+ // The command DELETE path used to hand before-interceptors the body only, so a
1274
+ // guard reading `?id=` saw nothing to object to and the delete went through
1275
+ // with a 200 (issue #4842).
1276
+ it('DELETE command route passes the query id to interceptor before hooks', async () => {
1277
+ const lockedId = '123e4567-e89b-12d3-a456-426614174010'
1278
+ const seenQueries: Array<Record<string, unknown> | undefined> = []
1279
+ registerApiInterceptors([
1280
+ {
1281
+ moduleId: 'example',
1282
+ interceptors: [
1283
+ {
1284
+ id: 'example.block-locked-delete',
1285
+ targetRoute: 'example/todos/command',
1286
+ methods: ['DELETE'],
1287
+ async before(request) {
1288
+ seenQueries.push(request.query)
1289
+ const queryId = request.query?.id
1290
+ if (typeof queryId === 'string' && queryId === lockedId) {
1291
+ return { ok: false, statusCode: 409, message: 'Record is locked' }
1292
+ }
1293
+ return { ok: true }
1294
+ },
1295
+ },
1296
+ ],
1297
+ },
1298
+ ])
1299
+ const commandRoute = makeCrudRoute({
1300
+ metadata: { DELETE: { requireAuth: true } },
1301
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
1302
+ indexer: { entityType: 'example.todo' },
1303
+ actions: {
1304
+ delete: {
1305
+ commandId: 'example.todo.delete',
1306
+ schema: z.any(),
1307
+ response: () => ({ ok: true }),
1308
+ },
1309
+ },
1310
+ })
1311
+
1312
+ const res = await commandRoute.DELETE(new Request(`http://x/api/example/todos/command?id=${lockedId}`, {
1313
+ method: 'DELETE',
1314
+ body: JSON.stringify({}),
1315
+ headers: { 'content-type': 'application/json' },
1316
+ }))
1317
+
1318
+ expect(res.status).toBe(409)
1319
+ expect(await res.json()).toMatchObject({ error: 'Record is locked' })
1320
+ expect(seenQueries).toEqual([{ id: lockedId }])
1321
+ expect(commandBus.execute).not.toHaveBeenCalled()
1322
+ })
1323
+
1324
+ it('DELETE route passes the whole query string to interceptor before hooks', async () => {
1325
+ const created = em.create(Todo, { title: 'Z', organizationId: defaultOrganizationId, tenantId: defaultTenantId }) as Rec
1326
+ created.id = '123e4567-e89b-12d3-a456-426614174011'
1327
+ await em.persist(created).flush()
1328
+ let seenQuery: Record<string, unknown> | undefined
1329
+ registerApiInterceptors([
1330
+ {
1331
+ moduleId: 'example',
1332
+ interceptors: [
1333
+ {
1334
+ id: 'example.capture-delete-query',
1335
+ targetRoute: 'example/todos',
1336
+ methods: ['DELETE'],
1337
+ async before(request) {
1338
+ seenQuery = request.query
1339
+ return { ok: true }
1340
+ },
1341
+ },
1342
+ ],
1343
+ },
1344
+ ])
1345
+
1346
+ const res = await route.DELETE(new Request(`http://x/api/example/todos?id=${created.id}&reason=cleanup`, { method: 'DELETE' }))
1347
+
1348
+ expect(res.status).toBe(200)
1349
+ expect(seenQuery).toEqual({ id: created.id, reason: 'cleanup' })
1350
+ })
787
1351
  })
788
1352
 
789
1353
  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',