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
@@ -27,9 +27,24 @@ function shouldShowImagePreview(url, accept) {
27
27
  return isImageAccept(accept) || isImageUrl(url);
28
28
  }
29
29
 
30
- function renderUploadedFilePreview(url, { accept, readonly, onRemove, label }) {
30
+ function renderUploadedFilePreview(url, { accept, readonly, onRemove, onSelectLibrary, label }) {
31
31
  if (!url) return null;
32
32
 
33
+ const actions = !readonly ? m('.flex.items-center.gap-3.mt-1', [
34
+ onSelectLibrary
35
+ ? m('button.text-indigo-600.dark:text-indigo-400.hover:text-indigo-800.dark:hover:text-indigo-300.text-xs.font-medium', {
36
+ type: 'button',
37
+ onclick: onSelectLibrary,
38
+ }, '📁 Change from Library')
39
+ : null,
40
+ onRemove
41
+ ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-xs', {
42
+ type: 'button',
43
+ onclick: onRemove,
44
+ }, 'Remove')
45
+ : null,
46
+ ]) : null;
47
+
33
48
  if (shouldShowImagePreview(url, accept)) {
34
49
  return m('.mt-3.flex.flex-col.items-start.gap-2', [
35
50
  m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
@@ -40,12 +55,7 @@ function renderUploadedFilePreview(url, { accept, readonly, onRemove, label }) {
40
55
  })
41
56
  ),
42
57
  m('p.text-xs.text-gray-500.dark:text-slate-400.break-all', url),
43
- !readonly && onRemove
44
- ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm', {
45
- type: 'button',
46
- onclick: onRemove,
47
- }, 'Remove')
48
- : null,
58
+ actions,
49
59
  ]);
50
60
  }
51
61
 
@@ -55,12 +65,7 @@ function renderUploadedFilePreview(url, { accept, readonly, onRemove, label }) {
55
65
  target: '_blank',
56
66
  rel: 'noopener noreferrer',
57
67
  }, url),
58
- !readonly && onRemove
59
- ? m('button.text-red-600.dark:text-red-400.hover:text-red-800.dark:hover:text-red-300.text-sm', {
60
- type: 'button',
61
- onclick: onRemove,
62
- }, 'Remove')
63
- : null,
68
+ actions,
64
69
  ]);
65
70
  }
66
71
 
@@ -141,6 +146,19 @@ module.exports = {
141
146
  if (vnode.attrs.onchange) vnode.attrs.onchange('');
142
147
  };
143
148
 
149
+ const openLibraryPicker = () => {
150
+ if (typeof window !== 'undefined' && window.__openFileManagerPicker) {
151
+ window.__openFileManagerPicker({
152
+ accept,
153
+ title: 'Choose from Media Library',
154
+ onSelect: (pickedUrl) => {
155
+ revokeLocalPreview(state);
156
+ if (vnode.attrs.onchange) vnode.attrs.onchange(pickedUrl);
157
+ }
158
+ });
159
+ }
160
+ };
161
+
144
162
  if (!uploadUrl) {
145
163
  return m('.mb-4', [
146
164
  m(
@@ -148,7 +166,14 @@ module.exports = {
148
166
  label,
149
167
  required ? m('span.text-red-500', ' *') : null
150
168
  ),
151
- m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
169
+ typeof window !== 'undefined' && window.__openFileManagerPicker
170
+ ? m('.flex.items-center.gap-2.mb-3', [
171
+ m('button.px-3.py-1.5.bg-indigo-600.hover:bg-indigo-700.text-white.text-xs.font-medium.rounded.flex.items-center.gap-1.shadow-sm', {
172
+ type: 'button',
173
+ onclick: openLibraryPicker,
174
+ }, '📁 Choose from Media Library')
175
+ ])
176
+ : m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
152
177
  m('input.w-full.px-3.py-2.border.rounded', {
153
178
  type: 'text',
154
179
  name,
@@ -161,7 +186,7 @@ module.exports = {
161
186
  },
162
187
  }),
163
188
  value
164
- ? renderUploadedFilePreview(value, { accept, readonly, onRemove: clearValue, label })
189
+ ? renderUploadedFilePreview(value, { accept, readonly, onRemove: clearValue, onSelectLibrary: openLibraryPicker, label })
165
190
  : null,
166
191
  ]);
167
192
  }
@@ -173,7 +198,7 @@ module.exports = {
173
198
  required ? m('span.text-red-500', ' *') : null
174
199
  ),
175
200
  displayUrl
176
- ? renderUploadedFilePreview(displayUrl, { accept, readonly, onRemove: clearValue, label })
201
+ ? renderUploadedFilePreview(displayUrl, { accept, readonly, onRemove: clearValue, onSelectLibrary: openLibraryPicker, label })
177
202
  : null,
178
203
  m(
179
204
  'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-6.text-center',
@@ -200,11 +225,26 @@ module.exports = {
200
225
  'p.text-gray-600.dark:text-slate-400.mb-2',
201
226
  displayUrl ? 'Drag and drop to replace, or' : 'Drag and drop a file here, or'
202
227
  ),
203
- m(
204
- 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
205
- { for: 'file-input-' + name },
206
- displayUrl ? 'choose another file' : 'browse'
207
- ),
228
+ m('.flex.items-center.justify-center.gap-3.flex-wrap', [
229
+ m(
230
+ 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
231
+ { for: 'file-input-' + name },
232
+ displayUrl ? 'choose another file' : 'browse'
233
+ ),
234
+ typeof window !== 'undefined' && window.__openFileManagerPicker
235
+ ? m('button.text-indigo-600.hover:text-indigo-800.dark:text-indigo-400.cursor-pointer.font-medium.flex.items-center.gap-1.text-sm', {
236
+ type: 'button',
237
+ onclick: (e) => {
238
+ e.preventDefault();
239
+ e.stopPropagation();
240
+ openLibraryPicker();
241
+ }
242
+ }, [
243
+ m('span', '•'),
244
+ m('span', '📁 Choose from Media Library')
245
+ ])
246
+ : null,
247
+ ]),
208
248
  ]),
209
249
  ]
210
250
  ),
@@ -280,6 +320,98 @@ module.exports = {
280
320
 
281
321
  const urls = Array.isArray(value) ? value : (value ? [value] : []);
282
322
 
323
+ const openLibraryPicker = () => {
324
+ if (typeof window !== 'undefined' && window.__openFileManagerPicker) {
325
+ window.__openFileManagerPicker({
326
+ accept,
327
+ title: 'Add from Media Library',
328
+ onSelect: (pickedUrl) => {
329
+ const newUrls = [...urls, pickedUrl];
330
+ if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
331
+ }
332
+ });
333
+ }
334
+ };
335
+
336
+ const renderGrid = () => {
337
+ if (urls.length === 0) return null;
338
+ return m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
339
+ const isDragOver = state.dragOverIndex === idx;
340
+ const baseClasses = '.relative.group.border.rounded-lg.p-2.transition-all';
341
+ const cursorClass = readonly ? '' : '.cursor-move';
342
+ const borderClasses = isDragOver ? '.border-blue-500.border-2.dark:border-blue-500' : '.border-gray-200.dark:border-slate-700';
343
+ const bgClasses = isDragOver ? '.bg-blue-50.dark:bg-slate-800' : '.bg-gray-50.dark:bg-slate-900';
344
+
345
+ return m(baseClasses + cursorClass + borderClasses + bgClasses, {
346
+ draggable: !readonly,
347
+ ondragstart: (e) => {
348
+ state.draggedIndex = idx;
349
+ e.dataTransfer.effectAllowed = 'move';
350
+ e.dataTransfer.setData('text/plain', idx);
351
+ },
352
+ ondragover: (e) => {
353
+ e.preventDefault();
354
+ e.dataTransfer.dropEffect = 'move';
355
+ },
356
+ ondragenter: (e) => {
357
+ state.dragOverIndex = idx;
358
+ },
359
+ ondragleave: (e) => {
360
+ if (state.dragOverIndex === idx) {
361
+ state.dragOverIndex = null;
362
+ }
363
+ },
364
+ ondrop: (e) => {
365
+ e.preventDefault();
366
+ const fromIdx = state.draggedIndex;
367
+ const toIdx = idx;
368
+ if (fromIdx !== null && fromIdx !== undefined && fromIdx !== toIdx) {
369
+ const newUrls = [...urls];
370
+ const [movedItem] = newUrls.splice(fromIdx, 1);
371
+ newUrls.splice(toIdx, 0, movedItem);
372
+ if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
373
+ }
374
+ state.draggedIndex = null;
375
+ state.dragOverIndex = null;
376
+ },
377
+ ondragend: (e) => {
378
+ state.draggedIndex = null;
379
+ state.dragOverIndex = null;
380
+ }
381
+ }, [
382
+ shouldShowImagePreview(url, accept)
383
+ ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer', draggable: false },
384
+ m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
385
+ src: url,
386
+ alt: `${label} ${idx + 1}`,
387
+ loading: 'lazy',
388
+ draggable: false,
389
+ })
390
+ )
391
+ : m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
392
+ href: url,
393
+ target: '_blank',
394
+ rel: 'noopener noreferrer',
395
+ draggable: false,
396
+ }, url.substring(url.lastIndexOf('/') + 1) || url),
397
+ !readonly
398
+ ? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.p-1.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
399
+ type: 'button',
400
+ onclick: () => {
401
+ const newUrls = urls.filter((_, i) => i !== idx);
402
+ if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
403
+ },
404
+ title: 'Remove file',
405
+ }, [
406
+ m('svg.w-4.h-4', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
407
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' })
408
+ )
409
+ ])
410
+ : null,
411
+ ]);
412
+ }));
413
+ };
414
+
283
415
  if (!uploadUrl) {
284
416
  return m('.mb-4', [
285
417
  m(
@@ -287,7 +419,19 @@ module.exports = {
287
419
  label,
288
420
  required ? m('span.text-red-500', ' *') : null
289
421
  ),
290
- m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
422
+ typeof window !== 'undefined' && window.__openFileManagerPicker
423
+ ? m('.flex.items-center.gap-2.mb-3', [
424
+ m('button.px-3.py-1.5.bg-indigo-600.hover:bg-indigo-700.text-white.text-xs.font-medium.rounded.flex.items-center.gap-1.shadow-sm', {
425
+ type: 'button',
426
+ onclick: openLibraryPicker,
427
+ }, '📁 Add from Media Library')
428
+ ])
429
+ : m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
430
+ renderGrid(),
431
+ m('input[type=hidden]', {
432
+ name,
433
+ value: JSON.stringify(urls),
434
+ }),
291
435
  ]);
292
436
  }
293
437
 
@@ -298,83 +442,7 @@ module.exports = {
298
442
  required ? m('span.text-red-500', ' *') : null
299
443
  ),
300
444
 
301
- urls.length > 0
302
- ? m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
303
- const isDragOver = state.dragOverIndex === idx;
304
- const baseClasses = '.relative.group.border.rounded-lg.p-2.transition-all';
305
- const cursorClass = readonly ? '' : '.cursor-move';
306
- const borderClasses = isDragOver ? '.border-blue-500.border-2.dark:border-blue-500' : '.border-gray-200.dark:border-slate-700';
307
- const bgClasses = isDragOver ? '.bg-blue-50.dark:bg-slate-800' : '.bg-gray-50.dark:bg-slate-900';
308
-
309
- return m(baseClasses + cursorClass + borderClasses + bgClasses, {
310
- draggable: !readonly,
311
- ondragstart: (e) => {
312
- state.draggedIndex = idx;
313
- e.dataTransfer.effectAllowed = 'move';
314
- e.dataTransfer.setData('text/plain', idx);
315
- },
316
- ondragover: (e) => {
317
- e.preventDefault();
318
- e.dataTransfer.dropEffect = 'move';
319
- },
320
- ondragenter: (e) => {
321
- state.dragOverIndex = idx;
322
- },
323
- ondragleave: (e) => {
324
- if (state.dragOverIndex === idx) {
325
- state.dragOverIndex = null;
326
- }
327
- },
328
- ondrop: (e) => {
329
- e.preventDefault();
330
- const fromIdx = state.draggedIndex;
331
- const toIdx = idx;
332
- if (fromIdx !== null && fromIdx !== undefined && fromIdx !== toIdx) {
333
- const newUrls = [...urls];
334
- const [movedItem] = newUrls.splice(fromIdx, 1);
335
- newUrls.splice(toIdx, 0, movedItem);
336
- if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
337
- }
338
- state.draggedIndex = null;
339
- state.dragOverIndex = null;
340
- },
341
- ondragend: (e) => {
342
- state.draggedIndex = null;
343
- state.dragOverIndex = null;
344
- }
345
- }, [
346
- shouldShowImagePreview(url, accept)
347
- ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer', draggable: false },
348
- m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
349
- src: url,
350
- alt: `${label} ${idx + 1}`,
351
- loading: 'lazy',
352
- draggable: false,
353
- })
354
- )
355
- : m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
356
- href: url,
357
- target: '_blank',
358
- rel: 'noopener noreferrer',
359
- draggable: false,
360
- }, url.substring(url.lastIndexOf('/') + 1) || url),
361
- !readonly
362
- ? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.p-1.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
363
- type: 'button',
364
- onclick: () => {
365
- const newUrls = urls.filter((_, i) => i !== idx);
366
- if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
367
- },
368
- title: 'Remove file',
369
- }, [
370
- m('svg.w-4.h-4', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
371
- m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' })
372
- )
373
- ])
374
- : null,
375
- ]);
376
- }))
377
- : null,
445
+ renderGrid(),
378
446
 
379
447
  m(
380
448
  'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-6.text-center',
@@ -405,11 +473,26 @@ module.exports = {
405
473
  'p.text-gray-600.dark:text-slate-400.mb-2',
406
474
  'Drag and drop files here, or'
407
475
  ),
408
- m(
409
- 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
410
- { for: 'file-input-multiple-' + name },
411
- 'browse'
412
- ),
476
+ m('.flex.items-center.justify-center.gap-3.flex-wrap', [
477
+ m(
478
+ 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
479
+ { for: 'file-input-multiple-' + name },
480
+ 'browse'
481
+ ),
482
+ typeof window !== 'undefined' && window.__openFileManagerPicker
483
+ ? m('button.text-indigo-600.hover:text-indigo-800.dark:text-indigo-400.cursor-pointer.font-medium.flex.items-center.gap-1.text-sm', {
484
+ type: 'button',
485
+ onclick: (e) => {
486
+ e.preventDefault();
487
+ e.stopPropagation();
488
+ openLibraryPicker();
489
+ }
490
+ }, [
491
+ m('span', '•'),
492
+ m('span', '📁 Add from Media Library')
493
+ ])
494
+ : null,
495
+ ]),
413
496
  ]),
414
497
  ]),
415
498
  ]
@@ -79,7 +79,9 @@ function initializeDefaultRenderers() {
79
79
  registerFieldRenderer('rich-text', richTextRenderer.RichTextField);
80
80
  registerFieldRenderer('file-upload', fileUploadRenderer.FileUploadField);
81
81
  registerFieldRenderer('file', fileUploadRenderer.FileUploadField);
82
+ registerFieldRenderer('image', fileUploadRenderer.FileUploadField);
82
83
  registerFieldRenderer('files', fileUploadRenderer.FilesUploadField);
84
+ registerFieldRenderer('files-upload', fileUploadRenderer.FilesUploadField);
83
85
 
84
86
  // Relation types
85
87
  registerFieldRenderer('belongsTo', relationRenderers.BelongsToField);
@@ -0,0 +1,101 @@
1
+ /**
2
+ * API Key Plugin — Cryptographic Utilities
3
+ * Zero external dependencies (uses native Node.js crypto)
4
+ * @module plugins/api-key/crypto
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const crypto = require('crypto');
10
+
11
+ /**
12
+ * Compare two strings in constant time to prevent timing attacks.
13
+ * Uses dummy buffer comparison if lengths differ to avoid leaking length via response timing.
14
+ *
15
+ * @param {string} a
16
+ * @param {string} b
17
+ * @returns {boolean}
18
+ */
19
+ function safeCompare(a, b) {
20
+ if (typeof a !== 'string' || typeof b !== 'string') {
21
+ return false;
22
+ }
23
+
24
+ const bufA = Buffer.from(a, 'utf8');
25
+ const bufB = Buffer.from(b, 'utf8');
26
+
27
+ if (bufA.length !== bufB.length) {
28
+ // Perform dummy timing-safe comparison with itself to preserve constant execution time
29
+ crypto.timingSafeEqual(bufA, bufA);
30
+ return false;
31
+ }
32
+
33
+ return crypto.timingSafeEqual(bufA, bufB);
34
+ }
35
+
36
+ /**
37
+ * Hash a raw API key using SHA-256
38
+ *
39
+ * @param {string} rawKey
40
+ * @returns {string} Hex-encoded SHA-256 hash (64 characters)
41
+ */
42
+ function hashKey(rawKey) {
43
+ if (typeof rawKey !== 'string' || rawKey.length === 0) {
44
+ return '';
45
+ }
46
+ return crypto.createHash('sha256').update(rawKey, 'utf8').digest('hex');
47
+ }
48
+
49
+ /**
50
+ * Mask an API key for safe UI display and logs.
51
+ * Shows prefix + '...' + last 4 characters.
52
+ *
53
+ * @param {string} rawKey
54
+ * @param {string} [prefix='wsp_live_']
55
+ * @returns {string}
56
+ */
57
+ function maskKey(rawKey, prefix = 'wsp_live_') {
58
+ if (!rawKey || typeof rawKey !== 'string') {
59
+ return '****';
60
+ }
61
+
62
+ if (rawKey.length <= 8) {
63
+ return '****';
64
+ }
65
+
66
+ const last4 = rawKey.slice(-4);
67
+ const effectivePrefix = rawKey.startsWith(prefix) ? prefix : rawKey.slice(0, Math.min(prefix.length, 9));
68
+ return `${effectivePrefix}...${last4}`;
69
+ }
70
+
71
+ /**
72
+ * Generate a cryptographically secure API key
73
+ *
74
+ * @param {Object} [options]
75
+ * @param {string} [options.prefix='wsp_live_'] - Prefix for the key
76
+ * @param {number} [options.byteLength=24] - Number of random bytes (produces 32 base64url chars)
77
+ * @returns {{ key: string, hash: string, hint: string, prefix: string }}
78
+ */
79
+ function generateKey(options = {}) {
80
+ const prefix = typeof options.prefix === 'string' ? options.prefix : 'wsp_live_';
81
+ const byteLength = typeof options.byteLength === 'number' && options.byteLength > 0 ? options.byteLength : 24;
82
+
83
+ const randomPart = crypto.randomBytes(byteLength).toString('base64url');
84
+ const key = `${prefix}${randomPart}`;
85
+ const hash = hashKey(key);
86
+ const hint = maskKey(key, prefix);
87
+
88
+ return {
89
+ key,
90
+ hash,
91
+ hint,
92
+ prefix,
93
+ };
94
+ }
95
+
96
+ module.exports = {
97
+ safeCompare,
98
+ hashKey,
99
+ maskKey,
100
+ generateKey,
101
+ };