Skip to content

Commit c478ae3

Browse files
refactor(core): Discover secret custom-field inputs from the schema
Replace the hand-maintained list of custom-field-carrying input type names with schema-driven discovery: the interceptor walks each mutation's input value against its GraphQL input types and strips secret placeholders wherever a value sits at a position typed as a *CustomFieldsInput type. Any input that carries custom fields — present, future or plugin-defined — is handled without updating a registry, since custom-field values always live inside a generated *CustomFieldsInput type regardless of the enclosing input's name. The secret-field set per input type is built once per schema and cached. Relates to #2648
1 parent a1263c9 commit c478ae3

1 file changed

Lines changed: 161 additions & 113 deletions

File tree

packages/core/src/api/middleware/custom-field-processing-interceptor.ts

Lines changed: 161 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { isForeignSecretPlaceholder, REDACTED_SECRET_PLACEHOLDER } from '@vendur
55
import { getGraphQlInputName } from '@vendure/common/lib/shared-utils';
66
import {
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

Comments
 (0)