millas 0.2.12-beta-2 → 0.2.13
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/package.json +3 -2
- package/src/admin/Admin.js +122 -38
- package/src/admin/ViewContext.js +12 -3
- package/src/admin/resources/AdminResource.js +10 -0
- package/src/admin/static/admin.css +95 -14
- package/src/admin/views/layouts/base.njk +23 -34
- package/src/admin/views/pages/detail.njk +16 -5
- package/src/admin/views/pages/error.njk +65 -0
- package/src/admin/views/pages/list.njk +127 -2
- package/src/admin/views/partials/form-scripts.njk +7 -3
- package/src/admin/views/partials/form-widget.njk +2 -1
- package/src/admin/views/partials/icons.njk +64 -0
- package/src/ai/AIManager.js +954 -0
- package/src/ai/AITokenBudget.js +250 -0
- package/src/ai/PromptGuard.js +216 -0
- package/src/ai/agents.js +218 -0
- package/src/ai/conversation.js +213 -0
- package/src/ai/drivers.js +734 -0
- package/src/ai/files.js +249 -0
- package/src/ai/media.js +303 -0
- package/src/ai/pricing.js +152 -0
- package/src/ai/provider_tools.js +114 -0
- package/src/ai/types.js +356 -0
- package/src/commands/createsuperuser.js +17 -4
- package/src/commands/serve.js +2 -4
- package/src/container/AppInitializer.js +39 -15
- package/src/container/Application.js +31 -1
- package/src/core/foundation.js +1 -1
- package/src/errors/HttpError.js +32 -16
- package/src/facades/AI.js +411 -0
- package/src/facades/Hash.js +67 -0
- package/src/facades/Process.js +144 -0
- package/src/hashing/Hash.js +262 -0
- package/src/http/HtmlEscape.js +162 -0
- package/src/http/MillasRequest.js +63 -7
- package/src/http/MillasResponse.js +70 -4
- package/src/http/ResponseDispatcher.js +21 -27
- package/src/http/SafeFilePath.js +195 -0
- package/src/http/SafeRedirect.js +62 -0
- package/src/http/SecurityBootstrap.js +70 -0
- package/src/http/helpers.js +40 -125
- package/src/http/index.js +10 -1
- package/src/http/middleware/CsrfMiddleware.js +258 -0
- package/src/http/middleware/RateLimiter.js +314 -0
- package/src/http/middleware/SecurityHeaders.js +281 -0
- package/src/i18n/Translator.js +10 -2
- package/src/logger/LogRedactor.js +247 -0
- package/src/logger/Logger.js +1 -1
- package/src/logger/formatters/JsonFormatter.js +11 -4
- package/src/logger/formatters/PrettyFormatter.js +3 -1
- package/src/logger/formatters/SimpleFormatter.js +14 -3
- package/src/middleware/ThrottleMiddleware.js +27 -4
- package/src/process/Process.js +333 -0
- package/src/router/MiddlewareRegistry.js +27 -2
- package/src/scaffold/templates.js +3 -0
- package/src/validation/Validator.js +348 -607
- package/src/admin.zip +0 -0
|
@@ -1,680 +1,421 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
alpha(msg) {
|
|
65
|
-
return this._addRule(
|
|
66
|
-
v => /^[a-zA-Z]+$/.test(v),
|
|
67
|
-
msg || ((key) => `${this._fieldLabel(key)} must contain only letters`)
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Letters and numbers only. */
|
|
72
|
-
alphanumeric(msg) {
|
|
73
|
-
return this._addRule(
|
|
74
|
-
v => /^[a-zA-Z0-9]+$/.test(v),
|
|
75
|
-
msg || ((key) => `${this._fieldLabel(key)} must contain only letters and numbers`)
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Valid URL. */
|
|
80
|
-
url(msg) {
|
|
81
|
-
return this._addRule(v => {
|
|
82
|
-
try { new URL(v); return true; } catch { return false; }
|
|
83
|
-
}, msg || ((key) => `${this._fieldLabel(key)} must be a valid URL`));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Lowercase only. */
|
|
87
|
-
lowercase(msg) {
|
|
88
|
-
return this._addRule(
|
|
89
|
-
v => v === v.toLowerCase(),
|
|
90
|
-
msg || ((key) => `${this._fieldLabel(key)} must be lowercase`)
|
|
91
|
-
);
|
|
92
|
-
}
|
|
3
|
+
/**
|
|
4
|
+
* Validator
|
|
5
|
+
*
|
|
6
|
+
* Input validation for Millas. Supports both inline usage (req.validate())
|
|
7
|
+
* and route-level declaration (rules declared at route definition time,
|
|
8
|
+
* making validation impossible to forget).
|
|
9
|
+
*
|
|
10
|
+
* ── Rule syntax ───────────────────────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* Rules are pipe-separated strings or arrays of strings:
|
|
13
|
+
*
|
|
14
|
+
* 'required|string|min:2|max:100'
|
|
15
|
+
* 'required|email'
|
|
16
|
+
* 'optional|number|min:0|max:150'
|
|
17
|
+
* 'required|boolean'
|
|
18
|
+
* 'required|array'
|
|
19
|
+
* 'required|in:admin,user,guest'
|
|
20
|
+
* 'required|regex:/^[a-z]+$/i'
|
|
21
|
+
* 'required|uuid'
|
|
22
|
+
* 'required|url'
|
|
23
|
+
* 'required|date'
|
|
24
|
+
* 'optional|string' — field may be absent; validated if present
|
|
25
|
+
* 'nullable|string' — field may be null or absent
|
|
26
|
+
*
|
|
27
|
+
* ── Inline usage ──────────────────────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* Route.post('/register', async (req) => {
|
|
30
|
+
* const data = await req.validate({
|
|
31
|
+
* name: 'required|string|min:2|max:100',
|
|
32
|
+
* email: 'required|email',
|
|
33
|
+
* password: 'required|string|min:8',
|
|
34
|
+
* age: 'optional|number|min:13',
|
|
35
|
+
* });
|
|
36
|
+
* // data is the validated + type-coerced subset of input
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* ── Route-level usage (validation before the handler runs) ────────────────────
|
|
40
|
+
*
|
|
41
|
+
* Route.post('/register', {
|
|
42
|
+
* validate: {
|
|
43
|
+
* name: 'required|string|min:2|max:100',
|
|
44
|
+
* email: 'required|email',
|
|
45
|
+
* password: 'required|string|min:8',
|
|
46
|
+
* },
|
|
47
|
+
* }, async (req) => {
|
|
48
|
+
* // req.validated contains the safe, validated subset
|
|
49
|
+
* const { name, email, password } = req.validated;
|
|
50
|
+
* });
|
|
51
|
+
*
|
|
52
|
+
* ── Error format ──────────────────────────────────────────────────────────────
|
|
53
|
+
*
|
|
54
|
+
* Throws a 422 ValidationError on failure. Error shape:
|
|
55
|
+
* {
|
|
56
|
+
* status: 422,
|
|
57
|
+
* message: 'Validation failed',
|
|
58
|
+
* errors: {
|
|
59
|
+
* email: ['Email is required', 'Must be a valid email address'],
|
|
60
|
+
* password: ['Must be at least 8 characters'],
|
|
61
|
+
* }
|
|
62
|
+
* }
|
|
63
|
+
*/
|
|
93
64
|
|
|
94
|
-
|
|
95
|
-
uppercase(msg) {
|
|
96
|
-
return this._addRule(
|
|
97
|
-
v => v === v.toUpperCase(),
|
|
98
|
-
msg || ((key) => `${this._fieldLabel(key)} must be uppercase`)
|
|
99
|
-
);
|
|
100
|
-
}
|
|
65
|
+
// ── ValidationError ────────────────────────────────────────────────────────────
|
|
101
66
|
|
|
67
|
+
class ValidationError extends Error {
|
|
102
68
|
/**
|
|
103
|
-
*
|
|
104
|
-
* string().required().confirmed() // checks password_confirmation
|
|
105
|
-
* string().required().confirmed('confirmPassword') // custom field name
|
|
69
|
+
* @param {object} errors — { field: [message, ...] }
|
|
106
70
|
*/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.
|
|
110
|
-
|
|
71
|
+
constructor(errors) {
|
|
72
|
+
super('Validation failed');
|
|
73
|
+
this.name = 'ValidationError';
|
|
74
|
+
this.status = 422;
|
|
75
|
+
this.code = 'EVALIDATION';
|
|
76
|
+
this.errors = errors;
|
|
111
77
|
}
|
|
112
78
|
|
|
113
79
|
/**
|
|
114
|
-
*
|
|
80
|
+
* Flatten to an array of { field, message } objects.
|
|
115
81
|
*/
|
|
116
|
-
|
|
117
|
-
this.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
async run(value, key, allData) {
|
|
122
|
-
// Apply trim
|
|
123
|
-
if (this._trim && typeof value === 'string') value = value.trim();
|
|
124
|
-
|
|
125
|
-
const result = await super.run(value, key, allData);
|
|
126
|
-
if (result.error) return result;
|
|
127
|
-
|
|
128
|
-
// confirmed() check
|
|
129
|
-
if (this._confirmedField !== undefined) {
|
|
130
|
-
const confirmKey = this._confirmedField || `${key}_confirmation`;
|
|
131
|
-
const match = allData[confirmKey];
|
|
132
|
-
if (result.value !== match) {
|
|
133
|
-
return {
|
|
134
|
-
error: this._confirmedMsg || `${this._fieldLabel(key)} confirmation does not match`,
|
|
135
|
-
value,
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return result;
|
|
82
|
+
toArray() {
|
|
83
|
+
return Object.entries(this.errors).flatMap(([field, messages]) =>
|
|
84
|
+
messages.map(message => ({ field, message }))
|
|
85
|
+
);
|
|
141
86
|
}
|
|
142
87
|
}
|
|
143
88
|
|
|
144
|
-
// ──
|
|
89
|
+
// ── Built-in rule handlers ─────────────────────────────────────────────────────
|
|
145
90
|
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* email()
|
|
149
|
-
* email('Please enter a valid email')
|
|
150
|
-
*/
|
|
151
|
-
constructor(typeError) {
|
|
152
|
-
super(typeError);
|
|
153
|
-
// Auto-apply email format check
|
|
154
|
-
this._emailCheck = true;
|
|
155
|
-
}
|
|
91
|
+
const RULES = {
|
|
156
92
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
161
|
-
return this._typeError || 'Must be a valid email address';
|
|
93
|
+
required(value, _param, field) {
|
|
94
|
+
if (value === undefined || value === null || value === '') {
|
|
95
|
+
return `${_humanise(field)} is required`;
|
|
162
96
|
}
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
}
|
|
97
|
+
},
|
|
166
98
|
|
|
167
|
-
|
|
99
|
+
optional() {
|
|
100
|
+
// Presence marker — no validation, handled at the field level
|
|
101
|
+
},
|
|
168
102
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
this._integer = false;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
_checkType(value) {
|
|
176
|
-
const n = Number(value);
|
|
177
|
-
if (isNaN(n)) return this._typeError || 'Must be a number';
|
|
178
|
-
return null;
|
|
179
|
-
}
|
|
103
|
+
nullable() {
|
|
104
|
+
// Allows null — no validation needed here
|
|
105
|
+
},
|
|
180
106
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// Coerce to number on success
|
|
185
|
-
if (!result.error && result.value !== null && result.value !== undefined && result.value !== '') {
|
|
186
|
-
result.value = Number(result.value);
|
|
107
|
+
string(value, _param, field) {
|
|
108
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
109
|
+
return `${_humanise(field)} must be a string`;
|
|
187
110
|
}
|
|
188
|
-
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/** Must be an integer. */
|
|
192
|
-
integer(msg) {
|
|
193
|
-
return this._addRule(
|
|
194
|
-
v => Number.isInteger(Number(v)),
|
|
195
|
-
msg || ((key) => `${this._fieldLabel(key)} must be an integer`)
|
|
196
|
-
);
|
|
197
|
-
}
|
|
111
|
+
},
|
|
198
112
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
msg || ((key) => `${this._fieldLabel(key)} must be at least ${n}`)
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Maximum value. */
|
|
208
|
-
max(n, msg) {
|
|
209
|
-
return this._addRule(
|
|
210
|
-
v => Number(v) <= n,
|
|
211
|
-
msg || ((key) => `${this._fieldLabel(key)} must be at most ${n}`)
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Must be positive (> 0). */
|
|
216
|
-
positive(msg) {
|
|
217
|
-
return this._addRule(
|
|
218
|
-
v => Number(v) > 0,
|
|
219
|
-
msg || ((key) => `${this._fieldLabel(key)} must be positive`)
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Must be negative (< 0). */
|
|
224
|
-
negative(msg) {
|
|
225
|
-
return this._addRule(
|
|
226
|
-
v => Number(v) < 0,
|
|
227
|
-
msg || ((key) => `${this._fieldLabel(key)} must be negative`)
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Must be between min and max (inclusive). */
|
|
232
|
-
between(min, max, msg) {
|
|
233
|
-
return this._addRule(
|
|
234
|
-
v => Number(v) >= min && Number(v) <= max,
|
|
235
|
-
msg || ((key) => `${this._fieldLabel(key)} must be between ${min} and ${max}`)
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ── BooleanValidator ───────────────────────────────────────────────────────────
|
|
241
|
-
|
|
242
|
-
class BooleanValidator extends BaseValidator {
|
|
243
|
-
constructor(typeError) {
|
|
244
|
-
super(typeError || null);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
_checkType(value) {
|
|
248
|
-
const truthy = [true, 'true', '1', 1, 'yes', 'on'];
|
|
249
|
-
const falsy = [false,'false', '0', 0, 'no', 'off'];
|
|
250
|
-
if (!truthy.includes(value) && !falsy.includes(value)) {
|
|
251
|
-
return this._typeError || 'Must be a boolean (true/false)';
|
|
113
|
+
number(value, _param, field) {
|
|
114
|
+
if (value !== undefined && value !== null) {
|
|
115
|
+
const n = Number(value);
|
|
116
|
+
if (isNaN(n)) return `${_humanise(field)} must be a number`;
|
|
252
117
|
}
|
|
253
|
-
|
|
254
|
-
}
|
|
118
|
+
},
|
|
255
119
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
120
|
+
boolean(value, _param, field) {
|
|
121
|
+
if (value !== undefined && value !== null) {
|
|
122
|
+
const acceptable = [true, false, 'true', 'false', '1', '0', 1, 0];
|
|
123
|
+
if (!acceptable.includes(value)) {
|
|
124
|
+
return `${_humanise(field)} must be a boolean`;
|
|
125
|
+
}
|
|
261
126
|
}
|
|
262
|
-
|
|
263
|
-
}
|
|
127
|
+
},
|
|
264
128
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
129
|
+
array(value, _param, field) {
|
|
130
|
+
if (value !== undefined && value !== null && !Array.isArray(value)) {
|
|
131
|
+
return `${_humanise(field)} must be an array`;
|
|
132
|
+
}
|
|
133
|
+
},
|
|
273
134
|
|
|
274
|
-
|
|
135
|
+
object(value, _param, field) {
|
|
136
|
+
if (value !== undefined && value !== null &&
|
|
137
|
+
(typeof value !== 'object' || Array.isArray(value))) {
|
|
138
|
+
return `${_humanise(field)} must be an object`;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
min(value, param, field) {
|
|
143
|
+
if (value === undefined || value === null) return;
|
|
144
|
+
const limit = Number(param);
|
|
145
|
+
if (typeof value === 'string' && !isNaN(Number(value))) {
|
|
146
|
+
// String that represents a number — compare numerically
|
|
147
|
+
if (Number(value) < limit) return `${_humanise(field)} must be at least ${limit}`;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (typeof value === 'string' || Array.isArray(value)) {
|
|
151
|
+
if (value.length < limit) {
|
|
152
|
+
return `${_humanise(field)} must be at least ${limit} character${limit !== 1 ? 's' : ''}`;
|
|
153
|
+
}
|
|
154
|
+
} else if (typeof value === 'number') {
|
|
155
|
+
if (value < limit) return `${_humanise(field)} must be at least ${limit}`;
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
max(value, param, field) {
|
|
160
|
+
if (value === undefined || value === null) return;
|
|
161
|
+
const limit = Number(param);
|
|
162
|
+
if (typeof value === 'string' && !isNaN(Number(value))) {
|
|
163
|
+
if (Number(value) > limit) return `${_humanise(field)} must not exceed ${limit}`;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (typeof value === 'string' || Array.isArray(value)) {
|
|
167
|
+
if (value.length > limit) {
|
|
168
|
+
return `${_humanise(field)} must not exceed ${limit} character${limit !== 1 ? 's' : ''}`;
|
|
169
|
+
}
|
|
170
|
+
} else if (typeof value === 'number') {
|
|
171
|
+
if (value > limit) return `${_humanise(field)} must not exceed ${limit}`;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
email(value, _param, field) {
|
|
176
|
+
if (value === undefined || value === null) return;
|
|
177
|
+
// RFC 5322 simplified — catches the common cases without being overly strict
|
|
178
|
+
const re = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
|
179
|
+
if (!re.test(String(value))) {
|
|
180
|
+
return `${_humanise(field)} must be a valid email address`;
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
url(value, _param, field) {
|
|
185
|
+
if (value === undefined || value === null) return;
|
|
186
|
+
try {
|
|
187
|
+
const u = new URL(String(value));
|
|
188
|
+
if (!['http:', 'https:'].includes(u.protocol)) {
|
|
189
|
+
return `${_humanise(field)} must be a valid URL (http or https)`;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
return `${_humanise(field)} must be a valid URL`;
|
|
193
|
+
}
|
|
194
|
+
},
|
|
275
195
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
196
|
+
uuid(value, _param, field) {
|
|
197
|
+
if (value === undefined || value === null) return;
|
|
198
|
+
const re = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
199
|
+
if (!re.test(String(value))) {
|
|
200
|
+
return `${_humanise(field)} must be a valid UUID`;
|
|
201
|
+
}
|
|
202
|
+
},
|
|
280
203
|
|
|
281
|
-
|
|
204
|
+
date(value, _param, field) {
|
|
205
|
+
if (value === undefined || value === null) return;
|
|
282
206
|
const d = new Date(value);
|
|
283
|
-
if (isNaN(d.getTime()))
|
|
284
|
-
|
|
285
|
-
|
|
207
|
+
if (isNaN(d.getTime())) {
|
|
208
|
+
return `${_humanise(field)} must be a valid date`;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
286
211
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
212
|
+
in(value, param, field) {
|
|
213
|
+
if (value === undefined || value === null) return;
|
|
214
|
+
const allowed = param.split(',').map(s => s.trim());
|
|
215
|
+
if (!allowed.includes(String(value))) {
|
|
216
|
+
return `${_humanise(field)} must be one of: ${allowed.join(', ')}`;
|
|
291
217
|
}
|
|
292
|
-
|
|
293
|
-
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
regex(value, param, field) {
|
|
221
|
+
if (value === undefined || value === null) return;
|
|
222
|
+
// param format: /pattern/flags or pattern
|
|
223
|
+
let re;
|
|
224
|
+
try {
|
|
225
|
+
const match = param.match(/^\/(.+)\/([gimsuy]*)$/);
|
|
226
|
+
re = match ? new RegExp(match[1], match[2]) : new RegExp(param);
|
|
227
|
+
} catch {
|
|
228
|
+
return `${_humanise(field)} has an invalid regex rule`;
|
|
229
|
+
}
|
|
230
|
+
if (!re.test(String(value))) {
|
|
231
|
+
return `${_humanise(field)} format is invalid`;
|
|
232
|
+
}
|
|
233
|
+
},
|
|
294
234
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
235
|
+
confirmed(value, _param, field, allData) {
|
|
236
|
+
// Expects a matching field named <field>_confirmation
|
|
237
|
+
const confirmKey = `${field}_confirmation`;
|
|
238
|
+
if (value !== allData[confirmKey]) {
|
|
239
|
+
return `${_humanise(field)} confirmation does not match`;
|
|
240
|
+
}
|
|
241
|
+
},
|
|
303
242
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const d = new Date(date);
|
|
307
|
-
return this._addRule(
|
|
308
|
-
v => new Date(v) < d,
|
|
309
|
-
msg || ((key) => `${this._fieldLabel(key)} must be before ${d.toDateString()}`)
|
|
310
|
-
);
|
|
311
|
-
}
|
|
243
|
+
// ── Type coercions (not validators — mutate returned data) ────────────────
|
|
244
|
+
// These are applied in the coerce pass after validation
|
|
312
245
|
|
|
313
|
-
|
|
314
|
-
future(msg) {
|
|
315
|
-
return this._addRule(
|
|
316
|
-
v => new Date(v) > new Date(),
|
|
317
|
-
msg || ((key) => `${this._fieldLabel(key)} must be a future date`)
|
|
318
|
-
);
|
|
319
|
-
}
|
|
246
|
+
};
|
|
320
247
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
248
|
+
// ── Coercions — applied after validation passes ────────────────────────────────
|
|
249
|
+
|
|
250
|
+
function coerce(value, ruleNames) {
|
|
251
|
+
if (value === undefined || value === null) return value;
|
|
252
|
+
if (ruleNames.includes('number')) return Number(value);
|
|
253
|
+
if (ruleNames.includes('boolean')) {
|
|
254
|
+
if (value === 'true' || value === '1' || value === 1) return true;
|
|
255
|
+
if (value === 'false' || value === '0' || value === 0) return false;
|
|
256
|
+
return Boolean(value);
|
|
327
257
|
}
|
|
258
|
+
if (ruleNames.includes('string')) return String(value);
|
|
259
|
+
return value;
|
|
328
260
|
}
|
|
329
261
|
|
|
330
|
-
// ──
|
|
262
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
331
263
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
this._itemValidator = null;
|
|
336
|
-
}
|
|
264
|
+
function _humanise(field) {
|
|
265
|
+
return field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
266
|
+
}
|
|
337
267
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
268
|
+
function _parseRule(ruleStr) {
|
|
269
|
+
const colonIdx = ruleStr.indexOf(':');
|
|
270
|
+
if (colonIdx === -1) return { name: ruleStr.trim(), param: null };
|
|
271
|
+
return {
|
|
272
|
+
name: ruleStr.slice(0, colonIdx).trim(),
|
|
273
|
+
param: ruleStr.slice(colonIdx + 1).trim(),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
342
276
|
|
|
277
|
+
// ── Validator class ────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
class Validator {
|
|
343
280
|
/**
|
|
344
|
-
* Validate
|
|
345
|
-
*
|
|
346
|
-
*
|
|
281
|
+
* Validate a data object against a rules map.
|
|
282
|
+
*
|
|
283
|
+
* @param {object} data — flat input object (e.g. req.all())
|
|
284
|
+
* @param {object} rules — { field: 'rule1|rule2|...' }
|
|
285
|
+
* @returns {object} — validated + coerced subset of data
|
|
286
|
+
* @throws {ValidationError}
|
|
347
287
|
*/
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
288
|
+
static validate(data, rules) {
|
|
289
|
+
const errors = {};
|
|
290
|
+
const output = {};
|
|
352
291
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
msg || ((key) => `${this._fieldLabel(key)} must have at least ${n} item${n === 1 ? '' : 's'}`)
|
|
358
|
-
);
|
|
359
|
-
}
|
|
292
|
+
for (const [field, ruleString] of Object.entries(rules)) {
|
|
293
|
+
const ruleParts = (Array.isArray(ruleString) ? ruleString : ruleString.split('|'))
|
|
294
|
+
.map(r => r.trim())
|
|
295
|
+
.filter(Boolean);
|
|
360
296
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
v => Array.isArray(v) && v.length <= n,
|
|
365
|
-
msg || ((key) => `${this._fieldLabel(key)} must have at most ${n} item${n === 1 ? '' : 's'}`)
|
|
366
|
-
);
|
|
367
|
-
}
|
|
297
|
+
const ruleNames = ruleParts.map(r => r.split(':')[0].trim());
|
|
298
|
+
const isOptional = ruleNames.includes('optional');
|
|
299
|
+
const isNullable = ruleNames.includes('nullable');
|
|
368
300
|
|
|
369
|
-
|
|
370
|
-
length(n, msg) {
|
|
371
|
-
return this._addRule(
|
|
372
|
-
v => Array.isArray(v) && v.length === n,
|
|
373
|
-
msg || ((key) => `${this._fieldLabel(key)} must have exactly ${n} item${n === 1 ? '' : 's'}`)
|
|
374
|
-
);
|
|
375
|
-
}
|
|
301
|
+
const value = data[field];
|
|
376
302
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
msg || ((key) => `${this._fieldLabel(key)} must not contain duplicates`)
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
async run(value, key, allData) {
|
|
386
|
-
const result = await super.run(value, key, allData);
|
|
387
|
-
if (result.error || !result.value || !this._itemValidator) return result;
|
|
388
|
-
|
|
389
|
-
// Validate each item
|
|
390
|
-
const items = result.value;
|
|
391
|
-
const errors = [];
|
|
392
|
-
|
|
393
|
-
for (let i = 0; i < items.length; i++) {
|
|
394
|
-
const itemResult = await this._itemValidator.run(items[i], `${key}[${i}]`, allData);
|
|
395
|
-
if (itemResult.error) errors.push(`Item ${i}: ${itemResult.error}`);
|
|
396
|
-
else items[i] = itemResult.value; // apply coercions (e.g. string→number)
|
|
397
|
-
}
|
|
303
|
+
// Skip optional fields that are absent
|
|
304
|
+
if (isOptional && (value === undefined || value === null || value === '')) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
398
307
|
|
|
399
|
-
|
|
308
|
+
// Allow null for nullable fields
|
|
309
|
+
if (isNullable && (value === null || value === undefined)) {
|
|
310
|
+
output[field] = null;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
400
313
|
|
|
401
|
-
|
|
402
|
-
}
|
|
403
|
-
}
|
|
314
|
+
const fieldErrors = [];
|
|
404
315
|
|
|
405
|
-
|
|
316
|
+
for (const rulePart of ruleParts) {
|
|
317
|
+
const { name, param } = _parseRule(rulePart);
|
|
318
|
+
const handler = RULES[name];
|
|
406
319
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
* zip: string().matches(/^\d{5}$/, 'Invalid ZIP'),
|
|
415
|
-
* }).optional()
|
|
416
|
-
*/
|
|
417
|
-
constructor(schema = {}, typeError) {
|
|
418
|
-
super(typeError || null);
|
|
419
|
-
this._schema = schema;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
_checkType(value) {
|
|
423
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
424
|
-
return this._typeError || 'Must be an object';
|
|
425
|
-
}
|
|
426
|
-
return null;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async run(value, key, allData) {
|
|
430
|
-
const result = await super.run(value, key, allData);
|
|
431
|
-
if (result.error || !result.value || !Object.keys(this._schema).length) return result;
|
|
320
|
+
if (!handler) {
|
|
321
|
+
// Unknown rule — fail loudly in development, skip silently in production
|
|
322
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
323
|
+
throw new Error(`[Millas Validator] Unknown rule: "${name}". Check your validation rules for field "${field}".`);
|
|
324
|
+
}
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
432
327
|
|
|
433
|
-
|
|
434
|
-
|
|
328
|
+
const error = handler(value, param, field, data);
|
|
329
|
+
if (error) fieldErrors.push(error);
|
|
330
|
+
}
|
|
435
331
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
prefixed[`${key}.${k}`] = v;
|
|
332
|
+
if (fieldErrors.length > 0) {
|
|
333
|
+
errors[field] = fieldErrors;
|
|
334
|
+
} else {
|
|
335
|
+
output[field] = coerce(value, ruleNames);
|
|
441
336
|
}
|
|
442
|
-
return { error: prefixed, value, _nested: true };
|
|
443
337
|
}
|
|
444
338
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// ── FileValidator ──────────────────────────────────────────────────────────────
|
|
450
|
-
|
|
451
|
-
class FileValidator extends BaseValidator {
|
|
452
|
-
constructor(typeError) {
|
|
453
|
-
super(typeError || null);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
_checkType(value) {
|
|
457
|
-
// File objects from multer have .originalname, .size, .mimetype
|
|
458
|
-
if (!value || typeof value !== 'object' || !value.originalname) {
|
|
459
|
-
return this._typeError || 'Must be a valid file';
|
|
339
|
+
if (Object.keys(errors).length > 0) {
|
|
340
|
+
throw new ValidationError(errors);
|
|
460
341
|
}
|
|
461
|
-
return null;
|
|
462
|
-
}
|
|
463
342
|
|
|
464
|
-
|
|
465
|
-
image(msg) {
|
|
466
|
-
return this._addRule(
|
|
467
|
-
v => v.mimetype && v.mimetype.startsWith('image/'),
|
|
468
|
-
msg || ((key) => `${this._fieldLabel(key)} must be an image`)
|
|
469
|
-
);
|
|
343
|
+
return output;
|
|
470
344
|
}
|
|
471
345
|
|
|
472
346
|
/**
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
347
|
+
* Like validate() but returns { data, errors } instead of throwing.
|
|
348
|
+
* Useful when you want to handle errors manually.
|
|
349
|
+
*
|
|
350
|
+
* const { data, errors } = Validator.check(input, rules);
|
|
351
|
+
* if (errors) { ... }
|
|
477
352
|
*/
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
mimeTypes(types, msg) {
|
|
488
|
-
return this._addRule(
|
|
489
|
-
v => types.includes(v.mimetype),
|
|
490
|
-
msg || ((key) => `${this._fieldLabel(key)} must be one of: ${types.join(', ')}`)
|
|
491
|
-
);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/** Allowed file extensions. */
|
|
495
|
-
extensions(exts, msg) {
|
|
496
|
-
return this._addRule(v => {
|
|
497
|
-
const ext = v.originalname.split('.').pop().toLowerCase();
|
|
498
|
-
return exts.map(e => e.toLowerCase().replace(/^\./, '')).includes(ext);
|
|
499
|
-
}, msg || ((key) => `${this._fieldLabel(key)} must have one of these extensions: ${exts.join(', ')}`));
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function _parseSize(str) {
|
|
504
|
-
const n = parseFloat(str);
|
|
505
|
-
const unit = str.replace(/[\d.]/g, '').trim().toLowerCase();
|
|
506
|
-
const map = { b: 1, kb: 1024, mb: 1024**2, gb: 1024**3 };
|
|
507
|
-
return n * (map[unit] || 1);
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function _formatSize(bytes) {
|
|
511
|
-
if (bytes >= 1024**3) return `${(bytes/1024**3).toFixed(1)}GB`;
|
|
512
|
-
if (bytes >= 1024**2) return `${(bytes/1024**2).toFixed(1)}MB`;
|
|
513
|
-
if (bytes >= 1024) return `${(bytes/1024).toFixed(1)}KB`;
|
|
514
|
-
return `${bytes}B`;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// ── Validator (the runner) ─────────────────────────────────────────────────────
|
|
518
|
-
|
|
519
|
-
/**
|
|
520
|
-
* Validator
|
|
521
|
-
*
|
|
522
|
-
* Runs a schema of field validators against a data object.
|
|
523
|
-
* Throws HttpError 422 on failure — caught by the router.
|
|
524
|
-
*
|
|
525
|
-
* Used by RequestContext.body.validate() and directly:
|
|
526
|
-
*
|
|
527
|
-
* const data = await Validator.validate(allData, {
|
|
528
|
-
* name: string().required('Name is required').max(100),
|
|
529
|
-
* email: email().required(),
|
|
530
|
-
* age: number().optional().min(0),
|
|
531
|
-
* });
|
|
532
|
-
*
|
|
533
|
-
* Also supports the legacy pipe-string format for backward compat:
|
|
534
|
-
* const data = await Validator.validate(allData, {
|
|
535
|
-
* name: 'required|string|min:2',
|
|
536
|
-
* email: 'required|email',
|
|
537
|
-
* });
|
|
538
|
-
*/
|
|
539
|
-
class Validator {
|
|
540
|
-
static async validate(data, schema) {
|
|
541
|
-
const { errors, validated } = await Validator._runSchema(schema, data);
|
|
542
|
-
|
|
543
|
-
if (Object.keys(errors).length) {
|
|
544
|
-
const HttpError = require('../errors/HttpError');
|
|
545
|
-
throw new HttpError(422, 'Validation failed', errors);
|
|
353
|
+
static check(data, rules) {
|
|
354
|
+
try {
|
|
355
|
+
const result = Validator.validate(data, rules);
|
|
356
|
+
return { data: result, errors: null };
|
|
357
|
+
} catch (err) {
|
|
358
|
+
if (err instanceof ValidationError) {
|
|
359
|
+
return { data: null, errors: err.errors };
|
|
360
|
+
}
|
|
361
|
+
throw err;
|
|
546
362
|
}
|
|
547
|
-
|
|
548
|
-
return validated;
|
|
549
363
|
}
|
|
550
364
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
} else {
|
|
567
|
-
// Resolve lazy message functions
|
|
568
|
-
errors[key] = typeof result.error === 'function'
|
|
569
|
-
? result.error(key)
|
|
570
|
-
: result.error;
|
|
571
|
-
}
|
|
572
|
-
} else if (result.value !== undefined) {
|
|
573
|
-
validated[key] = result.value;
|
|
574
|
-
}
|
|
575
|
-
continue;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// ── Legacy API: pipe string ──────────────────────────────────────────
|
|
579
|
-
if (typeof rule === 'string') {
|
|
580
|
-
const err = Validator._runPipeRules(key, value, rule);
|
|
581
|
-
if (err) {
|
|
582
|
-
errors[key] = err;
|
|
583
|
-
} else if (value !== undefined) {
|
|
584
|
-
validated[key] = value;
|
|
585
|
-
}
|
|
586
|
-
continue;
|
|
587
|
-
}
|
|
365
|
+
/**
|
|
366
|
+
* Register a custom validation rule globally.
|
|
367
|
+
*
|
|
368
|
+
* Validator.extend('phone', (value, param, field) => {
|
|
369
|
+
* if (!/^\+?[\d\s\-]{7,15}$/.test(value)) {
|
|
370
|
+
* return `${field} must be a valid phone number`;
|
|
371
|
+
* }
|
|
372
|
+
* });
|
|
373
|
+
*
|
|
374
|
+
* // Then use it:
|
|
375
|
+
* await req.validate({ phone: 'required|phone' });
|
|
376
|
+
*/
|
|
377
|
+
static extend(name, handler) {
|
|
378
|
+
if (RULES[name]) {
|
|
379
|
+
throw new Error(`[Millas Validator] Rule "${name}" is already defined. Use Validator.override() to replace it.`);
|
|
588
380
|
}
|
|
381
|
+
RULES[name] = handler;
|
|
382
|
+
}
|
|
589
383
|
|
|
590
|
-
|
|
384
|
+
/**
|
|
385
|
+
* Override a built-in rule.
|
|
386
|
+
*
|
|
387
|
+
* Validator.override('email', (value, param, field) => {
|
|
388
|
+
* // stricter email validation
|
|
389
|
+
* });
|
|
390
|
+
*/
|
|
391
|
+
static override(name, handler) {
|
|
392
|
+
RULES[name] = handler;
|
|
591
393
|
}
|
|
592
394
|
|
|
593
|
-
/**
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
break;
|
|
615
|
-
case 'boolean':
|
|
616
|
-
if (!isEmpty && ![true,false,'true','false','1','0',1,0].includes(value))
|
|
617
|
-
return `${label} must be a boolean`;
|
|
618
|
-
break;
|
|
619
|
-
case 'email':
|
|
620
|
-
if (!isEmpty && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
621
|
-
return `${label} must be a valid email address`;
|
|
622
|
-
break;
|
|
623
|
-
case 'min':
|
|
624
|
-
if (!isEmpty) {
|
|
625
|
-
if (typeof value === 'string' && value.length < Number(arg))
|
|
626
|
-
return `${label} must be at least ${arg} characters`;
|
|
627
|
-
if (typeof value === 'number' && value < Number(arg))
|
|
628
|
-
return `${label} must be at least ${arg}`;
|
|
629
|
-
}
|
|
630
|
-
break;
|
|
631
|
-
case 'max':
|
|
632
|
-
if (!isEmpty) {
|
|
633
|
-
if (typeof value === 'string' && value.length > Number(arg))
|
|
634
|
-
return `${label} must not exceed ${arg} characters`;
|
|
635
|
-
if (typeof value === 'number' && value > Number(arg))
|
|
636
|
-
return `${label} must not exceed ${arg}`;
|
|
637
|
-
}
|
|
638
|
-
break;
|
|
639
|
-
case 'url':
|
|
640
|
-
try { if (!isEmpty) new URL(value); } catch { return `${label} must be a valid URL`; }
|
|
641
|
-
break;
|
|
642
|
-
case 'in':
|
|
643
|
-
if (!isEmpty && !arg.split(',').includes(String(value)))
|
|
644
|
-
return `${label} must be one of: ${arg}`;
|
|
645
|
-
break;
|
|
646
|
-
case 'alpha':
|
|
647
|
-
if (!isEmpty && !/^[a-zA-Z]+$/.test(value)) return `${label} must contain only letters`;
|
|
648
|
-
break;
|
|
649
|
-
case 'alphanumeric':
|
|
650
|
-
if (!isEmpty && !/^[a-zA-Z0-9]+$/.test(value)) return `${label} must contain only letters and numbers`;
|
|
651
|
-
break;
|
|
652
|
-
default: break;
|
|
395
|
+
/**
|
|
396
|
+
* Returns the Express middleware that runs route-level validation.
|
|
397
|
+
* Attaches req.validated with the clean, coerced data on success.
|
|
398
|
+
*
|
|
399
|
+
* app.post('/register', Validator.middleware({ email: 'required|email' }), handler);
|
|
400
|
+
*
|
|
401
|
+
* @param {object} rules
|
|
402
|
+
*/
|
|
403
|
+
static middleware(rules) {
|
|
404
|
+
return (req, res, next) => {
|
|
405
|
+
const data = {
|
|
406
|
+
...req.params,
|
|
407
|
+
...req.query,
|
|
408
|
+
...req.body,
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
req.validated = Validator.validate(data, rules);
|
|
413
|
+
next();
|
|
414
|
+
} catch (err) {
|
|
415
|
+
next(err); // passes ValidationError to Express error handler
|
|
653
416
|
}
|
|
654
|
-
}
|
|
655
|
-
return null;
|
|
417
|
+
};
|
|
656
418
|
}
|
|
657
419
|
}
|
|
658
420
|
|
|
659
|
-
module.exports = {
|
|
660
|
-
Validator,
|
|
661
|
-
BaseValidator,
|
|
662
|
-
StringValidator,
|
|
663
|
-
EmailValidator,
|
|
664
|
-
NumberValidator,
|
|
665
|
-
BooleanValidator,
|
|
666
|
-
DateValidator,
|
|
667
|
-
ArrayValidator,
|
|
668
|
-
ObjectValidator,
|
|
669
|
-
FileValidator,
|
|
670
|
-
|
|
671
|
-
// Shorthand factory functions — the primary developer-facing API
|
|
672
|
-
string: (msg) => new StringValidator(msg),
|
|
673
|
-
email: (msg) => new EmailValidator(msg),
|
|
674
|
-
number: (msg) => new NumberValidator(msg),
|
|
675
|
-
boolean: (msg) => new BooleanValidator(msg),
|
|
676
|
-
date: (msg) => new DateValidator(msg),
|
|
677
|
-
array: (msg) => new ArrayValidator(msg),
|
|
678
|
-
object: (schema, msg) => new ObjectValidator(schema, msg),
|
|
679
|
-
file: (msg) => new FileValidator(msg),
|
|
680
|
-
};
|
|
421
|
+
module.exports = { Validator, ValidationError };
|