webspresso 0.0.97 → 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 (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -135,6 +135,176 @@ function registerDashboardWidgets(options) {
135
135
  return actions.slice(0, 5); // Max 5 quick actions
136
136
  },
137
137
  });
138
+
139
+ // Database Storage & Health widget
140
+ registry.registerWidget('database-storage', {
141
+ title: 'Database Storage & Health',
142
+ size: 'full',
143
+ order: 15,
144
+ dataLoader: async ({ db }) => {
145
+ return await collectDatabaseStorageStats(db);
146
+ },
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Collect database storage and health statistics
152
+ * @param {Object} db - Database instance
153
+ * @returns {Promise<Object>}
154
+ */
155
+ async function collectDatabaseStorageStats(db) {
156
+ if (!db || !db.knex) {
157
+ return {
158
+ engine: 'Unknown',
159
+ totalTables: 0,
160
+ totalRows: 0,
161
+ overallHealth: 'healthy',
162
+ tables: [],
163
+ sqliteInfo: null,
164
+ };
165
+ }
166
+
167
+ const clientName = String(db.knex.client?.config?.client || 'unknown').toLowerCase();
168
+ const isSqlite = clientName.includes('sqlite');
169
+ const isPostgres = clientName.includes('pg') || clientName.includes('postgres');
170
+ const isMysql = clientName.includes('mysql');
171
+
172
+ const { getAllModels } = require('../../../core/orm/model');
173
+ const allModels = db.getAllModels ? db.getAllModels() : Array.from(getAllModels().values());
174
+ const modelList = Array.isArray(allModels) ? allModels : Array.from(allModels.values());
175
+ const modelByTable = new Map();
176
+ for (const m of modelList) {
177
+ if (m.table) modelByTable.set(m.table, m);
178
+ }
179
+
180
+ // 1. Discover table names
181
+ const tableNamesSet = new Set();
182
+
183
+ try {
184
+ if (isSqlite) {
185
+ const res = await db.knex.raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';");
186
+ const rows = Array.isArray(res) ? res : (res?.rows || []);
187
+ for (const r of rows) {
188
+ if (r && r.name) tableNamesSet.add(r.name);
189
+ }
190
+ } else if (isPostgres) {
191
+ const res = await db.knex.raw("SELECT table_name as name FROM information_schema.tables WHERE table_schema='public';");
192
+ const rows = res?.rows || (Array.isArray(res) ? res : []);
193
+ for (const r of rows) {
194
+ if (r && r.name) tableNamesSet.add(r.name);
195
+ }
196
+ } else if (isMysql) {
197
+ const res = await db.knex.raw('SHOW TABLES;');
198
+ const rows = Array.isArray(res) && Array.isArray(res[0]) ? res[0] : (Array.isArray(res) ? res : []);
199
+ for (const r of rows) {
200
+ const val = Object.values(r || {})[0];
201
+ if (val) tableNamesSet.add(val);
202
+ }
203
+ }
204
+ } catch (e) {
205
+ // If raw query fails, fall back to known tables
206
+ }
207
+
208
+ // Always include model tables and known system tables
209
+ for (const m of modelList) {
210
+ if (m.table) tableNamesSet.add(m.table);
211
+ }
212
+ const knownSystemTables = [
213
+ 'admin_users',
214
+ 'admin_activity_logs',
215
+ 'audit_logs',
216
+ 'email_logs',
217
+ 'webspresso_jobs',
218
+ 'analytics_page_views',
219
+ 'analytics_sessions',
220
+ 'knex_migrations',
221
+ ];
222
+ for (const t of knownSystemTables) {
223
+ tableNamesSet.add(t);
224
+ }
225
+
226
+ const tables = [];
227
+ let totalRows = 0;
228
+ let hasAttention = false;
229
+
230
+ for (const table of tableNamesSet) {
231
+ try {
232
+ const exists = await db.knex.schema.hasTable(table);
233
+ if (!exists) continue;
234
+
235
+ const countRes = await db.knex(table).count('* as count').first();
236
+ const countVal = countRes ? (countRes.count ?? countRes['count(*)'] ?? countRes['COUNT(*)'] ?? 0) : 0;
237
+ const count = parseInt(countVal, 10) || 0;
238
+ totalRows += count;
239
+
240
+ let category = 'system';
241
+ const model = modelByTable.get(table);
242
+ if (model) {
243
+ category = 'orm';
244
+ } else if (table === 'audit_logs' || table === 'email_logs' || table === 'admin_activity_logs') {
245
+ category = 'logs';
246
+ } else if (table.startsWith('analytics_')) {
247
+ category = 'analytics';
248
+ } else if (table.includes('jobs') || table.includes('queue')) {
249
+ category = 'queue';
250
+ }
251
+
252
+ // Attention flag if logs or queue tables exceed 25,000 rows
253
+ const isHighVolume = (category === 'logs' || category === 'analytics' || category === 'queue') && count > 25000;
254
+ if (isHighVolume) hasAttention = true;
255
+
256
+ tables.push({
257
+ table,
258
+ count,
259
+ category,
260
+ modelName: model ? model.name : null,
261
+ modelLabel: model?.admin?.label || (model ? model.name : null),
262
+ modelIcon: model?.admin?.icon || null,
263
+ isHighVolume,
264
+ });
265
+ } catch (e) {
266
+ // Table query error, ignore
267
+ }
268
+ }
269
+
270
+ // Sort tables by row count descending
271
+ tables.sort((a, b) => b.count - a.count);
272
+
273
+ // SQLite storage stats if available
274
+ let sqliteInfo = null;
275
+ if (isSqlite) {
276
+ try {
277
+ const pageCountRes = await db.knex.raw('PRAGMA page_count;');
278
+ const pageSizeRes = await db.knex.raw('PRAGMA page_size;');
279
+ const freelistRes = await db.knex.raw('PRAGMA freelist_count;');
280
+
281
+ const pageCount = (pageCountRes[0]?.page_count ?? pageCountRes[0]?.[0]) || 0;
282
+ const pageSize = (pageSizeRes[0]?.page_size ?? pageSizeRes[0]?.[0]) || 4096;
283
+ const freelistCount = (freelistRes[0]?.freelist_count ?? freelistRes[0]?.[0]) || 0;
284
+
285
+ const sizeBytes = pageCount * pageSize;
286
+ const freeBytes = freelistCount * pageSize;
287
+ const fragmentation = pageCount > 0 ? ((freelistCount / pageCount) * 100).toFixed(1) : 0;
288
+
289
+ sqliteInfo = {
290
+ pageCount,
291
+ pageSize,
292
+ freelistCount,
293
+ sizeBytes,
294
+ freeBytes,
295
+ fragmentation: Number(fragmentation),
296
+ };
297
+ } catch (e) {}
298
+ }
299
+
300
+ return {
301
+ engine: isSqlite ? 'SQLite' : isPostgres ? 'PostgreSQL' : isMysql ? 'MySQL' : clientName,
302
+ totalTables: tables.length,
303
+ totalRows,
304
+ overallHealth: hasAttention ? 'attention' : 'healthy',
305
+ tables,
306
+ sqliteInfo,
307
+ };
138
308
  }
139
309
 
140
310
  /**
@@ -299,6 +469,118 @@ const WidgetRenderers = {
299
469
  },
300
470
  },
301
471
 
472
+ // Database Storage & Health Widget
473
+ 'database-storage': {
474
+ render: (data) => {
475
+ if (!data) return m('div.text-gray-500.dark:text-slate-400', 'No data');
476
+ const totalRows = data.totalRows || 0;
477
+ const tables = data.tables || [];
478
+ const isAttention = data.overallHealth === 'attention';
479
+
480
+ function formatStorageBytes(bytes) {
481
+ if (!bytes || bytes <= 0) return '0 B';
482
+ const k = 1024;
483
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
484
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
485
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
486
+ }
487
+
488
+ const categoryStyles = {
489
+ orm: { bg: 'bg-blue-100 dark:bg-blue-950/60', text: 'text-blue-700 dark:text-blue-300', bar: 'bg-blue-600' },
490
+ logs: { bg: 'bg-amber-100 dark:bg-amber-950/60', text: 'text-amber-800 dark:text-amber-300', bar: 'bg-amber-500' },
491
+ analytics: { bg: 'bg-purple-100 dark:bg-purple-950/60', text: 'text-purple-700 dark:text-purple-300', bar: 'bg-purple-600' },
492
+ queue: { bg: 'bg-rose-100 dark:bg-rose-950/60', text: 'text-rose-700 dark:text-rose-300', bar: 'bg-rose-500' },
493
+ system: { bg: 'bg-gray-100 dark:bg-slate-800', text: 'text-gray-700 dark:text-slate-300', bar: 'bg-gray-500' },
494
+ };
495
+
496
+ return m('div.space-y-4', [
497
+ // 4 Metric Tiles
498
+ m('div.grid.grid-cols-2.sm:grid-cols-4.gap-3', [
499
+ m('div.p-3.bg-gray-50.dark:bg-slate-900.rounded-xl.border.border-gray-200.dark:border-slate-800', [
500
+ m('p.text-xs.font-medium.text-gray-500.dark:text-slate-400', 'Database Engine'),
501
+ m('p.text-lg.font-bold.text-gray-900.dark:text-slate-100.mt-1', data.engine || 'SQL'),
502
+ data.sqliteInfo && data.sqliteInfo.sizeBytes > 0
503
+ ? m('p.text-xs.text-blue-600.dark:text-blue-400.mt-0.5', formatStorageBytes(data.sqliteInfo.sizeBytes))
504
+ : null,
505
+ ]),
506
+ m('div.p-3.bg-gray-50.dark:bg-slate-900.rounded-xl.border.border-gray-200.dark:border-slate-800', [
507
+ m('p.text-xs.font-medium.text-gray-500.dark:text-slate-400', 'Total Tables'),
508
+ m('p.text-lg.font-bold.text-gray-900.dark:text-slate-100.mt-1', data.totalTables || 0),
509
+ m('p.text-xs.text-gray-400.dark:text-slate-500.mt-0.5', 'Active in schema'),
510
+ ]),
511
+ m('div.p-3.bg-gray-50.dark:bg-slate-900.rounded-xl.border.border-gray-200.dark:border-slate-800', [
512
+ m('p.text-xs.font-medium.text-gray-500.dark:text-slate-400', 'Total Records'),
513
+ m('p.text-lg.font-bold.text-gray-900.dark:text-slate-100.mt-1', totalRows.toLocaleString()),
514
+ m('p.text-xs.text-gray-400.dark:text-slate-500.mt-0.5', 'Across all tables'),
515
+ ]),
516
+ m('div.p-3.rounded-xl.border', {
517
+ class: isAttention
518
+ ? 'bg-amber-50.border-amber-200.dark:bg-amber-950/40.dark:border-amber-800/80'
519
+ : 'bg-emerald-50.border-emerald-200.dark:bg-emerald-950/40.dark:border-emerald-800/80'
520
+ }, [
521
+ m('p.text-xs.font-medium', {
522
+ class: isAttention ? 'text-amber-700 dark:text-amber-300' : 'text-emerald-700 dark:text-emerald-300'
523
+ }, 'Storage Health'),
524
+ m('div.flex.items-center.gap-1.5.mt-1', [
525
+ m('span.w-2.5.h-2.5.rounded-full', {
526
+ class: isAttention ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'
527
+ }),
528
+ m('p.text-lg.font-bold', {
529
+ class: isAttention ? 'text-amber-800 dark:text-amber-200' : 'text-emerald-800 dark:text-emerald-200'
530
+ }, isAttention ? 'Attention' : 'Healthy'),
531
+ ]),
532
+ m('p.text-xs.mt-0.5', {
533
+ class: isAttention ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'
534
+ }, isAttention ? 'High row count in logs' : 'No table bloat detected'),
535
+ ]),
536
+ ]),
537
+
538
+ // Table Breakdown List with Progress Bars
539
+ m('div.bg-gray-50.dark:bg-slate-900.rounded-xl.border.border-gray-200.dark:border-slate-800.p-4', [
540
+ m('div.flex.items-center.justify-between.mb-3', [
541
+ m('h4.text-xs.font-bold.uppercase.tracking-wider.text-gray-500.dark:text-slate-400', 'Top Tables by Row Count'),
542
+ m('span.text-xs.text-gray-400.dark:text-slate-500', tables.length + ' tables'),
543
+ ]),
544
+ tables.length === 0
545
+ ? m('p.text-xs.text-gray-400.text-center.py-4', 'No tables detected')
546
+ : m('div.space-y-2.5', tables.slice(0, 8).map(t => {
547
+ const pct = totalRows > 0 ? ((t.count / totalRows) * 100).toFixed(1) : 0;
548
+ const style = categoryStyles[t.category] || categoryStyles.system;
549
+
550
+ return m('div.flex.flex-col.gap-1', [
551
+ m('div.flex.items-center.justify-between.text-xs', [
552
+ m('div.flex.items-center.gap-2', [
553
+ t.modelName
554
+ ? m('a.font-semibold.text-blue-600.dark:text-blue-400.hover:underline', {
555
+ href: '/models/' + t.modelName,
556
+ onclick: (e) => { e.preventDefault(); m.route.set('/models/' + t.modelName); }
557
+ }, (t.modelIcon ? t.modelIcon + ' ' : '') + t.table)
558
+ : m('span.font-medium.text-gray-800.dark:text-slate-200', t.table),
559
+ m('span.px-1.5.py-0.5.rounded.text-[10px].font-medium', {
560
+ class: style.bg + ' ' + style.text
561
+ }, t.category.toUpperCase()),
562
+ t.isHighVolume
563
+ ? m('span.text-[10px].text-amber-600.dark:text-amber-400.font-medium', '⚠️ High volume')
564
+ : null,
565
+ ]),
566
+ m('div.flex.items-center.gap-2', [
567
+ m('span.font-semibold.text-gray-900.dark:text-slate-100', t.count.toLocaleString() + ' rows'),
568
+ m('span.text-gray-400.dark:text-slate-500.w-10.text-right', pct + '%'),
569
+ ])
570
+ ]),
571
+ m('div.w-full.bg-gray-200.dark:bg-slate-700.rounded-full.h-1.5.overflow-hidden', [
572
+ m('div.h-1.5.rounded-full.transition-all', {
573
+ class: style.bar,
574
+ style: { width: Math.max(1, Math.min(100, Number(pct))) + '%' }
575
+ })
576
+ ])
577
+ ]);
578
+ }))
579
+ ])
580
+ ]);
581
+ }
582
+ },
583
+
302
584
  // Default renderer
303
585
  default: {
304
586
  render: (data) => {
@@ -473,4 +755,5 @@ const Dashboard = {
473
755
  module.exports = {
474
756
  registerDashboardWidgets,
475
757
  generateDashboardComponent,
758
+ collectDatabaseStorageStats,
476
759
  };
@@ -10,32 +10,68 @@
10
10
  * @param {Object} options.registry - Admin registry
11
11
  * @param {Object} options.db - Database instance
12
12
  */
13
- function registerModelMenuItems(options) {
14
- const { registry, db } = options;
13
+ function registerModelMenuItems(options, maybeModels) {
14
+ let registry = options?.registry;
15
+ let db = options?.db;
16
+ let models = options?.models;
17
+
18
+ if (!registry && options && typeof options.registerMenuItem === 'function') {
19
+ registry = options;
20
+ }
21
+ if (Array.isArray(maybeModels)) {
22
+ models = maybeModels;
23
+ }
24
+
15
25
  const { getAllModels } = require('../../../core/orm/model');
16
26
 
17
- // Get all admin-enabled models
18
- const allModels = db.getAllModels ? db.getAllModels() : Array.from(getAllModels().values());
19
- const modelList = Array.isArray(allModels) ? allModels : Array.from(allModels.values());
27
+ let modelList;
28
+ if (Array.isArray(models)) {
29
+ modelList = models;
30
+ } else if (db && typeof db.getAllModels === 'function') {
31
+ const res = db.getAllModels();
32
+ modelList = Array.isArray(res) ? res : Array.from(res.values());
33
+ } else if (getAllModels) {
34
+ modelList = Array.from(getAllModels().values());
35
+ } else {
36
+ modelList = [];
37
+ }
20
38
 
21
39
  // Create "Content" menu group for models
22
- registry.registerMenuGroup('content', {
23
- label: 'Content',
24
- icon: 'database',
25
- order: 0,
26
- });
40
+ if (registry && typeof registry.registerMenuGroup === 'function') {
41
+ registry.registerMenuGroup('content', {
42
+ label: 'Content',
43
+ icon: 'database',
44
+ order: 0,
45
+ });
46
+ }
27
47
 
28
48
  // Register menu items for each model
29
49
  for (const model of modelList) {
30
50
  if (model.admin?.enabled) {
31
- registry.registerMenuItem({
32
- id: `model-${model.name}`,
33
- label: model.admin.label || model.name,
34
- path: `/models/${model.name}`,
35
- icon: model.admin.icon || 'table',
36
- group: 'content',
37
- order: model.admin.menuOrder || 10,
38
- });
51
+ let group = 'content';
52
+ if (model.admin.group) {
53
+ group = String(model.admin.group).toLowerCase().trim().replace(/\s+/g, '-');
54
+ const hasGroup = registry.menuGroups && typeof registry.menuGroups.has === 'function'
55
+ ? registry.menuGroups.has(group)
56
+ : false;
57
+ if (!hasGroup && registry && typeof registry.registerMenuGroup === 'function') {
58
+ registry.registerMenuGroup(group, {
59
+ label: model.admin.group,
60
+ icon: 'database',
61
+ order: model.admin.groupOrder || 50,
62
+ });
63
+ }
64
+ }
65
+ if (registry && typeof registry.registerMenuItem === 'function') {
66
+ registry.registerMenuItem({
67
+ id: `model-${model.name}`,
68
+ label: model.admin.label || model.name,
69
+ path: `/models/${model.name}`,
70
+ icon: model.admin.icon || 'table',
71
+ group,
72
+ order: model.admin.menuOrder || 10,
73
+ });
74
+ }
39
75
  }
40
76
  }
41
77
  }
@@ -481,6 +517,8 @@ const Layout = {
481
517
  m(Sidebar),
482
518
  m('main.lg:ml-64.p-6.pt-20.lg:pt-6', vnode.children),
483
519
  m(VersionUpdateToast),
520
+ typeof ToastContainer !== 'undefined' ? m(ToastContainer) : null,
521
+ typeof ConfirmModalContainer !== 'undefined' ? m(ConfirmModalContainer) : null,
484
522
  ]);
485
523
  },
486
524
  };
@@ -191,7 +191,10 @@ function registerUserManagement(options) {
191
191
  models: modelName,
192
192
  visible: (record) => !record[fieldMap.active],
193
193
  handler: async (record, model, { db }) => {
194
- const repo = db.getRepository(model);
194
+ const targetModel = typeof model === 'object' && model !== null
195
+ ? (model.name || model.modelName || model.model || String(model))
196
+ : String(model);
197
+ const repo = db.getRepository(targetModel);
195
198
  await repo.update(record.id, { [fieldMap.active]: true });
196
199
  return { message: 'User activated' };
197
200
  },
@@ -204,7 +207,10 @@ function registerUserManagement(options) {
204
207
  models: modelName,
205
208
  visible: (record) => record[fieldMap.active],
206
209
  handler: async (record, model, { db }) => {
207
- const repo = db.getRepository(model);
210
+ const targetModel = typeof model === 'object' && model !== null
211
+ ? (model.name || model.modelName || model.model || String(model))
212
+ : String(model);
213
+ const repo = db.getRepository(targetModel);
208
214
  await repo.update(record.id, { [fieldMap.active]: false });
209
215
  return { message: 'User deactivated' };
210
216
  },
@@ -218,7 +224,10 @@ function registerUserManagement(options) {
218
224
  confirm: true,
219
225
  confirmMessage: 'This will generate a new random password. Continue?',
220
226
  handler: async (record, model, { db }) => {
221
- const repo = db.getRepository(model);
227
+ const targetModel = typeof model === 'object' && model !== null
228
+ ? (model.name || model.modelName || model.model || String(model))
229
+ : String(model);
230
+ const repo = db.getRepository(targetModel);
222
231
  // Generate random password
223
232
  const newPassword = generateRandomPassword(12);
224
233
  const hashedPassword = await hash(newPassword);
@@ -234,7 +243,10 @@ function registerUserManagement(options) {
234
243
  color: 'green',
235
244
  models: modelName,
236
245
  handler: async (records, model, { db }) => {
237
- const repo = db.getRepository(model);
246
+ const targetModel = typeof model === 'object' && model !== null
247
+ ? (model.name || model.modelName || model.model || String(model))
248
+ : String(model);
249
+ const repo = db.getRepository(targetModel);
238
250
  for (const record of records) {
239
251
  await repo.update(record.id, { [fieldMap.active]: true });
240
252
  }
@@ -248,7 +260,10 @@ function registerUserManagement(options) {
248
260
  color: 'red',
249
261
  models: modelName,
250
262
  handler: async (records, model, { db }) => {
251
- const repo = db.getRepository(model);
263
+ const targetModel = typeof model === 'object' && model !== null
264
+ ? (model.name || model.modelName || model.model || String(model))
265
+ : String(model);
266
+ const repo = db.getRepository(targetModel);
252
267
  for (const record of records) {
253
268
  await repo.update(record.id, { [fieldMap.active]: false });
254
269
  }
@@ -264,7 +279,10 @@ function registerUserManagement(options) {
264
279
  confirm: true,
265
280
  confirmMessage: 'Are you sure you want to delete the selected users? This cannot be undone.',
266
281
  handler: async (records, model, { db }) => {
267
- const repo = db.getRepository(model);
282
+ const targetModel = typeof model === 'object' && model !== null
283
+ ? (model.name || model.modelName || model.model || String(model))
284
+ : String(model);
285
+ const repo = db.getRepository(targetModel);
268
286
  for (const record of records) {
269
287
  await repo.delete(record.id);
270
288
  }
@@ -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
+ };