json-rest-api 1.0.0 → 1.0.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/.claude/settings.local.json +16 -0
- package/LICENSE +674 -0
- package/README.md +655 -309
- package/RELATIONSHIPS.md +1013 -0
- package/TODO_rest-api-plugin-extras.js +142 -0
- package/connectors_ideas/express-plugin.js +120 -0
- package/connectors_ideas/fastify.js +219 -0
- package/connectors_ideas/http-plugin.js +138 -0
- package/connectors_ideas/koa-plugin.js +113 -0
- package/docs/fileUploads.md +722 -0
- package/docs/mini-guides/file-uploads-complete.md +159 -0
- package/examples/.gitkeep +0 -0
- package/examples/express-example.js +262 -0
- package/examples/express-plugin.js +366 -0
- package/examples/file-upload-example.js +205 -0
- package/examples/filename-strategies.js +140 -0
- package/examples/knex-basic.js +66 -0
- package/index.js +31 -56
- package/lib/payload-validators.js +685 -0
- package/lib/rest-api-errors.js +58 -0
- package/package.json +41 -14
- package/payloadValdators.js +686 -0
- package/plugins/core/connectors/express-plugin.js +449 -0
- package/plugins/core/connectors/http-plugin.js +450 -0
- package/plugins/core/file-handling-plugin.js +338 -0
- package/plugins/core/lib/busboy-detector.js +167 -0
- package/plugins/core/lib/formidable-detector.js +130 -0
- package/plugins/core/rest-api-knex-plugin.js +200 -0
- package/plugins/core/rest-api-plugin.js +1419 -0
- package/plugins/storage/local-storage.js +218 -0
- package/plugins/storage/s3-storage.js +125 -0
- package/test-examples/package-lock.json +906 -0
- package/test-examples/package.json +15 -0
- package/test-examples/test-basic-api.js +11 -0
- package/test-examples/test-books-resource.js +116 -0
- package/test-examples/test-complete-file-upload.js +140 -0
- package/test-examples/test-debug.js +28 -0
- package/test-examples/test-express-plugin.js +177 -0
- package/test-examples/test-express-simple.js +28 -0
- package/test-examples/test-express-with-import.js +71 -0
- package/test-examples/test-file-upload.js +132 -0
- package/test-examples/test-http-plugin.js +159 -0
- package/test-examples/test-programmatic-api.js +188 -0
- package/test-examples/test-rest-api-plugin.js +13 -0
- package/TODO.todo +0 -113
- package/api.js +0 -628
- package/docs/API.md +0 -1306
- package/docs/FIXES_APPLIED.md +0 -59
- package/docs/GUIDES.md +0 -1792
- package/docs/RESOURCES_IN_EXPRESS.md +0 -456
- package/docs/example-versioning.js +0 -168
- package/docs/example.js +0 -195
- package/plugins/API guide.md +0 -364
- package/plugins/api-registry.js +0 -142
- package/plugins/api-versioning.md +0 -135
- package/plugins/http.js +0 -273
- package/plugins/logging.js +0 -255
- package/plugins/memory.js +0 -182
- package/plugins/mysql.js +0 -753
- package/plugins/openapi.js +0 -401
- package/plugins/positioning.js +0 -220
- package/plugins/security.js +0 -261
- package/plugins/validation.js +0 -173
- package/plugins/versioning.js +0 -288
- package/resource-helper.js +0 -79
- package/schema.js +0 -481
- package/test-basic.js +0 -107
package/plugins/security.js
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Security plugin implementing industry-standard security practices
|
|
3
|
-
*/
|
|
4
|
-
export const SecurityPlugin = {
|
|
5
|
-
install(api, options = {}) {
|
|
6
|
-
const defaultOptions = {
|
|
7
|
-
rateLimit: {
|
|
8
|
-
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
9
|
-
max: 100, // limit each IP to 100 requests per windowMs
|
|
10
|
-
message: 'Too many requests from this IP'
|
|
11
|
-
},
|
|
12
|
-
cors: {
|
|
13
|
-
origin: '*',
|
|
14
|
-
credentials: true,
|
|
15
|
-
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
16
|
-
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
|
17
|
-
exposedHeaders: ['X-Total-Count', 'Link']
|
|
18
|
-
},
|
|
19
|
-
contentSecurityPolicy: {
|
|
20
|
-
directives: {
|
|
21
|
-
defaultSrc: ["'self'"],
|
|
22
|
-
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
23
|
-
scriptSrc: ["'self'"],
|
|
24
|
-
imgSrc: ["'self'", 'data:', 'https:'],
|
|
25
|
-
connectSrc: ["'self'"],
|
|
26
|
-
fontSrc: ["'self'"],
|
|
27
|
-
objectSrc: ["'none'"],
|
|
28
|
-
mediaSrc: ["'self'"],
|
|
29
|
-
frameSrc: ["'none'"]
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
authentication: {
|
|
33
|
-
type: 'bearer', // 'bearer', 'basic', 'apikey'
|
|
34
|
-
header: 'Authorization',
|
|
35
|
-
queryParam: 'api_key'
|
|
36
|
-
},
|
|
37
|
-
...options
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
// Store rate limit data
|
|
41
|
-
const rateLimitStore = new Map();
|
|
42
|
-
|
|
43
|
-
// Add security headers
|
|
44
|
-
if (api.router) {
|
|
45
|
-
// CORS
|
|
46
|
-
api.router.use((req, res, next) => {
|
|
47
|
-
const cors = defaultOptions.cors;
|
|
48
|
-
res.header('Access-Control-Allow-Origin', cors.origin);
|
|
49
|
-
res.header('Access-Control-Allow-Methods', cors.methods.join(', '));
|
|
50
|
-
res.header('Access-Control-Allow-Headers', cors.allowedHeaders.join(', '));
|
|
51
|
-
res.header('Access-Control-Expose-Headers', cors.exposedHeaders.join(', '));
|
|
52
|
-
res.header('Access-Control-Allow-Credentials', cors.credentials);
|
|
53
|
-
|
|
54
|
-
// Security headers
|
|
55
|
-
res.header('X-Content-Type-Options', 'nosniff');
|
|
56
|
-
res.header('X-Frame-Options', 'DENY');
|
|
57
|
-
res.header('X-XSS-Protection', '1; mode=block');
|
|
58
|
-
res.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
|
59
|
-
res.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
60
|
-
|
|
61
|
-
// Content Security Policy
|
|
62
|
-
const csp = Object.entries(defaultOptions.contentSecurityPolicy.directives)
|
|
63
|
-
.map(([key, values]) => `${key} ${values.join(' ')}`)
|
|
64
|
-
.join('; ');
|
|
65
|
-
res.header('Content-Security-Policy', csp);
|
|
66
|
-
|
|
67
|
-
next();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// Rate limiting
|
|
71
|
-
api.router.use((req, res, next) => {
|
|
72
|
-
const ip = req.ip || req.connection.remoteAddress;
|
|
73
|
-
const now = Date.now();
|
|
74
|
-
const windowStart = now - defaultOptions.rateLimit.windowMs;
|
|
75
|
-
|
|
76
|
-
// Clean old entries
|
|
77
|
-
const requests = rateLimitStore.get(ip) || [];
|
|
78
|
-
const recentRequests = requests.filter(time => time > windowStart);
|
|
79
|
-
|
|
80
|
-
if (recentRequests.length >= defaultOptions.rateLimit.max) {
|
|
81
|
-
return res.status(429).json({
|
|
82
|
-
errors: [{
|
|
83
|
-
status: '429',
|
|
84
|
-
title: 'Too Many Requests',
|
|
85
|
-
detail: defaultOptions.rateLimit.message
|
|
86
|
-
}]
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
recentRequests.push(now);
|
|
91
|
-
rateLimitStore.set(ip, recentRequests);
|
|
92
|
-
|
|
93
|
-
// Add rate limit headers
|
|
94
|
-
res.header('X-RateLimit-Limit', defaultOptions.rateLimit.max);
|
|
95
|
-
res.header('X-RateLimit-Remaining', defaultOptions.rateLimit.max - recentRequests.length);
|
|
96
|
-
res.header('X-RateLimit-Reset', new Date(now + defaultOptions.rateLimit.windowMs).toISOString());
|
|
97
|
-
|
|
98
|
-
next();
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
// Request ID for tracing
|
|
102
|
-
api.router.use((req, res, next) => {
|
|
103
|
-
req.id = req.headers['x-request-id'] || generateRequestId();
|
|
104
|
-
res.header('X-Request-ID', req.id);
|
|
105
|
-
next();
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Authentication hook
|
|
110
|
-
api.hook('beforeValidate', async (context) => {
|
|
111
|
-
// Skip auth check for read operations if configured
|
|
112
|
-
if (options.publicRead && (context.method === 'get' || context.method === 'query')) {
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const authConfig = defaultOptions.authentication;
|
|
117
|
-
let token = null;
|
|
118
|
-
|
|
119
|
-
// Extract token based on auth type
|
|
120
|
-
if (context.options.request) {
|
|
121
|
-
const req = context.options.request;
|
|
122
|
-
|
|
123
|
-
switch (authConfig.type) {
|
|
124
|
-
case 'bearer':
|
|
125
|
-
const authHeader = req.headers[authConfig.header.toLowerCase()];
|
|
126
|
-
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
127
|
-
token = authHeader.substring(7);
|
|
128
|
-
}
|
|
129
|
-
break;
|
|
130
|
-
|
|
131
|
-
case 'apikey':
|
|
132
|
-
token = req.headers[authConfig.header.toLowerCase()] ||
|
|
133
|
-
req.query[authConfig.queryParam];
|
|
134
|
-
break;
|
|
135
|
-
|
|
136
|
-
case 'basic':
|
|
137
|
-
const basicAuth = req.headers[authConfig.header.toLowerCase()];
|
|
138
|
-
if (basicAuth && basicAuth.startsWith('Basic ')) {
|
|
139
|
-
token = basicAuth.substring(6);
|
|
140
|
-
}
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Verify token
|
|
146
|
-
if (!token && authConfig.required !== false) {
|
|
147
|
-
context.errors.push({
|
|
148
|
-
field: null,
|
|
149
|
-
message: 'Authentication required',
|
|
150
|
-
code: 'AUTH_REQUIRED'
|
|
151
|
-
});
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (token && options.verifyToken) {
|
|
156
|
-
try {
|
|
157
|
-
const user = await options.verifyToken(token, context);
|
|
158
|
-
context.options.user = user;
|
|
159
|
-
context.options.authenticated = true;
|
|
160
|
-
} catch (error) {
|
|
161
|
-
context.errors.push({
|
|
162
|
-
field: null,
|
|
163
|
-
message: 'Invalid authentication token',
|
|
164
|
-
code: 'AUTH_INVALID'
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// Input sanitization
|
|
171
|
-
api.hook('beforeValidate', async (context) => {
|
|
172
|
-
if (context.data) {
|
|
173
|
-
context.data = sanitizeObject(context.data);
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// SQL injection protection for MySQL
|
|
178
|
-
api.hook('beforeQuery', async (context) => {
|
|
179
|
-
if (context.params.filter) {
|
|
180
|
-
// Validate filter keys against schema
|
|
181
|
-
const type = context.options.type;
|
|
182
|
-
const schema = api.schemas.get(type);
|
|
183
|
-
|
|
184
|
-
if (schema) {
|
|
185
|
-
for (const key in context.params.filter) {
|
|
186
|
-
if (!schema.structure[key] && !options.allowUnknownFilters) {
|
|
187
|
-
delete context.params.filter[key];
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// Add security methods
|
|
195
|
-
api.generateToken = (payload, expiresIn = '24h') => {
|
|
196
|
-
// This would use JWT in production
|
|
197
|
-
return Buffer.from(JSON.stringify({
|
|
198
|
-
...payload,
|
|
199
|
-
exp: Date.now() + parseDuration(expiresIn)
|
|
200
|
-
})).toString('base64');
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
api.verifyToken = (token) => {
|
|
204
|
-
try {
|
|
205
|
-
const decoded = JSON.parse(Buffer.from(token, 'base64').toString());
|
|
206
|
-
if (decoded.exp && decoded.exp < Date.now()) {
|
|
207
|
-
throw new Error('Token expired');
|
|
208
|
-
}
|
|
209
|
-
return decoded;
|
|
210
|
-
} catch (error) {
|
|
211
|
-
throw new Error('Invalid token');
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
// Helper functions
|
|
218
|
-
function generateRequestId() {
|
|
219
|
-
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function sanitizeObject(obj) {
|
|
223
|
-
if (typeof obj !== 'object' || obj === null) return obj;
|
|
224
|
-
|
|
225
|
-
const sanitized = Array.isArray(obj) ? [] : {};
|
|
226
|
-
|
|
227
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
228
|
-
// Remove potential XSS in keys
|
|
229
|
-
const cleanKey = key.replace(/[<>]/g, '');
|
|
230
|
-
|
|
231
|
-
if (typeof value === 'string') {
|
|
232
|
-
// Basic XSS prevention
|
|
233
|
-
sanitized[cleanKey] = value
|
|
234
|
-
.replace(/</g, '<')
|
|
235
|
-
.replace(/>/g, '>')
|
|
236
|
-
.replace(/"/g, '"')
|
|
237
|
-
.replace(/'/g, ''')
|
|
238
|
-
.replace(/\//g, '/');
|
|
239
|
-
} else if (typeof value === 'object') {
|
|
240
|
-
sanitized[cleanKey] = sanitizeObject(value);
|
|
241
|
-
} else {
|
|
242
|
-
sanitized[cleanKey] = value;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return sanitized;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function parseDuration(duration) {
|
|
250
|
-
const units = {
|
|
251
|
-
s: 1000,
|
|
252
|
-
m: 60 * 1000,
|
|
253
|
-
h: 60 * 60 * 1000,
|
|
254
|
-
d: 24 * 60 * 60 * 1000
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
const match = duration.match(/^(\d+)([smhd])$/);
|
|
258
|
-
if (!match) return 24 * 60 * 60 * 1000; // Default 24h
|
|
259
|
-
|
|
260
|
-
return parseInt(match[1]) * units[match[2]];
|
|
261
|
-
}
|
package/plugins/validation.js
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Validation plugin for JSON REST API
|
|
3
|
-
*/
|
|
4
|
-
export const ValidationPlugin = {
|
|
5
|
-
install(api, options = {}) {
|
|
6
|
-
// Initialize schemas map if not already present
|
|
7
|
-
if (!api.schemas) {
|
|
8
|
-
api.schemas = new Map();
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Get schema for a type
|
|
12
|
-
api.getSchema = (type) => {
|
|
13
|
-
return api.schemas.get(type);
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
// Add validation hooks
|
|
17
|
-
api.hook('beforeValidate', async (context) => {
|
|
18
|
-
const { method, data, options } = context;
|
|
19
|
-
const type = options.type || 'default';
|
|
20
|
-
const schema = api.schemas.get(type);
|
|
21
|
-
|
|
22
|
-
if (!schema) {
|
|
23
|
-
return; // No schema registered for this type
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Skip validation for certain methods
|
|
27
|
-
if (method === 'delete' || method === 'get' || method === 'query') {
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Determine validation options
|
|
32
|
-
const validationOptions = {
|
|
33
|
-
emptyAsNull: options.emptyAsNull ?? schema.options?.emptyAsNull ?? false,
|
|
34
|
-
canBeNull: options.canBeNull ?? schema.options?.canBeNull ?? false,
|
|
35
|
-
onlyObjectValues: method === 'update' && !options.fullRecord,
|
|
36
|
-
skipParams: options.skipParams
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
// For updates, we might need to fetch the existing record
|
|
40
|
-
if (method === 'update' && options.validateFullRecord) {
|
|
41
|
-
const impl = api.implementers.get('get');
|
|
42
|
-
if (impl) {
|
|
43
|
-
const existingRecord = await impl({
|
|
44
|
-
id: context.id,
|
|
45
|
-
options: context.options
|
|
46
|
-
});
|
|
47
|
-
if (existingRecord) {
|
|
48
|
-
context.existingRecord = existingRecord;
|
|
49
|
-
context.data = { ...existingRecord, ...data };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Validate the data
|
|
55
|
-
const { validatedObject, errors } = await schema.validate(
|
|
56
|
-
context.data,
|
|
57
|
-
validationOptions
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
// Update context with validated data and errors
|
|
61
|
-
context.data = validatedObject;
|
|
62
|
-
context.errors.push(...errors);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
// Add permission checking hook
|
|
66
|
-
api.hook('afterValidate', async (context) => {
|
|
67
|
-
const { method, options } = context;
|
|
68
|
-
const type = options.type || 'default';
|
|
69
|
-
|
|
70
|
-
// Check permissions if handler is registered
|
|
71
|
-
if (options.checkPermissions) {
|
|
72
|
-
const permissionHandler = options.checkPermissions;
|
|
73
|
-
const { granted, message } = await permissionHandler(context);
|
|
74
|
-
|
|
75
|
-
if (!granted) {
|
|
76
|
-
context.errors.push({
|
|
77
|
-
field: null,
|
|
78
|
-
message: message || 'Permission denied',
|
|
79
|
-
code: 'PERMISSION_DENIED'
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Run custom validation if provided
|
|
85
|
-
if (options.validate) {
|
|
86
|
-
await options.validate(context, context.errors);
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
// Add search schema validation for queries
|
|
91
|
-
api.hook('beforeQuery', async (context) => {
|
|
92
|
-
const { params, options } = context;
|
|
93
|
-
const type = options.type || 'default';
|
|
94
|
-
const schema = api.schemas.get(type);
|
|
95
|
-
|
|
96
|
-
if (!schema || !options.searchSchema) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Validate search parameters
|
|
101
|
-
const searchData = {};
|
|
102
|
-
|
|
103
|
-
// Extract search parameters from query params
|
|
104
|
-
if (params.filter) {
|
|
105
|
-
Object.assign(searchData, params.filter);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Add any custom search fields
|
|
109
|
-
if (params.search && options.searchField) {
|
|
110
|
-
searchData[options.searchField] = params.search;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Validate search parameters
|
|
114
|
-
const { validatedObject, errors } = await options.searchSchema.validate(
|
|
115
|
-
searchData,
|
|
116
|
-
{ onlyObjectValues: true }
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
if (errors.length > 0) {
|
|
120
|
-
context.errors.push(...errors);
|
|
121
|
-
} else {
|
|
122
|
-
// Update filter with validated values
|
|
123
|
-
if (params.filter) {
|
|
124
|
-
params.filter = validatedObject;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// Helper to create a search schema from main schema
|
|
130
|
-
api.createSearchSchema = (schema, searchableFields) => {
|
|
131
|
-
const searchStructure = {};
|
|
132
|
-
|
|
133
|
-
for (const field of searchableFields) {
|
|
134
|
-
if (schema.structure[field]) {
|
|
135
|
-
searchStructure[field] = {
|
|
136
|
-
...schema.structure[field],
|
|
137
|
-
required: false // Search fields are never required
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Use the same Schema class as the main schema
|
|
143
|
-
return new schema.constructor(searchStructure);
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
// Add field cleanup functionality
|
|
147
|
-
api.hook('transformResult', async (context) => {
|
|
148
|
-
const { result, options } = context;
|
|
149
|
-
const type = options.type || 'default';
|
|
150
|
-
const schema = api.schemas.get(type);
|
|
151
|
-
|
|
152
|
-
if (!result || !schema || !options.cleanupFields) {
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Remove fields not in schema
|
|
157
|
-
for (const key in result) {
|
|
158
|
-
if (!schema.structure[key]) {
|
|
159
|
-
delete result[key];
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Remove silent fields if requested
|
|
164
|
-
if (options.removeSilentFields) {
|
|
165
|
-
for (const [key, definition] of Object.entries(schema.structure)) {
|
|
166
|
-
if (definition.silent && result[key] !== undefined) {
|
|
167
|
-
delete result[key];
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
};
|