@@ -5,7 +5,11 @@ import { isForeignSecretPlaceholder, REDACTED_SECRET_PLACEHOLDER } from '@vendur
55import { getGraphQlInputName } from '@vendure/common/lib/shared-utils' ;
66import {
77 getNamedType ,
8+ getNullableType ,
9+ GraphQLInputType ,
810 GraphQLSchema ,
11+ isInputObjectType ,
12+ isListType ,
913 OperationDefinitionNode ,
1014 TypeInfo ,
1115 visit ,
@@ -35,50 +39,23 @@ export class CustomFieldProcessingInterceptor implements NestInterceptor {
3539 private readonly createInputsWithCustomFields = new Set < string > ( ) ;
3640 private readonly updateInputsWithCustomFields = new Set < string > ( ) ;
3741 /**
38- * Every input type that carries an entity's custom fields, mapped to the owning entity. This
39- * includes the standard `Create<Entity>Input`/`Update<Entity>Input` and the alias inputs that
40- * embed custom fields under a non-standard name (kept in sync with the extensions added in
41- * `graphql-custom-fields.ts`). Used to strip secret redaction placeholders on every write path,
42- * not just the standard ones.
42+ * Per-schema cache mapping the name of each `*CustomFieldsInput` type to the set of its `secret`
43+ * field input-names. Built lazily from the schema (see {@link getSecretFieldsByInputType}) so that
44+ * secret redaction placeholders are stripped wherever custom fields appear in a mutation input,
45+ * regardless of the (arbitrary) name of the enclosing input type.
4346 */
44- private readonly secretCapableInputs = new Map < string , keyof CustomFields > ( ) ;
45- /**
46- * Input types whose value is itself the custom-fields object, rather than an object with a nested
47- * `customFields` property.
48- */
49- private readonly directCustomFieldsInputs = new Set < string > ( [ 'OrderLineCustomFieldsInput' ] ) ;
47+ private readonly secretFieldsByInputTypeCache = new WeakMap < GraphQLSchema , Map < string , Set < string > > > ( ) ;
5048
5149 constructor (
5250 private readonly configService : ConfigService ,
5351 private readonly moduleRef : ModuleRef ,
5452 ) {
55- const hasFields = ( entityName : keyof CustomFields ) =>
56- ( this . configService . customFields [ entityName ] ?. length ?? 0 ) > 0 ;
57- ( Object . keys ( configService . customFields ) as Array < keyof CustomFields > ) . forEach ( entityName => {
53+ Object . keys ( configService . customFields ) . forEach ( entityName => {
5854 this . createInputsWithCustomFields . add ( `Create${ entityName } Input` ) ;
5955 this . updateInputsWithCustomFields . add ( `Update${ entityName } Input` ) ;
60- if ( hasFields ( entityName ) ) {
61- this . secretCapableInputs . set ( `Create${ entityName } Input` , entityName ) ;
62- this . secretCapableInputs . set ( `Update${ entityName } Input` , entityName ) ;
63- }
6456 } ) ;
65- // Alias input types that embed an entity's custom fields under a non-standard input name.
66- const aliases : Array < [ string , keyof CustomFields ] > = [
67- [ 'UpdateActiveAdministratorInput' , 'Administrator' ] ,
68- [ 'RegisterCustomerInput' , 'Customer' ] ,
69- [ 'UpdateOrderAddressInput' , 'Address' ] ,
70- [ 'ModifyOrderInput' , 'Order' ] ,
71- [ 'OrderLineCustomFieldsInput' , 'OrderLine' ] ,
72- [ 'AddItemInput' , 'OrderLine' ] ,
73- [ 'OrderLineInput' , 'OrderLine' ] ,
74- [ 'AddItemToDraftOrderInput' , 'OrderLine' ] ,
75- [ 'AdjustDraftOrderLineInput' , 'OrderLine' ] ,
76- ] ;
77- for ( const [ inputType , entityName ] of aliases ) {
78- if ( hasFields ( entityName ) ) {
79- this . secretCapableInputs . set ( inputType , entityName ) ;
80- }
81- }
57+ // Note: OrderLineCustomFieldsInput is handled separately since it's used in both
58+ // create operations (addItemToOrder) and update operations (adjustOrderLine)
8259 }
8360
8461 async intercept ( context : ExecutionContext , next : CallHandler < any > ) {
@@ -106,116 +83,187 @@ export class CustomFieldProcessingInterceptor implements NestInterceptor {
10683 const ctx = internal_getRequestContext ( parseContext ( context ) . req ) ;
10784 const injector = new Injector ( this . moduleRef ) ;
10885
109- const inputTypeNames = this . getArgumentMap ( operation , schema ) ;
86+ // Strip secret redaction placeholders anywhere custom fields appear in the mutation input,
87+ // discovered from the schema so it does not depend on the enclosing input type's name.
88+ this . stripSecretPlaceholders ( operation , schema , variables ) ;
11089
90+ const inputTypeNames = this . getArgumentMap ( operation , schema ) ;
11191 for ( const [ inputName , typeName ] of Object . entries ( inputTypeNames ) ) {
112- if ( ! variables [ inputName ] ) {
113- continue ;
114- }
115- // Strip secret redaction placeholders on every write path that carries custom fields,
116- // including the alias inputs the defaults/validation path below does not handle.
117- this . stripSecretPlaceholders ( typeName , variables [ inputName ] , operation ) ;
118- if ( this . hasCustomFields ( typeName ) ) {
92+ if ( this . hasCustomFields ( typeName ) && variables [ inputName ] ) {
11993 await this . processInputVariables ( typeName , variables [ inputName ] , ctx , injector , operation ) ;
12094 }
12195 }
12296 }
12397
124- private hasCustomFields ( typeName : string ) : boolean {
125- return (
126- this . createInputsWithCustomFields . has ( typeName ) ||
127- this . updateInputsWithCustomFields . has ( typeName ) ||
128- typeName === 'OrderLineCustomFieldsInput'
129- ) ;
130- }
131-
132- private async processInputVariables (
133- typeName : string ,
134- variableInput : any ,
135- ctx : RequestContext ,
136- injector : Injector ,
98+ /**
99+ * Removes `secret` custom-field redaction placeholders from the mutation input before it reaches
100+ * the database. When the API redacts a secret on read, the placeholder is what an edit form
101+ * submits back; if it were persisted, the encryption transformer would encrypt the literal
102+ * placeholder and destroy the stored secret. Placeholders are therefore stripped (leaving the
103+ * stored value untouched), and a placeholder from a different Vendure version is rejected.
104+ *
105+ * The locations of custom fields are discovered from the schema — any value sitting at a position
106+ * typed as a `*CustomFieldsInput` type is a custom-fields object — so this works for every input
107+ * that carries custom fields (e.g. `updateActiveAdministrator`, `modifyOrder`, the order-line
108+ * inputs, and any future or plugin-defined mutation) without a hand-maintained list of input names.
109+ */
110+ private stripSecretPlaceholders (
137111 operation : OperationDefinitionNode ,
112+ schema : GraphQLSchema ,
113+ variables : Record < string , any > ,
138114 ) {
139- const inputVariables = Array . isArray ( variableInput ) ? variableInput : [ variableInput ] ;
140- const shouldApplyDefaults = this . shouldApplyDefaults ( typeName , operation ) ;
141-
142- for ( const inputVariable of inputVariables ) {
143- if ( shouldApplyDefaults ) {
144- this . applyDefaultsToInput ( typeName , inputVariable ) ;
115+ const secretFieldsByInputType = this . getSecretFieldsByInputType ( schema ) ;
116+ if ( secretFieldsByInputType . size === 0 ) {
117+ return ;
118+ }
119+ const mutationType = schema . getMutationType ( ) ;
120+ if ( ! mutationType ) {
121+ return ;
122+ }
123+ const mutationFields = mutationType . getFields ( ) ;
124+ for ( const selection of operation . selectionSet . selections ) {
125+ if ( selection . kind !== 'Field' ) {
126+ continue ;
127+ }
128+ const fieldDef = mutationFields [ selection . name . value ] ;
129+ if ( ! fieldDef ) {
130+ continue ;
131+ }
132+ for ( const arg of fieldDef . args ) {
133+ if ( arg . name in variables ) {
134+ // On a create there is no stored value to preserve, so a placeholder is rejected
135+ // rather than stripped. This is best-effort (set membership against the generated
136+ // create inputs); when unknown it defaults to stripping, which is always safe.
137+ const isCreate = this . createInputsWithCustomFields . has ( getNamedType ( arg . type ) . name ) ;
138+ this . walkAndStripSecrets (
139+ variables [ arg . name ] ,
140+ arg . type ,
141+ secretFieldsByInputType ,
142+ isCreate ,
143+ ) ;
144+ }
145145 }
146- await this . validateInput ( typeName , ctx , injector , inputVariable ) ;
147146 }
148147 }
149148
150149 /**
151- * For `secret` custom fields, the API returns a redaction placeholder rather than the real value.
152- * When that placeholder is submitted back on an update, the field is removed from the input so the
153- * stored (encrypted) value is preserved; otherwise the transformer would encrypt the literal
154- * placeholder and destroy the real secret. On a create there is nothing to preserve, so the
155- * placeholder is rejected. This runs for every custom-field-carrying input type, including alias
156- * inputs such as `UpdateActiveAdministratorInput`, `ModifyOrderInput` and the order-line inputs.
150+ * Recursively descends a mutation input value against its GraphQL input type. Wherever the value
151+ * sits at a position typed as a `*CustomFieldsInput` type, its `secret` fields have their redaction
152+ * placeholders stripped.
157153 */
158- private stripSecretPlaceholders (
159- typeName : string ,
160- variableInput : any ,
161- operation : OperationDefinitionNode ,
154+ private walkAndStripSecrets (
155+ value : any ,
156+ type : GraphQLInputType ,
157+ secretFieldsByInputType : Map < string , Set < string > > ,
158+ isCreate : boolean ,
162159 ) {
163- const entityName = this . secretCapableInputs . get ( typeName ) ;
164- if ( ! entityName ) {
160+ if ( value == null ) {
165161 return ;
166162 }
167- const customFieldConfig = this . configService . customFields [ entityName ] ;
168- if ( ! customFieldConfig ?. some ( c => c . secret === true ) ) {
163+ const nullableType = getNullableType ( type ) ;
164+ if ( isListType ( nullableType ) ) {
165+ if ( Array . isArray ( value ) ) {
166+ for ( const item of value ) {
167+ this . walkAndStripSecrets ( item , nullableType . ofType , secretFieldsByInputType , isCreate ) ;
168+ }
169+ }
169170 return ;
170171 }
171- const isDirect = this . directCustomFieldsInputs . has ( typeName ) ;
172- const isCreate = this . isCreateForSecretStripping ( typeName , entityName , operation ) ;
173- const inputVariables = Array . isArray ( variableInput ) ? variableInput : [ variableInput ] ;
174- for ( const inputVariable of inputVariables ) {
175- const customFieldsObject = isDirect ? inputVariable : inputVariable ?. customFields ;
176- if ( ! customFieldsObject ) {
177- continue ;
172+ if ( isInputObjectType ( nullableType ) && typeof value === 'object' ) {
173+ const secretFields = secretFieldsByInputType . get ( nullableType . name ) ;
174+ if ( secretFields ) {
175+ this . stripSecretPlaceholdersFromObject ( value , secretFields , isCreate ) ;
178176 }
179- for ( const config of customFieldConfig ) {
180- if ( config . secret !== true ) {
181- continue ;
177+ const fields = nullableType . getFields ( ) ;
178+ for ( const [ fieldName , field ] of Object . entries ( fields ) ) {
179+ if ( fieldName in value ) {
180+ this . walkAndStripSecrets ( value [ fieldName ] , field . type , secretFieldsByInputType , isCreate ) ;
182181 }
183- const fieldName = getGraphQlInputName ( config ) ;
184- const fieldValue = customFieldsObject [ fieldName ] ;
185- if ( fieldValue === REDACTED_SECRET_PLACEHOLDER ) {
186- if ( isCreate ) {
187- throw new UserInputError ( 'error.secret-custom-field-value-required' , {
188- name : fieldName ,
189- } ) ;
190- }
191- delete customFieldsObject [ fieldName ] ;
192- } else if ( isForeignSecretPlaceholder ( fieldValue ) ) {
193- // A placeholder from a different version must not be stored as a real value.
194- throw new UserInputError ( 'error.secret-custom-field-value-required' , {
195- name : fieldName ,
196- } ) ;
182+ }
183+ }
184+ }
185+
186+ private stripSecretPlaceholdersFromObject (
187+ customFieldsObject : any ,
188+ secretFields : Set < string > ,
189+ isCreate : boolean ,
190+ ) {
191+ for ( const fieldName of secretFields ) {
192+ const fieldValue = customFieldsObject [ fieldName ] ;
193+ if ( fieldValue === REDACTED_SECRET_PLACEHOLDER ) {
194+ if ( isCreate ) {
195+ throw new UserInputError ( 'error.secret-custom-field-value-required' , { name : fieldName } ) ;
197196 }
197+ // Preserve the stored value by not submitting anything for this field.
198+ delete customFieldsObject [ fieldName ] ;
199+ } else if ( isForeignSecretPlaceholder ( fieldValue ) ) {
200+ // A placeholder from a different version must not be stored as a real value.
201+ throw new UserInputError ( 'error.secret-custom-field-value-required' , { name : fieldName } ) ;
198202 }
199203 }
200204 }
201205
202206 /**
203- * Whether the placeholder should be rejected (a create, with nothing to preserve) rather than
204- * stripped (an update). When in doubt this returns `false` (treat as update/strip), which is the
205- * safe direction: it can never encrypt the literal placeholder over a real secret.
207+ * Builds, per schema, a map from each `*CustomFieldsInput` type name to the set of its `secret`
208+ * field input-names. The owning entity is resolved from the type name (e.g.
209+ * `UpdateAdministratorCustomFieldsInput` → `Administrator`) by the longest matching custom-field
210+ * entity name, which is unambiguous because these type names are generated as
211+ * `<verb><Entity>CustomFieldsInput`.
206212 */
207- private isCreateForSecretStripping (
213+ private getSecretFieldsByInputType ( schema : GraphQLSchema ) : Map < string , Set < string > > {
214+ const cached = this . secretFieldsByInputTypeCache . get ( schema ) ;
215+ if ( cached ) {
216+ return cached ;
217+ }
218+ const map = new Map < string , Set < string > > ( ) ;
219+ const suffix = 'CustomFieldsInput' ;
220+ const entityNames = ( Object . keys ( this . configService . customFields ) as Array < keyof CustomFields > ) . sort (
221+ ( a , b ) => ( b as string ) . length - ( a as string ) . length ,
222+ ) ;
223+ for ( const type of Object . values ( schema . getTypeMap ( ) ) ) {
224+ if ( ! isInputObjectType ( type ) || ! type . name . endsWith ( suffix ) ) {
225+ continue ;
226+ }
227+ const prefix = type . name . slice ( 0 , - suffix . length ) ;
228+ const entityName = entityNames . find ( name => prefix . endsWith ( name as string ) ) ;
229+ if ( ! entityName ) {
230+ continue ;
231+ }
232+ const secretFieldNames = ( this . configService . customFields [ entityName ] ?? [ ] )
233+ . filter ( config => config . secret === true )
234+ . map ( config => getGraphQlInputName ( config ) ) ;
235+ if ( secretFieldNames . length ) {
236+ map . set ( type . name , new Set ( secretFieldNames ) ) ;
237+ }
238+ }
239+ this . secretFieldsByInputTypeCache . set ( schema , map ) ;
240+ return map ;
241+ }
242+
243+ private hasCustomFields ( typeName : string ) : boolean {
244+ return (
245+ this . createInputsWithCustomFields . has ( typeName ) ||
246+ this . updateInputsWithCustomFields . has ( typeName ) ||
247+ typeName === 'OrderLineCustomFieldsInput'
248+ ) ;
249+ }
250+
251+ private async processInputVariables (
208252 typeName : string ,
209- entityName : keyof CustomFields ,
253+ variableInput : any ,
254+ ctx : RequestContext ,
255+ injector : Injector ,
210256 operation : OperationDefinitionNode ,
211- ) : boolean {
212- if ( this . createInputsWithCustomFields . has ( typeName ) || typeName === 'RegisterCustomerInput' ) {
213- return true ;
214- }
215- if ( entityName === 'OrderLine' ) {
216- return this . isOrderLineCreateOperation ( operation ) ;
257+ ) {
258+ const inputVariables = Array . isArray ( variableInput ) ? variableInput : [ variableInput ] ;
259+ const shouldApplyDefaults = this . shouldApplyDefaults ( typeName , operation ) ;
260+
261+ for ( const inputVariable of inputVariables ) {
262+ if ( shouldApplyDefaults ) {
263+ this . applyDefaultsToInput ( typeName , inputVariable ) ;
264+ }
265+ await this . validateInput ( typeName , ctx , injector , inputVariable ) ;
217266 }
218- return false ;
219267 }
220268
221269 private shouldApplyDefaults ( typeName : string , operation : OperationDefinitionNode ) : boolean {
0 commit comments