@zero-server/sdk 0.9.1 → 0.9.2
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/LICENSE +21 -21
- package/README.md +460 -443
- package/index.js +414 -412
- package/lib/app.js +1172 -1172
- package/lib/auth/authorize.js +399 -399
- package/lib/auth/enrollment.js +367 -367
- package/lib/auth/index.js +57 -57
- package/lib/auth/jwt.js +731 -731
- package/lib/auth/oauth.js +362 -362
- package/lib/auth/session.js +588 -588
- package/lib/auth/trustedDevice.js +409 -409
- package/lib/auth/twoFactor.js +1150 -1150
- package/lib/auth/webauthn.js +946 -946
- package/lib/body/index.js +14 -14
- package/lib/body/json.js +109 -109
- package/lib/body/multipart.js +440 -440
- package/lib/body/raw.js +71 -71
- package/lib/body/rawBuffer.js +160 -160
- package/lib/body/sendError.js +25 -25
- package/lib/body/text.js +75 -75
- package/lib/body/typeMatch.js +41 -41
- package/lib/body/urlencoded.js +235 -235
- package/lib/cli.js +845 -845
- package/lib/cluster.js +666 -666
- package/lib/debug.js +372 -372
- package/lib/env/index.js +465 -465
- package/lib/errors.js +683 -683
- package/lib/fetch/index.js +256 -256
- package/lib/grpc/balancer.js +378 -378
- package/lib/grpc/call.js +708 -708
- package/lib/grpc/client.js +764 -764
- package/lib/grpc/codec.js +1221 -1221
- package/lib/grpc/credentials.js +398 -398
- package/lib/grpc/frame.js +262 -262
- package/lib/grpc/health.js +287 -287
- package/lib/grpc/index.js +121 -121
- package/lib/grpc/metadata.js +461 -461
- package/lib/grpc/proto.js +821 -821
- package/lib/grpc/reflection.js +590 -590
- package/lib/grpc/server.js +445 -445
- package/lib/grpc/status.js +118 -118
- package/lib/grpc/watch.js +173 -173
- package/lib/http/index.js +10 -10
- package/lib/http/request.js +727 -727
- package/lib/http/response.js +799 -799
- package/lib/lifecycle.js +557 -557
- package/lib/middleware/compress.js +230 -230
- package/lib/middleware/cookieParser.js +237 -237
- package/lib/middleware/cors.js +93 -93
- package/lib/middleware/csrf.js +137 -137
- package/lib/middleware/errorHandler.js +101 -101
- package/lib/middleware/helmet.js +175 -175
- package/lib/middleware/index.js +19 -17
- package/lib/middleware/logger.js +74 -74
- package/lib/middleware/rateLimit.js +88 -88
- package/lib/middleware/requestId.js +53 -53
- package/lib/middleware/static.js +326 -326
- package/lib/middleware/timeout.js +71 -71
- package/lib/middleware/validator.js +255 -255
- package/lib/observe/health.js +326 -326
- package/lib/observe/index.js +50 -50
- package/lib/observe/logger.js +359 -359
- package/lib/observe/metrics.js +805 -805
- package/lib/observe/tracing.js +592 -592
- package/lib/orm/adapters/json.js +290 -290
- package/lib/orm/adapters/memory.js +764 -764
- package/lib/orm/adapters/mongo.js +764 -764
- package/lib/orm/adapters/mysql.js +933 -933
- package/lib/orm/adapters/postgres.js +1144 -1144
- package/lib/orm/adapters/redis.js +1534 -1534
- package/lib/orm/adapters/sql-base.js +212 -212
- package/lib/orm/adapters/sqlite.js +858 -858
- package/lib/orm/audit.js +649 -649
- package/lib/orm/cache.js +394 -394
- package/lib/orm/geo.js +387 -387
- package/lib/orm/index.js +784 -784
- package/lib/orm/migrate.js +432 -432
- package/lib/orm/model.js +1706 -1706
- package/lib/orm/plugin.js +375 -375
- package/lib/orm/procedures.js +836 -836
- package/lib/orm/profiler.js +233 -233
- package/lib/orm/query.js +1772 -1772
- package/lib/orm/replicas.js +241 -241
- package/lib/orm/schema.js +307 -307
- package/lib/orm/search.js +380 -380
- package/lib/orm/seed/data/commerce.js +136 -136
- package/lib/orm/seed/data/internet.js +111 -111
- package/lib/orm/seed/data/locations.js +204 -204
- package/lib/orm/seed/data/names.js +338 -338
- package/lib/orm/seed/data/person.js +128 -128
- package/lib/orm/seed/data/phone.js +211 -211
- package/lib/orm/seed/data/words.js +134 -134
- package/lib/orm/seed/factory.js +178 -178
- package/lib/orm/seed/fake.js +1186 -1186
- package/lib/orm/seed/index.js +18 -18
- package/lib/orm/seed/rng.js +70 -70
- package/lib/orm/seed/seeder.js +124 -124
- package/lib/orm/seed/unique.js +68 -68
- package/lib/orm/snapshot.js +366 -366
- package/lib/orm/tenancy.js +605 -605
- package/lib/orm/views.js +350 -350
- package/lib/router/index.js +436 -436
- package/lib/sse/index.js +8 -8
- package/lib/sse/stream.js +349 -349
- package/lib/ws/connection.js +451 -451
- package/lib/ws/handshake.js +125 -125
- package/lib/ws/index.js +14 -14
- package/lib/ws/room.js +223 -223
- package/package.json +73 -73
- package/types/app.d.ts +223 -223
- package/types/auth.d.ts +520 -520
- package/types/cluster.d.ts +75 -75
- package/types/env.d.ts +80 -80
- package/types/errors.d.ts +316 -316
- package/types/fetch.d.ts +43 -43
- package/types/grpc.d.ts +432 -432
- package/types/index.d.ts +384 -384
- package/types/lifecycle.d.ts +60 -60
- package/types/middleware.d.ts +320 -320
- package/types/observe.d.ts +304 -304
- package/types/orm.d.ts +1887 -1887
- package/types/request.d.ts +109 -109
- package/types/response.d.ts +157 -157
- package/types/router.d.ts +78 -78
- package/types/sse.d.ts +78 -78
- package/types/websocket.d.ts +126 -126
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module timeout
|
|
3
|
-
* @description Request timeout middleware.
|
|
4
|
-
* Automatically sends a 408 response if the handler doesn't
|
|
5
|
-
* respond within the configured time limit.
|
|
6
|
-
* Helps prevent Slowloris-style attacks and hung requests.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Create a request timeout middleware.
|
|
11
|
-
*
|
|
12
|
-
* @param {number} [ms=30000] - Timeout in milliseconds (default 30s).
|
|
1
|
+
/**
|
|
2
|
+
* @module timeout
|
|
3
|
+
* @description Request timeout middleware.
|
|
4
|
+
* Automatically sends a 408 response if the handler doesn't
|
|
5
|
+
* respond within the configured time limit.
|
|
6
|
+
* Helps prevent Slowloris-style attacks and hung requests.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create a request timeout middleware.
|
|
11
|
+
*
|
|
12
|
+
* @param {number} [ms=30000] - Timeout in milliseconds (default 30s).
|
|
13
13
|
* @param {object} [opts] - Configuration options.
|
|
14
|
-
* @param {number} [opts.status=408] - HTTP status code for timeout responses.
|
|
15
|
-
* @param {string} [opts.message='Request Timeout'] - Error message body.
|
|
16
|
-
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
17
|
-
*
|
|
18
|
-
* @example
|
|
19
|
-
* app.use(timeout(5000)); // 5 second timeout
|
|
20
|
-
* app.use(timeout(10000, { message: 'Too slow' }));
|
|
21
|
-
*/
|
|
22
|
-
const log = require('../debug')('zero:timeout');
|
|
23
|
-
|
|
24
|
-
function timeout(ms = 30000, opts = {})
|
|
25
|
-
{
|
|
26
|
-
if (typeof ms === 'object') { opts = ms; ms = 30000; }
|
|
27
|
-
|
|
28
|
-
const statusCode = opts.status || 408;
|
|
29
|
-
const message = opts.message || 'Request Timeout';
|
|
30
|
-
|
|
31
|
-
return (req, res, next) =>
|
|
32
|
-
{
|
|
33
|
-
let timedOut = false;
|
|
34
|
-
|
|
35
|
-
const timer = setTimeout(() =>
|
|
36
|
-
{
|
|
37
|
-
timedOut = true;
|
|
38
|
-
req._timedOut = true;
|
|
39
|
-
log.warn('request timed out after %dms: %s %s', ms, req.method, req.url);
|
|
40
|
-
|
|
41
|
-
// Only send response if headers haven't been sent yet
|
|
42
|
-
if (!res.headersSent && !res._sent)
|
|
43
|
-
{
|
|
44
|
-
res.status(statusCode).json({ error: message });
|
|
45
|
-
}
|
|
46
|
-
}, ms);
|
|
47
|
-
|
|
48
|
-
// Unref so the timer doesn't keep the process alive
|
|
49
|
-
if (timer.unref) timer.unref();
|
|
50
|
-
|
|
51
|
-
// Clear timeout when response finishes
|
|
52
|
-
const raw = res.raw;
|
|
53
|
-
const onFinish = () =>
|
|
54
|
-
{
|
|
55
|
-
clearTimeout(timer);
|
|
56
|
-
raw.removeListener('finish', onFinish);
|
|
57
|
-
raw.removeListener('close', onFinish);
|
|
58
|
-
};
|
|
59
|
-
raw.on('finish', onFinish);
|
|
60
|
-
raw.on('close', onFinish);
|
|
61
|
-
|
|
62
|
-
// Expose timedOut check on request
|
|
63
|
-
Object.defineProperty(req, 'timedOut', {
|
|
64
|
-
get() { return timedOut; },
|
|
65
|
-
configurable: true,
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
next();
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
module.exports = timeout;
|
|
14
|
+
* @param {number} [opts.status=408] - HTTP status code for timeout responses.
|
|
15
|
+
* @param {string} [opts.message='Request Timeout'] - Error message body.
|
|
16
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* app.use(timeout(5000)); // 5 second timeout
|
|
20
|
+
* app.use(timeout(10000, { message: 'Too slow' }));
|
|
21
|
+
*/
|
|
22
|
+
const log = require('../debug')('zero:timeout');
|
|
23
|
+
|
|
24
|
+
function timeout(ms = 30000, opts = {})
|
|
25
|
+
{
|
|
26
|
+
if (typeof ms === 'object') { opts = ms; ms = 30000; }
|
|
27
|
+
|
|
28
|
+
const statusCode = opts.status || 408;
|
|
29
|
+
const message = opts.message || 'Request Timeout';
|
|
30
|
+
|
|
31
|
+
return (req, res, next) =>
|
|
32
|
+
{
|
|
33
|
+
let timedOut = false;
|
|
34
|
+
|
|
35
|
+
const timer = setTimeout(() =>
|
|
36
|
+
{
|
|
37
|
+
timedOut = true;
|
|
38
|
+
req._timedOut = true;
|
|
39
|
+
log.warn('request timed out after %dms: %s %s', ms, req.method, req.url);
|
|
40
|
+
|
|
41
|
+
// Only send response if headers haven't been sent yet
|
|
42
|
+
if (!res.headersSent && !res._sent)
|
|
43
|
+
{
|
|
44
|
+
res.status(statusCode).json({ error: message });
|
|
45
|
+
}
|
|
46
|
+
}, ms);
|
|
47
|
+
|
|
48
|
+
// Unref so the timer doesn't keep the process alive
|
|
49
|
+
if (timer.unref) timer.unref();
|
|
50
|
+
|
|
51
|
+
// Clear timeout when response finishes
|
|
52
|
+
const raw = res.raw;
|
|
53
|
+
const onFinish = () =>
|
|
54
|
+
{
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
raw.removeListener('finish', onFinish);
|
|
57
|
+
raw.removeListener('close', onFinish);
|
|
58
|
+
};
|
|
59
|
+
raw.on('finish', onFinish);
|
|
60
|
+
raw.on('close', onFinish);
|
|
61
|
+
|
|
62
|
+
// Expose timedOut check on request
|
|
63
|
+
Object.defineProperty(req, 'timedOut', {
|
|
64
|
+
get() { return timedOut; },
|
|
65
|
+
configurable: true,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
next();
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = timeout;
|
|
@@ -1,255 +1,255 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module middleware/validator
|
|
3
|
-
* @description Request validation middleware.
|
|
4
|
-
* Validates `req.body`, `req.query`, and `req.params` against a
|
|
5
|
-
* schema object. Returns 422 with detailed errors on failure.
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* const { createApp, validate } = require('@zero-server/sdk');
|
|
9
|
-
* const app = createApp();
|
|
10
|
-
*
|
|
11
|
-
* app.post('/users', validate({
|
|
12
|
-
* body: {
|
|
13
|
-
* name: { type: 'string', required: true, minLength: 1, maxLength: 100 },
|
|
14
|
-
* email: { type: 'string', required: true, match: /^[^@]+@[^@]+\.[^@]+$/ },
|
|
15
|
-
* age: { type: 'integer', min: 0, max: 150 },
|
|
16
|
-
* },
|
|
17
|
-
* query: {
|
|
18
|
-
* format: { type: 'string', enum: ['json', 'xml'], default: 'json' },
|
|
19
|
-
* },
|
|
20
|
-
* }), (req, res) => {
|
|
21
|
-
* // req.body / req.query are now validated and sanitised
|
|
22
|
-
* });
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Supported shorthand types for validation rules.
|
|
27
|
-
* @private
|
|
28
|
-
*/
|
|
29
|
-
const COERCE = {
|
|
30
|
-
string(v) { return v == null ? v : String(v); },
|
|
31
|
-
integer(v) { const n = parseInt(v, 10); return Number.isNaN(n) ? v : n; },
|
|
32
|
-
number(v) { const n = Number(v); return Number.isNaN(n) ? v : n; },
|
|
33
|
-
float(v) { const n = parseFloat(v); return Number.isNaN(n) ? v : n; },
|
|
34
|
-
boolean(v)
|
|
35
|
-
{
|
|
36
|
-
if (typeof v === 'boolean') return v;
|
|
37
|
-
if (typeof v === 'string')
|
|
38
|
-
{
|
|
39
|
-
const l = v.toLowerCase();
|
|
40
|
-
if (l === 'true' || l === '1' || l === 'yes' || l === 'on') return true;
|
|
41
|
-
if (l === 'false' || l === '0' || l === 'no' || l === 'off') return false;
|
|
42
|
-
}
|
|
43
|
-
return v;
|
|
44
|
-
},
|
|
45
|
-
array(v)
|
|
46
|
-
{
|
|
47
|
-
if (Array.isArray(v)) return v;
|
|
48
|
-
if (typeof v === 'string')
|
|
49
|
-
{
|
|
50
|
-
try { const p = JSON.parse(v); if (Array.isArray(p)) return p; } catch {}
|
|
51
|
-
return v.split(',').map(s => s.trim());
|
|
52
|
-
}
|
|
53
|
-
return v;
|
|
54
|
-
},
|
|
55
|
-
json(v)
|
|
56
|
-
{
|
|
57
|
-
if (typeof v === 'string') { try { return JSON.parse(v); } catch {} }
|
|
58
|
-
return v;
|
|
59
|
-
},
|
|
60
|
-
date(v)
|
|
61
|
-
{
|
|
62
|
-
if (v instanceof Date) return v;
|
|
63
|
-
const d = new Date(v);
|
|
64
|
-
return Number.isNaN(d.getTime()) ? v : d;
|
|
65
|
-
},
|
|
66
|
-
uuid(v) { return v == null ? v : String(v); },
|
|
67
|
-
email(v) { return v == null ? v : String(v).trim().toLowerCase(); },
|
|
68
|
-
url(v) { return v == null ? v : String(v).trim(); },
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Validate a single value against a rule definition.
|
|
73
|
-
*
|
|
74
|
-
* @param {*} value - Raw input value.
|
|
75
|
-
* @param {object} rule - Rule definition.
|
|
76
|
-
* @param {string} field - Field name (for error messages).
|
|
77
|
-
* @returns {{ value: *, error: string|null }}
|
|
78
|
-
*/
|
|
79
|
-
function validateField(value, rule, field)
|
|
80
|
-
{
|
|
81
|
-
// Apply default
|
|
82
|
-
if ((value === undefined || value === null || value === '') && rule.default !== undefined)
|
|
83
|
-
{
|
|
84
|
-
value = typeof rule.default === 'function' ? rule.default() : rule.default;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Required check
|
|
88
|
-
if (rule.required && (value === undefined || value === null || value === ''))
|
|
89
|
-
{
|
|
90
|
-
return { value, error: `${field} is required` };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// If not required and absent, skip further checks
|
|
94
|
-
if (value === undefined || value === null) return { value, error: null };
|
|
95
|
-
|
|
96
|
-
// Type coercion
|
|
97
|
-
if (rule.type && COERCE[rule.type]) value = COERCE[rule.type](value);
|
|
98
|
-
|
|
99
|
-
// Type validation
|
|
100
|
-
if (rule.type)
|
|
101
|
-
{
|
|
102
|
-
switch (rule.type)
|
|
103
|
-
{
|
|
104
|
-
case 'string':
|
|
105
|
-
if (typeof value !== 'string') return { value, error: `${field} must be a string` };
|
|
106
|
-
break;
|
|
107
|
-
case 'integer':
|
|
108
|
-
if (!Number.isInteger(value)) return { value, error: `${field} must be an integer` };
|
|
109
|
-
break;
|
|
110
|
-
case 'number':
|
|
111
|
-
case 'float':
|
|
112
|
-
if (typeof value !== 'number' || Number.isNaN(value)) return { value, error: `${field} must be a number` };
|
|
113
|
-
break;
|
|
114
|
-
case 'boolean':
|
|
115
|
-
if (typeof value !== 'boolean') return { value, error: `${field} must be a boolean` };
|
|
116
|
-
break;
|
|
117
|
-
case 'array':
|
|
118
|
-
if (!Array.isArray(value)) return { value, error: `${field} must be an array` };
|
|
119
|
-
break;
|
|
120
|
-
case 'date':
|
|
121
|
-
if (!(value instanceof Date)) return { value, error: `${field} must be a valid date` };
|
|
122
|
-
break;
|
|
123
|
-
case 'email':
|
|
124
|
-
if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
125
|
-
return { value, error: `${field} must be a valid email` };
|
|
126
|
-
break;
|
|
127
|
-
case 'url':
|
|
128
|
-
try { new URL(value); }
|
|
129
|
-
catch { return { value, error: `${field} must be a valid URL` }; }
|
|
130
|
-
break;
|
|
131
|
-
case 'uuid':
|
|
132
|
-
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
|
133
|
-
return { value, error: `${field} must be a valid UUID` };
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Constraints
|
|
139
|
-
if (rule.minLength !== undefined && typeof value === 'string' && value.length < rule.minLength)
|
|
140
|
-
return { value, error: `${field} must be at least ${rule.minLength} characters` };
|
|
141
|
-
if (rule.maxLength !== undefined && typeof value === 'string' && value.length > rule.maxLength)
|
|
142
|
-
return { value, error: `${field} must be at most ${rule.maxLength} characters` };
|
|
143
|
-
if (rule.min !== undefined && typeof value === 'number' && value < rule.min)
|
|
144
|
-
return { value, error: `${field} must be >= ${rule.min}` };
|
|
145
|
-
if (rule.max !== undefined && typeof value === 'number' && value > rule.max)
|
|
146
|
-
return { value, error: `${field} must be <= ${rule.max}` };
|
|
147
|
-
if (rule.match && typeof value === 'string' && !rule.match.test(value))
|
|
148
|
-
return { value, error: `${field} format is invalid` };
|
|
149
|
-
if (rule.enum && !rule.enum.includes(value))
|
|
150
|
-
return { value, error: `${field} must be one of: ${rule.enum.join(', ')}` };
|
|
151
|
-
if (rule.minItems !== undefined && Array.isArray(value) && value.length < rule.minItems)
|
|
152
|
-
return { value, error: `${field} must have at least ${rule.minItems} items` };
|
|
153
|
-
if (rule.maxItems !== undefined && Array.isArray(value) && value.length > rule.maxItems)
|
|
154
|
-
return { value, error: `${field} must have at most ${rule.maxItems} items` };
|
|
155
|
-
|
|
156
|
-
// Custom validator function
|
|
157
|
-
if (typeof rule.validate === 'function')
|
|
158
|
-
{
|
|
159
|
-
const msg = rule.validate(value);
|
|
160
|
-
if (typeof msg === 'string') return { value, error: msg };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return { value, error: null };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Validate an object against a schema.
|
|
168
|
-
*
|
|
169
|
-
* @param {object} data - Input data.
|
|
170
|
-
* @param {object} schema - { fieldName: ruleObject }
|
|
171
|
-
* @param {object} [opts] - Configuration options.
|
|
172
|
-
* @param {boolean} [opts.stripUnknown=true] - Remove fields not in schema.
|
|
173
|
-
* @returns {{ sanitized: object, errors: string[] }}
|
|
174
|
-
*/
|
|
175
|
-
function validateObject(data, schema, opts = {})
|
|
176
|
-
{
|
|
177
|
-
const errors = [];
|
|
178
|
-
const sanitized = {};
|
|
179
|
-
const stripUnknown = opts.stripUnknown !== false;
|
|
180
|
-
const source = data || {};
|
|
181
|
-
|
|
182
|
-
for (const [field, rule] of Object.entries(schema))
|
|
183
|
-
{
|
|
184
|
-
const { value, error } = validateField(source[field], rule, field);
|
|
185
|
-
if (error) errors.push(error);
|
|
186
|
-
else if (value !== undefined) sanitized[field] = value;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Preserve unknown fields if not stripping
|
|
190
|
-
if (!stripUnknown)
|
|
191
|
-
{
|
|
192
|
-
for (const key of Object.keys(source))
|
|
193
|
-
{
|
|
194
|
-
if (!(key in schema)) sanitized[key] = source[key];
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return { sanitized, errors };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Create a validation middleware.
|
|
203
|
-
*
|
|
204
|
-
* @param {object} schema - Validation rules object.
|
|
205
|
-
* @param {object} [schema.body] - Rules for req.body fields.
|
|
206
|
-
* @param {object} [schema.query] - Rules for req.query fields.
|
|
207
|
-
* @param {object} [schema.params] - Rules for req.params fields.
|
|
208
|
-
* @param {object} [options] - Validation options.
|
|
209
|
-
* @param {boolean} [options.stripUnknown=true] - Remove fields not in schema.
|
|
210
|
-
* @param {Function} [options.onError] - Custom error handler `(errors, req, res) => {}`.
|
|
211
|
-
* @returns {Function} Middleware function.
|
|
212
|
-
*/
|
|
213
|
-
function validate(schema, options = {})
|
|
214
|
-
{
|
|
215
|
-
return function validatorMiddleware(req, res, next)
|
|
216
|
-
{
|
|
217
|
-
const allErrors = [];
|
|
218
|
-
|
|
219
|
-
if (schema.body)
|
|
220
|
-
{
|
|
221
|
-
const { sanitized, errors } = validateObject(req.body, schema.body, options);
|
|
222
|
-
if (errors.length) allErrors.push(...errors.map(e => `body.${e}`));
|
|
223
|
-
else req.body = sanitized;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (schema.query)
|
|
227
|
-
{
|
|
228
|
-
const { sanitized, errors } = validateObject(req.query, schema.query, options);
|
|
229
|
-
if (errors.length) allErrors.push(...errors.map(e => `query.${e}`));
|
|
230
|
-
else req.query = sanitized;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (schema.params)
|
|
234
|
-
{
|
|
235
|
-
const { sanitized, errors } = validateObject(req.params, schema.params, options);
|
|
236
|
-
if (errors.length) allErrors.push(...errors.map(e => `params.${e}`));
|
|
237
|
-
else req.params = sanitized;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (allErrors.length > 0)
|
|
241
|
-
{
|
|
242
|
-
if (options.onError) return options.onError(allErrors, req, res);
|
|
243
|
-
res.status(422).json({ errors: allErrors });
|
|
244
|
-
return;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
next();
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Also export helpers for standalone use
|
|
252
|
-
validate.field = validateField;
|
|
253
|
-
validate.object = validateObject;
|
|
254
|
-
|
|
255
|
-
module.exports = validate;
|
|
1
|
+
/**
|
|
2
|
+
* @module middleware/validator
|
|
3
|
+
* @description Request validation middleware.
|
|
4
|
+
* Validates `req.body`, `req.query`, and `req.params` against a
|
|
5
|
+
* schema object. Returns 422 with detailed errors on failure.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { createApp, validate } = require('@zero-server/sdk');
|
|
9
|
+
* const app = createApp();
|
|
10
|
+
*
|
|
11
|
+
* app.post('/users', validate({
|
|
12
|
+
* body: {
|
|
13
|
+
* name: { type: 'string', required: true, minLength: 1, maxLength: 100 },
|
|
14
|
+
* email: { type: 'string', required: true, match: /^[^@]+@[^@]+\.[^@]+$/ },
|
|
15
|
+
* age: { type: 'integer', min: 0, max: 150 },
|
|
16
|
+
* },
|
|
17
|
+
* query: {
|
|
18
|
+
* format: { type: 'string', enum: ['json', 'xml'], default: 'json' },
|
|
19
|
+
* },
|
|
20
|
+
* }), (req, res) => {
|
|
21
|
+
* // req.body / req.query are now validated and sanitised
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Supported shorthand types for validation rules.
|
|
27
|
+
* @private
|
|
28
|
+
*/
|
|
29
|
+
const COERCE = {
|
|
30
|
+
string(v) { return v == null ? v : String(v); },
|
|
31
|
+
integer(v) { const n = parseInt(v, 10); return Number.isNaN(n) ? v : n; },
|
|
32
|
+
number(v) { const n = Number(v); return Number.isNaN(n) ? v : n; },
|
|
33
|
+
float(v) { const n = parseFloat(v); return Number.isNaN(n) ? v : n; },
|
|
34
|
+
boolean(v)
|
|
35
|
+
{
|
|
36
|
+
if (typeof v === 'boolean') return v;
|
|
37
|
+
if (typeof v === 'string')
|
|
38
|
+
{
|
|
39
|
+
const l = v.toLowerCase();
|
|
40
|
+
if (l === 'true' || l === '1' || l === 'yes' || l === 'on') return true;
|
|
41
|
+
if (l === 'false' || l === '0' || l === 'no' || l === 'off') return false;
|
|
42
|
+
}
|
|
43
|
+
return v;
|
|
44
|
+
},
|
|
45
|
+
array(v)
|
|
46
|
+
{
|
|
47
|
+
if (Array.isArray(v)) return v;
|
|
48
|
+
if (typeof v === 'string')
|
|
49
|
+
{
|
|
50
|
+
try { const p = JSON.parse(v); if (Array.isArray(p)) return p; } catch {}
|
|
51
|
+
return v.split(',').map(s => s.trim());
|
|
52
|
+
}
|
|
53
|
+
return v;
|
|
54
|
+
},
|
|
55
|
+
json(v)
|
|
56
|
+
{
|
|
57
|
+
if (typeof v === 'string') { try { return JSON.parse(v); } catch {} }
|
|
58
|
+
return v;
|
|
59
|
+
},
|
|
60
|
+
date(v)
|
|
61
|
+
{
|
|
62
|
+
if (v instanceof Date) return v;
|
|
63
|
+
const d = new Date(v);
|
|
64
|
+
return Number.isNaN(d.getTime()) ? v : d;
|
|
65
|
+
},
|
|
66
|
+
uuid(v) { return v == null ? v : String(v); },
|
|
67
|
+
email(v) { return v == null ? v : String(v).trim().toLowerCase(); },
|
|
68
|
+
url(v) { return v == null ? v : String(v).trim(); },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate a single value against a rule definition.
|
|
73
|
+
*
|
|
74
|
+
* @param {*} value - Raw input value.
|
|
75
|
+
* @param {object} rule - Rule definition.
|
|
76
|
+
* @param {string} field - Field name (for error messages).
|
|
77
|
+
* @returns {{ value: *, error: string|null }}
|
|
78
|
+
*/
|
|
79
|
+
function validateField(value, rule, field)
|
|
80
|
+
{
|
|
81
|
+
// Apply default
|
|
82
|
+
if ((value === undefined || value === null || value === '') && rule.default !== undefined)
|
|
83
|
+
{
|
|
84
|
+
value = typeof rule.default === 'function' ? rule.default() : rule.default;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Required check
|
|
88
|
+
if (rule.required && (value === undefined || value === null || value === ''))
|
|
89
|
+
{
|
|
90
|
+
return { value, error: `${field} is required` };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If not required and absent, skip further checks
|
|
94
|
+
if (value === undefined || value === null) return { value, error: null };
|
|
95
|
+
|
|
96
|
+
// Type coercion
|
|
97
|
+
if (rule.type && COERCE[rule.type]) value = COERCE[rule.type](value);
|
|
98
|
+
|
|
99
|
+
// Type validation
|
|
100
|
+
if (rule.type)
|
|
101
|
+
{
|
|
102
|
+
switch (rule.type)
|
|
103
|
+
{
|
|
104
|
+
case 'string':
|
|
105
|
+
if (typeof value !== 'string') return { value, error: `${field} must be a string` };
|
|
106
|
+
break;
|
|
107
|
+
case 'integer':
|
|
108
|
+
if (!Number.isInteger(value)) return { value, error: `${field} must be an integer` };
|
|
109
|
+
break;
|
|
110
|
+
case 'number':
|
|
111
|
+
case 'float':
|
|
112
|
+
if (typeof value !== 'number' || Number.isNaN(value)) return { value, error: `${field} must be a number` };
|
|
113
|
+
break;
|
|
114
|
+
case 'boolean':
|
|
115
|
+
if (typeof value !== 'boolean') return { value, error: `${field} must be a boolean` };
|
|
116
|
+
break;
|
|
117
|
+
case 'array':
|
|
118
|
+
if (!Array.isArray(value)) return { value, error: `${field} must be an array` };
|
|
119
|
+
break;
|
|
120
|
+
case 'date':
|
|
121
|
+
if (!(value instanceof Date)) return { value, error: `${field} must be a valid date` };
|
|
122
|
+
break;
|
|
123
|
+
case 'email':
|
|
124
|
+
if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
|
|
125
|
+
return { value, error: `${field} must be a valid email` };
|
|
126
|
+
break;
|
|
127
|
+
case 'url':
|
|
128
|
+
try { new URL(value); }
|
|
129
|
+
catch { return { value, error: `${field} must be a valid URL` }; }
|
|
130
|
+
break;
|
|
131
|
+
case 'uuid':
|
|
132
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
|
133
|
+
return { value, error: `${field} must be a valid UUID` };
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Constraints
|
|
139
|
+
if (rule.minLength !== undefined && typeof value === 'string' && value.length < rule.minLength)
|
|
140
|
+
return { value, error: `${field} must be at least ${rule.minLength} characters` };
|
|
141
|
+
if (rule.maxLength !== undefined && typeof value === 'string' && value.length > rule.maxLength)
|
|
142
|
+
return { value, error: `${field} must be at most ${rule.maxLength} characters` };
|
|
143
|
+
if (rule.min !== undefined && typeof value === 'number' && value < rule.min)
|
|
144
|
+
return { value, error: `${field} must be >= ${rule.min}` };
|
|
145
|
+
if (rule.max !== undefined && typeof value === 'number' && value > rule.max)
|
|
146
|
+
return { value, error: `${field} must be <= ${rule.max}` };
|
|
147
|
+
if (rule.match && typeof value === 'string' && !rule.match.test(value))
|
|
148
|
+
return { value, error: `${field} format is invalid` };
|
|
149
|
+
if (rule.enum && !rule.enum.includes(value))
|
|
150
|
+
return { value, error: `${field} must be one of: ${rule.enum.join(', ')}` };
|
|
151
|
+
if (rule.minItems !== undefined && Array.isArray(value) && value.length < rule.minItems)
|
|
152
|
+
return { value, error: `${field} must have at least ${rule.minItems} items` };
|
|
153
|
+
if (rule.maxItems !== undefined && Array.isArray(value) && value.length > rule.maxItems)
|
|
154
|
+
return { value, error: `${field} must have at most ${rule.maxItems} items` };
|
|
155
|
+
|
|
156
|
+
// Custom validator function
|
|
157
|
+
if (typeof rule.validate === 'function')
|
|
158
|
+
{
|
|
159
|
+
const msg = rule.validate(value);
|
|
160
|
+
if (typeof msg === 'string') return { value, error: msg };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { value, error: null };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Validate an object against a schema.
|
|
168
|
+
*
|
|
169
|
+
* @param {object} data - Input data.
|
|
170
|
+
* @param {object} schema - { fieldName: ruleObject }
|
|
171
|
+
* @param {object} [opts] - Configuration options.
|
|
172
|
+
* @param {boolean} [opts.stripUnknown=true] - Remove fields not in schema.
|
|
173
|
+
* @returns {{ sanitized: object, errors: string[] }}
|
|
174
|
+
*/
|
|
175
|
+
function validateObject(data, schema, opts = {})
|
|
176
|
+
{
|
|
177
|
+
const errors = [];
|
|
178
|
+
const sanitized = {};
|
|
179
|
+
const stripUnknown = opts.stripUnknown !== false;
|
|
180
|
+
const source = data || {};
|
|
181
|
+
|
|
182
|
+
for (const [field, rule] of Object.entries(schema))
|
|
183
|
+
{
|
|
184
|
+
const { value, error } = validateField(source[field], rule, field);
|
|
185
|
+
if (error) errors.push(error);
|
|
186
|
+
else if (value !== undefined) sanitized[field] = value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Preserve unknown fields if not stripping
|
|
190
|
+
if (!stripUnknown)
|
|
191
|
+
{
|
|
192
|
+
for (const key of Object.keys(source))
|
|
193
|
+
{
|
|
194
|
+
if (!(key in schema)) sanitized[key] = source[key];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { sanitized, errors };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Create a validation middleware.
|
|
203
|
+
*
|
|
204
|
+
* @param {object} schema - Validation rules object.
|
|
205
|
+
* @param {object} [schema.body] - Rules for req.body fields.
|
|
206
|
+
* @param {object} [schema.query] - Rules for req.query fields.
|
|
207
|
+
* @param {object} [schema.params] - Rules for req.params fields.
|
|
208
|
+
* @param {object} [options] - Validation options.
|
|
209
|
+
* @param {boolean} [options.stripUnknown=true] - Remove fields not in schema.
|
|
210
|
+
* @param {Function} [options.onError] - Custom error handler `(errors, req, res) => {}`.
|
|
211
|
+
* @returns {Function} Middleware function.
|
|
212
|
+
*/
|
|
213
|
+
function validate(schema, options = {})
|
|
214
|
+
{
|
|
215
|
+
return function validatorMiddleware(req, res, next)
|
|
216
|
+
{
|
|
217
|
+
const allErrors = [];
|
|
218
|
+
|
|
219
|
+
if (schema.body)
|
|
220
|
+
{
|
|
221
|
+
const { sanitized, errors } = validateObject(req.body, schema.body, options);
|
|
222
|
+
if (errors.length) allErrors.push(...errors.map(e => `body.${e}`));
|
|
223
|
+
else req.body = sanitized;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (schema.query)
|
|
227
|
+
{
|
|
228
|
+
const { sanitized, errors } = validateObject(req.query, schema.query, options);
|
|
229
|
+
if (errors.length) allErrors.push(...errors.map(e => `query.${e}`));
|
|
230
|
+
else req.query = sanitized;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (schema.params)
|
|
234
|
+
{
|
|
235
|
+
const { sanitized, errors } = validateObject(req.params, schema.params, options);
|
|
236
|
+
if (errors.length) allErrors.push(...errors.map(e => `params.${e}`));
|
|
237
|
+
else req.params = sanitized;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (allErrors.length > 0)
|
|
241
|
+
{
|
|
242
|
+
if (options.onError) return options.onError(allErrors, req, res);
|
|
243
|
+
res.status(422).json({ errors: allErrors });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
next();
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Also export helpers for standalone use
|
|
252
|
+
validate.field = validateField;
|
|
253
|
+
validate.object = validateObject;
|
|
254
|
+
|
|
255
|
+
module.exports = validate;
|