@carecard/validate 3.1.23 → 3.1.24
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.
- package/.codex/AGENTS.md +1 -0
- package/.husky/pre-commit +2 -4
- package/index.d.ts +24 -12
- package/index.js +2 -0
- package/lib/validate.js +6 -0
- package/lib/validateNewUserRoleRequest.js +79 -0
- package/lib/validateProperties.js +7 -0
- package/lib/validateWhitelistProperties.js +6 -49
- package/package.json +14 -2
package/.codex/AGENTS.md
CHANGED
|
@@ -92,6 +92,7 @@ The `pkg-*` directories are reusable CareCard packages. Shared API response, err
|
|
|
92
92
|
|
|
93
93
|
- Write or update tests before implementation whenever changing behavior.
|
|
94
94
|
- Testing is mandatory before finalizing code changes. Do not stop after implementation if tests, `.junie`, or `.husky` checks remain unrun.
|
|
95
|
+
- Code coverage must never be lower than the previous commit. When coverage tooling exists, compare against the previous commit or recorded baseline before finalizing, add tests to maintain or improve coverage, and never reduce coverage thresholds to make checks pass.
|
|
95
96
|
- Keep tests readable and domain-specific. Prefer explicit helper names over generic test utilities that hide important behavior.
|
|
96
97
|
- Use existing test frameworks and layouts:
|
|
97
98
|
- JavaScript `api-*`: usually Mocha, Supertest, `test/index.test.js`, and Docker-backed Postgres scripts.
|
package/.husky/pre-commit
CHANGED
package/index.d.ts
CHANGED
|
@@ -13,18 +13,10 @@ export interface ValidateWhitelistPropertiesOptions {
|
|
|
13
13
|
convertToSnakeCase?: boolean;
|
|
14
14
|
/**
|
|
15
15
|
* When true, the returned object is flattened so that every validated leaf
|
|
16
|
-
* becomes a top-level key
|
|
17
|
-
* after snake_case conversion.
|
|
16
|
+
* becomes a top-level key, joined by `.` (e.g. `{ 'user.first_name': 'Jane' }`).
|
|
17
|
+
* No nested objects remain in the output. Applied after snake_case conversion.
|
|
18
18
|
*/
|
|
19
19
|
flattenOutput?: boolean;
|
|
20
|
-
/**
|
|
21
|
-
* Controls flattened key naming when `flattenOutput` is true.
|
|
22
|
-
* - `path` uses full dot-notation paths, e.g. `{ "user.email": "Jane" }`.
|
|
23
|
-
* - `leaf` uses only leaf names, e.g. `{ email: "Jane" }`.
|
|
24
|
-
*
|
|
25
|
-
* Defaults to `path`.
|
|
26
|
-
*/
|
|
27
|
-
flattenKeyStyle?: 'path' | 'leaf';
|
|
28
20
|
}
|
|
29
21
|
|
|
30
22
|
/**
|
|
@@ -46,11 +38,10 @@ export interface ValidateWhitelistPropertiesOptions {
|
|
|
46
38
|
* validated elements (e.g. `{ name: ["First", "Other"] }` is validated like
|
|
47
39
|
* `{ name: "First" }` and `{ name: "Other" }` individually).
|
|
48
40
|
* - Optionally converts the resulting keys (including nested keys) to snake_case.
|
|
49
|
-
* - Optionally flattens the result after snake_case conversion.
|
|
50
41
|
*
|
|
51
42
|
* @param inputObject The input object (e.g. `req.body` or `req.params`).
|
|
52
43
|
* @param requiredProperties Leaf paths that must be present and valid. Dot-notation supported.
|
|
53
|
-
* @param options Optional additional leaf paths
|
|
44
|
+
* @param options Optional list of additional allowed leaf paths and case-conversion flag.
|
|
54
45
|
*/
|
|
55
46
|
export function validateWhitelistProperties(
|
|
56
47
|
inputObject: Record<string, any>,
|
|
@@ -58,6 +49,24 @@ export function validateWhitelistProperties(
|
|
|
58
49
|
options?: ValidateWhitelistPropertiesOptions,
|
|
59
50
|
): Promise<Record<string, any>>;
|
|
60
51
|
|
|
52
|
+
export const DEFAULT_USER_ROLE_REQUEST_ROLE: 'student';
|
|
53
|
+
export const REQUIRE_SCOPE_WHEN_ROLE_OR_SCOPE_PRESENT: 'whenRoleOrScopePresent';
|
|
54
|
+
|
|
55
|
+
export interface ValidateNewUserRoleRequestOptions {
|
|
56
|
+
defaultRole?: 'student' | undefined;
|
|
57
|
+
requireScope?: boolean | typeof REQUIRE_SCOPE_WHEN_ROLE_OR_SCOPE_PRESENT;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalizes and validates a carecard.new_user_role_request payload.
|
|
62
|
+
* Only student, intern, and volunteer are accepted. When scope is required,
|
|
63
|
+
* both institution_id and campus_id must be provided.
|
|
64
|
+
*/
|
|
65
|
+
export function validateNewUserRoleRequestObject(
|
|
66
|
+
roleRequest?: Record<string, any>,
|
|
67
|
+
options?: ValidateNewUserRoleRequestOptions,
|
|
68
|
+
): Record<string, any>;
|
|
69
|
+
|
|
61
70
|
/** Checks if the string is a valid image URL format. */
|
|
62
71
|
export function isImageUrl(imageUrl: any): boolean;
|
|
63
72
|
/** Checks if the value is an integer. */
|
|
@@ -112,6 +121,8 @@ export function isTextString(str: any): boolean;
|
|
|
112
121
|
export function isInStringArray(StringArray: string[], inputString: any): boolean;
|
|
113
122
|
/** Checks if the string is one of the supported user role request statuses. */
|
|
114
123
|
export function isUserRoleRequestStatusString(inputString: any): boolean;
|
|
124
|
+
/** Checks if the string is a supported new user role request role. */
|
|
125
|
+
export function isUserRoleRequestRoleString(inputString: any): boolean;
|
|
115
126
|
/** Checks if the string is a valid country code (e.g., +1). */
|
|
116
127
|
export function isCountryCodeString(str: any): boolean;
|
|
117
128
|
/** Checks if the string is a valid domain name. */
|
|
@@ -158,6 +169,7 @@ export const validate: {
|
|
|
158
169
|
isTextString: typeof isTextString;
|
|
159
170
|
isInStringArray: typeof isInStringArray;
|
|
160
171
|
isUserRoleRequestStatusString: typeof isUserRoleRequestStatusString;
|
|
172
|
+
isUserRoleRequestRoleString: typeof isUserRoleRequestRoleString;
|
|
161
173
|
isCountryCodeString: typeof isCountryCodeString;
|
|
162
174
|
isValidDomainName: typeof isValidDomainName;
|
|
163
175
|
isValidTimestampzString: typeof isValidTimestampzString;
|
package/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
const validate = require('./lib/validate');
|
|
2
2
|
const validateProperties = require('./lib/validateProperties');
|
|
3
3
|
const validateWhitelistProperties = require('./lib/validateWhitelistProperties');
|
|
4
|
+
const validateNewUserRoleRequest = require('./lib/validateNewUserRoleRequest');
|
|
4
5
|
|
|
5
6
|
module.exports = {
|
|
6
7
|
validate,
|
|
7
8
|
validateProperties,
|
|
8
9
|
validateWhitelistProperties,
|
|
10
|
+
...validateNewUserRoleRequest,
|
|
9
11
|
...validate,
|
|
10
12
|
...validateProperties,
|
|
11
13
|
};
|
package/lib/validate.js
CHANGED
|
@@ -173,6 +173,11 @@ const isUserRoleRequestStatusString = inputString => {
|
|
|
173
173
|
return isInStringArray(statuses, inputString);
|
|
174
174
|
};
|
|
175
175
|
|
|
176
|
+
const isUserRoleRequestRoleString = inputString => {
|
|
177
|
+
const roles = ['student', 'intern', 'volunteer'];
|
|
178
|
+
return typeof inputString === 'string' && roles.includes(inputString.trim().toLowerCase());
|
|
179
|
+
};
|
|
180
|
+
|
|
176
181
|
const isCountryCodeString = str => {
|
|
177
182
|
if (typeof str !== 'string' || str.length === 0 || str.length > 4) return false;
|
|
178
183
|
|
|
@@ -243,6 +248,7 @@ module.exports = {
|
|
|
243
248
|
isTextString,
|
|
244
249
|
isInStringArray,
|
|
245
250
|
isUserRoleRequestStatusString,
|
|
251
|
+
isUserRoleRequestRoleString,
|
|
246
252
|
isCountryCodeString,
|
|
247
253
|
isValidDomainName,
|
|
248
254
|
isValidTimestampzString,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
error: { throwBadInputError },
|
|
5
|
+
} = require('@carecard/common-util');
|
|
6
|
+
const { isUserRoleRequestRoleString } = require('./validate');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_USER_ROLE_REQUEST_ROLE = 'student';
|
|
9
|
+
const REQUIRE_SCOPE_WHEN_ROLE_OR_SCOPE_PRESENT = 'whenRoleOrScopePresent';
|
|
10
|
+
|
|
11
|
+
function validateNewUserRoleRequestObject(roleRequest = {}, options = {}) {
|
|
12
|
+
const normalized = normalizeNewUserRoleRequestObject(roleRequest);
|
|
13
|
+
const defaultRole = Object.prototype.hasOwnProperty.call(options, 'defaultRole') ? options.defaultRole : DEFAULT_USER_ROLE_REQUEST_ROLE;
|
|
14
|
+
const requireScope = Object.prototype.hasOwnProperty.call(options, 'requireScope') ? options.requireScope : true;
|
|
15
|
+
|
|
16
|
+
if (normalized.role_name === undefined && defaultRole !== undefined) {
|
|
17
|
+
normalized.role_name = defaultRole;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (normalized.role_name !== undefined && !isUserRoleRequestRoleString(normalized.role_name)) {
|
|
21
|
+
throwBadInputError({
|
|
22
|
+
userMessage: 'Invalid property: role.role',
|
|
23
|
+
details: { role: 'Role requests are limited to student, intern, or volunteer' },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (shouldRequireScope(normalized, requireScope)) {
|
|
28
|
+
requireNewUserRoleRequestScope(normalized);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return normalized;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeNewUserRoleRequestObject(roleRequest) {
|
|
35
|
+
const normalized = { ...roleRequest };
|
|
36
|
+
|
|
37
|
+
assignAlias(normalized, 'role_name', 'roleName');
|
|
38
|
+
assignAlias(normalized, 'role_name', 'role');
|
|
39
|
+
assignAlias(normalized, 'institution_id', 'institutionId');
|
|
40
|
+
assignAlias(normalized, 'campus_id', 'campusId');
|
|
41
|
+
assignAlias(normalized, 'program_id', 'programId');
|
|
42
|
+
|
|
43
|
+
delete normalized.roleName;
|
|
44
|
+
delete normalized.role;
|
|
45
|
+
delete normalized.institutionId;
|
|
46
|
+
delete normalized.campusId;
|
|
47
|
+
delete normalized.programId;
|
|
48
|
+
|
|
49
|
+
return normalized;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assignAlias(target, canonicalKey, aliasKey) {
|
|
53
|
+
if (target[canonicalKey] === undefined && target[aliasKey] !== undefined) {
|
|
54
|
+
target[canonicalKey] = target[aliasKey];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function shouldRequireScope(roleRequest, requireScope) {
|
|
59
|
+
if (requireScope === true) return true;
|
|
60
|
+
if (requireScope !== REQUIRE_SCOPE_WHEN_ROLE_OR_SCOPE_PRESENT) return false;
|
|
61
|
+
|
|
62
|
+
return roleRequest.role_name !== undefined || roleRequest.institution_id !== undefined || roleRequest.campus_id !== undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function requireNewUserRoleRequestScope(roleRequest) {
|
|
66
|
+
if (!roleRequest.institution_id) {
|
|
67
|
+
throwBadInputError({ userMessage: 'Missing property: role.institutionId' });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!roleRequest.campus_id) {
|
|
71
|
+
throwBadInputError({ userMessage: 'Missing property: role.campusId' });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
DEFAULT_USER_ROLE_REQUEST_ROLE,
|
|
77
|
+
REQUIRE_SCOPE_WHEN_ROLE_OR_SCOPE_PRESENT,
|
|
78
|
+
validateNewUserRoleRequestObject,
|
|
79
|
+
};
|
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
isStreetString,
|
|
23
23
|
isTextString,
|
|
24
24
|
isUserRoleRequestStatusString,
|
|
25
|
+
isUserRoleRequestRoleString,
|
|
25
26
|
} = require('./validate');
|
|
26
27
|
|
|
27
28
|
function validateProperties(obj = {}) {
|
|
@@ -196,6 +197,12 @@ function validateProperties(obj = {}) {
|
|
|
196
197
|
returnObj[key] = value;
|
|
197
198
|
}
|
|
198
199
|
break;
|
|
200
|
+
case 'user_role_request_role':
|
|
201
|
+
case 'userRoleRequestRole':
|
|
202
|
+
if (isUserRoleRequestRoleString(value)) {
|
|
203
|
+
returnObj[key] = value;
|
|
204
|
+
}
|
|
205
|
+
break;
|
|
199
206
|
case 'period':
|
|
200
207
|
if (isCharactersString(value)) {
|
|
201
208
|
returnObj[key] = value;
|
|
@@ -16,8 +16,6 @@ const MAX_NESTING_DEPTH = 5;
|
|
|
16
16
|
* adversarial inputs.
|
|
17
17
|
*/
|
|
18
18
|
const MAX_KEYS_PER_CALL = 5000;
|
|
19
|
-
const DEFAULT_FLATTEN_KEY_STYLE = 'path';
|
|
20
|
-
const VALID_FLATTEN_KEY_STYLES = new Set(['path', 'leaf']);
|
|
21
19
|
|
|
22
20
|
/**
|
|
23
21
|
* Returns true if the segment contains a mix of snake_case (underscore) and
|
|
@@ -163,35 +161,6 @@ function flattenObject(obj, prefix = '', out = {}) {
|
|
|
163
161
|
return out;
|
|
164
162
|
}
|
|
165
163
|
|
|
166
|
-
/**
|
|
167
|
-
* Recursively flattens a nested plain object using only each leaf property
|
|
168
|
-
* name as the output key.
|
|
169
|
-
*
|
|
170
|
-
* Example: `{ a: { b: { c: 1, d: 2 } } }` => `{ c: 1, d: 2 }`.
|
|
171
|
-
* If duplicate leaf keys exist at different nesting levels, the higher-level
|
|
172
|
-
* leaf wins. If duplicate leaf keys exist at the same depth, the first
|
|
173
|
-
* traversal wins.
|
|
174
|
-
*
|
|
175
|
-
* @param {Object} obj
|
|
176
|
-
* @param {Object} [out]
|
|
177
|
-
* @param {Object} [depthByKey]
|
|
178
|
-
* @param {number} [depth]
|
|
179
|
-
* @returns {Object}
|
|
180
|
-
*/
|
|
181
|
-
function flattenObjectByLeafKey(obj, out = {}, depthByKey = {}, depth = 1) {
|
|
182
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
183
|
-
if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
|
|
184
|
-
flattenObjectByLeafKey(value, out, depthByKey, depth + 1);
|
|
185
|
-
} else {
|
|
186
|
-
if (!Object.prototype.hasOwnProperty.call(out, key) || depth < depthByKey[key]) {
|
|
187
|
-
out[key] = value;
|
|
188
|
-
depthByKey[key] = depth;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
return out;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
164
|
/**
|
|
196
165
|
* Validates and transforms whitelisted properties from an input object.
|
|
197
166
|
*
|
|
@@ -212,11 +181,8 @@ function flattenObjectByLeafKey(obj, out = {}, depthByKey = {}, depth = 1) {
|
|
|
212
181
|
* element passes validation, and the returned value is an array of the
|
|
213
182
|
* validated elements (in the same order).
|
|
214
183
|
* 5. Optionally converts all keys (including nested) to snake_case.
|
|
215
|
-
* 6. Optionally flattens the result
|
|
216
|
-
*
|
|
217
|
-
* names when requested (`flattenKeyStyle: 'leaf'`). For duplicate leaf
|
|
218
|
-
* keys in leaf mode, the shallower value wins; ties keep the first value
|
|
219
|
-
* encountered. Applied after snake_case conversion.
|
|
184
|
+
* 6. Optionally flattens the result so every leaf is a top-level key,
|
|
185
|
+
* joined by `.` (`flattenOutput`). Applied after snake_case conversion.
|
|
220
186
|
*
|
|
221
187
|
* @param {Object} inputObject - The input object (e.g., req.body / req.params).
|
|
222
188
|
* @param {Array<string>} [requiredProperties=[]] - Leaf paths that MUST be present and valid.
|
|
@@ -224,26 +190,17 @@ function flattenObjectByLeafKey(obj, out = {}, depthByKey = {}, depth = 1) {
|
|
|
224
190
|
* @param {Array<string>} [options.optionalProperties=[]] - Leaf paths allowed but not required.
|
|
225
191
|
* @param {boolean} [options.convertToSnakeCase=false] - Whether to convert keys to snake_case.
|
|
226
192
|
* @param {boolean} [options.flattenOutput=false] - Whether to flatten the result so that
|
|
227
|
-
* every leaf is a top-level key, with no nested objects in the output.
|
|
228
|
-
* @param {'path'|'leaf'} [options.flattenKeyStyle='path'] - Flattened key naming strategy
|
|
229
|
-
* when `flattenOutput` is true. `path` uses dot-joined paths; `leaf` uses leaf names.
|
|
193
|
+
* every leaf is a top-level key (joined by `.`), with no nested objects in the output.
|
|
230
194
|
* @returns {Promise<Object>} Resolves with the validated (and possibly transformed) object.
|
|
231
195
|
*/
|
|
232
196
|
function validateWhitelistProperties(
|
|
233
197
|
inputObject,
|
|
234
198
|
requiredProperties = [],
|
|
235
|
-
options = { optionalProperties: [], convertToSnakeCase: false, flattenOutput: false
|
|
199
|
+
options = { optionalProperties: [], convertToSnakeCase: false, flattenOutput: false },
|
|
236
200
|
) {
|
|
237
201
|
const optionalProperties = (options && options.optionalProperties) || [];
|
|
238
202
|
const convertToSnakeCase = !!(options && options.convertToSnakeCase);
|
|
239
203
|
const flattenOutput = !!(options && options.flattenOutput);
|
|
240
|
-
const flattenKeyStyle = options && options.flattenKeyStyle !== undefined ? options.flattenKeyStyle : DEFAULT_FLATTEN_KEY_STYLE;
|
|
241
|
-
|
|
242
|
-
if (!VALID_FLATTEN_KEY_STYLES.has(flattenKeyStyle)) {
|
|
243
|
-
throwBadInputError({
|
|
244
|
-
userMessage: `Invalid flattenKeyStyle: ${String(flattenKeyStyle)}. Expected "path" or "leaf"`,
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
204
|
|
|
248
205
|
// Cap the total number of paths to validate per call.
|
|
249
206
|
const totalKeys = (requiredProperties ? requiredProperties.length : 0) + optionalProperties.length;
|
|
@@ -314,9 +271,9 @@ function validateWhitelistProperties(
|
|
|
314
271
|
validatedObject = keysToSnakeCase(validatedObject);
|
|
315
272
|
}
|
|
316
273
|
|
|
317
|
-
// 6. Optional flattening.
|
|
274
|
+
// 6. Optional flattening: produce a flat object with dot-joined keys.
|
|
318
275
|
if (flattenOutput) {
|
|
319
|
-
validatedObject =
|
|
276
|
+
validatedObject = flattenObject(validatedObject);
|
|
320
277
|
}
|
|
321
278
|
|
|
322
279
|
return Promise.resolve(validatedObject);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carecard/validate",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.24",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/CareCard-ca/pkg-validate.git"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"test": "mocha --recursive",
|
|
13
13
|
"test:types": "tsc --noEmit && echo \"\\n ✔ Type tests passed: tsc --noEmit reported 0 errors across index.d.ts and test/**/*.ts\\n\"",
|
|
14
14
|
"test:coverage": "tsc --noEmit && export NODE_ENV=test && nyc mocha --recursive",
|
|
15
|
-
"test:All": "npm run test && npm run test:types",
|
|
15
|
+
"test:All": "npm run test:coverage && npm run test:types",
|
|
16
16
|
"format": "prettier --write .",
|
|
17
17
|
"format:check": "prettier --check .",
|
|
18
18
|
"prepare": "husky",
|
|
@@ -39,5 +39,17 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@carecard/common-util": "^3.1.13"
|
|
42
|
+
},
|
|
43
|
+
"nyc": {
|
|
44
|
+
"all": true,
|
|
45
|
+
"include": [
|
|
46
|
+
"index.js",
|
|
47
|
+
"lib/**/*.js"
|
|
48
|
+
],
|
|
49
|
+
"check-coverage": true,
|
|
50
|
+
"branches": 100,
|
|
51
|
+
"functions": 100,
|
|
52
|
+
"lines": 100,
|
|
53
|
+
"statements": 100
|
|
42
54
|
}
|
|
43
55
|
}
|