@hy_ong/zod-kit 0.0.4 → 0.0.6
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/.claude/settings.local.json +28 -0
- package/LICENSE +21 -0
- package/README.md +465 -97
- package/debug.js +21 -0
- package/debug.ts +16 -0
- package/dist/index.cjs +3127 -146
- package/dist/index.d.cts +3021 -25
- package/dist/index.d.ts +3021 -25
- package/dist/index.js +3081 -144
- package/eslint.config.mts +8 -0
- package/package.json +10 -9
- package/src/config.ts +1 -1
- package/src/i18n/locales/en.json +161 -25
- package/src/i18n/locales/zh-TW.json +165 -26
- package/src/index.ts +17 -7
- package/src/validators/common/boolean.ts +191 -0
- package/src/validators/common/date.ts +299 -0
- package/src/validators/common/datetime.ts +673 -0
- package/src/validators/common/email.ts +313 -0
- package/src/validators/common/file.ts +384 -0
- package/src/validators/common/id.ts +471 -0
- package/src/validators/common/number.ts +319 -0
- package/src/validators/common/password.ts +386 -0
- package/src/validators/common/text.ts +271 -0
- package/src/validators/common/time.ts +600 -0
- package/src/validators/common/url.ts +347 -0
- package/src/validators/taiwan/business-id.ts +262 -0
- package/src/validators/taiwan/fax.ts +327 -0
- package/src/validators/taiwan/mobile.ts +242 -0
- package/src/validators/taiwan/national-id.ts +425 -0
- package/src/validators/taiwan/postal-code.ts +1049 -0
- package/src/validators/taiwan/tel.ts +330 -0
- package/tests/common/boolean.test.ts +340 -92
- package/tests/common/date.test.ts +458 -0
- package/tests/common/datetime.test.ts +693 -0
- package/tests/common/email.test.ts +232 -60
- package/tests/common/file.test.ts +479 -0
- package/tests/common/id.test.ts +535 -0
- package/tests/common/number.test.ts +230 -60
- package/tests/common/password.test.ts +271 -44
- package/tests/common/text.test.ts +210 -13
- package/tests/common/time.test.ts +528 -0
- package/tests/common/url.test.ts +492 -67
- package/tests/taiwan/business-id.test.ts +240 -0
- package/tests/taiwan/fax.test.ts +463 -0
- package/tests/taiwan/mobile.test.ts +373 -0
- package/tests/taiwan/national-id.test.ts +435 -0
- package/tests/taiwan/postal-code.test.ts +705 -0
- package/tests/taiwan/tel.test.ts +467 -0
- package/eslint.config.mjs +0 -10
- package/src/common/boolean.ts +0 -36
- package/src/common/date.ts +0 -43
- package/src/common/email.ts +0 -44
- package/src/common/integer.ts +0 -46
- package/src/common/number.ts +0 -37
- package/src/common/password.ts +0 -33
- package/src/common/text.ts +0 -34
- package/src/common/url.ts +0 -37
- package/tests/common/integer.test.ts +0 -90
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Number validator for Zod Kit
|
|
3
|
+
*
|
|
4
|
+
* Provides comprehensive number validation with type constraints, range validation,
|
|
5
|
+
* precision control, and advanced parsing features including comma-separated numbers.
|
|
6
|
+
*
|
|
7
|
+
* @author Ong Hoe Yuan
|
|
8
|
+
* @version 0.0.5
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z, ZodNullable, ZodNumber } from "zod"
|
|
12
|
+
import { t } from "../../i18n"
|
|
13
|
+
import { getLocale, type Locale } from "../../config"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Type definition for number validation error messages
|
|
17
|
+
*
|
|
18
|
+
* @interface NumberMessages
|
|
19
|
+
* @property {string} [required] - Message when field is required but empty
|
|
20
|
+
* @property {string} [invalid] - Message when value is not a valid number
|
|
21
|
+
* @property {string} [integer] - Message when integer is required but float provided
|
|
22
|
+
* @property {string} [float] - Message when float is required but integer provided
|
|
23
|
+
* @property {string} [min] - Message when number is below minimum value
|
|
24
|
+
* @property {string} [max] - Message when number exceeds maximum value
|
|
25
|
+
* @property {string} [positive] - Message when positive number is required
|
|
26
|
+
* @property {string} [negative] - Message when negative number is required
|
|
27
|
+
* @property {string} [nonNegative] - Message when non-negative number is required
|
|
28
|
+
* @property {string} [nonPositive] - Message when non-positive number is required
|
|
29
|
+
* @property {string} [multipleOf] - Message when number is not a multiple of specified value
|
|
30
|
+
* @property {string} [finite] - Message when finite number is required
|
|
31
|
+
* @property {string} [precision] - Message when number has too many decimal places
|
|
32
|
+
*/
|
|
33
|
+
export type NumberMessages = {
|
|
34
|
+
required?: string
|
|
35
|
+
invalid?: string
|
|
36
|
+
integer?: string
|
|
37
|
+
float?: string
|
|
38
|
+
min?: string
|
|
39
|
+
max?: string
|
|
40
|
+
positive?: string
|
|
41
|
+
negative?: string
|
|
42
|
+
nonNegative?: string
|
|
43
|
+
nonPositive?: string
|
|
44
|
+
multipleOf?: string
|
|
45
|
+
finite?: string
|
|
46
|
+
precision?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Configuration options for number validation
|
|
51
|
+
*
|
|
52
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
53
|
+
*
|
|
54
|
+
* @interface NumberOptions
|
|
55
|
+
* @property {IsRequired} [required=true] - Whether the field is required
|
|
56
|
+
* @property {number} [min] - Minimum allowed value
|
|
57
|
+
* @property {number} [max] - Maximum allowed value
|
|
58
|
+
* @property {number | null} [defaultValue] - Default value when input is empty
|
|
59
|
+
* @property {"integer" | "float" | "both"} [type="both"] - Type constraint for the number
|
|
60
|
+
* @property {boolean} [positive] - Whether number must be positive (> 0)
|
|
61
|
+
* @property {boolean} [negative] - Whether number must be negative (< 0)
|
|
62
|
+
* @property {boolean} [nonNegative] - Whether number must be non-negative (>= 0)
|
|
63
|
+
* @property {boolean} [nonPositive] - Whether number must be non-positive (<= 0)
|
|
64
|
+
* @property {number} [multipleOf] - Number must be a multiple of this value
|
|
65
|
+
* @property {number} [precision] - Maximum number of decimal places allowed
|
|
66
|
+
* @property {boolean} [finite=true] - Whether to reject Infinity and -Infinity
|
|
67
|
+
* @property {Function} [transform] - Custom transformation function for number values
|
|
68
|
+
* @property {boolean} [parseCommas=false] - Whether to parse comma-separated numbers (e.g., "1,234")
|
|
69
|
+
* @property {Record<Locale, NumberMessages>} [i18n] - Custom error messages for different locales
|
|
70
|
+
*/
|
|
71
|
+
export type NumberOptions<IsRequired extends boolean = true> = {
|
|
72
|
+
required?: IsRequired
|
|
73
|
+
min?: number
|
|
74
|
+
max?: number
|
|
75
|
+
defaultValue?: IsRequired extends true ? number : number | null
|
|
76
|
+
type?: "integer" | "float" | "both"
|
|
77
|
+
positive?: boolean
|
|
78
|
+
negative?: boolean
|
|
79
|
+
nonNegative?: boolean
|
|
80
|
+
nonPositive?: boolean
|
|
81
|
+
multipleOf?: number
|
|
82
|
+
precision?: number
|
|
83
|
+
finite?: boolean
|
|
84
|
+
transform?: (value: number) => number
|
|
85
|
+
parseCommas?: boolean // Parse "1,234" as 1234
|
|
86
|
+
i18n?: Record<Locale, NumberMessages>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Type alias for number validation schema based on required flag
|
|
91
|
+
*
|
|
92
|
+
* @template IsRequired - Whether the field is required
|
|
93
|
+
* @typedef NumberSchema
|
|
94
|
+
* @description Returns ZodNumber if required, ZodNullable<ZodNumber> if optional
|
|
95
|
+
*/
|
|
96
|
+
export type NumberSchema<IsRequired extends boolean> = IsRequired extends true ? ZodNumber : ZodNullable<ZodNumber>
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Creates a Zod schema for number validation with comprehensive constraints
|
|
100
|
+
*
|
|
101
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
102
|
+
* @param {NumberOptions<IsRequired>} [options] - Configuration options for number validation
|
|
103
|
+
* @returns {NumberSchema<IsRequired>} Zod schema for number validation
|
|
104
|
+
*
|
|
105
|
+
* @description
|
|
106
|
+
* Creates a comprehensive number validator with type constraints, range validation,
|
|
107
|
+
* precision control, and advanced parsing features including comma-separated numbers.
|
|
108
|
+
*
|
|
109
|
+
* Features:
|
|
110
|
+
* - Type constraints (integer, float, or both)
|
|
111
|
+
* - Range validation (min/max)
|
|
112
|
+
* - Sign constraints (positive, negative, non-negative, non-positive)
|
|
113
|
+
* - Multiple-of validation
|
|
114
|
+
* - Precision control (decimal places)
|
|
115
|
+
* - Finite number validation
|
|
116
|
+
* - Comma-separated number parsing
|
|
117
|
+
* - Custom transformation functions
|
|
118
|
+
* - Comprehensive internationalization
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```typescript
|
|
122
|
+
* // Basic number validation
|
|
123
|
+
* const basicSchema = number()
|
|
124
|
+
* basicSchema.parse(42) // ✓ Valid
|
|
125
|
+
* basicSchema.parse("42") // ✓ Valid (converted to number)
|
|
126
|
+
*
|
|
127
|
+
* // Integer only
|
|
128
|
+
* const integerSchema = number({ type: "integer" })
|
|
129
|
+
* integerSchema.parse(42) // ✓ Valid
|
|
130
|
+
* integerSchema.parse(42.5) // ✗ Invalid
|
|
131
|
+
*
|
|
132
|
+
* // Range validation
|
|
133
|
+
* const rangeSchema = number({ min: 0, max: 100 })
|
|
134
|
+
* rangeSchema.parse(50) // ✓ Valid
|
|
135
|
+
* rangeSchema.parse(150) // ✗ Invalid
|
|
136
|
+
*
|
|
137
|
+
* // Positive numbers only
|
|
138
|
+
* const positiveSchema = number({ positive: true })
|
|
139
|
+
* positiveSchema.parse(5) // ✓ Valid
|
|
140
|
+
* positiveSchema.parse(-5) // ✗ Invalid
|
|
141
|
+
*
|
|
142
|
+
* // Multiple of constraint
|
|
143
|
+
* const multipleSchema = number({ multipleOf: 5 })
|
|
144
|
+
* multipleSchema.parse(10) // ✓ Valid
|
|
145
|
+
* multipleSchema.parse(7) // ✗ Invalid
|
|
146
|
+
*
|
|
147
|
+
* // Precision control
|
|
148
|
+
* const precisionSchema = number({ precision: 2 })
|
|
149
|
+
* precisionSchema.parse(3.14) // ✓ Valid
|
|
150
|
+
* precisionSchema.parse(3.14159) // ✗ Invalid
|
|
151
|
+
*
|
|
152
|
+
* // Comma-separated parsing
|
|
153
|
+
* const commaSchema = number({ parseCommas: true })
|
|
154
|
+
* commaSchema.parse("1,234.56") // ✓ Valid (parsed as 1234.56)
|
|
155
|
+
*
|
|
156
|
+
* // Optional with default
|
|
157
|
+
* const optionalSchema = number({
|
|
158
|
+
* required: false,
|
|
159
|
+
* defaultValue: 0
|
|
160
|
+
* })
|
|
161
|
+
* ```
|
|
162
|
+
*
|
|
163
|
+
* @throws {z.ZodError} When validation fails with specific error messages
|
|
164
|
+
* @see {@link NumberOptions} for all available configuration options
|
|
165
|
+
*/
|
|
166
|
+
export function number<IsRequired extends boolean = true>(options?: NumberOptions<IsRequired>): NumberSchema<IsRequired> {
|
|
167
|
+
const {
|
|
168
|
+
required = true,
|
|
169
|
+
min,
|
|
170
|
+
max,
|
|
171
|
+
defaultValue,
|
|
172
|
+
type = "both",
|
|
173
|
+
positive,
|
|
174
|
+
negative,
|
|
175
|
+
nonNegative,
|
|
176
|
+
nonPositive,
|
|
177
|
+
multipleOf,
|
|
178
|
+
precision,
|
|
179
|
+
finite = true,
|
|
180
|
+
transform,
|
|
181
|
+
parseCommas = false,
|
|
182
|
+
i18n,
|
|
183
|
+
} = options ?? {}
|
|
184
|
+
|
|
185
|
+
// Helper function to get custom message or fallback to default i18n
|
|
186
|
+
const getMessage = (key: keyof NumberMessages, params?: Record<string, any>) => {
|
|
187
|
+
if (i18n) {
|
|
188
|
+
const currentLocale = getLocale()
|
|
189
|
+
const customMessages = i18n[currentLocale]
|
|
190
|
+
if (customMessages && customMessages[key]) {
|
|
191
|
+
const template = customMessages[key]!
|
|
192
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return t(`common.number.${key}`, params)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Set appropriate default value based on required flag
|
|
199
|
+
const actualDefaultValue = defaultValue ?? null
|
|
200
|
+
|
|
201
|
+
const schema = z
|
|
202
|
+
.preprocess(
|
|
203
|
+
(val) => {
|
|
204
|
+
if (val === "" || val === undefined || val === null) {
|
|
205
|
+
return actualDefaultValue
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Handle string input
|
|
209
|
+
if (typeof val === "string") {
|
|
210
|
+
let processedVal = val.trim()
|
|
211
|
+
|
|
212
|
+
// Parse comma-separated numbers like "1,234.56"
|
|
213
|
+
if (parseCommas) {
|
|
214
|
+
processedVal = processedVal.replace(/,/g, "")
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const parsed = Number(processedVal)
|
|
218
|
+
|
|
219
|
+
// Return NaN as is so it can be caught in refine
|
|
220
|
+
if (isNaN(parsed)) {
|
|
221
|
+
return parsed
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (transform) {
|
|
225
|
+
return transform(parsed)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return parsed
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Handle existing numbers (including Infinity)
|
|
232
|
+
if (typeof val === "number") {
|
|
233
|
+
if (transform && Number.isFinite(val)) {
|
|
234
|
+
return transform(val)
|
|
235
|
+
}
|
|
236
|
+
return val
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return val
|
|
240
|
+
},
|
|
241
|
+
z.union([z.number(), z.null(), z.nan(), z.custom<number>((val) => val === Infinity || val === -Infinity)])
|
|
242
|
+
)
|
|
243
|
+
.refine((val) => {
|
|
244
|
+
// Required check first
|
|
245
|
+
if (required && val === null) {
|
|
246
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (val === null) return true
|
|
250
|
+
|
|
251
|
+
// Type validation for invalid inputs (NaN)
|
|
252
|
+
if (typeof val === "number" && isNaN(val)) {
|
|
253
|
+
if (type === "integer") {
|
|
254
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("integer"), path: [] }])
|
|
255
|
+
} else if (type === "float") {
|
|
256
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("float"), path: [] }])
|
|
257
|
+
} else {
|
|
258
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Invalid number check for non-numbers
|
|
263
|
+
if (typeof val !== "number") {
|
|
264
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid"), path: [] }])
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Finite check
|
|
268
|
+
if (finite && !Number.isFinite(val)) {
|
|
269
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("finite"), path: [] }])
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Type validation for valid numbers
|
|
273
|
+
if (type === "integer" && !Number.isInteger(val)) {
|
|
274
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("integer"), path: [] }])
|
|
275
|
+
}
|
|
276
|
+
if (type === "float" && Number.isInteger(val)) {
|
|
277
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("float"), path: [] }])
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Sign checks (more specific, should come first)
|
|
281
|
+
if (positive && val <= 0) {
|
|
282
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("positive"), path: [] }])
|
|
283
|
+
}
|
|
284
|
+
if (negative && val >= 0) {
|
|
285
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("negative"), path: [] }])
|
|
286
|
+
}
|
|
287
|
+
if (nonNegative && val < 0) {
|
|
288
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("nonNegative"), path: [] }])
|
|
289
|
+
}
|
|
290
|
+
if (nonPositive && val > 0) {
|
|
291
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("nonPositive"), path: [] }])
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Range checks
|
|
295
|
+
if (min !== undefined && val < min) {
|
|
296
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("min", { min }), path: [] }])
|
|
297
|
+
}
|
|
298
|
+
if (max !== undefined && val > max) {
|
|
299
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("max", { max }), path: [] }])
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Multiple of check
|
|
303
|
+
if (multipleOf !== undefined && val % multipleOf !== 0) {
|
|
304
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("multipleOf", { multipleOf }), path: [] }])
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Precision check
|
|
308
|
+
if (precision !== undefined) {
|
|
309
|
+
const decimalPlaces = (val.toString().split(".")[1] || "").length
|
|
310
|
+
if (decimalPlaces > precision) {
|
|
311
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("precision", { precision }), path: [] }])
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return true
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
return schema as unknown as NumberSchema<IsRequired>
|
|
319
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Password validator for Zod Kit
|
|
3
|
+
*
|
|
4
|
+
* Provides comprehensive password validation with strength analysis, character requirements,
|
|
5
|
+
* security checks, and protection against common weak passwords.
|
|
6
|
+
*
|
|
7
|
+
* @author Ong Hoe Yuan
|
|
8
|
+
* @version 0.0.5
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { z, ZodNullable, ZodString } from "zod"
|
|
12
|
+
import { t } from "../../i18n"
|
|
13
|
+
import { getLocale, type Locale } from "../../config"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Type definition for password validation error messages
|
|
17
|
+
*
|
|
18
|
+
* @interface PasswordMessages
|
|
19
|
+
* @property {string} [required] - Message when field is required but empty
|
|
20
|
+
* @property {string} [min] - Message when password is too short
|
|
21
|
+
* @property {string} [max] - Message when password is too long
|
|
22
|
+
* @property {string} [uppercase] - Message when uppercase letters are required
|
|
23
|
+
* @property {string} [lowercase] - Message when lowercase letters are required
|
|
24
|
+
* @property {string} [digits] - Message when digits are required
|
|
25
|
+
* @property {string} [special] - Message when special characters are required
|
|
26
|
+
* @property {string} [noRepeating] - Message when repeating characters are forbidden
|
|
27
|
+
* @property {string} [noSequential] - Message when sequential characters are forbidden
|
|
28
|
+
* @property {string} [noCommonWords] - Message when common passwords are forbidden
|
|
29
|
+
* @property {string} [minStrength] - Message when password strength is insufficient
|
|
30
|
+
* @property {string} [excludes] - Message when password contains forbidden strings
|
|
31
|
+
* @property {string} [includes] - Message when password doesn't contain required string
|
|
32
|
+
* @property {string} [invalid] - Message when password doesn't match custom regex
|
|
33
|
+
*/
|
|
34
|
+
export type PasswordMessages = {
|
|
35
|
+
required?: string
|
|
36
|
+
min?: string
|
|
37
|
+
max?: string
|
|
38
|
+
uppercase?: string
|
|
39
|
+
lowercase?: string
|
|
40
|
+
digits?: string
|
|
41
|
+
special?: string
|
|
42
|
+
noRepeating?: string
|
|
43
|
+
noSequential?: string
|
|
44
|
+
noCommonWords?: string
|
|
45
|
+
minStrength?: string
|
|
46
|
+
excludes?: string
|
|
47
|
+
includes?: string
|
|
48
|
+
invalid?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Password strength levels used for validation
|
|
53
|
+
*
|
|
54
|
+
* @typedef {"weak" | "medium" | "strong" | "very-strong"} PasswordStrength
|
|
55
|
+
* @description
|
|
56
|
+
* - weak: Basic passwords with minimal requirements
|
|
57
|
+
* - medium: Passwords with some character variety
|
|
58
|
+
* - strong: Passwords with good character variety and length
|
|
59
|
+
* - very-strong: Passwords with excellent character variety, length, and complexity
|
|
60
|
+
*/
|
|
61
|
+
export type PasswordStrength = "weak" | "medium" | "strong" | "very-strong"
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Configuration options for password validation
|
|
65
|
+
*
|
|
66
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
67
|
+
*
|
|
68
|
+
* @interface PasswordOptions
|
|
69
|
+
* @property {IsRequired} [required=true] - Whether the field is required
|
|
70
|
+
* @property {number} [min] - Minimum length of password
|
|
71
|
+
* @property {number} [max] - Maximum length of password
|
|
72
|
+
* @property {boolean} [uppercase] - Whether uppercase letters are required
|
|
73
|
+
* @property {boolean} [lowercase] - Whether lowercase letters are required
|
|
74
|
+
* @property {boolean} [digits] - Whether digits are required
|
|
75
|
+
* @property {boolean} [special] - Whether special characters are required
|
|
76
|
+
* @property {boolean} [noRepeating] - Whether to forbid repeating characters (3+ in a row)
|
|
77
|
+
* @property {boolean} [noSequential] - Whether to forbid sequential characters (abc, 123)
|
|
78
|
+
* @property {boolean} [noCommonWords] - Whether to forbid common weak passwords
|
|
79
|
+
* @property {PasswordStrength} [minStrength] - Minimum required password strength
|
|
80
|
+
* @property {string | string[]} [excludes] - String(s) that must not be included
|
|
81
|
+
* @property {string} [includes] - String that must be included in password
|
|
82
|
+
* @property {RegExp} [regex] - Custom regex pattern for validation
|
|
83
|
+
* @property {Function} [transform] - Custom transformation function for password
|
|
84
|
+
* @property {string | null} [defaultValue] - Default value when input is empty
|
|
85
|
+
* @property {Record<Locale, PasswordMessages>} [i18n] - Custom error messages for different locales
|
|
86
|
+
*/
|
|
87
|
+
export type PasswordOptions<IsRequired extends boolean = true> = {
|
|
88
|
+
required?: IsRequired
|
|
89
|
+
min?: number
|
|
90
|
+
max?: number
|
|
91
|
+
uppercase?: boolean
|
|
92
|
+
lowercase?: boolean
|
|
93
|
+
digits?: boolean
|
|
94
|
+
special?: boolean
|
|
95
|
+
noRepeating?: boolean
|
|
96
|
+
noSequential?: boolean
|
|
97
|
+
noCommonWords?: boolean
|
|
98
|
+
minStrength?: PasswordStrength
|
|
99
|
+
excludes?: string | string[]
|
|
100
|
+
includes?: string
|
|
101
|
+
regex?: RegExp
|
|
102
|
+
transform?: (value: string) => string
|
|
103
|
+
defaultValue?: IsRequired extends true ? string : string | null
|
|
104
|
+
i18n?: Record<Locale, PasswordMessages>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Type alias for password validation schema based on required flag
|
|
109
|
+
*
|
|
110
|
+
* @template IsRequired - Whether the field is required
|
|
111
|
+
* @typedef PasswordSchema
|
|
112
|
+
* @description Returns ZodString if required, ZodNullable<ZodString> if optional
|
|
113
|
+
*/
|
|
114
|
+
export type PasswordSchema<IsRequired extends boolean> = IsRequired extends true ? ZodString : ZodNullable<ZodString>
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* List of common weak passwords to check against
|
|
118
|
+
*
|
|
119
|
+
* @constant {string[]} COMMON_PASSWORDS
|
|
120
|
+
* @description Contains frequently used weak passwords that should be avoided
|
|
121
|
+
*/
|
|
122
|
+
const COMMON_PASSWORDS = [
|
|
123
|
+
"password",
|
|
124
|
+
"123456",
|
|
125
|
+
"123456789",
|
|
126
|
+
"12345678",
|
|
127
|
+
"12345",
|
|
128
|
+
"1234567",
|
|
129
|
+
"admin",
|
|
130
|
+
"qwerty",
|
|
131
|
+
"abc123",
|
|
132
|
+
"password123",
|
|
133
|
+
"letmein",
|
|
134
|
+
"welcome",
|
|
135
|
+
"monkey",
|
|
136
|
+
"dragon",
|
|
137
|
+
"sunshine",
|
|
138
|
+
"princess",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Calculates password strength based on various criteria
|
|
143
|
+
*
|
|
144
|
+
* @param {string} password - The password to analyze
|
|
145
|
+
* @returns {PasswordStrength} The calculated strength level
|
|
146
|
+
*
|
|
147
|
+
* @description
|
|
148
|
+
* Analyzes password strength using multiple factors:
|
|
149
|
+
* - Length bonuses (8+, 12+, 16+ characters)
|
|
150
|
+
* - Character variety (lowercase, uppercase, digits, special characters)
|
|
151
|
+
* - Deductions for repeating or sequential patterns
|
|
152
|
+
*
|
|
153
|
+
* Scoring system:
|
|
154
|
+
* - 0-2 points: weak
|
|
155
|
+
* - 3-4 points: medium
|
|
156
|
+
* - 5-6 points: strong
|
|
157
|
+
* - 7+ points: very-strong
|
|
158
|
+
*
|
|
159
|
+
* @example
|
|
160
|
+
* ```typescript
|
|
161
|
+
* calculatePasswordStrength("password") // "weak"
|
|
162
|
+
* calculatePasswordStrength("Password123") // "medium"
|
|
163
|
+
* calculatePasswordStrength("MyStr0ng!P@ssw0rd") // "very-strong"
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
const calculatePasswordStrength = (password: string): PasswordStrength => {
|
|
167
|
+
let score = 0
|
|
168
|
+
|
|
169
|
+
// Length bonus
|
|
170
|
+
if (password.length >= 8) score += 1
|
|
171
|
+
if (password.length >= 12) score += 1
|
|
172
|
+
if (password.length >= 16) score += 1
|
|
173
|
+
|
|
174
|
+
// Character variety
|
|
175
|
+
if (/[a-z]/.test(password)) score += 1
|
|
176
|
+
if (/[A-Z]/.test(password)) score += 1
|
|
177
|
+
if (/[0-9]/.test(password)) score += 1
|
|
178
|
+
if (/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) score += 1
|
|
179
|
+
|
|
180
|
+
// Deductions
|
|
181
|
+
if (/(.)\1{2,}/.test(password)) score -= 1 // Repeating characters
|
|
182
|
+
if (/(?:abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz|012|123|234|345|456|567|678|789)/i.test(password)) score -= 1 // Sequential
|
|
183
|
+
|
|
184
|
+
if (score <= 2) return "weak"
|
|
185
|
+
if (score <= 4) return "medium"
|
|
186
|
+
if (score <= 6) return "strong"
|
|
187
|
+
return "very-strong"
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Creates a Zod schema for password validation with comprehensive security checks
|
|
192
|
+
*
|
|
193
|
+
* @template IsRequired - Whether the field is required (affects return type)
|
|
194
|
+
* @param {PasswordOptions<IsRequired>} [options] - Configuration options for password validation
|
|
195
|
+
* @returns {PasswordSchema<IsRequired>} Zod schema for password validation
|
|
196
|
+
*
|
|
197
|
+
* @description
|
|
198
|
+
* Creates a comprehensive password validator with strength analysis, character requirements,
|
|
199
|
+
* security checks, and protection against common weak passwords.
|
|
200
|
+
*
|
|
201
|
+
* Features:
|
|
202
|
+
* - Length validation (min/max)
|
|
203
|
+
* - Character requirements (uppercase, lowercase, digits, special)
|
|
204
|
+
* - Security checks (no repeating, no sequential patterns)
|
|
205
|
+
* - Common password detection
|
|
206
|
+
* - Strength analysis with configurable minimum levels
|
|
207
|
+
* - Content inclusion/exclusion
|
|
208
|
+
* - Custom regex patterns
|
|
209
|
+
* - Custom transformation functions
|
|
210
|
+
* - Comprehensive internationalization
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* ```typescript
|
|
214
|
+
* // Basic password validation
|
|
215
|
+
* const basicSchema = password()
|
|
216
|
+
* basicSchema.parse("MyPassword123!") // ✓ Valid
|
|
217
|
+
*
|
|
218
|
+
* // Strong password requirements
|
|
219
|
+
* const strongSchema = password({
|
|
220
|
+
* min: 12,
|
|
221
|
+
* uppercase: true,
|
|
222
|
+
* lowercase: true,
|
|
223
|
+
* digits: true,
|
|
224
|
+
* special: true,
|
|
225
|
+
* minStrength: "strong"
|
|
226
|
+
* })
|
|
227
|
+
*
|
|
228
|
+
* // No common passwords
|
|
229
|
+
* const secureSchema = password({
|
|
230
|
+
* noCommonWords: true,
|
|
231
|
+
* noRepeating: true,
|
|
232
|
+
* noSequential: true
|
|
233
|
+
* })
|
|
234
|
+
* secureSchema.parse("password123") // ✗ Invalid (common password)
|
|
235
|
+
* secureSchema.parse("aaa123") // ✗ Invalid (repeating characters)
|
|
236
|
+
* secureSchema.parse("abc123") // ✗ Invalid (sequential characters)
|
|
237
|
+
*
|
|
238
|
+
* // Custom requirements
|
|
239
|
+
* const customSchema = password({
|
|
240
|
+
* min: 8,
|
|
241
|
+
* includes: "@", // Must contain @
|
|
242
|
+
* excludes: ["admin", "user"], // Cannot contain these words
|
|
243
|
+
* regex: /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)/ // Custom pattern
|
|
244
|
+
* })
|
|
245
|
+
*
|
|
246
|
+
* // Minimum strength requirement
|
|
247
|
+
* const strengthSchema = password({ minStrength: "very-strong" })
|
|
248
|
+
* strengthSchema.parse("weak") // ✗ Invalid (insufficient strength)
|
|
249
|
+
* strengthSchema.parse("MyVeryStr0ng!P@ssw0rd2024") // ✓ Valid
|
|
250
|
+
*
|
|
251
|
+
* // Optional with default
|
|
252
|
+
* const optionalSchema = password({
|
|
253
|
+
* required: false,
|
|
254
|
+
* defaultValue: null
|
|
255
|
+
* })
|
|
256
|
+
* ```
|
|
257
|
+
*
|
|
258
|
+
* @throws {z.ZodError} When validation fails with specific error messages
|
|
259
|
+
* @see {@link PasswordOptions} for all available configuration options
|
|
260
|
+
* @see {@link PasswordStrength} for strength level definitions
|
|
261
|
+
* @see {@link calculatePasswordStrength} for strength calculation logic
|
|
262
|
+
*/
|
|
263
|
+
export function password<IsRequired extends boolean = true>(options?: PasswordOptions<IsRequired>): PasswordSchema<IsRequired> {
|
|
264
|
+
const {
|
|
265
|
+
required = true,
|
|
266
|
+
min,
|
|
267
|
+
max,
|
|
268
|
+
uppercase,
|
|
269
|
+
lowercase,
|
|
270
|
+
digits,
|
|
271
|
+
special,
|
|
272
|
+
noRepeating,
|
|
273
|
+
noSequential,
|
|
274
|
+
noCommonWords,
|
|
275
|
+
minStrength,
|
|
276
|
+
excludes,
|
|
277
|
+
includes,
|
|
278
|
+
regex,
|
|
279
|
+
transform,
|
|
280
|
+
defaultValue,
|
|
281
|
+
i18n,
|
|
282
|
+
} = options ?? {}
|
|
283
|
+
|
|
284
|
+
// Set appropriate default value based on required flag
|
|
285
|
+
const actualDefaultValue = defaultValue ?? (required ? "" : null)
|
|
286
|
+
|
|
287
|
+
// Helper function to get custom message or fallback to default i18n
|
|
288
|
+
const getMessage = (key: keyof PasswordMessages, params?: Record<string, any>) => {
|
|
289
|
+
if (i18n) {
|
|
290
|
+
const currentLocale = getLocale()
|
|
291
|
+
const customMessages = i18n[currentLocale]
|
|
292
|
+
if (customMessages && customMessages[key]) {
|
|
293
|
+
const template = customMessages[key]!
|
|
294
|
+
return template.replace(/\$\{(\w+)}/g, (_, k) => params?.[k] ?? "")
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return t(`common.password.${key}`, params)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Preprocessing function
|
|
301
|
+
const preprocessFn = (val: unknown) => {
|
|
302
|
+
if (val === "" || val === null || val === undefined) {
|
|
303
|
+
return actualDefaultValue
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let processed = String(val)
|
|
307
|
+
if (transform) {
|
|
308
|
+
processed = transform(processed)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return processed
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const baseSchema = required ? z.preprocess(preprocessFn, z.string()) : z.preprocess(preprocessFn, z.string().nullable())
|
|
315
|
+
|
|
316
|
+
const schema = baseSchema.refine((val) => {
|
|
317
|
+
if (val === null) return true
|
|
318
|
+
|
|
319
|
+
// Required check
|
|
320
|
+
if (required && (val === "" || val === "null" || val === "undefined")) {
|
|
321
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("required"), path: [] }])
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Length checks
|
|
325
|
+
if (val !== null && min !== undefined && val.length < min) {
|
|
326
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("min", { min }), path: [] }])
|
|
327
|
+
}
|
|
328
|
+
if (val !== null && max !== undefined && val.length > max) {
|
|
329
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("max", { max }), path: [] }])
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Character requirements
|
|
333
|
+
if (val !== null && uppercase && !/[A-Z]/.test(val)) {
|
|
334
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("uppercase"), path: [] }])
|
|
335
|
+
}
|
|
336
|
+
if (val !== null && lowercase && !/[a-z]/.test(val)) {
|
|
337
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("lowercase"), path: [] }])
|
|
338
|
+
}
|
|
339
|
+
if (val !== null && digits && !/[0-9]/.test(val)) {
|
|
340
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("digits"), path: [] }])
|
|
341
|
+
}
|
|
342
|
+
if (val !== null && special && !/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(val)) {
|
|
343
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("special"), path: [] }])
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Advanced security checks
|
|
347
|
+
if (val !== null && noRepeating && /(.)\1{2,}/.test(val)) {
|
|
348
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noRepeating"), path: [] }])
|
|
349
|
+
}
|
|
350
|
+
if (val !== null && noSequential && /(?:abc|bcd|cde|def|efg|fgh|ghi|hij|ijk|jkl|klm|lmn|mno|nop|opq|pqr|qrs|rst|stu|tuv|uvw|vwx|wxy|xyz|012|123|234|345|456|567|678|789)/i.test(val)) {
|
|
351
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noSequential"), path: [] }])
|
|
352
|
+
}
|
|
353
|
+
if (val !== null && noCommonWords && COMMON_PASSWORDS.some((common) => val.toLowerCase().includes(common.toLowerCase()))) {
|
|
354
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("noCommonWords"), path: [] }])
|
|
355
|
+
}
|
|
356
|
+
if (val !== null && minStrength) {
|
|
357
|
+
const strength = calculatePasswordStrength(val)
|
|
358
|
+
const strengthLevels = ["weak", "medium", "strong", "very-strong"]
|
|
359
|
+
const currentLevel = strengthLevels.indexOf(strength)
|
|
360
|
+
const requiredLevel = strengthLevels.indexOf(minStrength)
|
|
361
|
+
if (currentLevel < requiredLevel) {
|
|
362
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("minStrength", { minStrength }), path: [] }])
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Content checks
|
|
367
|
+
if (val !== null && includes !== undefined && !val.includes(includes)) {
|
|
368
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("includes", { includes }), path: [] }])
|
|
369
|
+
}
|
|
370
|
+
if (val !== null && excludes !== undefined) {
|
|
371
|
+
const excludeList = Array.isArray(excludes) ? excludes : [excludes]
|
|
372
|
+
for (const exclude of excludeList) {
|
|
373
|
+
if (val.includes(exclude)) {
|
|
374
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("excludes", { excludes: exclude }), path: [] }])
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (val !== null && regex !== undefined && !regex.test(val)) {
|
|
379
|
+
throw new z.ZodError([{ code: "custom", message: getMessage("invalid", { regex }), path: [] }])
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return true
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
return schema as unknown as PasswordSchema<IsRequired>
|
|
386
|
+
}
|