webspresso 0.0.98 → 0.0.99

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 (56) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/AUTH.md +169 -0
  4. package/.agents/ORM.md +88 -4
  5. package/.agents/PLUGINS.md +73 -5
  6. package/.agents/QUEUE.md +84 -0
  7. package/.agents/ROUTING.md +127 -7
  8. package/AGENTS.md +1 -0
  9. package/core/auth/index.js +60 -0
  10. package/core/auth/manager.js +134 -0
  11. package/core/auth/middleware.js +88 -0
  12. package/core/auth/oauth.js +517 -0
  13. package/core/auth/qrcode.js +499 -0
  14. package/core/auth/totp.js +504 -0
  15. package/core/orm/eager-loader.js +144 -10
  16. package/core/orm/index.js +8 -0
  17. package/core/orm/model.js +4 -0
  18. package/core/orm/query-builder.js +90 -4
  19. package/core/orm/repository.js +44 -3
  20. package/core/orm/search.js +213 -0
  21. package/core/queue/cron.js +331 -0
  22. package/core/queue/index.js +8 -1
  23. package/core/queue/manager.js +82 -1
  24. package/core/queue/scheduler.js +359 -0
  25. package/core/sse/index.js +19 -0
  26. package/core/sse/stream.js +430 -0
  27. package/index.d.ts +528 -0
  28. package/index.js +44 -0
  29. package/package.json +1 -1
  30. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  31. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  32. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  33. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  34. package/plugins/admin-panel/field-renderers/index.js +2 -0
  35. package/plugins/api-key/crypto.js +101 -0
  36. package/plugins/api-key/index.js +288 -0
  37. package/plugins/api-key/middleware.js +461 -0
  38. package/plugins/api-key/migration-template.js +48 -0
  39. package/plugins/api-key/model.js +50 -0
  40. package/plugins/api-key/scopes.js +125 -0
  41. package/plugins/audit-log/admin-component.js +487 -70
  42. package/plugins/audit-log/api-handlers.js +114 -0
  43. package/plugins/audit-log/index.js +24 -0
  44. package/plugins/file-manager/admin-component.js +20 -4
  45. package/plugins/file-manager/index.js +21 -4
  46. package/plugins/index.js +6 -0
  47. package/plugins/oauth/index.js +338 -0
  48. package/plugins/queue/index.js +4 -0
  49. package/src/api/api-loader.js +1 -0
  50. package/src/discovery/file-route-parser.js +44 -1
  51. package/src/discovery/scan-routes.js +2 -2
  52. package/src/file-router.js +137 -100
  53. package/src/helpers.js +9 -0
  54. package/src/routing/cascading.js +378 -0
  55. package/src/routing/middleware-resolver.js +92 -0
  56. 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
+ };