@carecard/validate 3.1.19 → 3.1.21

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/index.d.ts CHANGED
@@ -13,10 +13,18 @@ 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, joined by `.` (e.g. `{ 'user.first_name': 'Jane' }`).
17
- * No nested objects remain in the output. Applied after snake_case conversion.
16
+ * becomes a top-level key. No nested objects remain in the output. Applied
17
+ * 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';
20
28
  }
21
29
 
22
30
  /**
@@ -38,10 +46,11 @@ export interface ValidateWhitelistPropertiesOptions {
38
46
  * validated elements (e.g. `{ name: ["First", "Other"] }` is validated like
39
47
  * `{ name: "First" }` and `{ name: "Other" }` individually).
40
48
  * - Optionally converts the resulting keys (including nested keys) to snake_case.
49
+ * - Optionally flattens the result after snake_case conversion.
41
50
  *
42
51
  * @param inputObject The input object (e.g. `req.body` or `req.params`).
43
52
  * @param requiredProperties Leaf paths that must be present and valid. Dot-notation supported.
44
- * @param options Optional list of additional allowed leaf paths and case-conversion flag.
53
+ * @param options Optional additional leaf paths plus output transformation flags.
45
54
  */
46
55
  export function validateWhitelistProperties(
47
56
  inputObject: Record<string, any>,
@@ -16,6 +16,8 @@ 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']);
19
21
 
20
22
  /**
21
23
  * Returns true if the segment contains a mix of snake_case (underscore) and
@@ -161,6 +163,35 @@ function flattenObject(obj, prefix = '', out = {}) {
161
163
  return out;
162
164
  }
163
165
 
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
+
164
195
  /**
165
196
  * Validates and transforms whitelisted properties from an input object.
166
197
  *
@@ -181,8 +212,11 @@ function flattenObject(obj, prefix = '', out = {}) {
181
212
  * element passes validation, and the returned value is an array of the
182
213
  * validated elements (in the same order).
183
214
  * 5. Optionally converts all keys (including nested) to snake_case.
184
- * 6. Optionally flattens the result so every leaf is a top-level key,
185
- * joined by `.` (`flattenOutput`). Applied after snake_case conversion.
215
+ * 6. Optionally flattens the result (`flattenOutput`). Flattened keys use
216
+ * full dot paths by default (`flattenKeyStyle: 'path'`) or direct leaf
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.
186
220
  *
187
221
  * @param {Object} inputObject - The input object (e.g., req.body / req.params).
188
222
  * @param {Array<string>} [requiredProperties=[]] - Leaf paths that MUST be present and valid.
@@ -190,17 +224,26 @@ function flattenObject(obj, prefix = '', out = {}) {
190
224
  * @param {Array<string>} [options.optionalProperties=[]] - Leaf paths allowed but not required.
191
225
  * @param {boolean} [options.convertToSnakeCase=false] - Whether to convert keys to snake_case.
192
226
  * @param {boolean} [options.flattenOutput=false] - Whether to flatten the result so that
193
- * every leaf is a top-level key (joined by `.`), with no nested objects in the output.
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.
194
230
  * @returns {Promise<Object>} Resolves with the validated (and possibly transformed) object.
195
231
  */
196
232
  function validateWhitelistProperties(
197
233
  inputObject,
198
234
  requiredProperties = [],
199
- options = { optionalProperties: [], convertToSnakeCase: false, flattenOutput: false },
235
+ options = { optionalProperties: [], convertToSnakeCase: false, flattenOutput: false, flattenKeyStyle: DEFAULT_FLATTEN_KEY_STYLE },
200
236
  ) {
201
237
  const optionalProperties = (options && options.optionalProperties) || [];
202
238
  const convertToSnakeCase = !!(options && options.convertToSnakeCase);
203
239
  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
+ }
204
247
 
205
248
  // Cap the total number of paths to validate per call.
206
249
  const totalKeys = (requiredProperties ? requiredProperties.length : 0) + optionalProperties.length;
@@ -271,9 +314,9 @@ function validateWhitelistProperties(
271
314
  validatedObject = keysToSnakeCase(validatedObject);
272
315
  }
273
316
 
274
- // 6. Optional flattening: produce a flat object with dot-joined keys.
317
+ // 6. Optional flattening.
275
318
  if (flattenOutput) {
276
- validatedObject = flattenObject(validatedObject);
319
+ validatedObject = flattenKeyStyle === 'leaf' ? flattenObjectByLeafKey(validatedObject) : flattenObject(validatedObject);
277
320
  }
278
321
 
279
322
  return Promise.resolve(validatedObject);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carecard/validate",
3
- "version": "3.1.19",
3
+ "version": "3.1.21",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/CareCard-ca/pkg-validate.git"
package/readme.md CHANGED
@@ -1,29 +1,363 @@
1
- #Validate
1
+ # @carecard/validate
2
2
 
3
- ### Functions
3
+ `@carecard/validate` is a small CommonJS validation package for CareCard
4
+ services. It exposes individual value validators, a bulk property sanitizer, and
5
+ a whitelist validator for request-like payloads.
4
6
 
5
- All functions return boolean value, false on failure
7
+ The package returns booleans from the low-level validators, strips unknown or
8
+ invalid values from `validateProperties`, and throws CareCard `BAD_INPUT` errors
9
+ from `validateWhitelistProperties` when required or provided whitelist values do
10
+ not pass validation.
6
11
 
7
- - Validates that a string is string of characters
12
+ ## Installation
13
+
14
+ ```sh
15
+ npm install @carecard/validate
16
+ ```
17
+
18
+ ## Importing
19
+
20
+ ```js
21
+ const { validate, validateProperties, validateWhitelistProperties, isEmailString, isValidUuidString } = require('@carecard/validate');
22
+ ```
23
+
24
+ The validators are available both as top-level exports and under the deprecated
25
+ `validate` namespace.
26
+
27
+ ```js
28
+ isEmailString('jane@example.com'); // true
29
+ validate.isEmailString('jane@example.com'); // true
30
+ ```
31
+
32
+ ## Direct Validators
33
+
34
+ Every direct validator returns `true` or `false`. Failure message helpers return
35
+ a string on failure and `null` on success.
36
+
37
+ | Function | Accepted value |
38
+ | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
39
+ | `isImageUrl(value)` | Non-empty string up to 2048 chars using letters, numbers, `-`, `_`, `.`, and `/`. Intended for safe image/file paths. |
40
+ | `isInteger(value)` | JavaScript integer number. String numbers are rejected. |
41
+ | `isValidJsonString(value)` | Non-empty string up to 10000 chars that parses to a non-null JSON object or array. JSON primitives are rejected. |
42
+ | `isValidIntegerString(value)` | Digit-only string, 1 to 20 chars. No signs or decimals. |
43
+ | `isValidUuidString(value)` | Canonical UUID string in `8-4-4-4-12` format, case-insensitive. |
44
+ | `isCharactersString(value)` | 1 to 1000 chars containing letters, numbers, spaces, `_`, or `-`. |
45
+ | `isNameString(value)` | 1 to 1000 char string that starts with a letter and uses letters, numbers, spaces, `_`, `-`, `.`, `,`, `'`, `(`, or `)`. Leading/trailing spaces are trimmed before pattern validation. |
46
+ | `isSafeSearchString(value)` | Trimmed string that starts with a letter and then uses letters, numbers, spaces, `_`, `-`, `.`, `,`, `'`, `(`, `)`, or `@`. |
47
+ | `isEmailString(value)` | Email-like string up to 320 chars using the package email regex. |
48
+ | `isJwtString(value)` | Non-blank JWT-like string up to 8192 chars that starts with `eyJ` and contains only letters, numbers, `-`, `_`, and `.`. |
49
+ | `isPasswordString(value)` | 6 to 32 chars from letters, numbers, and `!@#$%^&*_-`, with at least one alphanumeric char and one listed special char. |
50
+ | `isSimplePasswordString(value)` | 6 to 32 chars from letters, numbers, and `!@#$%^&*_-`. |
51
+ | `isPasswordStringFailureMessage(value)` | `null` when `isPasswordString` passes, otherwise a human-readable failure message. |
52
+ | `isSimplePasswordStringFailureMessage(value)` | `null` when `isSimplePasswordString` passes, otherwise a human-readable failure message. |
53
+ | `isUsernameString(value)` | 1 to 200 alphanumeric chars. |
54
+ | `isPhoneNumber(value)` | North American 10-digit phone number with optional parentheses around the area code and optional space, `-`, or `.` separators. |
55
+ | `isUrlSafeString(value)` | Non-blank string up to 2048 chars using letters, numbers, `-`, `_`, and `.`. |
56
+ | `isString6To24CharacterLong(value)` | String with length from 6 to 24. |
57
+ | `isString6To16CharacterLong(value)` | String with length from 6 to 16. |
58
+ | `isProvinceString(value)` | `ON` or `QC`, case-insensitive. |
59
+ | `isBoolValue(value)` | Boolean `true`/`false` or strings `"true"`/`"false"`. |
60
+ | `isPostalCodeString(value)` | Canadian postal code format, case-insensitive, with optional middle space. |
61
+ | `isSafeString(value)` | 1 to 10000 chars using letters, numbers, spaces, `-`, `_`, `.`, `,`, `#`, `*`, `'`, `(`, `)`, `[`, `]`, or `:`. |
62
+ | `isInStringArray(array, value)` | `value`, after lowercase/trim validation as a name string, is included in the supplied array. |
63
+ | `isCountryCodeString(value)` | Country dialing code in `+1` to `+999` format. |
64
+ | `isValidDomainName(value)` | Domain name with at least one dot, valid DNS-like labels, and max total length 253. |
65
+ | `isValidTimestampzString(value)` | ISO 8601 timestamp with `Z` or `+/-HH:MM` timezone offset. |
66
+ | `isValidTimestampString(value)` | ISO 8601 timestamp without timezone offset. |
67
+ | `isValidUrl(value)` | Absolute `http://` or `https://` URL up to 2048 chars. |
68
+ | `isValidArrayOfStrings(value)` | Array where every element passes `isSafeString`. |
69
+
70
+ ## `validateProperties(obj)`
71
+
72
+ `validateProperties` accepts an object and returns a new object that contains
73
+ only recognized keys whose values pass the validator assigned to that key.
74
+ Unknown keys and invalid values are silently omitted. `null`, `undefined`, or no
75
+ argument returns `{}`.
76
+
77
+ ```js
78
+ const input = {
79
+ first_name: 'Jane',
80
+ email: 'jane@example.com',
81
+ phone_number: '123',
82
+ unknown_key: 'ignored',
83
+ };
84
+
85
+ validateProperties(input);
86
+ // {
87
+ // first_name: 'Jane',
88
+ // email: 'jane@example.com'
89
+ // }
90
+ ```
91
+
92
+ ### Supported Property Keys
93
+
94
+ Keys are matched exactly. Both snake_case and camelCase variants are listed
95
+ where the package supports both.
96
+
97
+ | Validator | Keys |
98
+ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
99
+ | `isNameString` | `first_name`, `firstName`, `last_name`, `lastName`, `username`, `new_status`, `newStatus`, `description`, `comment`, `status`, `name`, `title`, `brand`, `short_description`, `shortDescription`, `college_name`, `collegeName`, `campus_name`, `campusName`, `role`, `role_id`, `roleId`, `campus`, `institution_name`, `institutionName`, `program_name`, `programName`, `role_name`, `roleName`, `document_type`, `documentType`, `reason`, `street`, `city`, `state`, `country`, `type` |
100
+ | `isCharactersString` | `postal_code`, `postalCode`, `period` |
101
+ | `isBoolValue` | `is_primary`, `isPrimary`, `active` |
102
+ | `isSafeSearchString` | `search_string`, `searchString` |
103
+ | `isString6To16CharacterLong` and `isSimplePasswordString` | `password`, `new_password`, `newPassword` |
104
+ | `isString6To16CharacterLong` and `isPasswordString` | `strong_password`, `strongPassword` |
105
+ | `isEmailString` | `email` |
106
+ | `isPhoneNumber` | `phone_number`, `phoneNumber` |
107
+ | `isUrlSafeString` | `token`, `email_confirm_token`, `emailConfirmToken`, `verification_token`, `verificationToken` |
108
+ | `isValidUuidString` | `uuid`, `item_id`, `itemId`, `user_id`, `userId`, `address_id`, `addressId`, `image_id`, `imageId`, `order_id`, `orderId`, `category_id`, `categoryId`, `parent_id`, `parentId`, `college_id`, `collegeId`, `campus_id`, `campusId`, `program_id`, `programId`, `id`, `institution_id`, `institutionId`, `role_assignment_id`, `roleAssignmentId`, `user_role_id`, `userRoleId`, `phone_number_id`, `phoneNumberId` |
109
+ | `isValidIntegerString` | `offset_number`, `offsetNumber`, `number_of_orders`, `numberOfOrders`, `price`, `from`, `number` |
110
+ | `isValidJsonString` on the raw value | `about` |
111
+ | `isValidJsonString(JSON.stringify(value))` | `weight`, `dimensions`, `permission`, `scope_data`, `scopeData`, `meta_data`, `metaData` |
112
+ | `isValidArrayOfStrings` | `aliases` |
113
+ | `isImageUrl` or `isValidUrl` | `image_url`, `imageUrl`, `website`, `file_url`, `fileUrl` |
114
+ | `isValidDomainName` | `domain_name`, `domainName`, `domain`, `email_domain`, `emailDomain`, `email_domain_name`, `emailDomainName` |
115
+ | `isValidTimestampzString` or `isValidTimestampString` | `expires_at`, `expiresAt` |
116
+
117
+ ## `validateWhitelistProperties(inputObject, requiredProperties, options)`
118
+
119
+ `validateWhitelistProperties` extracts only the required and optional property
120
+ paths you provide, validates each leaf through `validateProperties`, and returns
121
+ a `Promise<Record<string, any>>` with the sanitized output.
122
+
123
+ ```js
124
+ const body = {
125
+ first_name: 'Jane',
126
+ email: 'jane@example.com',
127
+ role: 'Admin',
128
+ extra: '<script>',
129
+ };
130
+
131
+ const out = await validateWhitelistProperties(body, ['first_name', 'email'], {
132
+ optionalProperties: ['role'],
133
+ });
134
+
135
+ // {
136
+ // first_name: 'Jane',
137
+ // email: 'jane@example.com',
138
+ // role: 'Admin'
139
+ // }
140
+ ```
141
+
142
+ ### Defaults
143
+
144
+ When omitted, `requiredProperties` defaults to `[]` and `options` defaults to:
145
+
146
+ ```js
147
+ {
148
+ optionalProperties: [],
149
+ convertToSnakeCase: false,
150
+ flattenOutput: false,
151
+ flattenKeyStyle: 'path',
152
+ }
153
+ ```
154
+
155
+ The default output preserves the nested shape described by whitelisted dot paths.
156
+ `flattenKeyStyle` only changes output when `flattenOutput` is `true`.
157
+
158
+ ```js
159
+ const input = {
160
+ user: {
161
+ first_name: 'Jane',
162
+ contact: { email: 'jane@example.com' },
163
+ },
164
+ };
165
+
166
+ await validateWhitelistProperties(input, ['user.first_name', 'user.contact.email']);
167
+ // {
168
+ // user: {
169
+ // first_name: 'Jane',
170
+ // contact: { email: 'jane@example.com' }
171
+ // }
172
+ // }
173
+ ```
174
+
175
+ ### Options
176
+
177
+ | Option | Default | Behavior |
178
+ | -------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
179
+ | `optionalProperties` | `[]` | Additional property paths that may be present. Absent optional paths are ignored. Present optional paths must be valid. |
180
+ | `convertToSnakeCase` | `false` | When `true`, converts returned keys, including nested keys, to snake_case using `@carecard/common-util`. Conversion happens before flattening. |
181
+ | `flattenOutput` | `false` | When `true`, removes nested objects from the returned value so every validated leaf becomes a top-level key. |
182
+ | `flattenKeyStyle` | `'path'` | Controls flattened key naming when `flattenOutput` is `true`. Use `'path'` for full dot-notation keys or `'leaf'` for direct leaf names. Invalid values throw a `BAD_INPUT` error. |
183
+
184
+ Example with only the default options:
185
+
186
+ ```js
187
+ await validateWhitelistProperties({ first_name: 'Jane', email: 'jane@example.com', ignored: 'x' }, ['first_name']);
188
+ // { first_name: 'Jane' }
189
+ ```
190
+
191
+ Example with optional properties:
192
+
193
+ ```js
194
+ await validateWhitelistProperties({ first_name: 'Jane', phone_number: '4165551234' }, ['first_name'], {
195
+ optionalProperties: ['phone_number'],
196
+ });
197
+ // { first_name: 'Jane', phone_number: '4165551234' }
198
+ ```
199
+
200
+ ### Required And Optional Values
201
+
202
+ Required paths must exist and pass validation. Missing or invalid required paths
203
+ throw a CareCard bad input error.
8
204
 
9
205
  ```js
10
- isStringOfCharacters(str);
206
+ await validateWhitelistProperties({ email: 'bad' }, ['email']);
207
+ // throws/rejects with:
208
+ // {
209
+ // code: 'BAD_INPUT',
210
+ // message: 'Bad_Input',
211
+ // userMessage: 'Missing or invalid property: email'
212
+ // }
11
213
  ```
12
214
 
13
- - Validates that a string is fit for username
215
+ Optional paths are ignored when absent, but invalid when present.
14
216
 
15
217
  ```js
16
- isStringOfUsername(str);
218
+ await validateWhitelistProperties({ first_name: 'Jane', email: 'bad' }, ['first_name'], {
219
+ optionalProperties: ['email'],
220
+ });
221
+ // userMessage: 'Invalid property value: email'
17
222
  ```
18
223
 
19
- - Validates that string is an email
224
+ ### Nested Paths
225
+
226
+ Use dot notation for nested objects. The leaf key decides which validator is
227
+ used.
20
228
 
21
229
  ```js
22
- isEmail(email);
230
+ const out = await validateWhitelistProperties(
231
+ {
232
+ user: {
233
+ first_name: 'Jane',
234
+ contact: { email: 'jane@example.com', ignored: 'x' },
235
+ },
236
+ },
237
+ ['user.first_name', 'user.contact.email'],
238
+ );
239
+
240
+ // {
241
+ // user: {
242
+ // first_name: 'Jane',
243
+ // contact: { email: 'jane@example.com' }
244
+ // }
245
+ // }
23
246
  ```
24
247
 
25
- - Validates that string is fit for password
248
+ Nested paths support up to 5 segments. The combined count of required and
249
+ optional paths must be 5000 or fewer.
250
+
251
+ ### Arrays
252
+
253
+ If a whitelisted leaf value is an array, each element is validated as if it were
254
+ the scalar value for that same leaf key. The array is accepted only when every
255
+ element passes. Empty arrays are accepted.
26
256
 
27
257
  ```js
28
- isStringOfPassword(password);
258
+ await validateWhitelistProperties({ email: ['a@example.com', 'b@example.com'] }, ['email']);
259
+ // { email: ['a@example.com', 'b@example.com'] }
29
260
  ```
261
+
262
+ This array behavior is intended for repeated scalar fields such as `email` or
263
+ `name`.
264
+
265
+ ### Case Conversion And Flattening
266
+
267
+ ```js
268
+ const out = await validateWhitelistProperties({ userInfo: { firstName: 'Jane', phoneNumber: '4165551234' } }, ['userInfo.firstName'], {
269
+ optionalProperties: ['userInfo.phoneNumber'],
270
+ convertToSnakeCase: true,
271
+ flattenOutput: true,
272
+ });
273
+
274
+ // {
275
+ // 'user_info.first_name': 'Jane',
276
+ // 'user_info.phone_number': '4165551234'
277
+ // }
278
+ ```
279
+
280
+ With `flattenOutput: false` or no `flattenOutput` option, nested paths keep the
281
+ nested output shape:
282
+
283
+ ```js
284
+ const input = { a: { b: { c: { d: { email: 'jane@example.com' } } } } };
285
+ await validateWhitelistProperties(input, ['a.b.c.d.email']);
286
+ // { a: { b: { c: { d: { email: 'jane@example.com' } } } } }
287
+ ```
288
+
289
+ With `flattenOutput: true`, keys are full dot paths by default:
290
+
291
+ ```js
292
+ const input = { a: { b: { c: { d: { email: 'jane@example.com', name: 'Jane' } } } } };
293
+ await validateWhitelistProperties(input, ['a.b.c.d.email', 'a.b.c.d.name'], {
294
+ flattenOutput: true,
295
+ });
296
+ // { 'a.b.c.d.email': 'jane@example.com', 'a.b.c.d.name': 'Jane' }
297
+ ```
298
+
299
+ Use `flattenKeyStyle: 'leaf'` to return top-level leaf keys instead:
300
+
301
+ ```js
302
+ const input = { a: { b: { c: { d: { email: 'jane@example.com', name: 'Jane' } } } } };
303
+ await validateWhitelistProperties(input, ['a.b.c.d.email', 'a.b.c.d.name'], {
304
+ flattenOutput: true,
305
+ flattenKeyStyle: 'leaf',
306
+ });
307
+ // { email: 'jane@example.com', name: 'Jane' }
308
+ ```
309
+
310
+ When `flattenKeyStyle: 'leaf'` produces duplicate keys at different nesting
311
+ levels, the higher-level value is kept and the lower-level duplicate is
312
+ discarded. Duplicate leaf keys at the same nesting depth keep the first value
313
+ encountered:
314
+
315
+ ```js
316
+ const input = {
317
+ name: 'Top Level Name',
318
+ user: { name: 'Nested Name', email: 'jane@example.com' },
319
+ };
320
+
321
+ await validateWhitelistProperties(input, ['name', 'user.name', 'user.email'], {
322
+ flattenOutput: true,
323
+ flattenKeyStyle: 'leaf',
324
+ });
325
+ // { name: 'Top Level Name', email: 'jane@example.com' }
326
+ ```
327
+
328
+ ## TypeScript
329
+
330
+ The package ships `index.d.ts` and declares types for the CommonJS exports.
331
+
332
+ ```ts
333
+ import { validateWhitelistProperties, isEmailString } from '@carecard/validate';
334
+
335
+ const valid: boolean = isEmailString('jane@example.com');
336
+ const output: Record<string, any> = await validateWhitelistProperties({ first_name: 'Jane' }, ['first_name']);
337
+ ```
338
+
339
+ ## Project Layout
340
+
341
+ ```text
342
+ index.js CommonJS public entry point
343
+ index.d.ts TypeScript declarations
344
+ lib/validate.js Direct value validators
345
+ lib/validateProperties.js Key-to-validator sanitizer
346
+ lib/validateWhitelistProperties.js Required/optional whitelist validator
347
+ test/*.test.js Mocha runtime tests
348
+ test/types.test.ts TypeScript declaration tests
349
+ ```
350
+
351
+ ## Development
352
+
353
+ ```sh
354
+ npm ci
355
+ npm run test
356
+ npm run test:types
357
+ npm run test:All
358
+ npm run lint
359
+ npm run format:check
360
+ ```
361
+
362
+ CI runs on Node.js 25 and executes `npm run test:All`. Publishing to npm happens
363
+ from `main` through the `Publish to npm` GitHub workflow.