@carecard/validate 3.38.0 → 3.39.0

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.
@@ -130,6 +130,16 @@ depend on those folders being present.
130
130
  such as `:reviewed:`. Do not use reaction-token syntax to relax human text.
131
131
  - General low-level validators should be deterministic predicate functions that
132
132
  return `true` or `false`.
133
+ - Use the direct field-specific predicates from `lib/inputValidation.js` only
134
+ for the matching contract. Keep existing stricter character rules and
135
+ source-owned lengths, requiredness and nullability. Validate raw input before
136
+ trimming; multiline fields may accept LF and CRLF, never standalone CR.
137
+ - Use `isHttpUrlString`, `isMimeTypeString`, `isIdempotencyKeyString` and
138
+ `isNonNegativeSafeInteger` at their structured boundaries. Do not parse a
139
+ numeric prefix or rely on a URL parser to discard unsafe raw characters.
140
+ - Keep extensible metadata as safe JSON data through `isSafeJsonValue` or
141
+ `isSafeJsonString`. Reconstructed and flattened whitelist properties must be
142
+ own data, including reserved-looking keys; never follow inherited paths.
133
143
  - `isEmailString` is the sole CareCard email-format decision. It accepts an
134
144
  unmodified ASCII address no longer than 254 characters, with one practical
135
145
  dot-atom local part no longer than 64 characters and a valid multi-label
package/index.d.ts CHANGED
@@ -8,6 +8,27 @@ export type ValidatePropertiesResult = Record<string, unknown>;
8
8
  export type BoolValidator = (input: unknown) => boolean;
9
9
  export type InStringArrayValidator = (stringArray: readonly string[], input: unknown) => boolean;
10
10
 
11
+ /** Checks raw and NFKC single-line human text characters; callers own lengths and requiredness. */
12
+ export const isSingleLineTextString: BoolValidator;
13
+ /** Checks the human text character allowlist with LF or CRLF line endings. */
14
+ export const isMultilineTextString: BoolValidator;
15
+ /** Checks an unmodified HTTP(S) URL without whitespace, controls, backslashes or angle brackets. */
16
+ export const isHttpUrlString: BoolValidator;
17
+ /** Checks a concrete MIME type and optional token/quoted parameters. */
18
+ export const isMimeTypeString: BoolValidator;
19
+ /** Checks an ASCII idempotency key of 1 through 128 code units. */
20
+ export const isIdempotencyKeyString: BoolValidator;
21
+ /** Checks a nonnegative safe integer number or complete decimal integer string. */
22
+ export const isNonNegativeSafeInteger: BoolValidator;
23
+ /** Checks a version-four UUID with an RFC variant. */
24
+ export const isValidUuidV4String: BoolValidator;
25
+ /** Checks an extensible JSON object/array and nested text, with a 10000-code-unit serialized budget. */
26
+ export const isSafeJsonValue: BoolValidator;
27
+ /** Checks serialized JSON metadata without altering its representation or original length budget. */
28
+ export const isSafeJsonString: BoolValidator;
29
+ /** Checks single-line documentation search text, including printable code punctuation. */
30
+ export const isDocumentationSearchString: BoolValidator;
31
+
11
32
  export type PasswordValidationFailureReason =
12
33
  'invalid_type' | 'invalid_unicode' | 'too_short' | 'too_long' | 'blocked';
13
34
  export type PasswordValidationResult =
package/index.js CHANGED
@@ -3,6 +3,7 @@ const { validatePassword, ...legacyValidate } = validate;
3
3
  const validateProperties = require('./lib/validateProperties');
4
4
  const validateWhitelistProperties = require('./lib/validateWhitelistProperties');
5
5
  const validateNewUserRoleRequest = require('./lib/validateNewUserRoleRequest');
6
+ const inputValidation = require('./lib/inputValidation');
6
7
 
7
8
  module.exports = {
8
9
  validate: legacyValidate,
@@ -12,4 +13,5 @@ module.exports = {
12
13
  ...validateNewUserRoleRequest,
13
14
  ...legacyValidate,
14
15
  ...validateProperties,
16
+ ...inputValidation,
15
17
  };
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+
3
+ const { isValidIntegerString, isValidJsonString } = require('./validate');
4
+
5
+ const SINGLE_LINE_TEXT =
6
+ /^[\p{L}\p{M}\p{N}\p{Zs}\p{Pd}\p{Sc}\p{Extended_Pictographic}\p{Emoji_Presentation}\p{Emoji_Modifier}_.,'’‘“”"():;!?/@&#+%=[\]{}*\u200c\u200d]*$/u;
7
+ const DOCUMENTATION_SEARCH =
8
+ /^[\x20-\x3b\x3d\x3f-\x7e\p{L}\p{M}\p{N}\p{Zs}\p{Pd}\p{Sc}\p{Extended_Pictographic}\p{Emoji_Presentation}\p{Emoji_Modifier}\u200c\u200d]*$/u;
9
+ const HTTP_URL_CHARACTERS =
10
+ /^[-\p{L}\p{M}\p{N}\p{Pd}\p{Sc}\p{Extended_Pictographic}\p{Emoji_Presentation}\p{Emoji_Modifier}._~:/?#[\]@!$&'()*+,;=%\u200c\u200d]+$/u;
11
+ const MIME_NAME = '[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]{0,126}';
12
+ const MIME_TOKEN = "[!#$%&'*+.^_`|~0-9A-Za-z-]+";
13
+ const MIME_QUOTED_VALUE =
14
+ '"(?:[\\x20\\x21\\x23-\\x3b\\x3d\\x3f-\\x5b\\x5d-\\x7e]|\\\\[\\x20-\\x3b\\x3d\\x3f-\\x7e])*"';
15
+ const MIME_TYPE = new RegExp(
16
+ `^${MIME_NAME}/${MIME_NAME}(?: *; *${MIME_NAME}=(?:${MIME_TOKEN}|${MIME_QUOTED_VALUE}))*$`,
17
+ );
18
+
19
+ function hasRawAndNormalizedCharacters(value, allowlist) {
20
+ if (typeof value !== 'string' || !value.isWellFormed()) {
21
+ return false;
22
+ }
23
+ const rawCharacters = value.replace(
24
+ /[\u2024-\u2026\u207d\u207e\u208d\u208e\ufe10-\ufe19\ufe30-\ufe36\ufe4d-\ufe52\ufe54-\ufe5e\ufe60-\ufe61\ufe68-\ufe6b\uff01-\uff02\uff03-\uff0b\uff0c-\uff0f\uff1a-\uff1b\uff1d\uff1f-\uff20\uff3b\uff3d\uff3f\uff5b\uff5d]/gu,
25
+ '',
26
+ );
27
+ return allowlist.test(rawCharacters) && allowlist.test(value.normalize('NFKC'));
28
+ }
29
+
30
+ /** Character validation only; the owning field defines length and requiredness. */
31
+ function isSingleLineTextString(value) {
32
+ return hasRawAndNormalizedCharacters(value, SINGLE_LINE_TEXT);
33
+ }
34
+
35
+ /** Allows LF and CRLF line endings, while rejecting standalone CR. */
36
+ function isMultilineTextString(value) {
37
+ return (
38
+ typeof value === 'string' &&
39
+ value.isWellFormed() &&
40
+ isSingleLineTextString(value.replace(/\r\n/g, '\n').replace(/\n/g, ''))
41
+ );
42
+ }
43
+
44
+ function isDocumentationSearchString(value) {
45
+ return hasRawAndNormalizedCharacters(value, DOCUMENTATION_SEARCH);
46
+ }
47
+
48
+ function isHttpUrlString(value) {
49
+ if (!hasRawAndNormalizedCharacters(value, HTTP_URL_CHARACTERS)) {
50
+ return false;
51
+ }
52
+ try {
53
+ const url = new URL(value);
54
+ return url.protocol === 'https:' || url.protocol === 'http:';
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function isMimeTypeString(value) {
61
+ return (
62
+ typeof value === 'string' && /^[\x20-\x3b\x3d\x3f-\x7e]+$/.test(value) && MIME_TYPE.test(value)
63
+ );
64
+ }
65
+
66
+ function isIdempotencyKeyString(value) {
67
+ return typeof value === 'string' && value.length <= 128 && /^[A-Za-z0-9_.:-]+$/.test(value);
68
+ }
69
+
70
+ function isNonNegativeSafeInteger(value) {
71
+ if (typeof value === 'number') {
72
+ return Number.isSafeInteger(value) && value >= 0;
73
+ }
74
+ return isValidIntegerString(value) && Number.isSafeInteger(Number(value));
75
+ }
76
+
77
+ function isValidUuidV4String(value) {
78
+ return (
79
+ typeof value === 'string' &&
80
+ value.length === 36 &&
81
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
82
+ );
83
+ }
84
+
85
+ function isJsonContainer(value) {
86
+ return (
87
+ value !== null &&
88
+ typeof value === 'object' &&
89
+ (Array.isArray(value) ||
90
+ Object.getPrototypeOf(value) === Object.prototype ||
91
+ Object.getPrototypeOf(value) === null)
92
+ );
93
+ }
94
+
95
+ function isSafeJsonScalar(value) {
96
+ return (
97
+ value === null ||
98
+ typeof value === 'boolean' ||
99
+ (typeof value === 'number' && Number.isFinite(value)) ||
100
+ (typeof value === 'string' && isMultilineTextString(value))
101
+ );
102
+ }
103
+
104
+ function appendSafeJsonProperties(value, pending) {
105
+ for (const [key, property] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
106
+ if (!property.enumerable) {
107
+ continue;
108
+ }
109
+ if (!isSingleLineTextString(key) || !Object.hasOwn(property, 'value')) {
110
+ return false;
111
+ }
112
+ pending.push({ value: property.value, finished: false });
113
+ }
114
+ return Object.getOwnPropertySymbols(value).length === 0;
115
+ }
116
+
117
+ function hasSafeJsonTree(root) {
118
+ const ancestors = new Set();
119
+ const pending = [{ value: root, finished: false }];
120
+ while (pending.length > 0) {
121
+ const { value, finished } = pending.pop();
122
+ if (finished) {
123
+ ancestors.delete(value);
124
+ } else if (!isSafeJsonScalar(value)) {
125
+ if (!isJsonContainer(value) || ancestors.has(value)) {
126
+ return false;
127
+ }
128
+ ancestors.add(value);
129
+ pending.push({ value, finished: true });
130
+ if (!appendSafeJsonProperties(value, pending)) {
131
+ return false;
132
+ }
133
+ }
134
+ }
135
+ return true;
136
+ }
137
+
138
+ /** Extensible JSON object/array, with the existing 10000-code-unit metadata budget. */
139
+ function isSafeJsonValue(value) {
140
+ try {
141
+ return (
142
+ isJsonContainer(value) && hasSafeJsonTree(value) && JSON.stringify(value).length <= 10000
143
+ );
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ function isSafeJsonString(value) {
150
+ return isValidJsonString(value) && isSafeJsonValue(JSON.parse(value));
151
+ }
152
+
153
+ module.exports = {
154
+ isSingleLineTextString,
155
+ isMultilineTextString,
156
+ isHttpUrlString,
157
+ isMimeTypeString,
158
+ isIdempotencyKeyString,
159
+ isNonNegativeSafeInteger,
160
+ isValidUuidV4String,
161
+ isSafeJsonValue,
162
+ isSafeJsonString,
163
+ isDocumentationSearchString,
164
+ };
@@ -11,7 +11,6 @@ const {
11
11
  isFileNameString,
12
12
  isCharactersString,
13
13
  isValidIntegerString,
14
- isValidJsonString,
15
14
  isImageUrl,
16
15
  isValidDomainName,
17
16
  isValidTimestampzString,
@@ -26,6 +25,12 @@ const {
26
25
  isUserRoleRequestRoleString,
27
26
  isCountryCodeString,
28
27
  } = require('./validate');
28
+ const {
29
+ isSingleLineTextString,
30
+ isHttpUrlString,
31
+ isSafeJsonValue,
32
+ isSafeJsonString,
33
+ } = require('./inputValidation');
29
34
 
30
35
  // Pattern: Boundary Validation - preserves field-specific formats while omitting invalid properties.
31
36
  function validateProperties(obj = {}) {
@@ -101,7 +106,7 @@ function validateProperties(obj = {}) {
101
106
  }
102
107
  break;
103
108
  case 'street':
104
- if (isStreetString(value)) {
109
+ if (isStreetString(value) && isSingleLineTextString(value)) {
105
110
  returnObj[key] = value;
106
111
  }
107
112
  break;
@@ -271,7 +276,7 @@ function validateProperties(obj = {}) {
271
276
  }
272
277
  break;
273
278
  case 'about':
274
- if (isValidJsonString(value)) {
279
+ if (isSafeJsonString(value)) {
275
280
  returnObj[key] = value;
276
281
  }
277
282
  break;
@@ -283,7 +288,7 @@ function validateProperties(obj = {}) {
283
288
  case 'meta_data':
284
289
  case 'metaData':
285
290
  case 'metadata':
286
- if (isValidJsonString(JSON.stringify(value))) {
291
+ if (isSafeJsonValue(value)) {
287
292
  returnObj[key] = value;
288
293
  }
289
294
  break;
@@ -297,7 +302,7 @@ function validateProperties(obj = {}) {
297
302
  case 'website':
298
303
  case 'file_url':
299
304
  case 'fileUrl':
300
- if (isImageUrl(value) || isValidUrl(value)) {
305
+ if (isImageUrl(value) || (isValidUrl(value) && isHttpUrlString(value))) {
301
306
  returnObj[key] = value;
302
307
  }
303
308
  break;
@@ -142,16 +142,25 @@ function readLeaf(obj, segments) {
142
142
  * @param {string[]} segments
143
143
  * @param {*} value
144
144
  */
145
+ function defineWhitelistDataProperty(target, key, value) {
146
+ Object.defineProperty(target, key, {
147
+ value,
148
+ enumerable: true,
149
+ writable: true,
150
+ configurable: true,
151
+ });
152
+ }
153
+
145
154
  function writeLeaf(target, segments, value) {
146
155
  let current = target;
147
156
  for (let i = 0; i < segments.length - 1; i++) {
148
157
  const key = segments[i];
149
- if (current[key] === null || typeof current[key] !== 'object') {
150
- current[key] = {};
158
+ if (!Object.hasOwn(current, key) || current[key] === null || typeof current[key] !== 'object') {
159
+ defineWhitelistDataProperty(current, key, {});
151
160
  }
152
161
  current = current[key];
153
162
  }
154
- current[segments[segments.length - 1]] = value;
163
+ defineWhitelistDataProperty(current, segments[segments.length - 1], value);
155
164
  }
156
165
 
157
166
  /**
@@ -176,7 +185,7 @@ function flattenObject(obj, prefix = '', out = {}) {
176
185
  ) {
177
186
  flattenObject(value, path, out);
178
187
  } else {
179
- out[path] = value;
188
+ defineWhitelistDataProperty(out, path, value);
180
189
  }
181
190
  }
182
191
  return out;
@@ -208,8 +217,8 @@ function flattenObjectByLeafKey(obj, out = {}, depthByKey = {}, depth = 1) {
208
217
  flattenObjectByLeafKey(value, out, depthByKey, depth + 1);
209
218
  } else {
210
219
  if (!Object.prototype.hasOwnProperty.call(out, key) || depth < depthByKey[key]) {
211
- out[key] = value;
212
- depthByKey[key] = depth;
220
+ defineWhitelistDataProperty(out, key, value);
221
+ defineWhitelistDataProperty(depthByKey, key, depth);
213
222
  }
214
223
  }
215
224
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carecard/validate",
3
- "version": "3.38.0",
3
+ "version": "3.39.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/CareCard-ca/pkg-validate.git"
@@ -47,7 +47,7 @@
47
47
  "typescript": "6.0.3"
48
48
  },
49
49
  "dependencies": {
50
- "@carecard/common-util": "3.38.0"
50
+ "@carecard/common-util": "3.39.0"
51
51
  },
52
52
  "nyc": {
53
53
  "all": true,
package/readme.md CHANGED
@@ -64,9 +64,9 @@ const {
64
64
  } = require('@carecard/validate');
65
65
  ```
66
66
 
67
- General validators are available both as top-level exports and under the
68
- deprecated `validate` namespace. `validatePassword` is intentionally available
69
- only as one direct export.
67
+ The original validators are available both as top-level exports and under the
68
+ deprecated `validate` namespace. New field-specific validators and
69
+ `validatePassword` use direct exports.
70
70
 
71
71
  The package root is safe to import from browser bundles. Its whitelist and role
72
72
  request validators use the focused `@carecard/common-util/case-converter` and
@@ -140,6 +140,46 @@ Passwords, emails, URLs, JSON, addresses, phones and reaction tokens keep their
140
140
  format contracts. This allowlist does not replace output encoding, parameterized
141
141
  queries, upload-content validation, or storage-path generation.
142
142
 
143
+ ### Field-specific input characters
144
+
145
+ Use `isSingleLineTextString` for previously unrestricted human text and
146
+ `isMultilineTextString` for comments that support line breaks. They check raw
147
+ and NFKC-normalized characters before any trimming. They allow Unicode letters,
148
+ marks, numbers, spaces, dashes, currency symbols, emoji and script/emoji joiners,
149
+ plus `_ . , ' ’ ‘ “ ” " ( ) : ; ! ? / @ & # + % = [ ] { } *` and their
150
+ explicitly supported compatibility forms. Both reject angle brackets,
151
+ malformed Unicode, private-use characters, control and unwanted format
152
+ characters. Multiline text permits LF and CRLF; standalone CR is rejected.
153
+ Empty strings and ordinary spaces pass the lexical check so the owning field
154
+ can retain its requiredness, nullability and length contract. Do not replace an
155
+ existing stricter character rule with these predicates.
156
+
157
+ `isHttpUrlString` checks an unmodified HTTP(S) URL, including its raw
158
+ characters. It rejects whitespace, controls, backslashes and angle brackets
159
+ instead of allowing a URL parser to discard or escape them. Keep the owning
160
+ field's existing length limit. `isMimeTypeString` checks concrete registered
161
+ type/subtype syntax and optional token or quoted parameters; see
162
+ [RFC 6838](https://www.rfc-editor.org/rfc/rfc6838.html#section-4.2) and
163
+ [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html#section-8.3.1).
164
+
165
+ `isIdempotencyKeyString` permits only ASCII letters, digits, `_ . : -`, at most
166
+ 128 code units. `isNonNegativeSafeInteger` accepts a safe integer number or a
167
+ complete decimal integer string, with no signs, suffixes, hexadecimal or
168
+ scientific notation. `isValidUuidV4String` retains version-four identifiers
169
+ where the service contract requires them. `isDocumentationSearchString`
170
+ permits single-line documentation queries with printable code punctuation,
171
+ excluding angle brackets and unsafe Unicode/control characters.
172
+
173
+ `isSafeJsonValue` preserves extensible JSON object/array metadata, including
174
+ reserved-looking keys as data. It validates nested keys as single-line text,
175
+ string values as multiline text, finite numbers, booleans, nulls, objects and
176
+ arrays. It rejects cycles and values that JSON would silently discard, and
177
+ retains the 10000-code-unit serialized budget. `isSafeJsonString` checks the
178
+ same contract in an existing serialized JSON field without rewriting it.
179
+ Whitelist output creates own data properties at every path and during
180
+ flattening. Existing property rules additionally require raw single-line
181
+ streets, safe HTTP(S) URL characters and safe nested JSON.
182
+
143
183
  ## `validateProperties(obj)`
144
184
 
145
185
  `validateProperties` accepts an object and returns a new object that contains