@intranefr/superbackend 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +10 -0
  2. package/analysis-only.skill +0 -0
  3. package/package.json +2 -1
  4. package/src/controllers/admin.controller.js +68 -1
  5. package/src/controllers/adminExperiments.controller.js +200 -0
  6. package/src/controllers/adminScripts.controller.js +105 -74
  7. package/src/controllers/experiments.controller.js +85 -0
  8. package/src/controllers/internalExperiments.controller.js +17 -0
  9. package/src/helpers/mongooseHelper.js +258 -0
  10. package/src/helpers/scriptBase.js +230 -0
  11. package/src/helpers/scriptRunner.js +335 -0
  12. package/src/middleware.js +65 -11
  13. package/src/models/CacheEntry.js +1 -1
  14. package/src/models/ConsoleLog.js +1 -1
  15. package/src/models/Experiment.js +75 -0
  16. package/src/models/ExperimentAssignment.js +23 -0
  17. package/src/models/ExperimentEvent.js +26 -0
  18. package/src/models/ExperimentMetricBucket.js +30 -0
  19. package/src/models/GlobalSetting.js +1 -2
  20. package/src/models/RateLimitCounter.js +1 -1
  21. package/src/models/ScriptDefinition.js +1 -0
  22. package/src/models/Webhook.js +2 -0
  23. package/src/routes/admin.routes.js +2 -0
  24. package/src/routes/adminConsoleManager.routes.js +1 -1
  25. package/src/routes/adminExperiments.routes.js +29 -0
  26. package/src/routes/blogInternal.routes.js +2 -2
  27. package/src/routes/experiments.routes.js +30 -0
  28. package/src/routes/internalExperiments.routes.js +15 -0
  29. package/src/services/blogCronsBootstrap.service.js +7 -6
  30. package/src/services/consoleManager.service.js +56 -18
  31. package/src/services/consoleOverride.service.js +1 -0
  32. package/src/services/experiments.service.js +273 -0
  33. package/src/services/experimentsAggregation.service.js +308 -0
  34. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  35. package/src/services/experimentsRetention.service.js +43 -0
  36. package/src/services/experimentsWs.service.js +134 -0
  37. package/src/services/globalSettings.service.js +15 -0
  38. package/src/services/jsonConfigs.service.js +2 -2
  39. package/src/services/scriptsRunner.service.js +214 -14
  40. package/src/utils/rbac/rightsRegistry.js +4 -0
  41. package/views/admin-dashboard.ejs +28 -8
  42. package/views/admin-experiments.ejs +91 -0
  43. package/views/admin-scripts.ejs +596 -2
  44. package/views/partials/dashboard/nav-items.ejs +1 -0
  45. package/views/partials/dashboard/palette.ejs +5 -3
  46. package/src/middleware/internalCronAuth.js +0 -29
@@ -1,11 +1,76 @@
1
1
  const { EventEmitter } = require('events');
2
2
  const { spawn } = require('child_process');
3
3
  const { NodeVM } = require('vm2');
4
+ const mongoose = require('mongoose');
4
5
 
5
6
  const ScriptRun = require('../models/ScriptRun');
7
+ const { mongooseHelper } = require('../helpers/mongooseHelper');
6
8
 
7
9
  const MAX_TAIL_BYTES = 64 * 1024;
8
10
 
11
+ function isTruthyEnv(v) {
12
+ const s = String(v || '').trim().toLowerCase();
13
+ return s === '1' || s === 'true' || s === 'yes' || s === 'y' || s === 'on';
14
+ }
15
+
16
+ function shouldAutoWrapAsyncScripts() {
17
+ if (process.env.SCRIPT_AUTO_ASYNC_WRAP === undefined) return true;
18
+ return isTruthyEnv(process.env.SCRIPT_AUTO_ASYNC_WRAP);
19
+ }
20
+
21
+ function detectTopLevelAwait(code) {
22
+ const s = String(code || '');
23
+ if (!/\bawait\b/.test(s)) return false;
24
+ if (/^\s*\(\s*async\s*\(/.test(s)) return false;
25
+ if (/^\s*async\s+function\b/.test(s)) return false;
26
+ if (/\bmodule\.exports\b/.test(s) || /\bexports\./.test(s)) return false;
27
+ return true;
28
+ }
29
+
30
+ function wrapInAsyncIife(code) {
31
+ const body = String(code || '');
32
+ return [
33
+ '(async () => {',
34
+ body,
35
+ '})().catch((err) => {',
36
+ ' try { console.error(err && err.stack ? err.stack : err); } catch {}',
37
+ '});',
38
+ '',
39
+ ].join('\n');
40
+ }
41
+
42
+ function prepareVmCodeForExecution(code) {
43
+ const raw = String(code || '');
44
+ if (!shouldAutoWrapAsyncScripts()) return { code: raw, wrapped: false };
45
+ if (!detectTopLevelAwait(raw)) return { code: raw, wrapped: false };
46
+ return { code: wrapInAsyncIife(raw), wrapped: true };
47
+ }
48
+
49
+ function buildAwaitSyntaxHelpMessage() {
50
+ return [
51
+ 'Your script uses `await` at top-level.',
52
+ 'Wrap it in an async IIFE, or rely on auto-wrapping:',
53
+ '',
54
+ '(async () => {',
55
+ ' const count = await countCollectionDocuments("users");',
56
+ ' console.log("count:", count);',
57
+ '})();',
58
+ '',
59
+ ].join('\n');
60
+ }
61
+
62
+ // Helper function to decode script content
63
+ function decodeScriptContent(script, format) {
64
+ if (format === 'base64') {
65
+ try {
66
+ return Buffer.from(script, 'base64').toString('utf8');
67
+ } catch (err) {
68
+ throw new Error('Failed to decode base64 script content');
69
+ }
70
+ }
71
+ return script;
72
+ }
73
+
9
74
  function nowIso() {
10
75
  return new Date().toISOString();
11
76
  }
@@ -114,24 +179,16 @@ async function startRun(scriptDef, options) {
114
179
  runId: runDoc._id,
115
180
  bus,
116
181
  command: 'bash',
117
- args: ['-lc', scriptDef.script],
182
+ args: ['-lc', decodeScriptContent(scriptDef.script, scriptDef.scriptFormat)],
118
183
  env,
119
184
  cwd,
120
185
  timeoutMs,
121
186
  });
122
187
  } else if (scriptDef.type === 'node') {
123
188
  if (scriptDef.runner === 'vm2') {
124
- exitCode = await runVm2({ runId: runDoc._id, bus, code: scriptDef.script, timeoutMs });
189
+ exitCode = await runVm2({ runId: runDoc._id, bus, code: decodeScriptContent(scriptDef.script, scriptDef.scriptFormat), timeoutMs });
125
190
  } else if (scriptDef.runner === 'host') {
126
- exitCode = await runSpawned({
127
- runId: runDoc._id,
128
- bus,
129
- command: 'node',
130
- args: ['-e', scriptDef.script],
131
- env,
132
- cwd,
133
- timeoutMs,
134
- });
191
+ exitCode = await runHostWithDatabase({ runId: runDoc._id, bus, code: decodeScriptContent(scriptDef.script, scriptDef.scriptFormat), env, cwd, timeoutMs });
135
192
  } else {
136
193
  throw Object.assign(new Error('Invalid runner for node script'), { code: 'VALIDATION' });
137
194
  }
@@ -213,6 +270,144 @@ async function runSpawned({ runId, bus, command, args, env, cwd, timeoutMs }) {
213
270
  });
214
271
  }
215
272
 
273
+ async function runHostWithDatabase({ runId, bus, code, env, cwd, timeoutMs }) {
274
+ let tail = '';
275
+
276
+ function pushLog(stream, line) {
277
+ const s = String(line || '');
278
+ tail = appendTail(tail, s);
279
+ bus.push({ type: 'log', ts: nowIso(), stream, line: s });
280
+ return ScriptRun.updateOne({ _id: runId }, { $set: { outputTail: tail } });
281
+ }
282
+
283
+ try {
284
+ // Use existing app connection if available, otherwise create new one
285
+ if (mongoose.connection.readyState !== 1) {
286
+ await pushLog('stdout', 'No existing connection found, establishing new connection...\n');
287
+ await mongooseHelper.connect();
288
+
289
+ // Wait for connection to be fully ready
290
+ await mongooseHelper.waitForConnection(5000);
291
+ } else {
292
+ await pushLog('stdout', 'Using existing app database connection\n');
293
+ }
294
+
295
+ // Validate connection is ready
296
+ if (mongoose.connection.readyState !== 1) {
297
+ throw new Error('Database connection is not ready');
298
+ }
299
+
300
+ const prepared = prepareVmCodeForExecution(code);
301
+ if (prepared.wrapped) {
302
+ await pushLog('stdout', 'Auto-wrapping script in async function (detected top-level await)\n');
303
+ }
304
+
305
+ // Create a VM with database context
306
+ const vm = new NodeVM({
307
+ console: 'inherit',
308
+ sandbox: {
309
+ // Expose pre-connected mongoose instance
310
+ mongoose: mongoose,
311
+ db: mongoose.connection.db,
312
+
313
+ // Expose helper functions
314
+ countCollectionDocuments: async (collectionName, query = {}) => {
315
+ try {
316
+ // Ensure connection is still valid
317
+ if (mongoose.connection.readyState !== 1) {
318
+ throw new Error('Database connection lost during operation');
319
+ }
320
+
321
+ const db = mongoose.connection.db;
322
+ if (!db) {
323
+ throw new Error('Database instance not available');
324
+ }
325
+
326
+ const collection = db.collection(collectionName);
327
+ const count = await collection.countDocuments(query);
328
+ return count;
329
+ } catch (error) {
330
+ throw new Error(`Failed to count documents in ${collectionName}: ${error.message}`);
331
+ }
332
+ },
333
+
334
+ // Expose connection status helper
335
+ getConnectionStatus: () => {
336
+ const readyStateMap = {
337
+ 0: 'disconnected',
338
+ 1: 'connected',
339
+ 2: 'connecting',
340
+ 3: 'disconnecting'
341
+ };
342
+
343
+ return {
344
+ readyState: mongoose.connection.readyState,
345
+ readyStateText: readyStateMap[mongoose.connection.readyState] || 'unknown',
346
+ host: mongoose.connection.host,
347
+ name: mongoose.connection.name,
348
+ hasActiveConnection: mongoose.connection.readyState === 1
349
+ };
350
+ },
351
+
352
+ // Expose models if available
353
+ models: mongoose.models || {},
354
+
355
+ // Global objects
356
+ JSON,
357
+ Date,
358
+ Math,
359
+ parseInt,
360
+ parseFloat,
361
+ String,
362
+ Number,
363
+ Object,
364
+ Array,
365
+
366
+ // Process environment
367
+ process: {
368
+ env: { ...process.env, ...env }
369
+ }
370
+ },
371
+ require: {
372
+ external: false,
373
+ builtin: ['util', 'path', 'os'], // Allow some basic built-ins
374
+ },
375
+ timeout: timeoutMs,
376
+ eval: false,
377
+ wasm: false,
378
+ });
379
+
380
+ // Set up console redirection
381
+ vm.on('console.log', (...args) => {
382
+ pushLog('stdout', args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
383
+ });
384
+ vm.on('console.error', (...args) => {
385
+ pushLog('stderr', args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
386
+ });
387
+
388
+ // Run the script code with better error handling
389
+ try {
390
+ vm.run(prepared.code, 'script.host.js');
391
+ } catch (vmError) {
392
+ const baseMsg = vmError?.message || 'Unknown VM error';
393
+ const help = baseMsg.includes('await is only valid in async functions') ? `\n\n${buildAwaitSyntaxHelpMessage()}` : '';
394
+ const errorMsg = `VM execution error: ${baseMsg}${help}`;
395
+ await pushLog('stderr', errorMsg + '\n');
396
+ return 1;
397
+ }
398
+
399
+ return 0;
400
+
401
+ } catch (err) {
402
+ const msg = err?.message || 'Host script error';
403
+ await pushLog('stderr', msg + '\n');
404
+ return 1;
405
+ } finally {
406
+ // Don't disconnect here - let mongooseHelper manage connection pooling
407
+ // The connection will be cleaned up when the helper decides
408
+ }
409
+ }
410
+
216
411
  async function runVm2({ runId, bus, code, timeoutMs }) {
217
412
  let tail = '';
218
413
 
@@ -243,11 +438,16 @@ async function runVm2({ runId, bus, code, timeoutMs }) {
243
438
  });
244
439
 
245
440
  try {
246
- vm.run(code, 'script.vm2.js');
441
+ const prepared = prepareVmCodeForExecution(code);
442
+ if (prepared.wrapped) {
443
+ await pushLog('stdout', 'Auto-wrapping script in async function (detected top-level await)\n');
444
+ }
445
+ vm.run(prepared.code, 'script.vm2.js');
247
446
  return 0;
248
447
  } catch (err) {
249
- const msg = err?.message || 'vm2 error';
250
- await pushLog('stderr', msg + '\n');
448
+ const baseMsg = err?.message || 'vm2 error';
449
+ const help = baseMsg.includes('await is only valid in async functions') ? `\n\n${buildAwaitSyntaxHelpMessage()}` : '';
450
+ await pushLog('stderr', baseMsg + help + '\n');
251
451
  return 1;
252
452
  }
253
453
  }
@@ -6,6 +6,10 @@ const DEFAULT_RIGHTS = [
6
6
  'rbac:grants:read',
7
7
  'rbac:grants:write',
8
8
  'rbac:test',
9
+ 'experiments:*',
10
+ 'experiments:read',
11
+ 'experiments:events:write',
12
+ 'experiments:admin',
9
13
  'file_manager:*',
10
14
  'file_manager:access',
11
15
  'file_manager:drives:read',
@@ -222,6 +222,10 @@
222
222
  const paletteQuery = ref('');
223
223
  const paletteCursor = ref(0);
224
224
  const paletteInput = ref(null);
225
+
226
+ // Toggle safeguards and debugging
227
+ let toggleTimeout = null;
228
+ let lastToggleTime = 0;
225
229
 
226
230
  // Flattened modules for search
227
231
  const allModules = computed(() => {
@@ -285,19 +289,34 @@
285
289
  };
286
290
 
287
291
  // Palette methods
288
- const togglePalette = () => {
292
+ const togglePalette = (source = 'unknown') => {
293
+ // Prevent rapid successive toggles
294
+ const now = Date.now();
295
+ if (now - lastToggleTime < 100) {
296
+ return;
297
+ }
298
+ lastToggleTime = now;
299
+
300
+ clearTimeout(toggleTimeout);
301
+
289
302
  showPalette.value = !showPalette.value;
290
303
  if (showPalette.value) {
291
304
  paletteQuery.value = '';
292
305
  paletteCursor.value = 0;
293
- nextTick(() => {
294
- if (paletteInput.value) paletteInput.value.focus();
295
- });
306
+ // Use setTimeout instead of nextTick for better timing
307
+ toggleTimeout = setTimeout(() => {
308
+ if (paletteInput.value) {
309
+ paletteInput.value.focus();
310
+ }
311
+ }, 50);
296
312
  }
297
313
  };
298
314
 
299
- const closePalette = () => {
300
- showPalette.value = false;
315
+ const closePalette = (source = 'unknown') => {
316
+ if (showPalette.value) {
317
+ showPalette.value = false;
318
+ clearTimeout(toggleTimeout);
319
+ }
301
320
  };
302
321
 
303
322
  const navigatePalette = (dir) => {
@@ -325,13 +344,14 @@
325
344
  const handleKeydown = (e) => {
326
345
  if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
327
346
  e.preventDefault();
328
- togglePalette();
347
+ e.stopPropagation();
348
+ togglePalette('keyboard-ctrl-k');
329
349
  }
330
350
  };
331
351
 
332
352
  const handleMessage = (e) => {
333
353
  if (e.data && e.data.type === 'keydown' && e.data.ctrlK) {
334
- togglePalette();
354
+ togglePalette('iframe-message');
335
355
  }
336
356
  };
337
357
 
@@ -0,0 +1,91 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Experiments</title>
7
+ <link rel="stylesheet" href="<%= baseUrl %><%= adminPath %>/assets/styles.css" />
8
+ <style>
9
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 16px; }
10
+ .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
11
+ input, select, button { padding: 8px 10px; }
12
+ table { width: 100%; border-collapse: collapse; margin-top: 12px; }
13
+ th, td { border-bottom: 1px solid #e5e7eb; padding: 10px; text-align: left; }
14
+ .muted { color: #6b7280; }
15
+ .pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #f3f4f6; }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <h1>Experiments</h1>
20
+
21
+ <div class="row">
22
+ <label>
23
+ orgId
24
+ <input id="orgId" placeholder="(required for RBAC)" style="min-width: 320px" />
25
+ </label>
26
+ <button id="refresh">Refresh</button>
27
+ <span id="status" class="muted"></span>
28
+ </div>
29
+
30
+ <table>
31
+ <thead>
32
+ <tr>
33
+ <th>Code</th>
34
+ <th>Status</th>
35
+ <th>Org</th>
36
+ <th>Winner</th>
37
+ <th>Updated</th>
38
+ </tr>
39
+ </thead>
40
+ <tbody id="tbody"></tbody>
41
+ </table>
42
+
43
+ <script>
44
+ const statusEl = document.getElementById('status');
45
+ const tbody = document.getElementById('tbody');
46
+ const orgIdEl = document.getElementById('orgId');
47
+
48
+ function esc(s) {
49
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
50
+ }
51
+
52
+ async function load() {
53
+ const orgId = orgIdEl.value.trim();
54
+ statusEl.textContent = 'Loading...';
55
+ tbody.innerHTML = '';
56
+
57
+ const url = new URL('<%= baseUrl %>/api/admin/experiments', window.location.origin);
58
+ if (orgId) url.searchParams.set('orgId', orgId);
59
+
60
+ const res = await fetch(url.toString(), { headers: { 'Content-Type': 'application/json' } });
61
+ const json = await res.json().catch(() => ({}));
62
+
63
+ if (!res.ok) {
64
+ statusEl.textContent = `Error: ${json.error || res.status}`;
65
+ return;
66
+ }
67
+
68
+ const items = Array.isArray(json.items) ? json.items : [];
69
+ statusEl.textContent = `${items.length} experiments`;
70
+
71
+ tbody.innerHTML = items
72
+ .map((e) => {
73
+ const winner = e.winnerVariantKey ? esc(e.winnerVariantKey) : '<span class="muted">-</span>';
74
+ return `
75
+ <tr>
76
+ <td><span class="pill">${esc(e.code)}</span></td>
77
+ <td>${esc(e.status)}</td>
78
+ <td class="muted">${esc(e.organizationId || '')}</td>
79
+ <td>${winner}</td>
80
+ <td class="muted">${esc(e.updatedAt || '')}</td>
81
+ </tr>
82
+ `;
83
+ })
84
+ .join('');
85
+ }
86
+
87
+ document.getElementById('refresh').addEventListener('click', () => load());
88
+ load();
89
+ </script>
90
+ </body>
91
+ </html>