snap-validate 0.4.1 → 0.4.3

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.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Snap Validate - Shared schema-runner helpers
3
+ * Extracted from the duplicated bodies of validate / validateAsync.
4
+ */
5
+
6
+ function assertSchemaAndData(schema, data) {
7
+ if (!schema || typeof schema !== 'object') {
8
+ throw new Error('Schema must be a valid object');
9
+ }
10
+
11
+ if (!data || typeof data !== 'object') {
12
+ throw new Error('Data must be a valid object');
13
+ }
14
+ }
15
+
16
+ function buildResponse(results, isValid) {
17
+ return {
18
+ isValid,
19
+ errors: results,
20
+ getErrors: () => {
21
+ const errors = {};
22
+ for (const [field, result] of Object.entries(results)) {
23
+ if (!result.isValid) {
24
+ errors[field] = result.errors;
25
+ }
26
+ }
27
+ return errors;
28
+ }
29
+ };
30
+ }
31
+
32
+ module.exports = { assertSchemaAndData, buildResponse };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Snap Validate - Schema validation entry points
3
+ */
4
+
5
+ const { ValidationResult } = require('../core/ValidationResult');
6
+ const { assertSchemaAndData, buildResponse } = require('./runner');
7
+
8
+ // Main validation function
9
+ const validate = (schema, data) => {
10
+ assertSchemaAndData(schema, data);
11
+
12
+ const results = {};
13
+ let isValid = true;
14
+
15
+ for (const [field, validator] of Object.entries(schema)) {
16
+ try {
17
+ const fieldValue = data[field];
18
+ const validatorInstance =
19
+ typeof validator === 'function' ? validator(fieldValue) : validator;
20
+
21
+ // Set field name for better error context
22
+ validatorInstance.setFieldName(field);
23
+
24
+ const result = validatorInstance.validate();
25
+
26
+ results[field] = result;
27
+ if (!result.isValid) {
28
+ isValid = false;
29
+ }
30
+ } catch (error) {
31
+ results[field] = new ValidationResult(false, [
32
+ `${field}: Validation setup error - ${error.message}`
33
+ ]);
34
+ isValid = false;
35
+ }
36
+ }
37
+
38
+ return buildResponse(results, isValid);
39
+ };
40
+
41
+ // Async validation function
42
+ const validateAsync = async (schema, data) => {
43
+ assertSchemaAndData(schema, data);
44
+
45
+ const results = {};
46
+ let isValid = true;
47
+
48
+ for (const [field, validator] of Object.entries(schema)) {
49
+ try {
50
+ const fieldValue = data[field];
51
+
52
+ const validatorInstance =
53
+ typeof validator === 'function' ? validator(fieldValue) : validator;
54
+
55
+ // Set field name for better error context
56
+ validatorInstance.setFieldName(field);
57
+
58
+ const result =
59
+ validatorInstance.asyncRules && validatorInstance.asyncRules.length > 0
60
+ ? await validatorInstance.validateAsync()
61
+ : validatorInstance.validate();
62
+
63
+ results[field] = result;
64
+ if (!result.isValid) {
65
+ isValid = false;
66
+ }
67
+ } catch (error) {
68
+ results[field] = new ValidationResult(false, [
69
+ `${field}: Validation setup error - ${error.message}`
70
+ ]);
71
+ isValid = false;
72
+ }
73
+ }
74
+
75
+ return buildResponse(results, isValid);
76
+ };
77
+
78
+ module.exports = { validate, validateAsync };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Snap Validate - Safe regex utilities
3
+ *
4
+ * Honest note on ReDoS protection
5
+ * --------------------------------
6
+ * JavaScript runs regexes synchronously on a single thread, so a timer CANNOT
7
+ * interrupt a regex that is mid-backtrack: the event loop is blocked until
8
+ * regex.test() returns on its own. This module therefore does NOT attempt
9
+ * (fake) timeout-based interruption. The protections that actually work are:
10
+ * 1. an input-length cap, which rejects oversized inputs before matching; and
11
+ * 2. isRegexSafe(), a best-effort STATIC check that flags a few common
12
+ * catastrophic-backtracking shapes.
13
+ * isRegexSafe is a heuristic - it can miss dangerous patterns and can
14
+ * occasionally over-reject safe ones. For guaranteed linear-time matching you
15
+ * need a non-backtracking engine (e.g. the native `re2` module) or a worker /
16
+ * subprocess with a real timeout.
17
+ */
18
+
19
+ const MAX_INPUT_LENGTH = 10000;
20
+
21
+ // Best-effort STATIC detection of a few catastrophic-backtracking shapes.
22
+ // Heuristic only - see the module note above.
23
+ const isRegexSafe = (regex) => {
24
+ const regexStr = regex.toString();
25
+
26
+ const dangerousPatterns = [
27
+ /\([^)]*[+*?][^)]*\)[+*?]/,
28
+ /\([^)]*\|[^)]*\)[+*]/,
29
+ /\([^)]*\.\*[^)]*\)\*/,
30
+ /[+*?]{2,}/,
31
+ /\([^)]*\|[^)]*\)\+.*\([^)]*\|[^)]*\)\+/
32
+ ];
33
+
34
+ const isDangerous = dangerousPatterns.some((pattern) =>
35
+ pattern.test(regexStr)
36
+ );
37
+
38
+ return !isDangerous;
39
+ };
40
+
41
+ // Asynchronous wrapper, kept returning a Promise for API compatibility.
42
+ // Applies the two REAL guards (length cap + static safety check) and then runs
43
+ // the match. It does NOT - and cannot - interrupt a running regex.
44
+ const safeRegexTest = (regex, str) => {
45
+ return new Promise((resolve, reject) => {
46
+ if (str.length > MAX_INPUT_LENGTH) {
47
+ reject(new Error('Input too long for regex validation'));
48
+ return;
49
+ }
50
+
51
+ if (!isRegexSafe(regex)) {
52
+ reject(new Error('Unsafe regex pattern detected'));
53
+ return;
54
+ }
55
+
56
+ try {
57
+ resolve(regex.test(str));
58
+ } catch (error) {
59
+ reject(error);
60
+ }
61
+ });
62
+ };
63
+
64
+ // Synchronous safe regex test with input-length protection.
65
+ const safeRegexTestSync = (regex, str, maxLength = MAX_INPUT_LENGTH) => {
66
+ if (str.length > maxLength) {
67
+ throw new Error('Input too long for pattern validation');
68
+ }
69
+ return regex.test(str);
70
+ };
71
+
72
+ module.exports = { isRegexSafe, safeRegexTest, safeRegexTestSync };
@@ -0,0 +1,9 @@
1
+ const { BaseValidator } = require('../core/BaseValidator');
2
+
3
+ const alphanumeric = (value) => {
4
+ return new BaseValidator(value)
5
+ .required('This field is required')
6
+ .pattern(/^[a-zA-Z0-9]+$/, 'Only letters and numbers are allowed');
7
+ };
8
+
9
+ module.exports = { alphanumeric };
@@ -0,0 +1,49 @@
1
+ const { BaseValidator } = require('../core/BaseValidator');
2
+ const { safeRegexTestSync } = require('../utils/safeRegex');
3
+
4
+ const luhnCheck = (num) => {
5
+ let sum = 0;
6
+ let isEven = false;
7
+
8
+ const cleanNum = String(num).replace(/\s/g, '');
9
+
10
+ for (let i = cleanNum.length - 1; i >= 0; i--) {
11
+ let digit = parseInt(cleanNum[i], 10);
12
+
13
+ if (isEven) {
14
+ digit *= 2;
15
+ if (digit > 9) digit -= 9;
16
+ }
17
+
18
+ sum += digit;
19
+ isEven = !isEven;
20
+ }
21
+
22
+ return sum % 10 === 0;
23
+ };
24
+
25
+ const creditCard = (value) => {
26
+ return new BaseValidator(value)
27
+ .required('Credit card number is required')
28
+ .custom((val) => {
29
+ // required() already handles emptiness; skip the digit/Luhn checks
30
+ // for falsy values to avoid emitting a second error.
31
+ if (!val) {
32
+ return true;
33
+ }
34
+
35
+ const cleanValue = String(val).replace(/\s/g, '');
36
+
37
+ if (!safeRegexTestSync(/^\d{13,19}$/, cleanValue)) {
38
+ return 'Credit card must be 13-19 digits';
39
+ }
40
+
41
+ if (!luhnCheck(cleanValue)) {
42
+ return 'Invalid credit card number';
43
+ }
44
+
45
+ return true;
46
+ });
47
+ };
48
+
49
+ module.exports = { creditCard };
@@ -0,0 +1,11 @@
1
+ const { BaseValidator } = require('../core/BaseValidator');
2
+
3
+ const email = (value) => {
4
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
5
+ return new BaseValidator(value)
6
+ .transform((v) => (typeof v === 'string' ? v.trim().toLowerCase() : v))
7
+ .required('Email is required')
8
+ .pattern(emailRegex, 'Invalid email format');
9
+ };
10
+
11
+ module.exports = { email };
@@ -0,0 +1,19 @@
1
+ const { email } = require('./email');
2
+ const { phone } = require('./phone');
3
+ const { creditCard } = require('./creditCard');
4
+ const { url } = require('./url');
5
+ const { password } = require('./password');
6
+ const { alphanumeric } = require('./alphanumeric');
7
+ const { numeric } = require('./numeric');
8
+ const { zipCode } = require('./zipCode');
9
+
10
+ module.exports = {
11
+ email,
12
+ phone,
13
+ creditCard,
14
+ url,
15
+ password,
16
+ alphanumeric,
17
+ numeric,
18
+ zipCode
19
+ };
@@ -0,0 +1,9 @@
1
+ const { BaseValidator } = require('../core/BaseValidator');
2
+
3
+ const numeric = (value) => {
4
+ return new BaseValidator(value)
5
+ .required('This field is required')
6
+ .pattern(/^\d+$/, 'Only numbers are allowed');
7
+ };
8
+
9
+ module.exports = { numeric };
@@ -0,0 +1,41 @@
1
+ const { BaseValidator } = require('../core/BaseValidator');
2
+
3
+ const password = (value, options = {}) => {
4
+ const {
5
+ minLength = 8,
6
+ requireUppercase = true,
7
+ requireLowercase = true,
8
+ requireNumbers = true,
9
+ requireSpecialChars = false
10
+ } = options;
11
+
12
+ const validator = new BaseValidator(value)
13
+ .required('Password is required')
14
+ .min(minLength, `Password must be at least ${minLength} characters`);
15
+
16
+ if (requireUppercase) {
17
+ validator.pattern(
18
+ /[A-Z]/,
19
+ 'Password must contain at least one uppercase letter'
20
+ );
21
+ }
22
+ if (requireLowercase) {
23
+ validator.pattern(
24
+ /[a-z]/,
25
+ 'Password must contain at least one lowercase letter'
26
+ );
27
+ }
28
+ if (requireNumbers) {
29
+ validator.pattern(/\d/, 'Password must contain at least one number');
30
+ }
31
+ if (requireSpecialChars) {
32
+ validator.pattern(
33
+ /[!@#$%^&*(),.?":{}|<>]/,
34
+ 'Password must contain at least one special character'
35
+ );
36
+ }
37
+
38
+ return validator;
39
+ };
40
+
41
+ module.exports = { password };
@@ -0,0 +1,24 @@
1
+ const { BaseValidator } = require('../core/BaseValidator');
2
+ const { safeRegexTestSync } = require('../utils/safeRegex');
3
+
4
+ const phone = (value, format = 'us') => {
5
+ const phoneRegex = {
6
+ us: /^[+]?[1]?[0-9]{10}$/,
7
+ international: /^[+][1-9][0-9]{7,14}$/,
8
+ simple: /^[0-9]{10,15}$/
9
+ };
10
+
11
+ return new BaseValidator(value)
12
+ .required('Phone number is required')
13
+ .custom((val) => {
14
+ const cleaned = String(val).replace(/[^+0-9]/g, '');
15
+ const regex = phoneRegex[format] || phoneRegex.simple;
16
+
17
+ if (!safeRegexTestSync(regex, cleaned)) {
18
+ return 'Invalid phone number format';
19
+ }
20
+ return true;
21
+ });
22
+ };
23
+
24
+ module.exports = { phone };
@@ -0,0 +1,10 @@
1
+ const { BaseValidator } = require('../core/BaseValidator');
2
+
3
+ const url = (value) => {
4
+ const urlRegex = /^(https?|ftp):\/\/[^\s/$.?#].[^\s]*$/i;
5
+ return new BaseValidator(value)
6
+ .required('URL is required')
7
+ .pattern(urlRegex, 'Invalid URL format');
8
+ };
9
+
10
+ module.exports = { url };
@@ -0,0 +1,15 @@
1
+ const { BaseValidator } = require('../core/BaseValidator');
2
+
3
+ const zipCode = (value, country = 'us') => {
4
+ const zipRegex = {
5
+ us: /^\d{5}(-\d{4})?$/,
6
+ ca: /^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$/,
7
+ uk: /^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i
8
+ };
9
+
10
+ return new BaseValidator(value)
11
+ .required('Zip code is required')
12
+ .pattern(zipRegex[country] || zipRegex.us, 'Invalid zip code format');
13
+ };
14
+
15
+ module.exports = { zipCode };
package/types/index.d.ts CHANGED
@@ -31,7 +31,8 @@ declare module 'snap-validate' {
31
31
  export type CountryCode = 'us' | 'ca' | 'uk';
32
32
 
33
33
  /**
34
- * Custom validation function that returns boolean
34
+ * Custom validation function that returns boolean, an error string, or a
35
+ * ValidationResult
35
36
  */
36
37
  export type CustomValidatorFunction = (
37
38
  value: any
@@ -44,6 +45,11 @@ declare module 'snap-validate' {
44
45
  value: any
45
46
  ) => Promise<boolean | string | ValidationResult>;
46
47
 
48
+ /**
49
+ * Value transform/sanitize function used by transform()
50
+ */
51
+ export type TransformFunction = (value: any) => any;
52
+
47
53
  /**
48
54
  * Conditional validation condition
49
55
  */
@@ -63,8 +69,17 @@ declare module 'snap-validate' {
63
69
  rules: Array<() => ValidationResult>;
64
70
  asyncRules: Array<() => Promise<ValidationResult>>;
65
71
  isOptional: boolean;
72
+ /**
73
+ * @deprecated No-op. A timer cannot interrupt a synchronous regex on a
74
+ * single thread, so this value is ignored. Retained for compatibility.
75
+ */
66
76
  regexTimeout: number;
67
77
 
78
+ /**
79
+ * Set the field name used to prefix contextual error messages
80
+ */
81
+ setFieldName(name: string): BaseValidator;
82
+
68
83
  /**
69
84
  * Make field required
70
85
  */
@@ -76,27 +91,82 @@ declare module 'snap-validate' {
76
91
  optional(): BaseValidator;
77
92
 
78
93
  /**
79
- * Set timeout for regex operations in milliseconds
94
+ * @deprecated No-op, retained for backward compatibility and chainability.
95
+ * Regex execution cannot be interrupted by a timeout on a single thread, so
96
+ * this setting is ignored.
80
97
  */
81
98
  setRegexTimeout(timeoutMs: number): BaseValidator;
82
99
 
83
100
  /**
84
- * Set minimum length/value
101
+ * Transform/sanitize the value before subsequent rules run
102
+ */
103
+ transform(fn: TransformFunction, errorMessage?: string): BaseValidator;
104
+
105
+ /**
106
+ * Require the value to strictly equal compareValue
107
+ */
108
+ equals(compareValue: any, message?: string): BaseValidator;
109
+
110
+ /**
111
+ * Require the value to be one of the allowed values
112
+ */
113
+ oneOf(allowedValues: any[], message?: string): BaseValidator;
114
+
115
+ /**
116
+ * Require a numeric value between min and max (inclusive)
117
+ */
118
+ between(min: number, max: number, message?: string): BaseValidator;
119
+
120
+ /**
121
+ * Set minimum length (string/array) or minimum value (number)
85
122
  */
86
123
  min(length: number, message?: string): BaseValidator;
87
124
 
88
125
  /**
89
- * Set maximum length/value
126
+ * Set maximum length (string/array) or maximum value (number)
90
127
  */
91
128
  max(length: number, message?: string): BaseValidator;
92
129
 
130
+ /**
131
+ * Require the value to be an array
132
+ */
133
+ array(message?: string): BaseValidator;
134
+
135
+ /**
136
+ * Validate each item of an array (synchronous)
137
+ */
138
+ arrayOf(
139
+ validator: BaseValidator | ValidationFunction,
140
+ message?: string
141
+ ): BaseValidator;
142
+
143
+ /**
144
+ * Validate each item of an array (asynchronous)
145
+ */
146
+ arrayOfAsync(
147
+ validator: BaseValidator | ValidationFunction,
148
+ message?: string
149
+ ): BaseValidator;
150
+
151
+ /**
152
+ * Validate a nested object against a schema (synchronous)
153
+ */
154
+ object(schema: Schema, message?: string): BaseValidator;
155
+
156
+ /**
157
+ * Validate a nested object against a schema (asynchronous)
158
+ */
159
+ objectAsync(schema: Schema, message?: string): BaseValidator;
160
+
93
161
  /**
94
162
  * Validate against regex pattern (synchronous)
95
163
  */
96
164
  pattern(regex: RegExp, message?: string): BaseValidator;
97
165
 
98
166
  /**
99
- * Validate against regex pattern with timeout protection (asynchronous)
167
+ * Validate against a regex pattern (asynchronous). Input-length and
168
+ * static-safety guards apply; there is no runtime timeout interruption
169
+ * (impossible for synchronous regex execution).
100
170
  */
101
171
  patternAsync(regex: RegExp, message?: string): BaseValidator;
102
172
 
@@ -190,7 +260,13 @@ declare module 'snap-validate' {
190
260
  ): Promise<SchemaValidationResult>;
191
261
 
192
262
  /**
193
- * Safely test regex with timeout protection (asynchronous)
263
+ * Safely test a regex asynchronously. Applies an input-length cap and the
264
+ * isRegexSafe static heuristic, then runs the match. Returns a Promise.
265
+ *
266
+ * Note: there is NO runtime timeout interruption - a timer cannot stop a
267
+ * synchronous regex on a single thread.
268
+ *
269
+ * @param timeoutMs @deprecated Ignored; retained for backward compatibility.
194
270
  */
195
271
  export function safeRegexTest(
196
272
  regex: RegExp,
@@ -208,7 +284,9 @@ declare module 'snap-validate' {
208
284
  ): boolean;
209
285
 
210
286
  /**
211
- * Check if a regex pattern is safe to use (ReDoS protection)
287
+ * Best-effort STATIC heuristic that flags a few common catastrophic-
288
+ * backtracking regex shapes. Not a guarantee: it can miss dangerous patterns
289
+ * and occasionally over-reject safe ones.
212
290
  */
213
291
  export function isRegexSafe(regex: RegExp): boolean;
214
292
  }