@flowerforce/flowerbase 1.8.4-beta.2 → 1.8.4-beta.4

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.
@@ -10,6 +10,7 @@ export interface FunctionConfig {
10
10
  }
11
11
  export type Function = Omit<FunctionConfig, 'name'> & {
12
12
  code: string;
13
+ sourcePath?: string;
13
14
  };
14
15
  export type Functions = Record<string, Function>;
15
16
  export type RegisterFunctionsParams = {
@@ -1 +1 @@
1
- {"version":3,"file":"interface.d.ts","sourceRoot":"","sources":["../../../src/features/functions/interface.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AACzC,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAClC,OAAO,EAAE,oBAAoB,EAAE,MAAM,oCAAoC,CAAA;AACzE,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAE1C,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,MAAM,MAAM,QAAQ,GAAG,IAAI,CAAC,cAAc,EAAE,MAAM,CAAC,GAAG;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAA;AAEtE,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;AAEhD,MAAM,MAAM,uBAAuB,GAAG;IACpC,GAAG,EAAE,eAAe,CAAA;IACpB,aAAa,EAAE,SAAS,CAAA;IACxB,SAAS,EAAE,KAAK,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,aAAa,EAAE,UAAU,CAAC,oBAAoB,CAAC,CAAC,MAAM,UAAU,CAAC,oBAAoB,CAAC,CAAC,CAAA;IACvF,KAAK,EAAE,UAAU,CAAC,oBAAoB,CAAC,CAAA;IACvC,MAAM,EAAE,QAAQ,CAAA;IAChB,MAAM,CAAC,EAAE,QAAQ,CAAA;IACjB,UAAU,CAAC,EAAE,QAAQ,CAAA;IACrB,OAAO,CAAC,EAAE,QAAQ,CAAA;IAClB,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,QAAQ,EAAE,QAAQ,CAAA;IAClB,SAAS,EAAE,QAAQ,EAAE,CAAA;IACrB,QAAQ,EAAE,QAAQ,EAAE,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAED,KAAK,0BAA0B,GAAG;IAChC,aAAa,EAAE,SAAS,CAAA;IACxB,KAAK,EAAE,KAAK,CAAA;CACb,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG,CAC/B,GAAG,EAAE,eAAe,EACpB,EAAE,aAAa,EAAE,KAAK,EAAE,EAAE,0BAA0B,KACjD,OAAO,CAAC,IAAI,CAAC,CAAA"}
1
+ {"version":3,"file":"interface.d.ts","sourceRoot":"","sources":["../../../src/features/functions/interface.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AACzC,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAClC,OAAO,EAAE,oBAAoB,EAAE,MAAM,oCAAoC,CAAA;AACzE,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAE1C,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,MAAM,MAAM,QAAQ,GAAG,IAAI,CAAC,cAAc,EAAE,MAAM,CAAC,GAAG;IACpD,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;AAEhD,MAAM,MAAM,uBAAuB,GAAG;IACpC,GAAG,EAAE,eAAe,CAAA;IACpB,aAAa,EAAE,SAAS,CAAA;IACxB,SAAS,EAAE,KAAK,CAAA;CACjB,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG;IAC/B,aAAa,EAAE,UAAU,CAAC,oBAAoB,CAAC,CAAC,MAAM,UAAU,CAAC,oBAAoB,CAAC,CAAC,CAAA;IACvF,KAAK,EAAE,UAAU,CAAC,oBAAoB,CAAC,CAAA;IACvC,MAAM,EAAE,QAAQ,CAAA;IAChB,MAAM,CAAC,EAAE,QAAQ,CAAA;IACjB,UAAU,CAAC,EAAE,QAAQ,CAAA;IACrB,OAAO,CAAC,EAAE,QAAQ,CAAA;IAClB,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,QAAQ,EAAE,QAAQ,CAAA;IAClB,SAAS,EAAE,QAAQ,EAAE,CAAA;IACrB,QAAQ,EAAE,QAAQ,EAAE,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAED,KAAK,0BAA0B,GAAG;IAChC,aAAa,EAAE,SAAS,CAAA;IACxB,KAAK,EAAE,KAAK,CAAA;CACb,CAAA;AAED,MAAM,MAAM,kBAAkB,GAAG,CAC/B,GAAG,EAAE,eAAe,EACpB,EAAE,aAAa,EAAE,KAAK,EAAE,EAAE,0BAA0B,KACjD,OAAO,CAAC,IAAI,CAAC,CAAA"}
@@ -46,7 +46,7 @@ const loadFunctions = (...args_1) => __awaiter(void 0, [...args_1], void 0, func
46
46
  throw new Error(`File ${name}.js or ${name}.ts not found`);
47
47
  }
48
48
  code = fs_1.default.readFileSync(fnPath, 'utf-8');
49
- acc[name] = Object.assign({ code }, opts);
49
+ acc[name] = Object.assign({ code, sourcePath: fnPath }, opts);
50
50
  return acc;
51
51
  }, {});
52
52
  return functions;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/services/mongodb-atlas/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAIL,QAAQ,EAQT,MAAM,SAAS,CAAA;AAOhB,OAAO,EAGL,oBAAoB,EAErB,MAAM,SAAS,CAAA;AA6JhB,eAAO,MAAM,kBAAkB,GAAI,OAAO,OAAO,KAAG,OA0BnD,CAAA;AA8BD,eAAO,MAAM,2BAA2B,GAAI,UAAU,QAAQ,EAAE,YAK5D,CAAA;AAwzCJ,QAAA,MAAM,YAAY,EAAE,oBAwBlB,CAAA;AAEF,eAAe,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/services/mongodb-atlas/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAIL,QAAQ,EAQT,MAAM,SAAS,CAAA;AAOhB,OAAO,EAGL,oBAAoB,EAErB,MAAM,SAAS,CAAA;AA8JhB,eAAO,MAAM,kBAAkB,GAAI,OAAO,OAAO,KAAG,OA0BnD,CAAA;AA8BD,eAAO,MAAM,2BAA2B,GAAI,UAAU,QAAQ,EAAE,YAK5D,CAAA;AA80CJ,QAAA,MAAM,YAAY,EAAE,oBAwBlB,CAAA;AAEF,eAAe,YAAY,CAAA"}
@@ -459,13 +459,17 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
459
459
  var _a;
460
460
  try {
461
461
  const { projection, options: normalizedOptions } = resolveFindArgs(projectionOrOptions, options);
462
- const resolvedOptions = projection || normalizedOptions
463
- ? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (projection ? { projection } : {})) : undefined;
464
462
  const resolvedQuery = query !== null && query !== void 0 ? query : {};
465
463
  if (!run_as_system) {
466
464
  (0, utils_3.checkDenyOperation)(normalizedRules, collection.collectionName, model_1.CRUD_OPERATIONS.READ);
467
465
  // Apply access control filters to the query
468
466
  const formattedQuery = (0, utils_3.getFormattedQuery)(filters, resolvedQuery, user);
467
+ // Rules-level projection has priority over client-provided projection.
468
+ // The merged projection is passed natively to MongoDB.
469
+ const rulesProjection = (0, utils_3.getFormattedProjection)(filters, user);
470
+ const finalProjection = (0, utils_3.mergeProjections)(projection, rulesProjection);
471
+ const resolvedOptions = finalProjection || normalizedOptions
472
+ ? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (finalProjection ? { projection: finalProjection } : {})) : undefined;
469
473
  logDebug('update formattedQuery', {
470
474
  collection: collName,
471
475
  query,
@@ -509,8 +513,10 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
509
513
  emitMongoEvent('findOne');
510
514
  return Promise.resolve(response);
511
515
  }
512
- // System mode: no validation applied
513
- const response = yield collection.findOne(resolvedQuery, resolvedOptions);
516
+ // System mode: no validation applied, only client-provided projection/options.
517
+ const systemOptions = projection || normalizedOptions
518
+ ? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (projection ? { projection } : {})) : undefined;
519
+ const response = yield collection.findOne(resolvedQuery, systemOptions);
514
520
  emitMongoEvent('findOne');
515
521
  return response;
516
522
  }
@@ -825,13 +831,17 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
825
831
  find: (query = {}, projectionOrOptions, options) => {
826
832
  try {
827
833
  const { projection, options: normalizedOptions } = resolveFindArgs(projectionOrOptions, options);
828
- const resolvedOptions = projection || normalizedOptions
829
- ? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (projection ? { projection } : {})) : undefined;
830
834
  if (!run_as_system) {
831
835
  (0, utils_3.checkDenyOperation)(normalizedRules, collection.collectionName, model_1.CRUD_OPERATIONS.READ);
832
836
  // Pre-query filtering based on access control rules
833
837
  const formattedQuery = (0, utils_3.getFormattedQuery)(filters, query, user);
834
838
  const currentQuery = formattedQuery.length ? { $and: formattedQuery } : {};
839
+ // Rules-level projection has priority over client-provided projection.
840
+ // The merged projection is passed natively to MongoDB.
841
+ const rulesProjection = (0, utils_3.getFormattedProjection)(filters, user);
842
+ const finalProjection = (0, utils_3.mergeProjections)(projection, rulesProjection);
843
+ const resolvedOptions = finalProjection || normalizedOptions
844
+ ? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (finalProjection ? { projection: finalProjection } : {})) : undefined;
835
845
  // aggiunto filter per evitare questo errore: $and argument's entries must be objects
836
846
  const cursor = collection.find(currentQuery, resolvedOptions);
837
847
  const originalToArray = cursor.toArray.bind(cursor);
@@ -866,8 +876,10 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
866
876
  emitMongoEvent('find');
867
877
  return cursor;
868
878
  }
869
- // System mode: return original unfiltered cursor
870
- const cursor = collection.find(query, resolvedOptions);
879
+ // System mode: return original unfiltered cursor (only client projection/options).
880
+ const systemOptions = projection || normalizedOptions
881
+ ? Object.assign(Object.assign({}, (normalizedOptions !== null && normalizedOptions !== void 0 ? normalizedOptions : {})), (projection ? { projection } : {})) : undefined;
882
+ const cursor = collection.find(query, systemOptions);
871
883
  emitMongoEvent('find');
872
884
  return cursor;
873
885
  }
@@ -1041,7 +1053,7 @@ const getOperators = (mongo, { rules, dbName, collName, user, run_as_system, mon
1041
1053
  formattedQuery,
1042
1054
  pipeline
1043
1055
  });
1044
- const projection = (0, utils_3.getFormattedProjection)(filters);
1056
+ const projection = (0, utils_3.getFormattedProjection)(filters, user);
1045
1057
  const hiddenFields = (0, utils_3.getHiddenFieldsFromRulesConfig)(rulesConfig);
1046
1058
  const sanitizedPipeline = (0, utils_3.applyAccessControlToPipeline)(pipeline, normalizedRules, user, collName, { isClientPipeline: true });
1047
1059
  logDebug('aggregate sanitizedPipeline', {
@@ -7,6 +7,21 @@ import { CRUD_OPERATIONS, GetValidRuleParams } from './model';
7
7
  export declare const getValidRule: <T extends Role | Filter>({ filters, user, record }: GetValidRuleParams<T>) => T[];
8
8
  export declare const getFormattedQuery: (filters?: Filter[], query?: Parameters<Collection<Document>["findOne"]>[0], user?: User) => FilterMongoDB<Document>[];
9
9
  export declare const getFormattedProjection: (filters?: Filter[], user?: User) => Projection | null;
10
+ /**
11
+ * Merges a client-provided projection with the one computed from rules filters.
12
+ *
13
+ * Rules have higher priority over the client:
14
+ * - If rules exclude a top-level field (e.g. `{ instock: 0 }`), every client
15
+ * reference to that field — including dotted sub-paths such as
16
+ * `"instock.qty": 1` — is dropped from the final projection.
17
+ * - If rules include a field (value `1`), it is always part of the final
18
+ * projection and overrides any conflicting client value.
19
+ * - The returned projection is always a valid MongoDB projection (no mixing of
20
+ * inclusion and exclusion on non-`_id` keys), so it can be passed as-is to
21
+ * native MongoDB methods.
22
+ * - Returns `undefined` when neither side provided a meaningful projection.
23
+ */
24
+ export declare const mergeProjections: (clientProjection: Projection | Document | undefined, rulesProjection: Projection | null | undefined) => Projection | Document | undefined;
10
25
  export declare const applyAccessControlToPipeline: (pipeline: AggregationPipeline, rules: Record<string, {
11
26
  filters?: Filter[];
12
27
  roles?: Role[];
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/services/mongodb-atlas/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAElC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,SAAS,CAAA;AACvE,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAA;AACtC,OAAO,EACL,mBAAmB,EAEnB,MAAM,EAEN,UAAU,EACV,KAAK,EAGN,MAAM,gCAAgC,CAAA;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,6BAA6B,CAAA;AAGlD,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAE7D,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS,IAAI,GAAG,MAAM,EAAE,2BAInD,kBAAkB,CAAC,CAAC,CAAC,QA8BvB,CAAA;AAED,eAAO,MAAM,iBAAiB,GAC5B,UAAS,MAAM,EAAO,EACtB,QAAQ,UAAU,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,EACtD,OAAO,IAAI,8BAcZ,CAAA;AAED,eAAO,MAAM,sBAAsB,GACjC,UAAS,MAAM,EAAO,EACtB,OAAO,IAAI,KACV,UAAU,GAAG,IAaf,CAAA;AAED,eAAO,MAAM,4BAA4B,GACvC,UAAU,mBAAmB,EAC7B,OAAO,MAAM,CACX,MAAM,EACN;IACE,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,KAAK,CAAC,EAAE,IAAI,EAAE,CAAA;CACf,CACF,EACD,MAAM,IAAI,EACV,gBAAgB,MAAM,EACtB,UAAU;IACR,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B,KACA,mBA6GF,CAAA;AAED,eAAO,MAAM,kBAAkB,GAC7B,OAAO,KAAK,EACZ,gBAAgB,MAAM,EACtB,WAAW,eAAe,SAM3B,CAAA;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,CAAC,QAAQ,CAAC,EAAE;;;;;;;;iBA0I8iqS,CAAC;sBAAgC,CAAC;2BAAsC,CAAC;;;;IAlIrrqS;AAED,eAAO,MAAM,0BAA0B,GAAI,UAAU,QAAQ,EAAE,aAgC9D,CAAA;AAYD,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,mBAAmB,QA+BvE;AAED,wBAAgB,8BAA8B,CAAC,WAAW,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,IAAI,EAAE,CAAA;CAAE,YAK9E;AAwCD,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,EAAE,uBAKtF"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/services/mongodb-atlas/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAElC,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,SAAS,CAAA;AACvE,OAAO,EAAE,IAAI,EAAE,MAAM,iBAAiB,CAAA;AACtC,OAAO,EACL,mBAAmB,EAEnB,MAAM,EAEN,UAAU,EACV,KAAK,EAGN,MAAM,gCAAgC,CAAA;AACvC,OAAO,EAAE,IAAI,EAAE,MAAM,6BAA6B,CAAA;AAGlD,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAE7D,eAAO,MAAM,YAAY,GAAI,CAAC,SAAS,IAAI,GAAG,MAAM,EAAE,2BAInD,kBAAkB,CAAC,CAAC,CAAC,QA8BvB,CAAA;AAED,eAAO,MAAM,iBAAiB,GAC5B,UAAS,MAAM,EAAO,EACtB,QAAQ,UAAU,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,EACtD,OAAO,IAAI,8BAcZ,CAAA;AAED,eAAO,MAAM,sBAAsB,GACjC,UAAS,MAAM,EAAO,EACtB,OAAO,IAAI,KACV,UAAU,GAAG,IAMf,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAC3B,kBAAkB,UAAU,GAAG,QAAQ,GAAG,SAAS,EACnD,iBAAiB,UAAU,GAAG,IAAI,GAAG,SAAS,KAC7C,UAAU,GAAG,QAAQ,GAAG,SAyD1B,CAAA;AAED,eAAO,MAAM,4BAA4B,GACvC,UAAU,mBAAmB,EAC7B,OAAO,MAAM,CACX,MAAM,EACN;IACE,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,KAAK,CAAC,EAAE,IAAI,EAAE,CAAA;CACf,CACF,EACD,MAAM,IAAI,EACV,gBAAgB,MAAM,EACtB,UAAU;IACR,gBAAgB,CAAC,EAAE,OAAO,CAAA;CAC3B,KACA,mBA6GF,CAAA;AAED,eAAO,MAAM,kBAAkB,GAC7B,OAAO,KAAK,EACZ,gBAAgB,MAAM,EACtB,WAAW,eAAe,SAM3B,CAAA;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,aAAa,CAAC,QAAQ,CAAC,EAAE;;;;;;;;iBA0I2jkS,CAAC;sBAAgC,CAAC;2BAAsC,CAAC;;;;IAlIlskS;AAED,eAAO,MAAM,0BAA0B,GAAI,UAAU,QAAQ,EAAE,aAgC9D,CAAA;AAYD,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,mBAAmB,QA+BvE;AAED,wBAAgB,8BAA8B,CAAC,WAAW,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,IAAI,EAAE,CAAA;CAAE,YAK9E;AAwCD,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,EAAE,uBAKtF"}
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.getCollectionsFromPipeline = exports.checkDenyOperation = exports.applyAccessControlToPipeline = exports.getFormattedProjection = exports.getFormattedQuery = exports.getValidRule = void 0;
6
+ exports.getCollectionsFromPipeline = exports.checkDenyOperation = exports.applyAccessControlToPipeline = exports.mergeProjections = exports.getFormattedProjection = exports.getFormattedQuery = exports.getValidRule = void 0;
7
7
  exports.normalizeQuery = normalizeQuery;
8
8
  exports.ensureClientPipelineStages = ensureClientPipelineStages;
9
9
  exports.getHiddenFieldsFromRulesConfig = getHiddenFieldsFromRulesConfig;
@@ -48,21 +48,83 @@ const getFormattedQuery = (filters = [], query, user) => {
48
48
  };
49
49
  exports.getFormattedQuery = getFormattedQuery;
50
50
  const getFormattedProjection = (filters = [], user) => {
51
- const projections = filters
52
- .filter((filter) => {
53
- if (filter.projection) {
54
- const preFilter = (0, exports.getValidRule)({ filters, user });
55
- const isValidPreFilter = !!(preFilter === null || preFilter === void 0 ? void 0 : preFilter.length);
56
- return isValidPreFilter;
57
- }
58
- return false;
59
- })
51
+ const projections = (0, exports.getValidRule)({ filters, user })
52
+ .filter((f) => !!f.projection)
60
53
  .map((f) => f.projection);
61
54
  if (!projections.length)
62
55
  return null;
63
56
  return Object.assign({}, ...projections);
64
57
  };
65
58
  exports.getFormattedProjection = getFormattedProjection;
59
+ /**
60
+ * Merges a client-provided projection with the one computed from rules filters.
61
+ *
62
+ * Rules have higher priority over the client:
63
+ * - If rules exclude a top-level field (e.g. `{ instock: 0 }`), every client
64
+ * reference to that field — including dotted sub-paths such as
65
+ * `"instock.qty": 1` — is dropped from the final projection.
66
+ * - If rules include a field (value `1`), it is always part of the final
67
+ * projection and overrides any conflicting client value.
68
+ * - The returned projection is always a valid MongoDB projection (no mixing of
69
+ * inclusion and exclusion on non-`_id` keys), so it can be passed as-is to
70
+ * native MongoDB methods.
71
+ * - Returns `undefined` when neither side provided a meaningful projection.
72
+ */
73
+ const mergeProjections = (clientProjection, rulesProjection) => {
74
+ const hasClient = !!clientProjection && Object.keys(clientProjection).length > 0;
75
+ const hasRules = !!rulesProjection && Object.keys(rulesProjection).length > 0;
76
+ if (!hasClient && !hasRules)
77
+ return undefined;
78
+ const client = (hasClient ? clientProjection : {});
79
+ const rules = (hasRules ? rulesProjection : {});
80
+ const getTopLevel = (key) => key.split('.')[0];
81
+ const rulesEntries = Object.entries(rules);
82
+ const rulesIncludeKeys = rulesEntries
83
+ .filter(([, value]) => value === 1)
84
+ .map(([key]) => key);
85
+ const rulesExcludeKeys = rulesEntries
86
+ .filter(([, value]) => value === 0)
87
+ .map(([key]) => key);
88
+ // Top-level fields excluded by rules (excluding `_id` which has special
89
+ // MongoDB semantics and is allowed alongside inclusion projections).
90
+ const excludedTopLevel = new Set(rulesExcludeKeys.map(getTopLevel).filter((key) => key !== '_id'));
91
+ const filteredClient = {};
92
+ for (const [key, value] of Object.entries(client)) {
93
+ if (excludedTopLevel.has(getTopLevel(key)))
94
+ continue;
95
+ filteredClient[key] = value;
96
+ }
97
+ const hasInclusion = rulesIncludeKeys.some((key) => key !== '_id') ||
98
+ Object.entries(filteredClient).some(([key, value]) => value === 1 && key !== '_id');
99
+ const merged = {};
100
+ if (hasInclusion) {
101
+ // Inclusion mode: keep only client inclusions, then overlay rules inclusions.
102
+ // Client exclusions (other than `_id: 0`) are incompatible with inclusion
103
+ // mode and are dropped; not-included fields are implicitly excluded anyway.
104
+ for (const [key, value] of Object.entries(filteredClient)) {
105
+ if (value === 1 || key === '_id')
106
+ merged[key] = value;
107
+ }
108
+ for (const key of rulesIncludeKeys)
109
+ merged[key] = 1;
110
+ // Allow `_id: 0` to be forced by rules in inclusion mode.
111
+ for (const key of rulesExcludeKeys) {
112
+ if (key === '_id')
113
+ merged[key] = 0;
114
+ }
115
+ }
116
+ else {
117
+ // Pure exclusion mode: combine all exclusions from both sides.
118
+ for (const [key, value] of Object.entries(filteredClient)) {
119
+ if (value === 0)
120
+ merged[key] = 0;
121
+ }
122
+ for (const key of rulesExcludeKeys)
123
+ merged[key] = 0;
124
+ }
125
+ return Object.keys(merged).length > 0 ? merged : undefined;
126
+ };
127
+ exports.mergeProjections = mergeProjections;
66
128
  const applyAccessControlToPipeline = (pipeline, rules, user, collectionName, options) => {
67
129
  const { isClientPipeline = false } = options || {};
68
130
  const hiddenFieldsForCollection = isClientPipeline
@@ -77,7 +139,7 @@ const applyAccessControlToPipeline = (pipeline, rules, user, collectionName, opt
77
139
  (0, exports.checkDenyOperation)(rules, currentCollection, model_1.CRUD_OPERATIONS.READ);
78
140
  const lookupRules = rules[currentCollection] || {};
79
141
  const formattedQuery = (0, exports.getFormattedQuery)(lookupRules.filters, {}, user);
80
- const projection = (0, exports.getFormattedProjection)(lookupRules.filters);
142
+ const projection = (0, exports.getFormattedProjection)(lookupRules.filters, user);
81
143
  const nestedPipeline = (0, exports.applyAccessControlToPipeline)(lookUpStage.pipeline || [], rules, user, currentCollection, { isClientPipeline });
82
144
  const lookupPipeline = [
83
145
  ...(formattedQuery.length ? [{ $match: { $and: formattedQuery } }] : []),
@@ -98,7 +160,7 @@ const applyAccessControlToPipeline = (pipeline, rules, user, collectionName, opt
98
160
  (0, exports.checkDenyOperation)(rules, currentCollection, model_1.CRUD_OPERATIONS.READ);
99
161
  const unionRules = rules[currentCollection] || {};
100
162
  const formattedQuery = (0, exports.getFormattedQuery)(unionRules.filters, {}, user);
101
- const projection = (0, exports.getFormattedProjection)(unionRules.filters);
163
+ const projection = (0, exports.getFormattedProjection)(unionRules.filters, user);
102
164
  if (isSimpleStage) {
103
165
  return stage;
104
166
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/utils/context/index.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA;AA4JnD;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CAAC,EACpC,IAAI,EACJ,GAAG,EACH,KAAK,EACL,IAAI,EACJ,eAAe,EACf,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,WAAW,EACX,eAAsB,EACtB,OAAO,EACP,OAAO,EACR,EAAE,qBAAqB,GAAG,OAAO,CAAC,OAAO,CAAC,CA2G1C;AAED,wBAAgB,mBAAmB,CAAC,EAClC,IAAI,EACJ,GAAG,EACH,KAAK,EACL,IAAI,EACJ,eAAe,EACf,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,WAAW,EACX,eAAsB,EACtB,OAAO,EACR,EAAE,qBAAqB,GAAG,OAAO,CA6BjC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/utils/context/index.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA;AA8RnD;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CAAC,EACpC,IAAI,EACJ,GAAG,EACH,KAAK,EACL,IAAI,EACJ,eAAe,EACf,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,WAAW,EACX,eAAsB,EACtB,OAAO,EACP,OAAO,EACR,EAAE,qBAAqB,GAAG,OAAO,CAAC,OAAO,CAAC,CA2G1C;AAED,wBAAgB,mBAAmB,CAAC,EAClC,IAAI,EACJ,GAAG,EACH,KAAK,EACL,IAAI,EACJ,eAAe,EACf,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,WAAW,EACX,eAAsB,EACtB,OAAO,EACR,EAAE,qBAAqB,GAAG,OAAO,CA0BjC"}
@@ -14,6 +14,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.GenerateContext = GenerateContext;
16
16
  exports.GenerateContextSync = GenerateContextSync;
17
+ const node_fs_1 = __importDefault(require("node:fs"));
17
18
  const node_module_1 = require("node:module");
18
19
  const node_path_1 = __importDefault(require("node:path"));
19
20
  const node_url_1 = require("node:url");
@@ -89,6 +90,27 @@ const wrapEsmModule = (code) => {
89
90
  ].join('\n');
90
91
  return `${prelude}\n${code}\n${trailer}`;
91
92
  };
93
+ const transpileSandboxModule = (code) => {
94
+ const exportedNames = [];
95
+ let transformed = code.includes('import ')
96
+ ? transformImportsToRequire(code)
97
+ : code;
98
+ transformed = transformed.replace(/^\s*export\s+function\s+([A-Za-z_$][\w$]*)\s*\(/gm, (_match, name) => {
99
+ exportedNames.push(name);
100
+ return `function ${name}(`;
101
+ });
102
+ transformed = transformed.replace(/^\s*export\s+(const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm, (_match, kind, name) => {
103
+ exportedNames.push(name);
104
+ return `${kind} ${name}`;
105
+ });
106
+ transformed = transformed.replace(/^\s*export\s+default\s+/gm, 'module.exports = ');
107
+ if (exportedNames.length === 0) {
108
+ return transformed;
109
+ }
110
+ return `${transformed}\n${[...new Set(exportedNames)]
111
+ .map((name) => `exports.${name} = ${name}`)
112
+ .join('\n')}`;
113
+ };
92
114
  const resolveImportTarget = (specifier, customRequire) => {
93
115
  try {
94
116
  const resolved = customRequire.resolve(specifier);
@@ -109,6 +131,53 @@ const shouldFallbackFromVmModules = (error) => {
109
131
  const code = error.code;
110
132
  return code === 'ERR_VM_MODULES_DISABLED' || code === 'ERR_VM_MODULES_NOT_SUPPORTED';
111
133
  };
134
+ const resolveModulePath = (specifier, parentFile) => {
135
+ const parentDir = node_path_1.default.dirname(parentFile);
136
+ const basePath = node_path_1.default.resolve(parentDir, specifier);
137
+ const candidates = [
138
+ basePath,
139
+ `${basePath}.js`,
140
+ `${basePath}.ts`,
141
+ node_path_1.default.join(basePath, 'index.js'),
142
+ node_path_1.default.join(basePath, 'index.ts')
143
+ ];
144
+ return candidates.find((candidate) => {
145
+ try {
146
+ return node_fs_1.default.statSync(candidate).isFile();
147
+ }
148
+ catch (_a) {
149
+ return false;
150
+ }
151
+ });
152
+ };
153
+ const executeSandboxModule = ({ code, contextData, filePath, moduleCache }) => {
154
+ var _a;
155
+ if (moduleCache.has(filePath)) {
156
+ return moduleCache.get(filePath);
157
+ }
158
+ const sandboxModule = { exports: {} };
159
+ moduleCache.set(filePath, sandboxModule.exports);
160
+ const baseRequire = (0, node_module_1.createRequire)(filePath);
161
+ const localRequire = ((specifier) => {
162
+ if (specifier.startsWith('.') || specifier.startsWith('/')) {
163
+ const resolvedPath = resolveModulePath(specifier, filePath);
164
+ if (resolvedPath) {
165
+ return executeSandboxModule({
166
+ code: node_fs_1.default.readFileSync(resolvedPath, 'utf-8'),
167
+ contextData,
168
+ filePath: resolvedPath,
169
+ moduleCache
170
+ });
171
+ }
172
+ }
173
+ return baseRequire(specifier);
174
+ });
175
+ const vmContext = vm_1.default.createContext(Object.assign(Object.assign({}, contextData), { require: localRequire, exports: sandboxModule.exports, module: sandboxModule, __filename: filePath, __dirname: node_path_1.default.dirname(filePath), __fb_require: localRequire, __fb_filename: filePath, __fb_dirname: node_path_1.default.dirname(filePath) }));
176
+ vm_1.default.runInContext(transpileSandboxModule(code), vmContext, { filename: filePath });
177
+ sandboxModule.exports = (_a = resolveExport(vmContext)) !== null && _a !== void 0 ? _a : sandboxModule.exports;
178
+ moduleCache.set(filePath, sandboxModule.exports);
179
+ return sandboxModule.exports;
180
+ };
112
181
  const isExportedFunction = (value) => typeof value === 'function';
113
182
  const getDefaultExport = (value) => {
114
183
  if (!value || typeof value !== 'object')
@@ -128,11 +197,25 @@ const resolveExport = (ctx) => {
128
197
  return contextExports;
129
198
  return (_e = getDefaultExport(moduleExports)) !== null && _e !== void 0 ? _e : getDefaultExport(contextExports);
130
199
  };
131
- const buildVmContext = (contextData) => {
132
- var _a, _b;
200
+ const buildVmContext = (contextData, currentFunction) => {
201
+ var _a, _b, _c;
133
202
  const sandboxModule = { exports: {} };
134
- const entryFile = (_b = (_a = require.main) === null || _a === void 0 ? void 0 : _a.filename) !== null && _b !== void 0 ? _b : process.cwd();
135
- const customRequire = (0, node_module_1.createRequire)(entryFile);
203
+ const entryFile = (_c = (_a = currentFunction === null || currentFunction === void 0 ? void 0 : currentFunction.sourcePath) !== null && _a !== void 0 ? _a : (_b = require.main) === null || _b === void 0 ? void 0 : _b.filename) !== null && _c !== void 0 ? _c : process.cwd();
204
+ const moduleCache = new Map();
205
+ const customRequire = ((specifier) => {
206
+ if ((specifier.startsWith('.') || specifier.startsWith('/')) && (currentFunction === null || currentFunction === void 0 ? void 0 : currentFunction.sourcePath)) {
207
+ const resolvedPath = resolveModulePath(specifier, currentFunction.sourcePath);
208
+ if (resolvedPath) {
209
+ return executeSandboxModule({
210
+ code: node_fs_1.default.readFileSync(resolvedPath, 'utf-8'),
211
+ contextData,
212
+ filePath: resolvedPath,
213
+ moduleCache
214
+ });
215
+ }
216
+ }
217
+ return (0, node_module_1.createRequire)(entryFile)(specifier);
218
+ });
136
219
  const vmContext = vm_1.default.createContext(Object.assign(Object.assign({}, contextData), { require: customRequire, exports: sandboxModule.exports, module: sandboxModule, __filename,
137
220
  __dirname, __fb_require: customRequire, __fb_filename: __filename, __fb_dirname: __dirname }));
138
221
  return { sandboxModule, entryFile, customRequire, vmContext };
@@ -169,7 +252,7 @@ function GenerateContext(_a) {
169
252
  GenerateContextSync,
170
253
  request
171
254
  });
172
- const { sandboxModule, entryFile, customRequire, vmContext } = buildVmContext(contextData);
255
+ const { sandboxModule, entryFile, customRequire, vmContext } = buildVmContext(contextData, functionToRun);
173
256
  const vmModules = vm_1.default;
174
257
  const hasStaticImport = /\bimport\s+/.test(functionToRun.code);
175
258
  let usedVmModules = false;
@@ -214,10 +297,7 @@ function GenerateContext(_a) {
214
297
  }
215
298
  }
216
299
  if (!usedVmModules) {
217
- const codeToRun = functionToRun.code.includes('import ')
218
- ? transformImportsToRequire(functionToRun.code)
219
- : functionToRun.code;
220
- vm_1.default.runInContext(codeToRun, vmContext);
300
+ vm_1.default.runInContext(transpileSandboxModule(functionToRun.code), vmContext, { filename: entryFile });
221
301
  }
222
302
  sandboxModule.exports = (_a = resolveExport(vmContext)) !== null && _a !== void 0 ? _a : sandboxModule.exports;
223
303
  if (deserializeArgs) {
@@ -247,11 +327,8 @@ function GenerateContextSync({ args, app, rules, user, currentFunction, function
247
327
  GenerateContextSync,
248
328
  request
249
329
  });
250
- const { sandboxModule, vmContext } = buildVmContext(contextData);
251
- const codeToRun = functionToRun.code.includes('import ')
252
- ? transformImportsToRequire(functionToRun.code)
253
- : functionToRun.code;
254
- vm_1.default.runInContext(codeToRun, vmContext);
330
+ const { sandboxModule, entryFile, vmContext } = buildVmContext(contextData, functionToRun);
331
+ vm_1.default.runInContext(transpileSandboxModule(functionToRun.code), vmContext, { filename: entryFile });
255
332
  sandboxModule.exports = (_a = resolveExport(vmContext)) !== null && _a !== void 0 ? _a : sandboxModule.exports;
256
333
  const fn = sandboxModule.exports;
257
334
  if (deserializeArgs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowerforce/flowerbase",
3
- "version": "1.8.4-beta.2",
3
+ "version": "1.8.4-beta.4",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -10,7 +10,10 @@ export interface FunctionConfig {
10
10
  disable_arg_logs?: boolean
11
11
  }
12
12
 
13
- export type Function = Omit<FunctionConfig, 'name'> & { code: string }
13
+ export type Function = Omit<FunctionConfig, 'name'> & {
14
+ code: string
15
+ sourcePath?: string
16
+ }
14
17
 
15
18
  export type Functions = Record<string, Function>
16
19
 
@@ -27,7 +27,7 @@ export const loadFunctions = async (rootDir = process.cwd()): Promise<Functions>
27
27
  throw new Error(`File ${name}.js or ${name}.ts not found`)
28
28
  }
29
29
  code = fs.readFileSync(fnPath, 'utf-8')
30
- acc[name] = { code, ...opts }
30
+ acc[name] = { code, sourcePath: fnPath, ...opts }
31
31
 
32
32
  return acc
33
33
  }, {} as Functions)
@@ -74,8 +74,8 @@ export const executeQuery = async ({
74
74
  typeof projection !== 'undefined'
75
75
  ? parsedProjection
76
76
  : parsedOptions &&
77
- typeof parsedOptions === 'object' &&
78
- 'projection' in parsedOptions
77
+ typeof parsedOptions === 'object' &&
78
+ 'projection' in parsedOptions
79
79
  ? (parsedOptions as Document).projection
80
80
  : undefined
81
81
  return {
@@ -1,4 +1,4 @@
1
- import { ensureClientPipelineStages, getHiddenFieldsFromRulesConfig, prependUnsetStage, applyAccessControlToPipeline } from '../utils'
1
+ import { ensureClientPipelineStages, getHiddenFieldsFromRulesConfig, prependUnsetStage, applyAccessControlToPipeline, mergeProjections } from '../utils'
2
2
  import { Role } from '../../../utils/roles/interface'
3
3
 
4
4
  describe('MongoDB Atlas aggregate helpers', () => {
@@ -165,4 +165,89 @@ describe('MongoDB Atlas aggregate helpers', () => {
165
165
  })
166
166
  })
167
167
  })
168
+
169
+ describe('mergeProjections', () => {
170
+ it('returns undefined when both sides are empty', () => {
171
+ expect(mergeProjections(undefined, undefined)).toBeUndefined()
172
+ expect(mergeProjections({}, null)).toBeUndefined()
173
+ })
174
+
175
+ it('returns the client projection when rules have none', () => {
176
+ expect(mergeProjections({ a: 1, b: 1 }, null)).toEqual({ a: 1, b: 1 })
177
+ })
178
+
179
+ it('normalizes the rules projection when client has none', () => {
180
+ // Mixed inclusion/exclusion rules are normalized to pure inclusion mode.
181
+ expect(
182
+ mergeProjections(undefined, { item: 1, status: 1, instock: 0 })
183
+ ).toEqual({ item: 1, status: 1 })
184
+ })
185
+
186
+ it('merges plain inclusion projections (rules wins on conflict)', () => {
187
+ expect(
188
+ mergeProjections(
189
+ { item: 1, price: 1 },
190
+ { item: 1, status: 1 }
191
+ )
192
+ ).toEqual({ item: 1, status: 1, price: 1 })
193
+ })
194
+
195
+ it('supports dotted client keys alongside plain rules keys', () => {
196
+ expect(
197
+ mergeProjections(
198
+ { price: 1 },
199
+ { item: 1, status: 1, 'instock.qty': 1 }
200
+ )
201
+ ).toEqual({ item: 1, status: 1, 'instock.qty': 1, price: 1 })
202
+ })
203
+
204
+ it('drops client dotted keys when rules exclude the top-level field', () => {
205
+ // Rules: include item/status, exclude the whole `instock` subtree.
206
+ // Client tries to read `instock.qty` — it must be stripped.
207
+ expect(
208
+ mergeProjections(
209
+ { item: 1, status: 1, 'instock.qty': 1 },
210
+ { item: 1, status: 1, instock: 0 }
211
+ )
212
+ ).toEqual({ item: 1, status: 1 })
213
+ })
214
+
215
+ it('drops every client inclusion whose top-level is excluded by rules', () => {
216
+ expect(
217
+ mergeProjections(
218
+ {
219
+ item: 1,
220
+ 'instock.qty': 1,
221
+ 'instock.warehouse': 1,
222
+ price: 1
223
+ },
224
+ { item: 1, instock: 0 }
225
+ )
226
+ ).toEqual({ item: 1, price: 1 })
227
+ })
228
+
229
+ it('produces pure exclusion output when neither side has inclusions', () => {
230
+ expect(
231
+ mergeProjections({ secretA: 0 }, { secretB: 0 })
232
+ ).toEqual({ secretA: 0, secretB: 0 })
233
+ })
234
+
235
+ it('drops non-_id client exclusions when switching to inclusion mode', () => {
236
+ // Can't mix `{ price: 0, item: 1 }` in MongoDB — rules force inclusion
237
+ // mode so the client exclusion is silently dropped (price is implicitly
238
+ // excluded because it is not included).
239
+ expect(
240
+ mergeProjections({ price: 0 }, { item: 1 })
241
+ ).toEqual({ item: 1 })
242
+ })
243
+
244
+ it('keeps _id: 0 alongside inclusion mode', () => {
245
+ expect(
246
+ mergeProjections({ _id: 0 }, { item: 1 })
247
+ ).toEqual({ _id: 0, item: 1 })
248
+ expect(
249
+ mergeProjections({ item: 1 }, { _id: 0 })
250
+ ).toEqual({ _id: 0, item: 1 })
251
+ })
252
+ })
168
253
  })
@@ -35,6 +35,7 @@ import {
35
35
  getFormattedProjection,
36
36
  getFormattedQuery,
37
37
  getHiddenFieldsFromRulesConfig,
38
+ mergeProjections,
38
39
  normalizeQuery
39
40
  } from './utils'
40
41
 
@@ -558,13 +559,6 @@ const getOperators: GetOperatorsFunction = (
558
559
  projectionOrOptions,
559
560
  options
560
561
  )
561
- const resolvedOptions =
562
- projection || normalizedOptions
563
- ? {
564
- ...(normalizedOptions ?? {}),
565
- ...(projection ? { projection } : {})
566
- }
567
- : undefined
568
562
  const resolvedQuery = query ?? {}
569
563
  if (!run_as_system) {
570
564
  checkDenyOperation(
@@ -574,6 +568,17 @@ const getOperators: GetOperatorsFunction = (
574
568
  )
575
569
  // Apply access control filters to the query
576
570
  const formattedQuery = getFormattedQuery(filters, resolvedQuery, user)
571
+ // Rules-level projection has priority over client-provided projection.
572
+ // The merged projection is passed natively to MongoDB.
573
+ const rulesProjection = getFormattedProjection(filters, user)
574
+ const finalProjection = mergeProjections(projection, rulesProjection)
575
+ const resolvedOptions =
576
+ finalProjection || normalizedOptions
577
+ ? {
578
+ ...(normalizedOptions ?? {}),
579
+ ...(finalProjection ? { projection: finalProjection } : {})
580
+ }
581
+ : undefined
577
582
  logDebug('update formattedQuery', {
578
583
  collection: collName,
579
584
  query,
@@ -629,8 +634,15 @@ const getOperators: GetOperatorsFunction = (
629
634
  emitMongoEvent('findOne')
630
635
  return Promise.resolve(response)
631
636
  }
632
- // System mode: no validation applied
633
- const response = await collection.findOne(resolvedQuery, resolvedOptions)
637
+ // System mode: no validation applied, only client-provided projection/options.
638
+ const systemOptions =
639
+ projection || normalizedOptions
640
+ ? {
641
+ ...(normalizedOptions ?? {}),
642
+ ...(projection ? { projection } : {})
643
+ }
644
+ : undefined
645
+ const response = await collection.findOne(resolvedQuery, systemOptions)
634
646
  emitMongoEvent('findOne')
635
647
  return response
636
648
  } catch (error) {
@@ -1023,13 +1035,6 @@ const getOperators: GetOperatorsFunction = (
1023
1035
  projectionOrOptions,
1024
1036
  options
1025
1037
  )
1026
- const resolvedOptions =
1027
- projection || normalizedOptions
1028
- ? {
1029
- ...(normalizedOptions ?? {}),
1030
- ...(projection ? { projection } : {})
1031
- }
1032
- : undefined
1033
1038
  if (!run_as_system) {
1034
1039
  checkDenyOperation(
1035
1040
  normalizedRules,
@@ -1039,6 +1044,17 @@ const getOperators: GetOperatorsFunction = (
1039
1044
  // Pre-query filtering based on access control rules
1040
1045
  const formattedQuery = getFormattedQuery(filters, query, user)
1041
1046
  const currentQuery = formattedQuery.length ? { $and: formattedQuery } : {}
1047
+ // Rules-level projection has priority over client-provided projection.
1048
+ // The merged projection is passed natively to MongoDB.
1049
+ const rulesProjection = getFormattedProjection(filters, user)
1050
+ const finalProjection = mergeProjections(projection, rulesProjection)
1051
+ const resolvedOptions =
1052
+ finalProjection || normalizedOptions
1053
+ ? {
1054
+ ...(normalizedOptions ?? {}),
1055
+ ...(finalProjection ? { projection: finalProjection } : {})
1056
+ }
1057
+ : undefined
1042
1058
  // aggiunto filter per evitare questo errore: $and argument's entries must be objects
1043
1059
  const cursor = collection.find(currentQuery, resolvedOptions)
1044
1060
  const originalToArray = cursor.toArray.bind(cursor)
@@ -1084,8 +1100,15 @@ const getOperators: GetOperatorsFunction = (
1084
1100
  emitMongoEvent('find')
1085
1101
  return cursor
1086
1102
  }
1087
- // System mode: return original unfiltered cursor
1088
- const cursor = collection.find(query, resolvedOptions)
1103
+ // System mode: return original unfiltered cursor (only client projection/options).
1104
+ const systemOptions =
1105
+ projection || normalizedOptions
1106
+ ? {
1107
+ ...(normalizedOptions ?? {}),
1108
+ ...(projection ? { projection } : {})
1109
+ }
1110
+ : undefined
1111
+ const cursor = collection.find(query, systemOptions)
1089
1112
  emitMongoEvent('find')
1090
1113
  return cursor
1091
1114
  } catch (error) {
@@ -1327,7 +1350,7 @@ const getOperators: GetOperatorsFunction = (
1327
1350
  formattedQuery,
1328
1351
  pipeline
1329
1352
  })
1330
- const projection = getFormattedProjection(filters)
1353
+ const projection = getFormattedProjection(filters, user)
1331
1354
  const hiddenFields = getHiddenFieldsFromRulesConfig(rulesConfig)
1332
1355
 
1333
1356
  const sanitizedPipeline = applyAccessControlToPipeline(
@@ -76,20 +76,89 @@ export const getFormattedProjection = (
76
76
  filters: Filter[] = [],
77
77
  user?: User
78
78
  ): Projection | null => {
79
- const projections = filters
80
- .filter((filter) => {
81
- if (filter.projection) {
82
- const preFilter = getValidRule({ filters, user })
83
- const isValidPreFilter = !!preFilter?.length
84
- return isValidPreFilter
85
- }
86
- return false
87
- })
88
- .map((f) => f.projection)
79
+ const projections = getValidRule({ filters, user })
80
+ .filter((f) => !!f.projection)
81
+ .map((f) => f.projection as Projection)
89
82
  if (!projections.length) return null
90
83
  return Object.assign({}, ...projections)
91
84
  }
92
85
 
86
+ /**
87
+ * Merges a client-provided projection with the one computed from rules filters.
88
+ *
89
+ * Rules have higher priority over the client:
90
+ * - If rules exclude a top-level field (e.g. `{ instock: 0 }`), every client
91
+ * reference to that field — including dotted sub-paths such as
92
+ * `"instock.qty": 1` — is dropped from the final projection.
93
+ * - If rules include a field (value `1`), it is always part of the final
94
+ * projection and overrides any conflicting client value.
95
+ * - The returned projection is always a valid MongoDB projection (no mixing of
96
+ * inclusion and exclusion on non-`_id` keys), so it can be passed as-is to
97
+ * native MongoDB methods.
98
+ * - Returns `undefined` when neither side provided a meaningful projection.
99
+ */
100
+ export const mergeProjections = (
101
+ clientProjection: Projection | Document | undefined,
102
+ rulesProjection: Projection | null | undefined
103
+ ): Projection | Document | undefined => {
104
+ const hasClient = !!clientProjection && Object.keys(clientProjection).length > 0
105
+ const hasRules = !!rulesProjection && Object.keys(rulesProjection).length > 0
106
+ if (!hasClient && !hasRules) return undefined
107
+
108
+ const client = (hasClient ? (clientProjection as Projection) : {}) as Projection
109
+ const rules = (hasRules ? (rulesProjection as Projection) : {}) as Projection
110
+
111
+ const getTopLevel = (key: string) => key.split('.')[0]
112
+
113
+ const rulesEntries = Object.entries(rules)
114
+ const rulesIncludeKeys = rulesEntries
115
+ .filter(([, value]) => value === 1)
116
+ .map(([key]) => key)
117
+ const rulesExcludeKeys = rulesEntries
118
+ .filter(([, value]) => value === 0)
119
+ .map(([key]) => key)
120
+
121
+ // Top-level fields excluded by rules (excluding `_id` which has special
122
+ // MongoDB semantics and is allowed alongside inclusion projections).
123
+ const excludedTopLevel = new Set(
124
+ rulesExcludeKeys.map(getTopLevel).filter((key) => key !== '_id')
125
+ )
126
+
127
+ const filteredClient: Record<string, 0 | 1> = {}
128
+ for (const [key, value] of Object.entries(client)) {
129
+ if (excludedTopLevel.has(getTopLevel(key))) continue
130
+ filteredClient[key] = value as 0 | 1
131
+ }
132
+
133
+ const hasInclusion =
134
+ rulesIncludeKeys.some((key) => key !== '_id') ||
135
+ Object.entries(filteredClient).some(([key, value]) => value === 1 && key !== '_id')
136
+
137
+ const merged: Record<string, 0 | 1> = {}
138
+
139
+ if (hasInclusion) {
140
+ // Inclusion mode: keep only client inclusions, then overlay rules inclusions.
141
+ // Client exclusions (other than `_id: 0`) are incompatible with inclusion
142
+ // mode and are dropped; not-included fields are implicitly excluded anyway.
143
+ for (const [key, value] of Object.entries(filteredClient)) {
144
+ if (value === 1 || key === '_id') merged[key] = value
145
+ }
146
+ for (const key of rulesIncludeKeys) merged[key] = 1
147
+ // Allow `_id: 0` to be forced by rules in inclusion mode.
148
+ for (const key of rulesExcludeKeys) {
149
+ if (key === '_id') merged[key] = 0
150
+ }
151
+ } else {
152
+ // Pure exclusion mode: combine all exclusions from both sides.
153
+ for (const [key, value] of Object.entries(filteredClient)) {
154
+ if (value === 0) merged[key] = 0
155
+ }
156
+ for (const key of rulesExcludeKeys) merged[key] = 0
157
+ }
158
+
159
+ return Object.keys(merged).length > 0 ? merged : undefined
160
+ }
161
+
93
162
  export const applyAccessControlToPipeline = (
94
163
  pipeline: AggregationPipeline,
95
164
  rules: Record<
@@ -120,7 +189,7 @@ export const applyAccessControlToPipeline = (
120
189
  checkDenyOperation(rules as Rules, currentCollection, CRUD_OPERATIONS.READ)
121
190
  const lookupRules = rules[currentCollection] || {}
122
191
  const formattedQuery = getFormattedQuery(lookupRules.filters, {}, user)
123
- const projection = getFormattedProjection(lookupRules.filters)
192
+ const projection = getFormattedProjection(lookupRules.filters, user)
124
193
 
125
194
  const nestedPipeline = applyAccessControlToPipeline(
126
195
  lookUpStage.pipeline || [],
@@ -155,7 +224,7 @@ export const applyAccessControlToPipeline = (
155
224
  checkDenyOperation(rules as Rules, currentCollection, CRUD_OPERATIONS.READ)
156
225
  const unionRules = rules[currentCollection] || {}
157
226
  const formattedQuery = getFormattedQuery(unionRules.filters, {}, user)
158
- const projection = getFormattedProjection(unionRules.filters)
227
+ const projection = getFormattedProjection(unionRules.filters, user)
159
228
 
160
229
  if (isSimpleStage) {
161
230
  return stage
@@ -1,5 +1,8 @@
1
1
  import { GenerateContextSync } from '../context'
2
2
  import { Functions } from '../../features/functions/interface'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
3
6
 
4
7
  const mockServices = {
5
8
  api: jest.fn().mockReturnValue({}),
@@ -117,4 +120,41 @@ describe('context.functions.execute compatibility', () => {
117
120
 
118
121
  expect(result).toBe(true)
119
122
  })
123
+
124
+ it('loads same-directory helper modules for sandboxed functions', () => {
125
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flowerbase-context-'))
126
+ const helperPath = path.join(tempDir, 'getFreightRate.ts')
127
+
128
+ fs.writeFileSync(
129
+ helperPath,
130
+ 'export function getFreightRate([address, amount, freightRateValues]) { return { address, total: amount * freightRateValues.multiplier } }'
131
+ )
132
+
133
+ const functionsList = {
134
+ caller: {
135
+ sourcePath: path.join(tempDir, 'caller.ts'),
136
+ code: `
137
+ import { getFreightRate } from './getFreightRate'
138
+
139
+ module.exports = function() {
140
+ return getFreightRate(['rome', 4, { multiplier: 2.5 }])
141
+ }
142
+ `
143
+ }
144
+ } as Functions
145
+
146
+ const result = GenerateContextSync({
147
+ args: [],
148
+ app: {} as any,
149
+ rules: {} as any,
150
+ user: {} as any,
151
+ currentFunction: functionsList.caller,
152
+ functionsList,
153
+ services: mockServices,
154
+ functionName: 'caller'
155
+ })
156
+
157
+ expect(result).toEqual({ address: 'rome', total: 10 })
158
+ fs.rmSync(tempDir, { recursive: true, force: true })
159
+ })
120
160
  })
@@ -1,9 +1,11 @@
1
+ import fs from 'node:fs'
1
2
  import { createRequire } from 'node:module'
2
3
  import path from 'node:path'
3
4
  import { pathToFileURL } from 'node:url'
4
5
  import vm from 'vm'
5
6
  import { EJSON } from 'bson'
6
7
  import { StateManager } from '../../state'
8
+ import { Function as AppFunction } from '../../features/functions/interface'
7
9
  import { generateContextData } from './helpers'
8
10
  import { GenerateContextParams } from './interface'
9
11
 
@@ -92,6 +94,42 @@ const wrapEsmModule = (code: string): string => {
92
94
  return `${prelude}\n${code}\n${trailer}`
93
95
  }
94
96
 
97
+ const transpileSandboxModule = (code: string): string => {
98
+ const exportedNames: string[] = []
99
+ let transformed = code.includes('import ')
100
+ ? transformImportsToRequire(code)
101
+ : code
102
+
103
+ transformed = transformed.replace(
104
+ /^\s*export\s+function\s+([A-Za-z_$][\w$]*)\s*\(/gm,
105
+ (_match, name: string) => {
106
+ exportedNames.push(name)
107
+ return `function ${name}(`
108
+ }
109
+ )
110
+
111
+ transformed = transformed.replace(
112
+ /^\s*export\s+(const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm,
113
+ (_match, kind: string, name: string) => {
114
+ exportedNames.push(name)
115
+ return `${kind} ${name}`
116
+ }
117
+ )
118
+
119
+ transformed = transformed.replace(
120
+ /^\s*export\s+default\s+/gm,
121
+ 'module.exports = '
122
+ )
123
+
124
+ if (exportedNames.length === 0) {
125
+ return transformed
126
+ }
127
+
128
+ return `${transformed}\n${[...new Set(exportedNames)]
129
+ .map((name) => `exports.${name} = ${name}`)
130
+ .join('\n')}`
131
+ }
132
+
95
133
  const resolveImportTarget = (specifier: string, customRequire: NodeRequire): string => {
96
134
  try {
97
135
  const resolved = customRequire.resolve(specifier)
@@ -123,6 +161,82 @@ type SandboxContext = vm.Context & {
123
161
  __fb_dirname?: string
124
162
  }
125
163
 
164
+ type SandboxExecutionContext = ReturnType<typeof generateContextData>
165
+
166
+ const resolveModulePath = (specifier: string, parentFile: string): string | undefined => {
167
+ const parentDir = path.dirname(parentFile)
168
+ const basePath = path.resolve(parentDir, specifier)
169
+ const candidates = [
170
+ basePath,
171
+ `${basePath}.js`,
172
+ `${basePath}.ts`,
173
+ path.join(basePath, 'index.js'),
174
+ path.join(basePath, 'index.ts')
175
+ ]
176
+
177
+ return candidates.find((candidate) => {
178
+ try {
179
+ return fs.statSync(candidate).isFile()
180
+ } catch {
181
+ return false
182
+ }
183
+ })
184
+ }
185
+
186
+ const executeSandboxModule = ({
187
+ code,
188
+ contextData,
189
+ filePath,
190
+ moduleCache
191
+ }: {
192
+ code: string
193
+ contextData: SandboxExecutionContext
194
+ filePath: string
195
+ moduleCache: Map<string, unknown>
196
+ }): unknown => {
197
+ if (moduleCache.has(filePath)) {
198
+ return moduleCache.get(filePath)
199
+ }
200
+
201
+ const sandboxModule: SandboxModule = { exports: {} }
202
+ moduleCache.set(filePath, sandboxModule.exports)
203
+ const baseRequire = createRequire(filePath)
204
+
205
+ const localRequire = ((specifier: string) => {
206
+ if (specifier.startsWith('.') || specifier.startsWith('/')) {
207
+ const resolvedPath = resolveModulePath(specifier, filePath)
208
+ if (resolvedPath) {
209
+ return executeSandboxModule({
210
+ code: fs.readFileSync(resolvedPath, 'utf-8'),
211
+ contextData,
212
+ filePath: resolvedPath,
213
+ moduleCache
214
+ })
215
+ }
216
+ }
217
+
218
+ return baseRequire(specifier)
219
+ }) as NodeRequire
220
+
221
+ const vmContext = vm.createContext({
222
+ ...contextData,
223
+ require: localRequire,
224
+ exports: sandboxModule.exports,
225
+ module: sandboxModule,
226
+ __filename: filePath,
227
+ __dirname: path.dirname(filePath),
228
+ __fb_require: localRequire,
229
+ __fb_filename: filePath,
230
+ __fb_dirname: path.dirname(filePath)
231
+ }) as SandboxContext
232
+
233
+ vm.runInContext(transpileSandboxModule(code), vmContext, { filename: filePath })
234
+ sandboxModule.exports = resolveExport(vmContext) ?? sandboxModule.exports
235
+ moduleCache.set(filePath, sandboxModule.exports)
236
+
237
+ return sandboxModule.exports
238
+ }
239
+
126
240
  const isExportedFunction = (value: unknown): value is ExportedFunction =>
127
241
  typeof value === 'function'
128
242
 
@@ -141,10 +255,28 @@ const resolveExport = (ctx: SandboxContext): ExportedFunction | undefined => {
141
255
  return getDefaultExport(moduleExports) ?? getDefaultExport(contextExports)
142
256
  }
143
257
 
144
- const buildVmContext = (contextData: ReturnType<typeof generateContextData>) => {
258
+ const buildVmContext = (
259
+ contextData: ReturnType<typeof generateContextData>,
260
+ currentFunction?: AppFunction
261
+ ) => {
145
262
  const sandboxModule: SandboxModule = { exports: {} }
146
- const entryFile = require.main?.filename ?? process.cwd()
147
- const customRequire = createRequire(entryFile)
263
+ const entryFile = currentFunction?.sourcePath ?? require.main?.filename ?? process.cwd()
264
+ const moduleCache = new Map<string, unknown>()
265
+ const customRequire = ((specifier: string) => {
266
+ if ((specifier.startsWith('.') || specifier.startsWith('/')) && currentFunction?.sourcePath) {
267
+ const resolvedPath = resolveModulePath(specifier, currentFunction.sourcePath)
268
+ if (resolvedPath) {
269
+ return executeSandboxModule({
270
+ code: fs.readFileSync(resolvedPath, 'utf-8'),
271
+ contextData,
272
+ filePath: resolvedPath,
273
+ moduleCache
274
+ })
275
+ }
276
+ }
277
+
278
+ return createRequire(entryFile)(specifier)
279
+ }) as NodeRequire
148
280
 
149
281
  const vmContext: SandboxContext = vm.createContext({
150
282
  ...contextData,
@@ -206,7 +338,10 @@ export async function GenerateContext({
206
338
  GenerateContextSync,
207
339
  request
208
340
  })
209
- const { sandboxModule, entryFile, customRequire, vmContext } = buildVmContext(contextData)
341
+ const { sandboxModule, entryFile, customRequire, vmContext } = buildVmContext(
342
+ contextData,
343
+ functionToRun
344
+ )
210
345
 
211
346
  const vmModules = vm as typeof vm & {
212
347
  SourceTextModule?: typeof vm.SourceTextModule
@@ -271,10 +406,7 @@ export async function GenerateContext({
271
406
  }
272
407
 
273
408
  if (!usedVmModules) {
274
- const codeToRun = functionToRun.code.includes('import ')
275
- ? transformImportsToRequire(functionToRun.code)
276
- : functionToRun.code
277
- vm.runInContext(codeToRun, vmContext)
409
+ vm.runInContext(transpileSandboxModule(functionToRun.code), vmContext, { filename: entryFile })
278
410
  }
279
411
 
280
412
  sandboxModule.exports = resolveExport(vmContext) ?? sandboxModule.exports
@@ -323,12 +455,9 @@ export function GenerateContextSync({
323
455
  GenerateContextSync,
324
456
  request
325
457
  })
326
- const { sandboxModule, vmContext } = buildVmContext(contextData)
327
- const codeToRun = functionToRun.code.includes('import ')
328
- ? transformImportsToRequire(functionToRun.code)
329
- : functionToRun.code
458
+ const { sandboxModule, entryFile, vmContext } = buildVmContext(contextData, functionToRun)
330
459
 
331
- vm.runInContext(codeToRun, vmContext)
460
+ vm.runInContext(transpileSandboxModule(functionToRun.code), vmContext, { filename: entryFile })
332
461
  sandboxModule.exports = resolveExport(vmContext) ?? sandboxModule.exports
333
462
  const fn = sandboxModule.exports as ExportedFunction
334
463
  if (deserializeArgs) {