webspresso 0.0.98 → 0.0.100
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/.agents/ADMIN.md +11 -1
- package/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +415 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +178 -4
- package/.agents/PLUGINS.md +113 -8
- package/.agents/QUEUE.md +92 -0
- package/.agents/ROUTING.md +160 -7
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +123 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +477 -3
- package/core/auth/middleware.js +118 -5
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +507 -0
- package/core/auth/user-agent.js +161 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +10 -1
- package/core/orm/model.js +55 -1
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +327 -16
- package/core/orm/scopes.js +3 -2
- package/core/orm/search.js +213 -0
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +544 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +100 -1
- package/core/queue/scheduler.js +415 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +794 -6
- package/index.js +48 -0
- package/package.json +7 -5
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +69 -53
- package/plugins/file-manager/index.js +25 -8
- package/plugins/index.js +8 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -0
- package/src/bundle/index.js +2 -1
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +310 -104
- package/src/helpers.js +16 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +490 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Key Plugin — Express Middleware & Request Extraction
|
|
3
|
+
* @module plugins/api-key/middleware
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const { hashKey, safeCompare } = require('./crypto');
|
|
9
|
+
const { hasRequiredScopes, normalizeScopes } = require('./scopes');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if request matches path prefix list
|
|
13
|
+
*
|
|
14
|
+
* @param {string} path
|
|
15
|
+
* @param {string|string[]|boolean} routes
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
function shouldApplyToRoute(path, routes) {
|
|
19
|
+
if (routes === true || routes == null) return true;
|
|
20
|
+
const list = Array.isArray(routes) ? routes : [routes];
|
|
21
|
+
return list.some((prefix) => path.startsWith(prefix));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Check if request path should be skipped
|
|
26
|
+
*
|
|
27
|
+
* @param {import('express').Request} req
|
|
28
|
+
* @param {string[]} skipPaths
|
|
29
|
+
* @param {Function} [skipFn]
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
function isSkipped(req, skipPaths = [], skipFn) {
|
|
33
|
+
if (typeof skipFn === 'function' && skipFn(req)) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const p = req.path || req.url || '';
|
|
37
|
+
if (Array.isArray(skipPaths) && skipPaths.some((prefix) => p.startsWith(prefix))) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract raw API key from incoming request
|
|
45
|
+
*
|
|
46
|
+
* @param {import('express').Request} req
|
|
47
|
+
* @param {Object} [options]
|
|
48
|
+
* @returns {string|null}
|
|
49
|
+
*/
|
|
50
|
+
function extractApiKey(req, options = {}) {
|
|
51
|
+
if (typeof options.extractKey === 'function') {
|
|
52
|
+
const customKey = options.extractKey(req);
|
|
53
|
+
if (customKey && typeof customKey === 'string') {
|
|
54
|
+
return customKey.trim();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const headers = req.headers || {};
|
|
59
|
+
|
|
60
|
+
// 1. Check custom configured header if given
|
|
61
|
+
if (options.headerName && typeof options.headerName === 'string') {
|
|
62
|
+
const headerVal = headers[options.headerName.toLowerCase()];
|
|
63
|
+
if (headerVal && typeof headerVal === 'string') {
|
|
64
|
+
return headerVal.trim();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Check standard X-API-Key header
|
|
69
|
+
const xApiKey = headers['x-api-key'];
|
|
70
|
+
if (xApiKey && typeof xApiKey === 'string') {
|
|
71
|
+
return xApiKey.trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. Check Api-Key header
|
|
75
|
+
const apiKeyHdr = headers['api-key'];
|
|
76
|
+
if (apiKeyHdr && typeof apiKeyHdr === 'string') {
|
|
77
|
+
return apiKeyHdr.trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 4. Check Authorization: Bearer <key> or Authorization: ApiKey <key>
|
|
81
|
+
const authHeader = headers['authorization'];
|
|
82
|
+
if (authHeader && typeof authHeader === 'string') {
|
|
83
|
+
const trimmed = authHeader.trim();
|
|
84
|
+
if (/^bearer\s+/i.test(trimmed)) {
|
|
85
|
+
return trimmed.replace(/^bearer\s+/i, '').trim();
|
|
86
|
+
}
|
|
87
|
+
if (/^apikey\s+/i.test(trimmed)) {
|
|
88
|
+
return trimmed.replace(/^apikey\s+/i, '').trim();
|
|
89
|
+
}
|
|
90
|
+
if (/^api-key\s+/i.test(trimmed)) {
|
|
91
|
+
return trimmed.replace(/^api-key\s+/i, '').trim();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 5. Check query parameter if explicitly enabled
|
|
96
|
+
const allowQuery = options.allowQueryParam === true || options.queryParam === true;
|
|
97
|
+
if (allowQuery && req.query && typeof req.query === 'object') {
|
|
98
|
+
const queryParamName = options.queryParamName || 'apiKey';
|
|
99
|
+
const val = req.query[queryParamName] || req.query.apiKey || req.query.api_key;
|
|
100
|
+
if (val && typeof val === 'string') {
|
|
101
|
+
return val.trim();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if the request accepts or prefers JSON
|
|
110
|
+
*
|
|
111
|
+
* @param {import('express').Request} req
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
function prefersJson(req) {
|
|
115
|
+
const p = req.path || req.url || '';
|
|
116
|
+
if (p.startsWith('/api/') || p.startsWith('/_admin/api/')) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
const accept = req.headers && req.headers.accept;
|
|
120
|
+
if (accept && accept.includes('application/json')) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
return !!(req.xhr || (req.headers && req.headers['x-requested-with'] === 'XMLHttpRequest'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Send 401 Unauthorized response
|
|
128
|
+
*
|
|
129
|
+
* @param {import('express').Request} req
|
|
130
|
+
* @param {import('express').Response} res
|
|
131
|
+
* @param {Object} config
|
|
132
|
+
* @param {string} message
|
|
133
|
+
*/
|
|
134
|
+
function sendUnauthorized(req, res, config, message = 'Authentication required: API key is missing or invalid.') {
|
|
135
|
+
res.statusCode = 401;
|
|
136
|
+
|
|
137
|
+
if (typeof config.unauthorizedResponse === 'function') {
|
|
138
|
+
return config.unauthorizedResponse(req, res, { reason: 'unauthorized', message });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (config.unauthorizedResponse && typeof config.unauthorizedResponse === 'object') {
|
|
142
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
143
|
+
return res.end(JSON.stringify(config.unauthorizedResponse));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (typeof config.unauthorizedResponse === 'string') {
|
|
147
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
148
|
+
return res.end(config.unauthorizedResponse);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (prefersJson(req)) {
|
|
152
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
153
|
+
return res.end(JSON.stringify({ error: 'Unauthorized', message }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
157
|
+
res.end('Unauthorized');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Send 403 Forbidden response
|
|
162
|
+
*
|
|
163
|
+
* @param {import('express').Request} req
|
|
164
|
+
* @param {import('express').Response} res
|
|
165
|
+
* @param {Object} config
|
|
166
|
+
* @param {string} message
|
|
167
|
+
* @param {Object} [details]
|
|
168
|
+
*/
|
|
169
|
+
function sendForbidden(req, res, config, message = 'Forbidden: Insufficient API key permissions.', details = {}) {
|
|
170
|
+
res.statusCode = 403;
|
|
171
|
+
|
|
172
|
+
if (typeof config.forbiddenResponse === 'function') {
|
|
173
|
+
return config.forbiddenResponse(req, res, { reason: 'forbidden', message, ...details });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (config.forbiddenResponse && typeof config.forbiddenResponse === 'object') {
|
|
177
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
178
|
+
return res.end(JSON.stringify(config.forbiddenResponse));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (typeof config.forbiddenResponse === 'string') {
|
|
182
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
183
|
+
return res.end(config.forbiddenResponse);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (prefersJson(req)) {
|
|
187
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
188
|
+
return res.end(JSON.stringify({
|
|
189
|
+
error: 'Forbidden',
|
|
190
|
+
message,
|
|
191
|
+
...(details.requiredScopes ? { requiredScopes: details.requiredScopes } : {}),
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
196
|
+
res.end('Forbidden');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Helper to resolve Knex instance from options or request
|
|
201
|
+
*
|
|
202
|
+
* @param {Object} config
|
|
203
|
+
* @param {import('express').Request} [req]
|
|
204
|
+
* @returns {any|null}
|
|
205
|
+
*/
|
|
206
|
+
function resolveKnex(config, req) {
|
|
207
|
+
if (config.knex) return config.knex;
|
|
208
|
+
if (config.db) {
|
|
209
|
+
if (typeof config.db === 'function') return config.db;
|
|
210
|
+
if (config.db.knex) return config.db.knex;
|
|
211
|
+
}
|
|
212
|
+
if (req && req.db) {
|
|
213
|
+
if (typeof req.db === 'function') return req.db;
|
|
214
|
+
if (req.db.knex) return req.db.knex;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Find API key record from static in-memory keys
|
|
221
|
+
*
|
|
222
|
+
* @param {string} rawKey
|
|
223
|
+
* @param {string} keyHash
|
|
224
|
+
* @param {Array|Object} keys
|
|
225
|
+
* @returns {Object|null}
|
|
226
|
+
*/
|
|
227
|
+
function findInStaticKeys(rawKey, keyHash, keys) {
|
|
228
|
+
if (!keys) return null;
|
|
229
|
+
|
|
230
|
+
if (Array.isArray(keys)) {
|
|
231
|
+
for (const item of keys) {
|
|
232
|
+
if (item.key && safeCompare(item.key, rawKey)) {
|
|
233
|
+
return item;
|
|
234
|
+
}
|
|
235
|
+
const itemHash = item.key_hash || item.keyHash || (item.key ? hashKey(item.key) : null);
|
|
236
|
+
if (itemHash && safeCompare(itemHash, keyHash)) {
|
|
237
|
+
return item;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} else if (typeof keys === 'object') {
|
|
241
|
+
// Map of key/hash => record
|
|
242
|
+
for (const [keyOrHash, val] of Object.entries(keys)) {
|
|
243
|
+
if (safeCompare(keyOrHash, rawKey) || safeCompare(keyOrHash, keyHash)) {
|
|
244
|
+
return typeof val === 'object' && val !== null ? { ...val } : { name: val, key_hash: keyHash };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create API Key Express middleware
|
|
254
|
+
*
|
|
255
|
+
* @param {Object} [options]
|
|
256
|
+
* @param {any} [options.db] - Webspresso db or Knex instance
|
|
257
|
+
* @param {any} [options.knex] - Knex instance
|
|
258
|
+
* @param {string} [options.tableName='api_keys'] - API keys table name
|
|
259
|
+
* @param {Array|Object} [options.keys] - Static in-memory keys for testing/serverless
|
|
260
|
+
* @param {string|string[]} [options.scopes] - Required scopes for route
|
|
261
|
+
* @param {boolean} [options.requireAllScopes=true] - Require all scopes (true) or any scope (false)
|
|
262
|
+
* @param {boolean} [options.optional=false] - If true, passes through when no key is provided
|
|
263
|
+
* @param {boolean} [options.trackUsage=true] - Automatically update last_used_at
|
|
264
|
+
* @param {number} [options.updateLastUsedThrottleMs=60000] - Throttle time for last_used_at writes
|
|
265
|
+
* @param {Function} [options.findKeyByHash] - Custom lookup (hash, req) => Promise<record|null>
|
|
266
|
+
* @param {Function} [options.verify] - Custom verification (rawKey, req) => Promise<record|null>
|
|
267
|
+
* @param {Function} [options.getUser] - Custom user loader (record, req) => Promise<user|null>
|
|
268
|
+
* @param {Function} [options.onAuthenticated] - Callback (req, record) => void
|
|
269
|
+
* @param {boolean} [options.allowQueryParam=false] - Allow query parameter extraction
|
|
270
|
+
* @param {string} [options.queryParamName='apiKey'] - Query parameter name
|
|
271
|
+
* @param {string|string[]|boolean} [options.routes=true] - Route prefixes to protect
|
|
272
|
+
* @param {string[]} [options.skipPaths] - Array of path prefixes to skip
|
|
273
|
+
* @param {Function} [options.skip] - Custom predicate function (req) => boolean
|
|
274
|
+
* @param {Function|string|Object} [options.unauthorizedResponse] - Custom 401 response
|
|
275
|
+
* @param {Function|string|Object} [options.forbiddenResponse] - Custom 403 response
|
|
276
|
+
* @returns {Function} Express middleware handler
|
|
277
|
+
*/
|
|
278
|
+
function createApiKeyMiddleware(options = {}) {
|
|
279
|
+
const config = {
|
|
280
|
+
tableName: 'api_keys',
|
|
281
|
+
requireAllScopes: true,
|
|
282
|
+
optional: false,
|
|
283
|
+
trackUsage: true,
|
|
284
|
+
updateLastUsedThrottleMs: 60000,
|
|
285
|
+
routes: true,
|
|
286
|
+
skipPaths: [],
|
|
287
|
+
...options,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const requiredScopes = normalizeScopes(config.scopes);
|
|
291
|
+
|
|
292
|
+
return async (req, res, next) => {
|
|
293
|
+
const reqPath = req.path || req.url || '/';
|
|
294
|
+
|
|
295
|
+
if (!shouldApplyToRoute(reqPath, config.routes)) {
|
|
296
|
+
return next();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (isSkipped(req, config.skipPaths, config.skip)) {
|
|
300
|
+
return next();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const rawKey = extractApiKey(req, config);
|
|
304
|
+
|
|
305
|
+
// If key missing:
|
|
306
|
+
if (!rawKey) {
|
|
307
|
+
if (config.optional) {
|
|
308
|
+
return next();
|
|
309
|
+
}
|
|
310
|
+
return sendUnauthorized(req, res, config, 'Authentication required: API key is missing.');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const computedHash = hashKey(rawKey);
|
|
314
|
+
let record = null;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
// 1. Custom verify hook
|
|
318
|
+
if (typeof config.verify === 'function') {
|
|
319
|
+
record = await config.verify(rawKey, req);
|
|
320
|
+
}
|
|
321
|
+
// 2. Custom findKeyByHash hook
|
|
322
|
+
else if (typeof config.findKeyByHash === 'function') {
|
|
323
|
+
record = await config.findKeyByHash(computedHash, req);
|
|
324
|
+
}
|
|
325
|
+
// 3. Static in-memory keys
|
|
326
|
+
else if (config.keys) {
|
|
327
|
+
record = findInStaticKeys(rawKey, computedHash, config.keys);
|
|
328
|
+
}
|
|
329
|
+
// 4. Database lookup via Knex / ORM
|
|
330
|
+
else {
|
|
331
|
+
const kx = resolveKnex(config, req);
|
|
332
|
+
if (kx) {
|
|
333
|
+
record = await kx(config.tableName).where({ key_hash: computedHash }).first();
|
|
334
|
+
} else if (req.db && typeof req.db.getRepository === 'function') {
|
|
335
|
+
try {
|
|
336
|
+
const apiKeyRepo = req.db.getRepository('ApiKey');
|
|
337
|
+
if (apiKeyRepo) {
|
|
338
|
+
record = await apiKeyRepo.findOne({ key_hash: computedHash });
|
|
339
|
+
}
|
|
340
|
+
} catch (_) {
|
|
341
|
+
// Ignore repository lookup error and fallback
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Timing-safe constant check: If record has key_hash, verify it matches
|
|
347
|
+
if (record && record.key_hash) {
|
|
348
|
+
const isValid = safeCompare(record.key_hash, computedHash);
|
|
349
|
+
if (!isValid) {
|
|
350
|
+
record = null;
|
|
351
|
+
}
|
|
352
|
+
} else if (!record) {
|
|
353
|
+
// Dummy timing-safe comparison to prevent timing leak on existence
|
|
354
|
+
safeCompare(computedHash, computedHash);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Record not found
|
|
358
|
+
if (!record) {
|
|
359
|
+
return sendUnauthorized(req, res, config, 'Invalid API key.');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Check if key is revoked
|
|
363
|
+
if (record.revoked_at || record.is_active === false || record.revoked === true) {
|
|
364
|
+
return sendUnauthorized(req, res, config, 'API key has been revoked.');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Check if key has expired
|
|
368
|
+
if (record.expires_at) {
|
|
369
|
+
const expiresTime = new Date(record.expires_at).getTime();
|
|
370
|
+
if (!isNaN(expiresTime) && expiresTime <= Date.now()) {
|
|
371
|
+
return sendUnauthorized(req, res, config, 'API key has expired.');
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Normalize scopes on the record
|
|
376
|
+
const grantedScopes = normalizeScopes(record.scopes);
|
|
377
|
+
|
|
378
|
+
// Verify required scopes
|
|
379
|
+
if (requiredScopes.length > 0) {
|
|
380
|
+
const hasAccess = hasRequiredScopes(grantedScopes, requiredScopes, config.requireAllScopes);
|
|
381
|
+
if (!hasAccess) {
|
|
382
|
+
return sendForbidden(req, res, config, 'Forbidden: Insufficient API key permissions.', {
|
|
383
|
+
requiredScopes,
|
|
384
|
+
grantedScopes,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Non-blocking usage tracking (last_used_at)
|
|
390
|
+
if (config.trackUsage !== false && record.id) {
|
|
391
|
+
const lastUsedMs = record.last_used_at ? new Date(record.last_used_at).getTime() : 0;
|
|
392
|
+
const throttleMs = typeof config.updateLastUsedThrottleMs === 'number' ? config.updateLastUsedThrottleMs : 60000;
|
|
393
|
+
const now = Date.now();
|
|
394
|
+
|
|
395
|
+
if (now - lastUsedMs >= throttleMs) {
|
|
396
|
+
record.last_used_at = new Date(now);
|
|
397
|
+
const kx = resolveKnex(config, req);
|
|
398
|
+
setImmediate(async () => {
|
|
399
|
+
try {
|
|
400
|
+
if (typeof config.updateLastUsed === 'function') {
|
|
401
|
+
await config.updateLastUsed(record.id, record);
|
|
402
|
+
} else if (kx) {
|
|
403
|
+
await kx(config.tableName).where({ id: record.id }).update({ last_used_at: new Date(now) });
|
|
404
|
+
}
|
|
405
|
+
} catch (_) {
|
|
406
|
+
// Non-blocking write: never fail the API request
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Attach context to request
|
|
413
|
+
req.apiKey = record;
|
|
414
|
+
req.auth = req.auth || {};
|
|
415
|
+
req.auth.apiKey = record;
|
|
416
|
+
|
|
417
|
+
// User resolution
|
|
418
|
+
let user = null;
|
|
419
|
+
if (typeof config.getUser === 'function') {
|
|
420
|
+
user = await config.getUser(record, req);
|
|
421
|
+
} else if (record.user) {
|
|
422
|
+
user = record.user;
|
|
423
|
+
} else if (record.user_id && req.db && typeof req.db.getRepository === 'function') {
|
|
424
|
+
try {
|
|
425
|
+
const userRepo = req.db.getRepository('User');
|
|
426
|
+
if (userRepo) {
|
|
427
|
+
user = await userRepo.findById(record.user_id);
|
|
428
|
+
}
|
|
429
|
+
} catch (_) {
|
|
430
|
+
// Swallow user lookup failure
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (user) {
|
|
435
|
+
req.auth.user = user;
|
|
436
|
+
if (!req.user) {
|
|
437
|
+
req.user = user;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// onAuthenticated hook
|
|
442
|
+
if (typeof config.onAuthenticated === 'function') {
|
|
443
|
+
await config.onAuthenticated(req, record);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
next();
|
|
447
|
+
} catch (err) {
|
|
448
|
+
next(err);
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
module.exports = {
|
|
454
|
+
createApiKeyMiddleware,
|
|
455
|
+
extractApiKey,
|
|
456
|
+
sendUnauthorized,
|
|
457
|
+
sendForbidden,
|
|
458
|
+
resolveKnex,
|
|
459
|
+
shouldApplyToRoute,
|
|
460
|
+
isSkipped,
|
|
461
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration template for api_keys table
|
|
3
|
+
* @module plugins/api-key/migration-template
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate migration file content for api_keys
|
|
10
|
+
* @param {string} [tableName='api_keys']
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
function generateApiKeysMigration(tableName = 'api_keys') {
|
|
14
|
+
return `/**
|
|
15
|
+
* Migration: Create ${tableName} table
|
|
16
|
+
* Auto-generated by api-key plugin
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
exports.up = function(knex) {
|
|
20
|
+
return knex.schema.createTable('${tableName}', (table) => {
|
|
21
|
+
table.bigIncrements('id').primary();
|
|
22
|
+
table.string('name', 255).notNullable();
|
|
23
|
+
table.string('key_hash', 64).notNullable().unique();
|
|
24
|
+
table.string('key_hint', 64).notNullable();
|
|
25
|
+
table.string('prefix', 32).notNullable().defaultTo('wsp_live_');
|
|
26
|
+
table.bigInteger('user_id').nullable().index();
|
|
27
|
+
table.json('scopes').nullable();
|
|
28
|
+
table.integer('rate_limit').nullable();
|
|
29
|
+
table.json('metadata').nullable();
|
|
30
|
+
table.timestamp('expires_at').nullable().index();
|
|
31
|
+
table.timestamp('last_used_at').nullable();
|
|
32
|
+
table.timestamp('revoked_at').nullable().index();
|
|
33
|
+
table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable().index();
|
|
34
|
+
table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable();
|
|
35
|
+
|
|
36
|
+
table.index(['user_id', 'created_at']);
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
exports.down = function(knex) {
|
|
41
|
+
return knex.schema.dropTableIfExists('${tableName}');
|
|
42
|
+
};
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
generateApiKeysMigration,
|
|
48
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Key Model
|
|
3
|
+
* Webspresso ORM model definition for API keys
|
|
4
|
+
* @module plugins/api-key/model
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const { defineModel } = require('../../core/orm/model');
|
|
10
|
+
const { zdb } = require('../../core/orm');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create and return the ApiKey ORM Model definition
|
|
14
|
+
*
|
|
15
|
+
* @param {string} [tableName='api_keys']
|
|
16
|
+
* @returns {import('../../core/orm/types').ModelDefinition}
|
|
17
|
+
*/
|
|
18
|
+
function createApiKeyModel(tableName = 'api_keys') {
|
|
19
|
+
const ApiKeySchema = zdb.schema({
|
|
20
|
+
id: zdb.id(),
|
|
21
|
+
name: zdb.string({ maxLength: 255 }),
|
|
22
|
+
key_hash: zdb.string({ unique: true, maxLength: 64 }),
|
|
23
|
+
key_hint: zdb.string({ maxLength: 64 }),
|
|
24
|
+
prefix: zdb.string({ maxLength: 32, default: 'wsp_live_' }),
|
|
25
|
+
user_id: zdb.integer({ nullable: true }),
|
|
26
|
+
scopes: zdb.json({ nullable: true }),
|
|
27
|
+
rate_limit: zdb.integer({ nullable: true }),
|
|
28
|
+
metadata: zdb.json({ nullable: true }),
|
|
29
|
+
expires_at: zdb.timestamp({ nullable: true }),
|
|
30
|
+
last_used_at: zdb.timestamp({ nullable: true }),
|
|
31
|
+
revoked_at: zdb.timestamp({ nullable: true }),
|
|
32
|
+
created_at: zdb.timestamp({ auto: 'create' }),
|
|
33
|
+
updated_at: zdb.timestamp({ auto: 'update' }),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return defineModel({
|
|
37
|
+
name: 'ApiKey',
|
|
38
|
+
table: tableName,
|
|
39
|
+
schema: ApiKeySchema,
|
|
40
|
+
scopes: {
|
|
41
|
+
timestamps: true,
|
|
42
|
+
active: (query) => query.whereNull('revoked_at'),
|
|
43
|
+
},
|
|
44
|
+
hidden: ['key_hash'], // Never expose SHA-256 hash in JSON responses
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
createApiKeyModel,
|
|
50
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Key Plugin — Scope and Permission Authorization
|
|
3
|
+
* @module plugins/api-key/scopes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Normalize scopes input into an array of string tokens
|
|
10
|
+
*
|
|
11
|
+
* @param {string|string[]|null|undefined} scopes
|
|
12
|
+
* @returns {string[]}
|
|
13
|
+
*/
|
|
14
|
+
function normalizeScopes(scopes) {
|
|
15
|
+
if (!scopes) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (Array.isArray(scopes)) {
|
|
20
|
+
return scopes
|
|
21
|
+
.map((s) => (typeof s === 'string' ? s.trim() : String(s).trim()))
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (typeof scopes === 'string') {
|
|
26
|
+
const trimmed = scopes.trim();
|
|
27
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(trimmed);
|
|
30
|
+
if (Array.isArray(parsed)) {
|
|
31
|
+
return parsed.map((s) => String(s).trim()).filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
} catch (_) {
|
|
34
|
+
// Fallback to comma-separated parsing
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return trimmed
|
|
38
|
+
.split(',')
|
|
39
|
+
.map((s) => s.trim())
|
|
40
|
+
.filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Match a granted scope against a required scope with wildcard support
|
|
48
|
+
*
|
|
49
|
+
* @param {string} grantedScope - e.g. "*", "reports:*", "users:read"
|
|
50
|
+
* @param {string} requiredScope - e.g. "reports:read"
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
function matchScope(grantedScope, requiredScope) {
|
|
54
|
+
if (typeof grantedScope !== 'string' || typeof requiredScope !== 'string') {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const granted = grantedScope.trim();
|
|
59
|
+
const required = requiredScope.trim();
|
|
60
|
+
|
|
61
|
+
// 1. Exact match
|
|
62
|
+
if (granted === required) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Global wildcard
|
|
67
|
+
if (granted === '*') {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3. Prefix / namespace wildcard (e.g. "reports:*" matches "reports:read" or "reports:export:csv")
|
|
72
|
+
if (granted.endsWith(':*')) {
|
|
73
|
+
const prefix = granted.slice(0, -2);
|
|
74
|
+
if (required === prefix || required.startsWith(prefix + ':')) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if granted scopes satisfy a single required scope
|
|
84
|
+
*
|
|
85
|
+
* @param {string|string[]} grantedScopes
|
|
86
|
+
* @param {string} requiredScope
|
|
87
|
+
* @returns {boolean}
|
|
88
|
+
*/
|
|
89
|
+
function hasScope(grantedScopes, requiredScope) {
|
|
90
|
+
const granted = normalizeScopes(grantedScopes);
|
|
91
|
+
return granted.some((g) => matchScope(g, requiredScope));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if granted scopes satisfy a list of required scopes
|
|
96
|
+
*
|
|
97
|
+
* @param {string|string[]} grantedScopes
|
|
98
|
+
* @param {string|string[]} requiredScopes
|
|
99
|
+
* @param {boolean} [requireAll=true] - If true, all required scopes must be present. If false, any scope matches.
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
function hasRequiredScopes(grantedScopes, requiredScopes, requireAll = true) {
|
|
103
|
+
const required = normalizeScopes(requiredScopes);
|
|
104
|
+
if (required.length === 0) {
|
|
105
|
+
return true; // No scopes required -> access permitted
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const granted = normalizeScopes(grantedScopes);
|
|
109
|
+
if (granted.length === 0) {
|
|
110
|
+
return false; // Scopes required but none granted
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (requireAll) {
|
|
114
|
+
return required.every((req) => granted.some((g) => matchScope(g, req)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return required.some((req) => granted.some((g) => matchScope(g, req)));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
normalizeScopes,
|
|
122
|
+
matchScope,
|
|
123
|
+
hasScope,
|
|
124
|
+
hasRequiredScopes,
|
|
125
|
+
};
|