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.
Files changed (57) hide show
  1. package/package.json +3 -2
  2. package/src/admin/Admin.js +122 -38
  3. package/src/admin/ViewContext.js +12 -3
  4. package/src/admin/resources/AdminResource.js +10 -0
  5. package/src/admin/static/admin.css +95 -14
  6. package/src/admin/views/layouts/base.njk +23 -34
  7. package/src/admin/views/pages/detail.njk +16 -5
  8. package/src/admin/views/pages/error.njk +65 -0
  9. package/src/admin/views/pages/list.njk +127 -2
  10. package/src/admin/views/partials/form-scripts.njk +7 -3
  11. package/src/admin/views/partials/form-widget.njk +2 -1
  12. package/src/admin/views/partials/icons.njk +64 -0
  13. package/src/ai/AIManager.js +954 -0
  14. package/src/ai/AITokenBudget.js +250 -0
  15. package/src/ai/PromptGuard.js +216 -0
  16. package/src/ai/agents.js +218 -0
  17. package/src/ai/conversation.js +213 -0
  18. package/src/ai/drivers.js +734 -0
  19. package/src/ai/files.js +249 -0
  20. package/src/ai/media.js +303 -0
  21. package/src/ai/pricing.js +152 -0
  22. package/src/ai/provider_tools.js +114 -0
  23. package/src/ai/types.js +356 -0
  24. package/src/commands/createsuperuser.js +17 -4
  25. package/src/commands/serve.js +2 -4
  26. package/src/container/AppInitializer.js +39 -15
  27. package/src/container/Application.js +31 -1
  28. package/src/core/foundation.js +1 -1
  29. package/src/errors/HttpError.js +32 -16
  30. package/src/facades/AI.js +411 -0
  31. package/src/facades/Hash.js +67 -0
  32. package/src/facades/Process.js +144 -0
  33. package/src/hashing/Hash.js +262 -0
  34. package/src/http/HtmlEscape.js +162 -0
  35. package/src/http/MillasRequest.js +63 -7
  36. package/src/http/MillasResponse.js +70 -4
  37. package/src/http/ResponseDispatcher.js +21 -27
  38. package/src/http/SafeFilePath.js +195 -0
  39. package/src/http/SafeRedirect.js +62 -0
  40. package/src/http/SecurityBootstrap.js +70 -0
  41. package/src/http/helpers.js +40 -125
  42. package/src/http/index.js +10 -1
  43. package/src/http/middleware/CsrfMiddleware.js +258 -0
  44. package/src/http/middleware/RateLimiter.js +314 -0
  45. package/src/http/middleware/SecurityHeaders.js +281 -0
  46. package/src/i18n/Translator.js +10 -2
  47. package/src/logger/LogRedactor.js +247 -0
  48. package/src/logger/Logger.js +1 -1
  49. package/src/logger/formatters/JsonFormatter.js +11 -4
  50. package/src/logger/formatters/PrettyFormatter.js +3 -1
  51. package/src/logger/formatters/SimpleFormatter.js +14 -3
  52. package/src/middleware/ThrottleMiddleware.js +27 -4
  53. package/src/process/Process.js +333 -0
  54. package/src/router/MiddlewareRegistry.js +27 -2
  55. package/src/scaffold/templates.js +3 -0
  56. package/src/validation/Validator.js +348 -607
  57. package/src/admin.zip +0 -0
@@ -1,680 +1,421 @@
1
1
  'use strict';
2
2
 
3
- const { BaseValidator, _titleCase } = require('./BaseValidator');
4
-
5
- // ── StringValidator ────────────────────────────────────────────────────────────
6
-
7
- class StringValidator extends BaseValidator {
8
- /**
9
- * @param {string} [typeError] — shown when value is not a string
10
- *
11
- * string('Must be text')
12
- * string().required('Name is required').min(2, 'Too short')
13
- */
14
- constructor(typeError) {
15
- super(typeError || null);
16
- }
17
-
18
- _checkType(value) {
19
- if (typeof value !== 'string') return 'Must be a string';
20
- return null;
21
- }
22
-
23
- /** Minimum character length. */
24
- min(n, msg) {
25
- return this._addRule(
26
- v => String(v).length >= n,
27
- msg || ((key) => `${this._fieldLabel(key)} must be at least ${n} character${n === 1 ? '' : 's'}`)
28
- );
29
- }
30
-
31
- /** Maximum character length. */
32
- max(n, msg) {
33
- return this._addRule(
34
- v => String(v).length <= n,
35
- msg || ((key) => `${this._fieldLabel(key)} must not exceed ${n} character${n === 1 ? '' : 's'}`)
36
- );
37
- }
38
-
39
- /** Exact character length. */
40
- length(n, msg) {
41
- return this._addRule(
42
- v => String(v).length === n,
43
- msg || ((key) => `${this._fieldLabel(key)} must be exactly ${n} character${n === 1 ? '' : 's'}`)
44
- );
45
- }
46
-
47
- /** Match a regex pattern. */
48
- matches(regex, msg) {
49
- return this._addRule(
50
- v => regex.test(String(v)),
51
- msg || ((key) => `${this._fieldLabel(key)} format is invalid`)
52
- );
53
- }
54
-
55
- /** Must be one of the allowed values. */
56
- oneOf(values, msg) {
57
- return this._addRule(
58
- v => values.includes(v),
59
- msg || ((key) => `${this._fieldLabel(key)} must be one of: ${values.join(', ')}`)
60
- );
61
- }
62
-
63
- /** Letters only. */
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
- /** Uppercase only. */
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
- * Must match another field in the request (e.g. password confirmation).
104
- * string().required().confirmed() // checks password_confirmation
105
- * string().required().confirmed('confirmPassword') // custom field name
69
+ * @param {object} errors — { field: [message, ...] }
106
70
  */
107
- confirmed(field, msg) {
108
- this._confirmedField = field || null; // resolved at run-time
109
- this._confirmedMsg = msg || null;
110
- return this;
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
- * Trim whitespace before validation (and in the returned value).
80
+ * Flatten to an array of { field, message } objects.
115
81
  */
116
- trim() {
117
- this._trim = true;
118
- return this;
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
- // ── EmailValidator ─────────────────────────────────────────────────────────────
89
+ // ── Built-in rule handlers ─────────────────────────────────────────────────────
145
90
 
146
- class EmailValidator extends StringValidator {
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
- _checkType(value) {
158
- const base = super._checkType(value);
159
- if (base) return base;
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
- return null;
164
- }
165
- }
97
+ },
166
98
 
167
- // ── NumberValidator ────────────────────────────────────────────────────────────
99
+ optional() {
100
+ // Presence marker — no validation, handled at the field level
101
+ },
168
102
 
169
- class NumberValidator extends BaseValidator {
170
- constructor(typeError) {
171
- super(typeError || null);
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
- /** Coerce string to number in the returned value. */
182
- async run(value, key, allData) {
183
- const result = await super.run(value, key, allData);
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
- return result;
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
- /** Minimum value. */
200
- min(n, msg) {
201
- return this._addRule(
202
- v => Number(v) >= n,
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
- return null;
254
- }
118
+ },
255
119
 
256
- async run(value, key, allData) {
257
- const result = await super.run(value, key, allData);
258
- if (!result.error && result.value !== null && result.value !== undefined && result.value !== '') {
259
- const truthy = [true, 'true', '1', 1, 'yes', 'on'];
260
- result.value = truthy.includes(result.value);
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
- return result;
263
- }
127
+ },
264
128
 
265
- /** Must be true. */
266
- isTrue(msg) {
267
- return this._addRule(
268
- v => [true, 'true', '1', 1, 'yes', 'on'].includes(v),
269
- msg || ((key) => `${this._fieldLabel(key)} must be accepted`)
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
- // ── DateValidator ──────────────────────────────────────────────────────────────
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
- class DateValidator extends BaseValidator {
277
- constructor(typeError) {
278
- super(typeError || null);
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
- _checkType(value) {
204
+ date(value, _param, field) {
205
+ if (value === undefined || value === null) return;
282
206
  const d = new Date(value);
283
- if (isNaN(d.getTime())) return this._typeError || 'Must be a valid date';
284
- return null;
285
- }
207
+ if (isNaN(d.getTime())) {
208
+ return `${_humanise(field)} must be a valid date`;
209
+ }
210
+ },
286
211
 
287
- async run(value, key, allData) {
288
- const result = await super.run(value, key, allData);
289
- if (!result.error && result.value !== null && result.value !== undefined && result.value !== '') {
290
- result.value = new Date(result.value);
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
- return result;
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
- /** Must be after a given date. */
296
- after(date, msg) {
297
- const d = new Date(date);
298
- return this._addRule(
299
- v => new Date(v) > d,
300
- msg || ((key) => `${this._fieldLabel(key)} must be after ${d.toDateString()}`)
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
- /** Must be before a given date. */
305
- before(date, msg) {
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
- /** Must be in the future. */
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
- /** Must be in the past. */
322
- past(msg) {
323
- return this._addRule(
324
- v => new Date(v) < new Date(),
325
- msg || ((key) => `${this._fieldLabel(key)} must be a past date`)
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
- // ── ArrayValidator ─────────────────────────────────────────────────────────────
262
+ // ── Helpers ────────────────────────────────────────────────────────────────────
331
263
 
332
- class ArrayValidator extends BaseValidator {
333
- constructor(typeError) {
334
- super(typeError || null);
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
- _checkType(value) {
339
- if (!Array.isArray(value)) return this._typeError || 'Must be an array';
340
- return null;
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 each item in the array with a given validator.
345
- * array().of(string().min(1))
346
- * array().of(number().positive())
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
- of(validator) {
349
- this._itemValidator = validator;
350
- return this;
351
- }
288
+ static validate(data, rules) {
289
+ const errors = {};
290
+ const output = {};
352
291
 
353
- /** Minimum number of items. */
354
- min(n, msg) {
355
- return this._addRule(
356
- v => Array.isArray(v) && v.length >= n,
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
- /** Maximum number of items. */
362
- max(n, msg) {
363
- return this._addRule(
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
- /** Exact number of items. */
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
- /** No duplicate values. */
378
- unique(msg) {
379
- return this._addRule(
380
- v => Array.isArray(v) && new Set(v).size === v.length,
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
- if (errors.length) return { error: errors.join('; '), value };
308
+ // Allow null for nullable fields
309
+ if (isNullable && (value === null || value === undefined)) {
310
+ output[field] = null;
311
+ continue;
312
+ }
400
313
 
401
- return { error: null, value: items };
402
- }
403
- }
314
+ const fieldErrors = [];
404
315
 
405
- // ── ObjectValidator ────────────────────────────────────────────────────────────
316
+ for (const rulePart of ruleParts) {
317
+ const { name, param } = _parseRule(rulePart);
318
+ const handler = RULES[name];
406
319
 
407
- class ObjectValidator extends BaseValidator {
408
- /**
409
- * Validate a nested object against a schema.
410
- *
411
- * object({
412
- * street: string().required(),
413
- * city: string().required(),
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
- // Validate each field in the nested schema
434
- const { errors, validated } = await Validator._runSchema(this._schema, result.value);
328
+ const error = handler(value, param, field, data);
329
+ if (error) fieldErrors.push(error);
330
+ }
435
331
 
436
- if (Object.keys(errors).length) {
437
- // Prefix field names with parent key
438
- const prefixed = {};
439
- for (const [k, v] of Object.entries(errors)) {
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
- return { error: null, value: validated };
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
- /** Must be an image (by MIME type). */
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
- * Maximum file size.
474
- * file().maxSize('2mb')
475
- * file().maxSize('500kb')
476
- * file().maxSize(1024 * 1024) // bytes
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
- maxSize(size, msg) {
479
- const bytes = typeof size === 'string' ? _parseSize(size) : size;
480
- return this._addRule(
481
- v => v.size <= bytes,
482
- msg || ((key) => `${this._fieldLabel(key)} must be smaller than ${typeof size === 'string' ? size : _formatSize(size)}`)
483
- );
484
- }
485
-
486
- /** Allowed MIME types. */
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
- static async _runSchema(schema, data) {
552
- const errors = {};
553
- const validated = {};
554
-
555
- for (const [key, rule] of Object.entries(schema)) {
556
- const value = data[key];
557
-
558
- // ── New API: validator instance ──────────────────────────────────────
559
- if (rule instanceof BaseValidator) {
560
- const result = await rule.run(value, key, data);
561
-
562
- if (result.error) {
563
- if (result._nested) {
564
- // Object validator returns prefixed nested errors
565
- Object.assign(errors, result.error);
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
- return { errors, validated };
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
- /** Backward-compatible pipe-string validation. */
594
- static _runPipeRules(field, value, ruleString) {
595
- const label = _titleCase(field);
596
- const rules = ruleString.split('|').map(r => r.trim());
597
- const isEmpty = value === undefined || value === null || value === '';
598
-
599
- for (const rule of rules) {
600
- const [name, arg] = rule.split(':');
601
-
602
- switch (name) {
603
- case 'required':
604
- if (isEmpty) return `${label} is required`;
605
- break;
606
- case 'optional':
607
- if (isEmpty) return null;
608
- break;
609
- case 'string':
610
- if (!isEmpty && typeof value !== 'string') return `${label} must be a string`;
611
- break;
612
- case 'number':
613
- if (!isEmpty && isNaN(Number(value))) return `${label} must be a number`;
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 };