@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
@@ -4,11 +4,15 @@ import {
4
4
  registerCommand,
5
5
  registerCommandLoaders,
6
6
  CommandBus,
7
+ isCommandInterceptorError,
7
8
  } from '@open-mercato/shared/lib/commands'
9
+ import { registerCommandInterceptors } from '@open-mercato/shared/lib/commands/command-interceptor-store'
10
+ import type { CommandInterceptor } from '@open-mercato/shared/lib/commands/command-interceptor'
8
11
 
9
12
  describe('CommandBus', () => {
10
13
  afterEach(() => {
11
14
  commandRegistry.clear()
15
+ registerCommandInterceptors([])
12
16
  })
13
17
 
14
18
  it('executes registered command and logs action metadata', async () => {
@@ -119,4 +123,63 @@ describe('CommandBus', () => {
119
123
  expect(result).toEqual({ ok: true })
120
124
  expect(execute).toHaveBeenCalledTimes(1)
121
125
  })
126
+
127
+ describe('interceptor rejections', () => {
128
+ const blockingInterceptor = (result: Record<string, unknown>): CommandInterceptor => ({
129
+ id: 'test.block',
130
+ targetCommand: 'test.*',
131
+ beforeExecute: async () => result,
132
+ })
133
+
134
+ const runBlockedCommand = async (interceptor: CommandInterceptor) => {
135
+ const execute = jest.fn(async () => ({ ok: true }))
136
+ registerCommand({ id: 'test.command.blocked', execute })
137
+ registerCommandInterceptors([{ moduleId: 'test', interceptors: [interceptor] }])
138
+
139
+ const container = createContainer({ injectionMode: InjectionMode.CLASSIC })
140
+ const bus = new CommandBus()
141
+ const ctx = {
142
+ container,
143
+ auth: { sub: 'user-4', tenantId: 'tenant-4', orgId: null },
144
+ organizationScope: null,
145
+ selectedOrganizationId: null,
146
+ organizationIds: null,
147
+ }
148
+
149
+ const error = await bus.execute('test.command.blocked', { input: {}, ctx }).catch((e: unknown) => e)
150
+ return { error, execute }
151
+ }
152
+
153
+ it('forwards the interceptor status and derived body onto the thrown error', async () => {
154
+ const { error, execute } = await runBlockedCommand(
155
+ blockingInterceptor({ ok: false, message: 'Missing required fields: VAT id', status: 422 }),
156
+ )
157
+
158
+ expect(isCommandInterceptorError(error)).toBe(true)
159
+ expect((error as { status?: number }).status).toBe(422)
160
+ expect((error as { body?: Record<string, unknown> }).body).toEqual({
161
+ error: 'Missing required fields: VAT id',
162
+ })
163
+ expect(execute).not.toHaveBeenCalled()
164
+ })
165
+
166
+ it('forwards an explicit interceptor body verbatim', async () => {
167
+ const body = { error: 'Blocked', missingFields: ['vatId'] }
168
+ const { error } = await runBlockedCommand(
169
+ blockingInterceptor({ ok: false, message: 'Blocked', status: 409, body }),
170
+ )
171
+
172
+ expect((error as { status?: number }).status).toBe(409)
173
+ expect((error as { body?: Record<string, unknown> }).body).toEqual(body)
174
+ })
175
+
176
+ it('leaves status and body undefined when the interceptor supplies no status', async () => {
177
+ const { error } = await runBlockedCommand(blockingInterceptor({ ok: false, message: 'Blocked' }))
178
+
179
+ expect(isCommandInterceptorError(error)).toBe(true)
180
+ expect((error as Error).message).toBe('Blocked')
181
+ expect((error as { status?: number }).status).toBeUndefined()
182
+ expect((error as { body?: unknown }).body).toBeUndefined()
183
+ })
184
+ })
122
185
  })
@@ -1,4 +1,8 @@
1
- import { CommandInterceptorError } from '../errors'
1
+ import {
2
+ CommandInterceptorError,
3
+ getCommandInterceptorHttpRejection,
4
+ isCommandInterceptorError,
5
+ } from '../errors'
2
6
 
3
7
  describe('CommandInterceptorError', () => {
4
8
  it('has the correct name', () => {
@@ -15,4 +19,90 @@ describe('CommandInterceptorError', () => {
15
19
  const error = new CommandInterceptorError('test')
16
20
  expect(error).toBeInstanceOf(Error)
17
21
  })
22
+
23
+ it('carries no transport information when constructed with a message alone', () => {
24
+ const error = new CommandInterceptorError('test')
25
+ expect(error.status).toBeUndefined()
26
+ expect(error.body).toBeUndefined()
27
+ })
28
+
29
+ it('carries the status and derives the body from the message', () => {
30
+ const error = new CommandInterceptorError('Missing required fields: VAT id', { status: 422 })
31
+ expect(error.status).toBe(422)
32
+ expect(error.body).toEqual({ error: 'Missing required fields: VAT id' })
33
+ })
34
+
35
+ it('keeps an explicit body verbatim', () => {
36
+ const body = { error: 'Blocked', missingFields: ['vatId'] }
37
+ const error = new CommandInterceptorError('Blocked', { status: 422, body })
38
+ expect(error.body).toEqual(body)
39
+ })
40
+
41
+ it('ignores a body supplied without a status, so status and body move together', () => {
42
+ const error = new CommandInterceptorError('Blocked', { body: { error: 'Blocked' } })
43
+ expect(error.status).toBeUndefined()
44
+ expect(error.body).toBeUndefined()
45
+ })
46
+
47
+ it('preserves the cause when one is supplied', () => {
48
+ const cause = new Error('underlying')
49
+ const error = new CommandInterceptorError('Blocked', { cause })
50
+ expect(error.cause).toBe(cause)
51
+ })
52
+
53
+ it('is detected by isCommandInterceptorError across module boundaries', () => {
54
+ expect(isCommandInterceptorError(new CommandInterceptorError('test'))).toBe(true)
55
+ // A structurally identical error from a duplicated bundle carries the same registered symbol.
56
+ const duplicated = Object.assign(new Error('test'), {
57
+ [Symbol.for('@open-mercato/CommandInterceptorError')]: true,
58
+ })
59
+ expect(isCommandInterceptorError(duplicated)).toBe(true)
60
+ })
61
+
62
+ it('does not treat unrelated values as interceptor errors', () => {
63
+ expect(isCommandInterceptorError(new Error('plain'))).toBe(false)
64
+ expect(isCommandInterceptorError(null)).toBe(false)
65
+ expect(isCommandInterceptorError('CommandInterceptorError')).toBe(false)
66
+ })
67
+ })
68
+
69
+ describe('getCommandInterceptorHttpRejection', () => {
70
+ it('returns the status and body of a status-carrying rejection', () => {
71
+ const error = new CommandInterceptorError('Missing required fields: VAT id', { status: 422 })
72
+ expect(getCommandInterceptorHttpRejection(error)).toEqual({
73
+ status: 422,
74
+ body: { error: 'Missing required fields: VAT id' },
75
+ })
76
+ })
77
+
78
+ it('returns the explicit body when the interceptor supplied one', () => {
79
+ const body = { error: 'Blocked', missingFields: ['vatId'] }
80
+ const error = new CommandInterceptorError('Blocked', { status: 409, body })
81
+ expect(getCommandInterceptorHttpRejection(error)).toEqual({ status: 409, body })
82
+ })
83
+
84
+ it('returns null for a rejection that carries no status', () => {
85
+ expect(getCommandInterceptorHttpRejection(new CommandInterceptorError('Blocked'))).toBeNull()
86
+ })
87
+
88
+ it('returns null for errors that are not interceptor rejections', () => {
89
+ expect(getCommandInterceptorHttpRejection(new Error('plain'))).toBeNull()
90
+ expect(getCommandInterceptorHttpRejection(null)).toBeNull()
91
+ })
92
+
93
+ it('rejects a status outside 4xx/5xx so the transport layer keeps its generic handling', () => {
94
+ // Statuses arrive as third-party interceptor data: outside 200-599 the Response constructor
95
+ // throws RangeError, and a 2xx would report a deliberate block as a success.
96
+ for (const status of [200, 302, 399, 600, 999, 0, -1]) {
97
+ const error = Object.assign(new CommandInterceptorError('Blocked'), { status })
98
+ expect(getCommandInterceptorHttpRejection(error)).toBeNull()
99
+ }
100
+ })
101
+
102
+ it('rejects a non-integer status', () => {
103
+ for (const status of [Number.NaN, 422.5, Number.POSITIVE_INFINITY]) {
104
+ const error = Object.assign(new CommandInterceptorError('Blocked'), { status })
105
+ expect(getCommandInterceptorHttpRejection(error)).toBeNull()
106
+ }
107
+ })
18
108
  })
@@ -7,6 +7,10 @@ import {
7
7
  } from '../command-interceptor-runner'
8
8
  import type { CommandInterceptor, CommandInterceptorContext, CommandInterceptorUndoContext } from '../command-interceptor'
9
9
  import { createLogger } from '@open-mercato/shared/lib/logger'
10
+ import {
11
+ applyAclFeatureOverrides,
12
+ resetModuleContractOverridesForTests,
13
+ } from '@open-mercato/shared/modules/overrides'
10
14
 
11
15
  jest.mock('@open-mercato/shared/lib/logger', () => {
12
16
  const mocked = {
@@ -59,6 +63,10 @@ describe('matchesCommandPattern', () => {
59
63
  })
60
64
 
61
65
  describe('runCommandInterceptorsBefore', () => {
66
+ afterEach(() => {
67
+ resetModuleContractOverridesForTests()
68
+ })
69
+
62
70
  it('returns ok when no interceptors match', async () => {
63
71
  const interceptor = makeInterceptor({
64
72
  id: 'i1',
@@ -104,6 +112,56 @@ describe('runCommandInterceptorsBefore', () => {
104
112
  expect(i2.beforeExecute).not.toHaveBeenCalled()
105
113
  })
106
114
 
115
+ // Issue #5045 — a rejection only carries transport information when the interceptor asks for it.
116
+ it('omits status and body when the rejection carries no status', async () => {
117
+ const interceptor = makeInterceptor({
118
+ id: 'i1',
119
+ beforeExecute: jest.fn().mockResolvedValue({ ok: false, message: 'Blocked' }),
120
+ })
121
+ const result = await runCommandInterceptorsBefore([interceptor], 'customers.create-person', {}, baseContext, [])
122
+ expect(result.error).toEqual({ message: 'Blocked' })
123
+ })
124
+
125
+ it('propagates the status and derives a body from the message', async () => {
126
+ const interceptor = makeInterceptor({
127
+ id: 'i1',
128
+ beforeExecute: jest.fn().mockResolvedValue({ ok: false, message: 'Missing required fields', status: 422 }),
129
+ })
130
+ const result = await runCommandInterceptorsBefore([interceptor], 'customers.create-person', {}, baseContext, [])
131
+ expect(result.error).toEqual({
132
+ message: 'Missing required fields',
133
+ status: 422,
134
+ body: { error: 'Missing required fields' },
135
+ })
136
+ })
137
+
138
+ it('propagates an explicit body alongside the status', async () => {
139
+ const interceptor = makeInterceptor({
140
+ id: 'i1',
141
+ beforeExecute: jest.fn().mockResolvedValue({
142
+ ok: false,
143
+ message: 'Blocked',
144
+ status: 422,
145
+ body: { error: 'Blocked', missingFields: ['vatId'] },
146
+ }),
147
+ })
148
+ const result = await runCommandInterceptorsBefore([interceptor], 'customers.create-person', {}, baseContext, [])
149
+ expect(result.error?.body).toEqual({ error: 'Blocked', missingFields: ['vatId'] })
150
+ })
151
+
152
+ it('falls back to the generated message when a status-carrying rejection has none', async () => {
153
+ const interceptor = makeInterceptor({
154
+ id: 'i1',
155
+ beforeExecute: jest.fn().mockResolvedValue({ ok: false, status: 422 }),
156
+ })
157
+ const result = await runCommandInterceptorsBefore([interceptor], 'customers.create-person', {}, baseContext, [])
158
+ expect(result.error).toEqual({
159
+ message: 'Blocked by command interceptor: i1',
160
+ status: 422,
161
+ body: { error: 'Blocked by command interceptor: i1' },
162
+ })
163
+ })
164
+
107
165
  it('accumulates modified input', async () => {
108
166
  const i1 = makeInterceptor({
109
167
  id: 'i1',
@@ -158,6 +216,26 @@ describe('runCommandInterceptorsBefore', () => {
158
216
  expect(withWildcard.ok).toBe(false)
159
217
  expect(withWildcard.error?.message).toBe('Blocked by wildcard')
160
218
  })
219
+
220
+ it('does not run an interceptor gated by a nulled ACL feature', async () => {
221
+ applyAclFeatureOverrides({ 'premium.audit': null })
222
+ const interceptor = makeInterceptor({
223
+ id: 'i1',
224
+ features: ['premium.audit'],
225
+ beforeExecute: jest.fn().mockResolvedValue({ ok: false }),
226
+ })
227
+
228
+ const result = await runCommandInterceptorsBefore(
229
+ [interceptor],
230
+ 'customers.create-person',
231
+ {},
232
+ baseContext,
233
+ ['*', 'premium.audit'],
234
+ )
235
+
236
+ expect(result.ok).toBe(true)
237
+ expect(interceptor.beforeExecute).not.toHaveBeenCalled()
238
+ })
161
239
  })
162
240
 
163
241
  describe('runCommandInterceptorsAfter', () => {
@@ -208,6 +286,29 @@ describe('runCommandInterceptorsBeforeUndo', () => {
208
286
  expect(result.ok).toBe(false)
209
287
  expect(result.error?.message).toBe('Cannot undo')
210
288
  })
289
+
290
+ // Issue #5045 — the undo path shares the before-result contract, so it carries a status too.
291
+ it('omits status and body when the undo rejection carries no status', async () => {
292
+ const interceptor = makeInterceptor({
293
+ id: 'i1',
294
+ beforeUndo: jest.fn().mockResolvedValue({ ok: false, message: 'Cannot undo' }),
295
+ })
296
+ const result = await runCommandInterceptorsBeforeUndo([interceptor], 'customers.create-person', undoContext, baseContext, [])
297
+ expect(result.error).toEqual({ message: 'Cannot undo' })
298
+ })
299
+
300
+ it('propagates the status and body of an undo rejection', async () => {
301
+ const interceptor = makeInterceptor({
302
+ id: 'i1',
303
+ beforeUndo: jest.fn().mockResolvedValue({ ok: false, message: 'Cannot undo a settled invoice', status: 409 }),
304
+ })
305
+ const result = await runCommandInterceptorsBeforeUndo([interceptor], 'customers.create-person', undoContext, baseContext, [])
306
+ expect(result.error).toEqual({
307
+ message: 'Cannot undo a settled invoice',
308
+ status: 409,
309
+ body: { error: 'Cannot undo a settled invoice' },
310
+ })
311
+ })
211
312
  })
212
313
 
213
314
  describe('runCommandInterceptorsAfterUndo', () => {
@@ -31,6 +31,7 @@ import {
31
31
  } from './command-interceptor-runner'
32
32
  import type { CommandInterceptorContext } from './command-interceptor'
33
33
  import { CommandInterceptorError } from './errors'
34
+ import { isReadProjectionAlwaysConsistent } from '@open-mercato/shared/lib/data/consistency'
34
35
  import { createLogger } from '../logger'
35
36
 
36
37
  const logger = createLogger('shared').child({ component: 'commands' })
@@ -244,7 +245,8 @@ export class CommandBus {
244
245
  allInterceptors, commandId, options.input, interceptorCtx, userFeatures,
245
246
  )
246
247
  if (!beforeResult.ok) {
247
- throw new CommandInterceptorError(beforeResult.error!.message)
248
+ const blocked = beforeResult.error!
249
+ throw new CommandInterceptorError(blocked.message, { status: blocked.status, body: blocked.body })
248
250
  }
249
251
  interceptorMetadata = beforeResult.metadataByInterceptor
250
252
  if (beforeResult.modifiedInput) {
@@ -362,7 +364,8 @@ export class CommandBus {
362
364
  allInterceptors, log.commandId, undoCtx, interceptorCtx, userFeatures,
363
365
  )
364
366
  if (!beforeResult.ok) {
365
- throw new CommandInterceptorError(beforeResult.error!.message)
367
+ const blocked = beforeResult.error!
368
+ throw new CommandInterceptorError(blocked.message, { status: blocked.status, body: blocked.body })
366
369
  }
367
370
  undoInterceptorMetadata = beforeResult.metadataByInterceptor
368
371
  }
@@ -697,7 +700,10 @@ export class CommandBus {
697
700
  try {
698
701
  const dataEngine = (container.resolve('dataEngine') as DataEngine)
699
702
  await dataEngine.flushOrmEntityChanges(suppress)
700
- } catch {
703
+ } catch (error) {
704
+ if (isReadProjectionAlwaysConsistent()) {
705
+ throw error
706
+ }
701
707
  // best-effort: failures should not block command execution
702
708
  }
703
709
  }
@@ -1,13 +1,34 @@
1
1
  import type {
2
2
  CommandInterceptor,
3
+ CommandInterceptorBeforeResult,
3
4
  CommandInterceptorContext,
4
5
  CommandInterceptorUndoContext,
5
6
  } from './command-interceptor'
6
- import { hasAllFeatures } from '../../security/features'
7
+ import { authorizeFeatures } from '../../security/featurePolicy'
7
8
  import { createLogger } from '../logger'
8
9
 
9
10
  const logger = createLogger('shared').child({ component: 'commands' })
10
11
 
12
+ /**
13
+ * A blocking verdict from a before-hook, normalized for the command bus. `status`/`body` are
14
+ * present only when the interceptor supplied a status, so a rejection without one keeps the
15
+ * historical generic-500 handling downstream.
16
+ */
17
+ export type CommandInterceptorBlockedError = {
18
+ message: string
19
+ status?: number
20
+ body?: Record<string, unknown>
21
+ }
22
+
23
+ function buildBlockedError(
24
+ result: CommandInterceptorBeforeResult,
25
+ fallbackMessage: string,
26
+ ): CommandInterceptorBlockedError {
27
+ const message = result.message ?? fallbackMessage
28
+ if (typeof result.status !== 'number') return { message }
29
+ return { message, status: result.status, body: result.body ?? { error: message } }
30
+ }
31
+
11
32
  // ---------------------------------------------------------------------------
12
33
  // Command pattern matching
13
34
  // ---------------------------------------------------------------------------
@@ -33,7 +54,7 @@ function collectMatching(
33
54
  ): CommandInterceptor[] {
34
55
  return interceptors
35
56
  .filter((i) => matchesCommandPattern(i.targetCommand, commandId))
36
- .filter((i) => hasAllFeatures(userFeatures, i.features))
57
+ .filter((i) => authorizeFeatures(i.features ?? [], { grantedFeatures: userFeatures }))
37
58
  .sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
38
59
  }
39
60
 
@@ -49,7 +70,7 @@ export async function runCommandInterceptorsBefore(
49
70
  userFeatures: string[],
50
71
  ): Promise<{
51
72
  ok: boolean
52
- error?: { message: string }
73
+ error?: CommandInterceptorBlockedError
53
74
  modifiedInput?: Record<string, unknown>
54
75
  metadataByInterceptor: Map<string, Record<string, unknown>>
55
76
  }> {
@@ -65,7 +86,7 @@ export async function runCommandInterceptorsBefore(
65
86
  if (result?.ok === false) {
66
87
  return {
67
88
  ok: false,
68
- error: { message: result.message ?? `Blocked by command interceptor: ${interceptor.id}` },
89
+ error: buildBlockedError(result, `Blocked by command interceptor: ${interceptor.id}`),
69
90
  metadataByInterceptor,
70
91
  }
71
92
  }
@@ -139,7 +160,7 @@ export async function runCommandInterceptorsBeforeUndo(
139
160
  userFeatures: string[],
140
161
  ): Promise<{
141
162
  ok: boolean
142
- error?: { message: string }
163
+ error?: CommandInterceptorBlockedError
143
164
  metadataByInterceptor: Map<string, Record<string, unknown>>
144
165
  }> {
145
166
  const matching = collectMatching(interceptors, commandId, userFeatures)
@@ -153,7 +174,7 @@ export async function runCommandInterceptorsBeforeUndo(
153
174
  if (result?.ok === false) {
154
175
  return {
155
176
  ok: false,
156
- error: { message: result.message ?? `Undo blocked by command interceptor: ${interceptor.id}` },
177
+ error: buildBlockedError(result, `Undo blocked by command interceptor: ${interceptor.id}`),
157
178
  metadataByInterceptor,
158
179
  }
159
180
  }
@@ -67,6 +67,14 @@ export interface CommandInterceptorBeforeResult {
67
67
  ok?: boolean
68
68
  /** Error message when blocking */
69
69
  message?: string
70
+ /**
71
+ * HTTP status code when blocking. Omit to keep the historical behaviour, where a rejection
72
+ * without a status surfaces as a generic 500 — set it (e.g. 422) to have the CRUD transport
73
+ * layer answer with a deliberate business-rejection status instead.
74
+ */
75
+ status?: number
76
+ /** Error body when blocking (overrides message). Only used together with `status`. */
77
+ body?: Record<string, unknown>
70
78
  /** Modified input — shallow-merged into command input if ok:true */
71
79
  modifiedInput?: Record<string, unknown>
72
80
  /** Metadata passed to the corresponding after hook */
@@ -1,6 +1,71 @@
1
+ // Use Symbol.for so the marker survives module duplication across bundle boundaries
2
+ // (same behaviour as CrudHttpError and the globalThis-based DI registries)
3
+ const COMMAND_INTERCEPTOR_ERROR_MARKER = Symbol.for('@open-mercato/CommandInterceptorError')
4
+
5
+ export type CommandInterceptorErrorOptions = {
6
+ /** HTTP status the transport layer should answer with. Omit to keep the generic 500. */
7
+ status?: number
8
+ /**
9
+ * Response body the transport layer should answer with, defaulting to `{ error: message }`.
10
+ * Only meaningful together with `status` — a body on its own is ignored, mirroring the same
11
+ * rule on `CommandInterceptorBeforeResult`.
12
+ */
13
+ body?: Record<string, unknown>
14
+ cause?: unknown
15
+ }
16
+
1
17
  export class CommandInterceptorError extends Error {
2
- constructor(message: string) {
3
- super(message)
18
+ readonly [COMMAND_INTERCEPTOR_ERROR_MARKER] = true
19
+ /**
20
+ * HTTP status a deliberate interceptor rejection wants to surface. `undefined` when the
21
+ * interceptor supplied none, in which case the transport layer keeps its generic 500.
22
+ */
23
+ readonly status?: number
24
+ /**
25
+ * Response body for the rejection — `options.body`, else `{ error: message }`. Set only
26
+ * alongside `status`, so the two are always populated together or not at all.
27
+ */
28
+ readonly body?: Record<string, unknown>
29
+
30
+ constructor(message: string, options?: CommandInterceptorErrorOptions) {
31
+ super(message, options?.cause !== undefined ? { cause: options.cause } : undefined)
4
32
  this.name = 'CommandInterceptorError'
33
+ if (typeof options?.status === 'number') {
34
+ this.status = options.status
35
+ this.body = options.body ?? { error: message }
36
+ }
5
37
  }
6
38
  }
39
+
40
+ /**
41
+ * Type-safe check for CommandInterceptorError that works across module/bundle boundaries.
42
+ * Prefer this over `instanceof CommandInterceptorError` whenever the error may originate
43
+ * from a different module bundle (e.g. enterprise packages, dynamic imports).
44
+ */
45
+ export function isCommandInterceptorError(err: unknown): err is CommandInterceptorError {
46
+ return !!err && typeof err === 'object'
47
+ && (err as Record<symbol, unknown>)[COMMAND_INTERCEPTOR_ERROR_MARKER] === true
48
+ }
49
+
50
+ export type CommandInterceptorHttpRejection = {
51
+ status: number
52
+ body: Record<string, unknown>
53
+ }
54
+
55
+ const MIN_REJECTION_STATUS = 400
56
+ const MAX_REJECTION_STATUS = 599
57
+
58
+ /**
59
+ * Transport data for a deliberate interceptor rejection, or `null` when the error is not one or
60
+ * carries no usable status. The status is restricted to 4xx/5xx on purpose: interceptor data is
61
+ * third-party input travelling through the runner and the bus, `new Response(body, { status })`
62
+ * throws `RangeError` outside 200-599, and a rejection answered with a 2xx would report a block as
63
+ * a success. Anything else falls through to the caller's generic handling.
64
+ */
65
+ export function getCommandInterceptorHttpRejection(err: unknown): CommandInterceptorHttpRejection | null {
66
+ if (!isCommandInterceptorError(err)) return null
67
+ const status = err.status
68
+ if (typeof status !== 'number' || !Number.isInteger(status)) return null
69
+ if (status < MIN_REJECTION_STATUS || status > MAX_REJECTION_STATUS) return null
70
+ return { status, body: err.body ?? { error: err.message } }
71
+ }
@@ -4,7 +4,13 @@ export { CommandBus } from './command-bus'
4
4
  export * from './customFieldSnapshots'
5
5
  export * from './undo'
6
6
  export * from './redo'
7
- export { CommandInterceptorError } from './errors'
7
+ export {
8
+ CommandInterceptorError,
9
+ isCommandInterceptorError,
10
+ getCommandInterceptorHttpRejection,
11
+ type CommandInterceptorErrorOptions,
12
+ type CommandInterceptorHttpRejection,
13
+ } from './errors'
8
14
  export {
9
15
  runCrudCommandWrite,
10
16
  type RunCrudCommandWriteOptions,