@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
@@ -1,16 +1,98 @@
1
1
  import type { BootstrapData } from './types'
2
+ import type { AppDiRegistrar } from '../di/container'
2
3
  import { findAppRoot, type AppRoot } from './appResolver'
3
4
  import { registerEntityIds } from '../encryption/entityIds'
5
+ import { createLogger } from '../logger'
4
6
  import {
5
7
  ensureMikroOrmV7GeneratedCacheCompatibility,
6
8
  recoverMikroOrmV7GeneratedCacheFromImportError,
7
9
  } from './generatedCacheRecovery'
10
+ import { CLIENT_ONLY_STUB_NAMESPACE, createClientOnlyStubPlugin } from './clientOnlyModules'
8
11
  import path from 'node:path'
9
12
  import fs from 'node:fs'
10
13
  import crypto from 'node:crypto'
11
14
  import { createRequire } from 'node:module'
12
15
  import { pathToFileURL } from 'node:url'
13
16
 
17
+ const logger = createLogger('shared').child({ component: 'bootstrap' })
18
+
19
+ /**
20
+ * Thrown when an expected generated source file is absent.
21
+ *
22
+ * Optional registries treat this as the supported compatibility case (an app
23
+ * that never generated the file), which is what makes it distinguishable from
24
+ * a file that exists but fails to compile or import.
25
+ */
26
+ class GeneratedFileNotFoundError extends Error {
27
+ readonly filePath: string
28
+
29
+ constructor(filePath: string) {
30
+ super(`Generated file not found: ${filePath}`)
31
+ this.name = 'GeneratedFileNotFoundError'
32
+ this.filePath = filePath
33
+ }
34
+ }
35
+
36
+ /**
37
+ * esbuild plugins for the CLI bundle, in resolution order. The client-only stub must come
38
+ * first so it wins over the alias and external plugins for `*.client` dynamic imports.
39
+ *
40
+ * Exported so the wiring itself is testable: a test that only exercises
41
+ * `createClientOnlyStubPlugin` in isolation stays green if the plugin is dropped from this
42
+ * list, which would silently reintroduce #4623.
43
+ */
44
+ export function createCliBundlePlugins(appRoot: string): import('esbuild').Plugin[] {
45
+ // Plugin to resolve the @/ alias the way the app tsconfig maps it:
46
+ // `@/.mercato/*` to the app root, every other `@/*` to the app's src/ directory.
47
+ const aliasPlugin: import('esbuild').Plugin = {
48
+ name: 'alias-resolver',
49
+ setup(build) {
50
+ build.onResolve({ filter: /^@\// }, (args) => {
51
+ const rest = args.path.slice('@/'.length)
52
+ const bases = rest.startsWith('.mercato/')
53
+ ? [path.join(appRoot, rest)]
54
+ : [path.join(appRoot, 'src', rest), path.join(appRoot, rest)]
55
+ for (const base of bases) {
56
+ if (fs.existsSync(base) && fs.statSync(base).isFile()) {
57
+ return { path: base }
58
+ }
59
+ for (const suffix of ['.ts', '.tsx', '/index.ts', '/index.tsx']) {
60
+ if (fs.existsSync(base + suffix)) {
61
+ return { path: base + suffix }
62
+ }
63
+ }
64
+ }
65
+ // Nothing matched — hand esbuild the literal mapping so it reports the
66
+ // missing file against the path the app author actually wrote.
67
+ return { path: path.join(appRoot, rest) }
68
+ })
69
+ },
70
+ }
71
+
72
+ // Plugin to mark non-JSON package imports as external
73
+ const externalNonJsonPlugin: import('esbuild').Plugin = {
74
+ name: 'external-non-json',
75
+ setup(build) {
76
+ // Mark all package imports as external EXCEPT JSON files
77
+ // Filter matches paths that don't start with . or / (package imports like @open-mercato/shared)
78
+ build.onResolve({ filter: /^[^./]/ }, (args) => {
79
+ // Skip Windows absolute paths (e.g., C:\...) - they're local files, not packages
80
+ if (/^[a-zA-Z]:/.test(args.path)) {
81
+ return null // Let esbuild handle it
82
+ }
83
+ // If it's a JSON file, let esbuild bundle it
84
+ if (args.path.endsWith('.json')) {
85
+ return null // Let esbuild handle it
86
+ }
87
+ // Otherwise mark as external
88
+ return { path: args.path, external: true }
89
+ })
90
+ },
91
+ }
92
+
93
+ return [createClientOnlyStubPlugin(), aliasPlugin, externalNonJsonPlugin]
94
+ }
95
+
14
96
  const DYNAMIC_LOADER_CACHE_VERSION = 4
15
97
 
16
98
  type DynamicLoaderCacheMetadata = {
@@ -167,6 +249,7 @@ function collectDependencyHashes(
167
249
  ): Record<string, string> {
168
250
  return Object.fromEntries(
169
251
  Object.keys(inputs)
252
+ .filter((inputPath) => !inputPath.startsWith(`${CLIENT_ONLY_STUB_NAMESPACE}:`))
170
253
  .map((inputPath) => {
171
254
  const absolutePath = path.isAbsolute(inputPath)
172
255
  ? inputPath
@@ -221,14 +304,33 @@ function cacheIsValid(
221
304
  && dependenciesAreValid(appRoot, metadata.dependencies)
222
305
  }
223
306
 
307
+ /**
308
+ * Options for `compileAndImport`.
309
+ *
310
+ * Both paths default to the generated-registry layout (`<appRoot>/.mercato/generated/<file>.ts`
311
+ * compiled to a `.mjs` sibling). Sources that live elsewhere in the app — `src/di.ts` — MUST pass
312
+ * both explicitly: the default app root is derived by walking three directories up from the source,
313
+ * which only holds inside `.mercato/generated`.
314
+ */
315
+ type CompileAndImportOptions = {
316
+ appRoot?: string
317
+ outFile?: string
318
+ allowRecovery?: boolean
319
+ }
320
+
224
321
  /**
225
322
  * Compile a TypeScript file to JavaScript using esbuild bundler.
226
323
  * This bundles the file and all its dependencies, handling JSON imports properly.
227
- * The compiled file is written next to the source file with a .mjs extension.
324
+ * The compiled file is written next to the source file with a .mjs extension unless
325
+ * `outFile` says otherwise.
228
326
  */
229
- async function compileAndImport(tsPath: string, allowRecovery: boolean = true): Promise<Record<string, unknown>> {
230
- const jsPath = tsPath.replace(/\.ts$/, '.mjs')
231
- const appRoot = path.dirname(path.dirname(path.dirname(tsPath)))
327
+ async function compileAndImport(
328
+ tsPath: string,
329
+ options: CompileAndImportOptions = {},
330
+ ): Promise<Record<string, unknown>> {
331
+ const allowRecovery = options.allowRecovery ?? true
332
+ const jsPath = options.outFile ?? tsPath.replace(/\.ts$/, '.mjs')
333
+ const appRoot = options.appRoot ?? path.dirname(path.dirname(path.dirname(tsPath)))
232
334
  const appTsconfig = path.join(appRoot, 'tsconfig.json')
233
335
  const metadataPath = cacheMetadataPath(jsPath)
234
336
 
@@ -236,7 +338,7 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
236
338
  const tsconfigExists = fs.existsSync(appTsconfig)
237
339
 
238
340
  if (!tsExists) {
239
- throw new Error(`Generated file not found: ${tsPath}`)
341
+ throw new GeneratedFileNotFoundError(tsPath)
240
342
  }
241
343
  if (!tsconfigExists) {
242
344
  throw new Error(`App TypeScript config not found: ${appTsconfig}`)
@@ -247,50 +349,10 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
247
349
  const needsCompile = !cacheIsValid(appRoot, jsPath, metadataPath, expectedInputHash)
248
350
 
249
351
  if (needsCompile) {
352
+ fs.mkdirSync(path.dirname(jsPath), { recursive: true })
250
353
  // Dynamically import esbuild only when needed
251
354
  const esbuild = await import('esbuild')
252
355
 
253
- // Plugin to resolve @/ alias to app root (works for @app modules)
254
- const aliasPlugin: import('esbuild').Plugin = {
255
- name: 'alias-resolver',
256
- setup(build) {
257
- // Resolve @/ alias to app root
258
- build.onResolve({ filter: /^@\// }, (args) => {
259
- const resolved = path.join(appRoot, args.path.slice(2))
260
- // Try with .ts extension if base path doesn't exist
261
- if (!fs.existsSync(resolved) && fs.existsSync(resolved + '.ts')) {
262
- return { path: resolved + '.ts' }
263
- }
264
- // Also check for /index.ts if it's a directory
265
- if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory() && fs.existsSync(path.join(resolved, 'index.ts'))) {
266
- return { path: path.join(resolved, 'index.ts') }
267
- }
268
- return { path: resolved }
269
- })
270
- },
271
- }
272
-
273
- // Plugin to mark non-JSON package imports as external
274
- const externalNonJsonPlugin: import('esbuild').Plugin = {
275
- name: 'external-non-json',
276
- setup(build) {
277
- // Mark all package imports as external EXCEPT JSON files
278
- // Filter matches paths that don't start with . or / (package imports like @open-mercato/shared)
279
- build.onResolve({ filter: /^[^./]/ }, (args) => {
280
- // Skip Windows absolute paths (e.g., C:\...) - they're local files, not packages
281
- if (/^[a-zA-Z]:/.test(args.path)) {
282
- return null // Let esbuild handle it
283
- }
284
- // If it's a JSON file, let esbuild bundle it
285
- if (args.path.endsWith('.json')) {
286
- return null // Let esbuild handle it
287
- }
288
- // Otherwise mark as external
289
- return { path: args.path, external: true }
290
- })
291
- },
292
- }
293
-
294
356
  // Use esbuild.build with bundling to handle JSON imports
295
357
  const result = await esbuild.build({
296
358
  entryPoints: [tsPath],
@@ -302,7 +364,7 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
302
364
  platform: 'node',
303
365
  target: 'node18',
304
366
  tsconfig: appTsconfig,
305
- plugins: [aliasPlugin, externalNonJsonPlugin],
367
+ plugins: createCliBundlePlugins(appRoot),
306
368
  // Allow JSON imports
307
369
  loader: { '.json': 'json' },
308
370
  })
@@ -333,25 +395,45 @@ async function compileAndImport(tsPath: string, allowRecovery: boolean = true):
333
395
  throw error
334
396
  }
335
397
 
336
- return compileAndImport(tsPath, false)
398
+ return compileAndImport(tsPath, { ...options, allowRecovery: false })
337
399
  }
338
400
  }
339
401
 
340
402
 
341
403
  /**
342
- * Dynamically load bootstrap data from a resolved app directory.
343
- *
344
- * IMPORTANT: This only works in unbundled contexts (CLI, tsx).
345
- * Do NOT use this in Next.js bundled code - use static imports instead.
404
+ * Load a generated registry that older apps may not have generated yet.
346
405
  *
347
- * For CLI context, we skip loading modules.generated.ts which has Next.js dependencies.
348
- * CLI commands are discovered separately via the CLI module system.
349
- *
350
- * @param appRoot - Optional explicit app root path. If not provided, will search from cwd.
351
- * @returns The loaded bootstrap data
352
- * @throws Error if app root cannot be found or generated files are missing
406
+ * An absent source file is the supported compatibility case and resolves to
407
+ * `fallback` quietly. Any other failure — a compile error, a broken import, a
408
+ * runtime throw at module scope — still resolves to `fallback` so bootstrap
409
+ * keeps working, but is reported at error level: a registry that silently
410
+ * degrades to nothing is exactly how command interceptors stopped applying in
411
+ * worker/CLI processes (#4327, #4491).
353
412
  */
354
- export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData> {
413
+ async function loadOptionalGeneratedModule(
414
+ tsPath: string,
415
+ fallback: Record<string, unknown>,
416
+ ): Promise<Record<string, unknown>> {
417
+ try {
418
+ return await compileAndImport(tsPath)
419
+ } catch (error) {
420
+ if (error instanceof GeneratedFileNotFoundError) {
421
+ logger.debug('Optional generated registry not present, using empty fallback', {
422
+ file: path.basename(tsPath),
423
+ })
424
+ return fallback
425
+ }
426
+
427
+ logger.error('Failed to load generated registry, continuing without its entries', {
428
+ file: path.basename(tsPath),
429
+ filePath: tsPath,
430
+ err: error,
431
+ })
432
+ return fallback
433
+ }
434
+ }
435
+
436
+ function resolveAppRootOrThrow(appRoot?: string): AppRoot {
355
437
  const resolved: AppRoot | null = appRoot
356
438
  ? {
357
439
  generatedDir: path.join(appRoot, '.mercato', 'generated'),
@@ -368,6 +450,68 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
368
450
  )
369
451
  }
370
452
 
453
+ return resolved
454
+ }
455
+
456
+ /**
457
+ * Load the app-level DI registrar (`src/di.ts`) for the dynamic bootstrap path.
458
+ *
459
+ * The Next.js runtime imports `@/di` statically from its own `src/bootstrap.ts` and hands the
460
+ * registrar to `createBootstrap`. Worker, scheduler and CLI processes bootstrap through
461
+ * `bootstrapFromAppRoot` instead, where the `@/` alias does not exist — so without this the app's
462
+ * DI registrations silently never ran there, and every request container paid a failed
463
+ * `import('@/di')` resolution (the compatibility fallback in `lib/di/container.ts`).
464
+ *
465
+ * An absent `src/di.ts` is the supported case and resolves to `null` quietly. A file that exists
466
+ * but cannot be compiled, imported, or does not export `register` is reported at error level and
467
+ * still resolves to `null`, so a broken app DI module degrades the same way a broken generated
468
+ * registry does (#4327, #4491) instead of taking the whole process down.
469
+ */
470
+ async function loadAppDiRegistrar(appDir: string): Promise<AppDiRegistrar | null> {
471
+ const tsPath = path.join(appDir, 'src', 'di.ts')
472
+ if (!fs.existsSync(tsPath)) {
473
+ logger.debug('App-level DI module not present, skipping its registrations', { filePath: tsPath })
474
+ return null
475
+ }
476
+
477
+ try {
478
+ const appDiModule = await compileAndImport(tsPath, {
479
+ appRoot: appDir,
480
+ outFile: path.join(appDir, '.mercato', 'generated', 'app-di.compiled.mjs'),
481
+ })
482
+ const register = appDiModule.register
483
+ if (typeof register !== 'function') {
484
+ logger.error('App-level DI module exports no register(); its registrations are skipped', {
485
+ filePath: tsPath,
486
+ })
487
+ return null
488
+ }
489
+ return register as AppDiRegistrar
490
+ } catch (error) {
491
+ logger.error('Failed to load the app-level DI module; its registrations are skipped', {
492
+ filePath: tsPath,
493
+ err: error,
494
+ })
495
+ return null
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Dynamically load bootstrap data from a resolved app directory.
501
+ *
502
+ * IMPORTANT: This only works in unbundled contexts (CLI, tsx).
503
+ * Do NOT use this in Next.js bundled code - use static imports instead.
504
+ *
505
+ * For CLI context, we skip loading modules.generated.ts which has Next.js dependencies.
506
+ * CLI commands are discovered separately via the CLI module system.
507
+ *
508
+ * @param appRoot - Optional explicit app root path. If not provided, will search from cwd.
509
+ * @returns The loaded bootstrap data
510
+ * @throws Error if app root cannot be found or generated files are missing
511
+ */
512
+ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData> {
513
+ const resolved = resolveAppRootOrThrow(appRoot)
514
+
371
515
  const { generatedDir } = resolved
372
516
 
373
517
  ensureMikroOrmV7GeneratedCacheCompatibility(resolved.appDir)
@@ -392,10 +536,12 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
392
536
  compileAndImport(path.join(generatedDir, 'modules.cli.generated.ts')),
393
537
  compileAndImport(path.join(generatedDir, 'entities.generated.ts')),
394
538
  compileAndImport(path.join(generatedDir, 'di.generated.ts')),
395
- compileAndImport(path.join(generatedDir, 'search.generated.ts')).catch(() => ({ searchModuleConfigs: [] })),
396
- compileAndImport(path.join(generatedDir, 'command-loaders.generated.ts')).catch(() => ({ commandLoaderEntries: [] })),
397
- compileAndImport(path.join(generatedDir, 'command-interceptors.generated.ts')).catch(() => ({ commandInterceptorEntries: [] })),
398
- compileAndImport(path.join(generatedDir, 'workflows.generated.ts')).catch(() => ({ allCodeWorkflows: [] })),
539
+ loadOptionalGeneratedModule(path.join(generatedDir, 'search.generated.ts'), { searchModuleConfigs: [] }),
540
+ loadOptionalGeneratedModule(path.join(generatedDir, 'command-loaders.generated.ts'), { commandLoaderEntries: [] }),
541
+ loadOptionalGeneratedModule(path.join(generatedDir, 'command-interceptors.generated.ts'), {
542
+ commandInterceptorEntries: [],
543
+ }),
544
+ loadOptionalGeneratedModule(path.join(generatedDir, 'workflows.generated.ts'), { allCodeWorkflows: [] }),
399
545
  ])
400
546
 
401
547
  return {
@@ -437,8 +583,10 @@ export async function loadBootstrapData(appRoot?: string): Promise<BootstrapData
437
583
  */
438
584
  export async function bootstrapFromAppRoot(appRoot?: string): Promise<BootstrapData> {
439
585
  const { createBootstrap, waitForAsyncRegistration } = await import('./factory.js')
440
- const data = await loadBootstrapData(appRoot)
441
- const bootstrap = createBootstrap(data)
586
+ const resolved = resolveAppRootOrThrow(appRoot)
587
+ const data = await loadBootstrapData(resolved.appDir)
588
+ const appDiRegistrar = await loadAppDiRegistrar(resolved.appDir)
589
+ const bootstrap = createBootstrap(data, appDiRegistrar ? { appDiRegistrar } : {})
442
590
  bootstrap()
443
591
  // In CLI context, wait for async registrations (UI widgets, search configs, etc.)
444
592
  await waitForAsyncRegistration()
@@ -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',
@@ -158,6 +166,26 @@ describe('runCommandInterceptorsBefore', () => {
158
166
  expect(withWildcard.ok).toBe(false)
159
167
  expect(withWildcard.error?.message).toBe('Blocked by wildcard')
160
168
  })
169
+
170
+ it('does not run an interceptor gated by a nulled ACL feature', async () => {
171
+ applyAclFeatureOverrides({ 'premium.audit': null })
172
+ const interceptor = makeInterceptor({
173
+ id: 'i1',
174
+ features: ['premium.audit'],
175
+ beforeExecute: jest.fn().mockResolvedValue({ ok: false }),
176
+ })
177
+
178
+ const result = await runCommandInterceptorsBefore(
179
+ [interceptor],
180
+ 'customers.create-person',
181
+ {},
182
+ baseContext,
183
+ ['*', 'premium.audit'],
184
+ )
185
+
186
+ expect(result.ok).toBe(true)
187
+ expect(interceptor.beforeExecute).not.toHaveBeenCalled()
188
+ })
161
189
  })
162
190
 
163
191
  describe('runCommandInterceptorsAfter', () => {
@@ -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' })
@@ -697,7 +698,10 @@ export class CommandBus {
697
698
  try {
698
699
  const dataEngine = (container.resolve('dataEngine') as DataEngine)
699
700
  await dataEngine.flushOrmEntityChanges(suppress)
700
- } catch {
701
+ } catch (error) {
702
+ if (isReadProjectionAlwaysConsistent()) {
703
+ throw error
704
+ }
701
705
  // best-effort: failures should not block command execution
702
706
  }
703
707
  }
@@ -3,7 +3,7 @@ import type {
3
3
  CommandInterceptorContext,
4
4
  CommandInterceptorUndoContext,
5
5
  } from './command-interceptor'
6
- import { hasAllFeatures } from '../../security/features'
6
+ import { authorizeFeatures } from '../../security/featurePolicy'
7
7
  import { createLogger } from '../logger'
8
8
 
9
9
  const logger = createLogger('shared').child({ component: 'commands' })
@@ -33,7 +33,7 @@ function collectMatching(
33
33
  ): CommandInterceptor[] {
34
34
  return interceptors
35
35
  .filter((i) => matchesCommandPattern(i.targetCommand, commandId))
36
- .filter((i) => hasAllFeatures(userFeatures, i.features))
36
+ .filter((i) => authorizeFeatures(i.features ?? [], { grantedFeatures: userFeatures }))
37
37
  .sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50))
38
38
  }
39
39
 
@@ -0,0 +1,171 @@
1
+ // Regression coverage for the CRUD list cache key missing a caller-identity
2
+ // segment: two callers in the same tenant/org scope requesting the identical
3
+ // URL must NOT share cache entries, because list payloads can vary per caller —
4
+ // buildFilters may narrow by ctx.auth (e.g. ?mine=true on staff time-projects,
5
+ // the isSuperAdmin branch on scheduler jobs), before-interceptor query rewrites
6
+ // are feature-gated per user, and afterList/after-interceptor output is
7
+ // embedded in the stored payload.
8
+
9
+ jest.mock('@open-mercato/cache', () => ({
10
+ runWithCacheTenant: async (_tenantId: string | null, fn: () => Promise<unknown>) => fn(),
11
+ }), { virtual: true })
12
+
13
+ import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
14
+ import { registerApiInterceptors } from '@open-mercato/shared/lib/crud/interceptor-registry'
15
+ import { z } from 'zod'
16
+
17
+ const defaultOrganizationId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
18
+ const defaultTenantId = '123e4567-e89b-12d3-a456-426614174000'
19
+
20
+ let mockAuthSub = 'user-a'
21
+ let mockAuthKeyId: string | null = null
22
+
23
+ const em = {}
24
+
25
+ // Returns rows derived from the per-caller filter buildFilters produced, so a
26
+ // cross-user cache hit is observable as one user's rows in another's response.
27
+ const queryEngine = {
28
+ query: jest.fn(async (_entity: string, opts: Record<string, any>) => {
29
+ const owner = opts?.filters?.owner_user_id?.$eq ?? 'unfiltered'
30
+ return {
31
+ items: [{ id: `todo-of-${owner}`, title: `Owned by ${owner}`, organization_id: defaultOrganizationId, tenant_id: defaultTenantId }],
32
+ total: 1,
33
+ }
34
+ }),
35
+ }
36
+
37
+ const store = new Map<string, unknown>()
38
+ const cache = {
39
+ get: jest.fn(async (key: string) => (store.has(key) ? store.get(key) : null)),
40
+ set: jest.fn(async (key: string, value: unknown) => { store.set(key, value) }),
41
+ delete: jest.fn(async (key: string) => { store.delete(key) }),
42
+ deleteByTags: jest.fn(async () => 0),
43
+ }
44
+
45
+ const accessLogService = { log: jest.fn(async () => {}) }
46
+
47
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
48
+ createRequestContainer: async () => ({
49
+ resolve: (name: string) => ({
50
+ em,
51
+ queryEngine,
52
+ cache,
53
+ accessLogService,
54
+ } as any)[name],
55
+ }),
56
+ }))
57
+
58
+ jest.mock('@open-mercato/shared/lib/auth/server', () => {
59
+ const buildAuth = () => ({
60
+ sub: mockAuthSub,
61
+ keyId: mockAuthKeyId ?? undefined,
62
+ orgId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
63
+ tenantId: '123e4567-e89b-12d3-a456-426614174000',
64
+ roles: ['admin'],
65
+ })
66
+ return {
67
+ getAuthFromCookies: async () => buildAuth(),
68
+ getAuthFromRequest: async () => buildAuth(),
69
+ }
70
+ })
71
+
72
+ jest.mock('@open-mercato/core/modules/directory/utils/organizationScope', () => ({
73
+ resolveOrganizationScopeForRequest: jest.fn(async () => ({
74
+ selectedId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
75
+ filterIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
76
+ allowedIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
77
+ tenantId: '123e4567-e89b-12d3-a456-426614174000',
78
+ })),
79
+ }))
80
+
81
+ jest.mock('@open-mercato/core/modules/entities/lib/helpers', () => ({
82
+ setRecordCustomFields: jest.fn(async () => {}),
83
+ }))
84
+
85
+ class Todo {}
86
+
87
+ const querySchema = z.object({
88
+ page: z.coerce.number().default(1),
89
+ pageSize: z.coerce.number().default(50),
90
+ sortField: z.string().default('id'),
91
+ sortDir: z.enum(['asc', 'desc']).default('asc'),
92
+ })
93
+
94
+ const route = makeCrudRoute({
95
+ metadata: { GET: { requireAuth: true } },
96
+ orm: { entity: Todo, idField: 'id', orgField: 'organizationId', tenantField: 'tenantId', softDeleteField: 'deletedAt' },
97
+ indexer: { entityType: 'example.todo' },
98
+ list: {
99
+ schema: querySchema,
100
+ entityId: 'example.todo',
101
+ fields: ['id', 'title'],
102
+ sortFieldMap: { id: 'id', title: 'title' },
103
+ buildFilters: (_query: any, ctx: any) => ({ owner_user_id: { $eq: ctx.auth?.sub ?? null } } as any),
104
+ transformItem: (i: any) => ({ id: i.id, title: i.title }),
105
+ },
106
+ })
107
+
108
+ const url = 'http://x/api/example/todos?page=1&pageSize=10&sortField=id&sortDir=asc'
109
+
110
+ describe('CRUD Factory — list cache is partitioned per caller identity', () => {
111
+ const previousCacheFlag = process.env.ENABLE_CRUD_API_CACHE
112
+
113
+ beforeAll(() => {
114
+ process.env.ENABLE_CRUD_API_CACHE = 'true'
115
+ })
116
+
117
+ afterAll(() => {
118
+ if (previousCacheFlag === undefined) delete process.env.ENABLE_CRUD_API_CACHE
119
+ else process.env.ENABLE_CRUD_API_CACHE = previousCacheFlag
120
+ })
121
+
122
+ beforeEach(() => {
123
+ jest.clearAllMocks()
124
+ store.clear()
125
+ mockAuthSub = 'user-a'
126
+ mockAuthKeyId = null
127
+ registerApiInterceptors([])
128
+ })
129
+
130
+ it('caches per user and still serves same-user repeat requests from cache', async () => {
131
+ const first = await route.GET(new Request(url))
132
+ expect(first.status).toBe(200)
133
+ expect(first.headers.get('x-om-cache')).toBe('miss')
134
+ expect((await first.json()).items[0].id).toBe('todo-of-user-a')
135
+
136
+ const second = await route.GET(new Request(url))
137
+ expect(second.headers.get('x-om-cache')).toBe('hit')
138
+ expect((await second.json()).items[0].id).toBe('todo-of-user-a')
139
+ expect(queryEngine.query).toHaveBeenCalledTimes(1)
140
+ })
141
+
142
+ it('does not serve one user\'s cached rows to another user on the identical URL', async () => {
143
+ mockAuthSub = 'user-a'
144
+ const aRes = await route.GET(new Request(url))
145
+ expect((await aRes.json()).items[0].id).toBe('todo-of-user-a')
146
+ expect(store.size).toBe(1)
147
+
148
+ mockAuthSub = 'user-b'
149
+ const bRes = await route.GET(new Request(url))
150
+ expect(bRes.headers.get('x-om-cache')).toBe('miss')
151
+ expect((await bRes.json()).items[0].id).toBe('todo-of-user-b')
152
+ expect(store.size).toBe(2)
153
+
154
+ mockAuthSub = 'user-a'
155
+ const aAgain = await route.GET(new Request(url))
156
+ expect(aAgain.headers.get('x-om-cache')).toBe('hit')
157
+ expect((await aAgain.json()).items[0].id).toBe('todo-of-user-a')
158
+ })
159
+
160
+ it('keys every entry with an identity segment, preferring the API key id over the user id', async () => {
161
+ await route.GET(new Request(url))
162
+ const userKey = Array.from(store.keys())[0] as string
163
+ expect(userKey).toContain('user:user-a')
164
+
165
+ store.clear()
166
+ mockAuthKeyId = 'api-key-9'
167
+ await route.GET(new Request(url))
168
+ const apiKeyKey = Array.from(store.keys())[0] as string
169
+ expect(apiKeyKey).toContain('user:api-key-9')
170
+ })
171
+ })