Skip to content

Commit a3d54e9

Browse files
asithadedealako
andauthored
fix(projects): nullify empty strings on permissions update payload (#673)
* fix(projects): nullify empty strings on permissions update payload LFXV2-1724 Upstream LFX_V2_SERVICE rejects empty strings on validated fields in the project settings PUT body. The prior workaround only omitted the avatar key when empty; other fields (email, name, optional user refs) echoed empty strings back to the upstream and could fail validation. Introduces a generic, non-mutating nullifyEmptyStrings helper in @lfx-one/shared/utils (new object.utils.ts) and applies it to the full settings payload before the ETag PUT in ProjectService. Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(review): address PR #673 review feedback Address review comments from copilot-pull-request-reviewer, coderabbitai: - packages/shared/src/utils/object.utils.ts: introduce NullifyEmptyStrings<T> mapped return type so callers see accurate nullability for nested string fields, removing the unsound `T` return when T is `string` (per copilot-pull-request-reviewer). The mapped type also flows through to the ProjectSettings call site, resolving the runtime/declared type divergence flagged on project.service.ts. - packages/shared/src/utils/object.utils.ts: guard the object branch with a plain-prototype check so Date, Map, Set, URL, RegExp, and class instances are returned unchanged instead of being rebuilt into bare records (per copilot-pull-request-reviewer and coderabbitai). Resolves 4 review threads. Signed-off-by: Asitha de Silva <asithade@gmail.com> * refactor(shared): move NullifyEmptyStrings type to interfaces folder Per repo convention, type aliases live alongside interfaces in @lfx-one/shared/interfaces, not in utils files. Also flatten the mapped type into single-conditional aliases so there are no chained (nested) ternaries. - packages/shared/src/interfaces/object.interface.ts: new home for NullifyEmptyStrings<T>, split into NullifyStringBranch / PreservedBranch / ArrayBranch / ObjectBranch helpers, each with a single conditional. - packages/shared/src/utils/object.utils.ts: imports the type from interfaces and keeps only the runtime helper. Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(review): address PR #673 review feedback Address review comment from copilot[bot]: - packages/shared/src/utils/object.utils.ts: use Object.defineProperty when rebuilding plain objects to prevent prototype pollution via __proto__ keys (per copilot[bot]) Resolves 1 review thread. Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(review): address PR #673 review feedback Address review comments from copilot[bot]: - packages/shared/src/interfaces/object.interface.ts: add function branch to NullifyEmptyStrings so callable types are preserved instead of being mapped into their key records (per copilot[bot]) - packages/shared/src/utils/object.utils.ts: rename `trimmed` to `nullIfBlank` since the value is the original string or null, not the trimmed string (per copilot[bot]) Resolves 2 review threads. Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Asitha de Silva <asithade@gmail.com> Signed-off-by: David Deal <ddeal@linuxfoundation.org> Co-authored-by: David Deal <ddeal@linuxfoundation.org>
1 parent 6415a29 commit a3d54e9

5 files changed

Lines changed: 75 additions & 27 deletions

File tree

apps/lfx-one/src/server/services/project.service.ts

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ import {
102102
UploadProjectDocumentRequest,
103103
WebActivitiesSummaryResponse,
104104
} from '@lfx-one/shared/interfaces';
105-
import { computeIsFoundation } from '@lfx-one/shared/utils';
105+
import { computeIsFoundation, nullifyEmptyStrings } from '@lfx-one/shared/utils';
106106
import { Request } from 'express';
107107
import FormData from 'form-data';
108108

@@ -453,31 +453,8 @@ export class ProjectService {
453453
}
454454
// For 'remove' operation, user is already removed from both arrays above
455455

456-
// Step 3: Clean up empty avatar fields before sending to API
457-
// The API validation doesn't accept empty strings for avatar URLs
458-
updatedSettings.writers = updatedSettings.writers.map((user) => {
459-
const cleanUser: any = {
460-
name: user.name,
461-
email: user.email,
462-
username: user.username,
463-
};
464-
if (user.avatar && user.avatar.trim() !== '') {
465-
cleanUser.avatar = user.avatar;
466-
}
467-
return cleanUser;
468-
});
469-
470-
updatedSettings.auditors = updatedSettings.auditors.map((user) => {
471-
const cleanUser: any = {
472-
name: user.name,
473-
email: user.email,
474-
username: user.username,
475-
};
476-
if (user.avatar && user.avatar.trim() !== '') {
477-
cleanUser.avatar = user.avatar;
478-
}
479-
return cleanUser;
480-
});
456+
// Upstream rejects empty strings on validated fields; send null instead so the key is preserved.
457+
const sanitizedSettings = nullifyEmptyStrings(updatedSettings);
481458

482459
// Step 4: Update settings with ETag
483460
const startTime = logger.startOperation(req, `${operation}_user_project_permissions`, {
@@ -491,7 +468,7 @@ export class ProjectService {
491468
'LFX_V2_SERVICE',
492469
`/projects/${uid}/settings`,
493470
etag,
494-
updatedSettings,
471+
sanitizedSettings,
495472
`${operation}_user_project_permissions`
496473
);
497474

packages/shared/src/interfaces/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,5 +148,8 @@ export * from './stat-card.interface';
148148
// Changelog interfaces
149149
export * from './changelog.interface';
150150

151+
// Object utility types
152+
export * from './object.interface';
153+
151154
// Marketing Impact interfaces
152155
export * from './marketing-impact.interface';
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
/**
5+
* Built-in object types that should be passed through `NullifyEmptyStrings`
6+
* unchanged. Without this exclusion, the mapped-object branch would rebuild
7+
* them into bare records and lose their prototype.
8+
*/
9+
type PreservedObjectTypes = Date | Map<unknown, unknown> | Set<unknown> | URL | RegExp;
10+
11+
/**
12+
* Maps `T` so every nested `string` becomes `string | null`. Arrays map
13+
* elementwise, plain objects map each property, and {@link PreservedObjectTypes}
14+
* and functions are returned as-is. Implemented as a chain of single-conditional
15+
* aliases to avoid nested ternary expressions.
16+
*/
17+
export type NullifyEmptyStrings<T> = NullifyStringBranch<T>;
18+
19+
type NullifyStringBranch<T> = T extends string ? string | null : NullifyFunctionBranch<T>;
20+
type NullifyFunctionBranch<T> = T extends (...args: any[]) => any ? T : NullifyPreservedBranch<T>;
21+
type NullifyPreservedBranch<T> = T extends PreservedObjectTypes ? T : NullifyArrayBranch<T>;
22+
type NullifyArrayBranch<T> = T extends readonly (infer U)[] ? NullifyEmptyStrings<U>[] : NullifyObjectBranch<T>;
23+
type NullifyObjectBranch<T> = T extends object ? { [K in keyof T]: NullifyEmptyStrings<T[K]> } : T;

packages/shared/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './survey.utils';
1515
export * from './vote.utils';
1616
export * from './committee.utils';
1717
export * from './number.utils';
18+
export * from './object.utils';
1819
export * from './platform.utils';
1920
export * from './project.utils';
2021
export * from './badge.utils';
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { NullifyEmptyStrings } from '../interfaces/object.interface';
5+
6+
/**
7+
* Recursively replaces empty / whitespace-only strings with `null` throughout a
8+
* value. Useful when sending payloads to APIs that reject `""` on validated
9+
* fields but accept `null`. Non-string primitives, arrays, and plain objects are
10+
* walked; non-plain objects (Date, Map, Set, class instances, etc.) are returned
11+
* unchanged so their prototype is preserved. The original value is not mutated.
12+
*
13+
* @param value - The value to sanitize (object, array, or primitive)
14+
* @returns A new value with empty strings replaced by `null`
15+
*/
16+
export function nullifyEmptyStrings<T>(value: T): NullifyEmptyStrings<T> {
17+
if (value === null || value === undefined) return value as NullifyEmptyStrings<T>;
18+
if (typeof value === 'string') {
19+
const nullIfBlank = value.trim() === '' ? null : value;
20+
return nullIfBlank as NullifyEmptyStrings<T>;
21+
}
22+
if (Array.isArray(value)) {
23+
return value.map((item) => nullifyEmptyStrings(item)) as NullifyEmptyStrings<T>;
24+
}
25+
if (typeof value === 'object') {
26+
const proto = Object.getPrototypeOf(value);
27+
if (proto !== Object.prototype && proto !== null) {
28+
return value as NullifyEmptyStrings<T>;
29+
}
30+
const result: Record<string, unknown> = {};
31+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
32+
// Use defineProperty to bypass setters (e.g. __proto__) and prevent prototype pollution
33+
// when keys come from untrusted sources like JSON.parse.
34+
Object.defineProperty(result, key, {
35+
value: nullifyEmptyStrings(val),
36+
writable: true,
37+
enumerable: true,
38+
configurable: true,
39+
});
40+
}
41+
return result as NullifyEmptyStrings<T>;
42+
}
43+
return value as NullifyEmptyStrings<T>;
44+
}

0 commit comments

Comments
 (0)