@intranefr/superbackend 1.5.1 → 1.5.3

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 (73) hide show
  1. package/.env.example +10 -0
  2. package/index.js +2 -0
  3. package/manage.js +745 -0
  4. package/package.json +5 -2
  5. package/src/controllers/admin.controller.js +79 -6
  6. package/src/controllers/adminAgents.controller.js +37 -0
  7. package/src/controllers/adminExperiments.controller.js +200 -0
  8. package/src/controllers/adminLlm.controller.js +19 -0
  9. package/src/controllers/adminMarkdowns.controller.js +157 -0
  10. package/src/controllers/adminScripts.controller.js +243 -74
  11. package/src/controllers/adminTelegram.controller.js +72 -0
  12. package/src/controllers/experiments.controller.js +85 -0
  13. package/src/controllers/internalExperiments.controller.js +17 -0
  14. package/src/controllers/markdowns.controller.js +42 -0
  15. package/src/helpers/mongooseHelper.js +258 -0
  16. package/src/helpers/scriptBase.js +230 -0
  17. package/src/helpers/scriptRunner.js +335 -0
  18. package/src/middleware.js +195 -34
  19. package/src/models/Agent.js +105 -0
  20. package/src/models/AgentMessage.js +82 -0
  21. package/src/models/CacheEntry.js +1 -1
  22. package/src/models/ConsoleLog.js +1 -1
  23. package/src/models/Experiment.js +75 -0
  24. package/src/models/ExperimentAssignment.js +23 -0
  25. package/src/models/ExperimentEvent.js +26 -0
  26. package/src/models/ExperimentMetricBucket.js +30 -0
  27. package/src/models/GlobalSetting.js +1 -2
  28. package/src/models/Markdown.js +75 -0
  29. package/src/models/RateLimitCounter.js +1 -1
  30. package/src/models/ScriptDefinition.js +1 -0
  31. package/src/models/ScriptRun.js +8 -0
  32. package/src/models/TelegramBot.js +42 -0
  33. package/src/models/Webhook.js +2 -0
  34. package/src/routes/admin.routes.js +2 -0
  35. package/src/routes/adminAgents.routes.js +13 -0
  36. package/src/routes/adminConsoleManager.routes.js +1 -1
  37. package/src/routes/adminExperiments.routes.js +29 -0
  38. package/src/routes/adminLlm.routes.js +1 -0
  39. package/src/routes/adminMarkdowns.routes.js +16 -0
  40. package/src/routes/adminScripts.routes.js +4 -1
  41. package/src/routes/adminTelegram.routes.js +14 -0
  42. package/src/routes/blogInternal.routes.js +2 -2
  43. package/src/routes/experiments.routes.js +30 -0
  44. package/src/routes/internalExperiments.routes.js +15 -0
  45. package/src/routes/markdowns.routes.js +16 -0
  46. package/src/services/agent.service.js +546 -0
  47. package/src/services/agentHistory.service.js +345 -0
  48. package/src/services/agentTools.service.js +578 -0
  49. package/src/services/blogCronsBootstrap.service.js +7 -6
  50. package/src/services/consoleManager.service.js +56 -18
  51. package/src/services/consoleOverride.service.js +1 -0
  52. package/src/services/experiments.service.js +273 -0
  53. package/src/services/experimentsAggregation.service.js +308 -0
  54. package/src/services/experimentsCronsBootstrap.service.js +118 -0
  55. package/src/services/experimentsRetention.service.js +43 -0
  56. package/src/services/experimentsWs.service.js +134 -0
  57. package/src/services/globalSettings.service.js +15 -0
  58. package/src/services/jsonConfigs.service.js +24 -12
  59. package/src/services/llm.service.js +219 -6
  60. package/src/services/markdowns.service.js +522 -0
  61. package/src/services/scriptsRunner.service.js +514 -23
  62. package/src/services/telegram.service.js +130 -0
  63. package/src/utils/rbac/rightsRegistry.js +4 -0
  64. package/views/admin-agents.ejs +273 -0
  65. package/views/admin-coolify-deploy.ejs +8 -8
  66. package/views/admin-dashboard.ejs +63 -12
  67. package/views/admin-experiments.ejs +91 -0
  68. package/views/admin-markdowns.ejs +905 -0
  69. package/views/admin-scripts.ejs +817 -6
  70. package/views/admin-telegram.ejs +269 -0
  71. package/views/partials/dashboard/nav-items.ejs +4 -0
  72. package/views/partials/dashboard/palette.ejs +5 -3
  73. package/src/middleware/internalCronAuth.js +0 -29
@@ -0,0 +1,118 @@
1
+ const crypto = require('crypto');
2
+
3
+ const GlobalSetting = require('../models/GlobalSetting');
4
+ const CronJob = require('../models/CronJob');
5
+
6
+ const globalSettingsService = require('./globalSettings.service');
7
+
8
+ const INTERNAL_CRON_TOKEN_SETTING_KEY = 'experiments.internalCronToken';
9
+
10
+ const CRON_NAME_AGGREGATE = 'Experiments: Aggregate + Evaluate Winner';
11
+ const CRON_NAME_RETENTION = 'Experiments: Retention Cleanup';
12
+
13
+ function getDefaultInternalCronToken() {
14
+ return crypto.randomBytes(24).toString('hex');
15
+ }
16
+
17
+ async function ensureSettingExists({ key, type, description, defaultValue }) {
18
+ const existing = await GlobalSetting.findOne({ key }).lean();
19
+ if (existing) return existing;
20
+
21
+ const doc = await GlobalSetting.create({
22
+ key,
23
+ type,
24
+ description,
25
+ value: type === 'json' ? JSON.stringify(defaultValue) : String(defaultValue ?? ''),
26
+ templateVariables: [],
27
+ public: false,
28
+ });
29
+
30
+ globalSettingsService.clearSettingsCache();
31
+ return doc.toObject();
32
+ }
33
+
34
+ async function ensureInternalTokenExists() {
35
+ await ensureSettingExists({
36
+ key: INTERNAL_CRON_TOKEN_SETTING_KEY,
37
+ type: 'string',
38
+ description: 'Bearer token used by CronJobs to call internal experiments endpoints.',
39
+ defaultValue: getDefaultInternalCronToken(),
40
+ });
41
+
42
+ const raw = await globalSettingsService.getSettingValue(INTERNAL_CRON_TOKEN_SETTING_KEY, '');
43
+ return String(raw || '').trim();
44
+ }
45
+
46
+ async function ensureCronJobs({ baseUrl }) {
47
+ const aggregateUrl = `${baseUrl}/api/internal/experiments/aggregate/run`;
48
+ const retentionUrl = `${baseUrl}/api/internal/experiments/retention/run`;
49
+
50
+ // Use the same Basic Auth credentials as the admin API.
51
+ // This keeps a single source of truth and avoids CronJobs drifting to other env vars.
52
+ const internalCronUsername = process.env.ADMIN_USERNAME || 'admin';
53
+ const internalCronPassword = process.env.ADMIN_PASSWORD || 'admin';
54
+
55
+ const aggDoc = {
56
+ name: CRON_NAME_AGGREGATE,
57
+ description: 'Aggregates experiment events into buckets and evaluates winners.',
58
+ cronExpression: '*/15 * * * *',
59
+ timezone: 'UTC',
60
+ enabled: true,
61
+ nextRunAt: null,
62
+ taskType: 'http',
63
+ httpMethod: 'POST',
64
+ httpUrl: aggregateUrl,
65
+ httpHeaders: [],
66
+ httpBody: JSON.stringify({}),
67
+ httpBodyType: 'json',
68
+ httpAuth: { type: 'basic', username: internalCronUsername, password: internalCronPassword },
69
+ timeoutMs: 5 * 60 * 1000,
70
+ createdBy: 'system',
71
+ };
72
+
73
+ await CronJob.updateOne(
74
+ { name: CRON_NAME_AGGREGATE, taskType: 'http' },
75
+ { $set: aggDoc, $setOnInsert: { createdAt: new Date() } },
76
+ { upsert: true },
77
+ );
78
+
79
+ const retentionDoc = {
80
+ name: CRON_NAME_RETENTION,
81
+ description: 'Deletes old experiment events and metric buckets based on retention settings.',
82
+ cronExpression: '0 3 * * *',
83
+ timezone: 'UTC',
84
+ enabled: true,
85
+ nextRunAt: null,
86
+ taskType: 'http',
87
+ httpMethod: 'POST',
88
+ httpUrl: retentionUrl,
89
+ httpHeaders: [],
90
+ httpBody: JSON.stringify({}),
91
+ httpBodyType: 'json',
92
+ httpAuth: { type: 'basic', username: internalCronUsername, password: internalCronPassword },
93
+ timeoutMs: 10 * 60 * 1000,
94
+ createdBy: 'system',
95
+ };
96
+
97
+ await CronJob.updateOne(
98
+ { name: CRON_NAME_RETENTION, taskType: 'http' },
99
+ { $set: retentionDoc, $setOnInsert: { createdAt: new Date() } },
100
+ { upsert: true },
101
+ );
102
+ }
103
+
104
+ async function bootstrap() {
105
+ // CronScheduler HTTP jobs need an absolute base URL.
106
+ const baseUrl =
107
+ String(process.env.SUPERBACKEND_BASE_URL || process.env.PUBLIC_URL || '').trim() ||
108
+ 'http://localhost:3000';
109
+
110
+ await ensureCronJobs({ baseUrl: baseUrl.replace(/\/+$/, '') });
111
+ }
112
+
113
+ module.exports = {
114
+ bootstrap,
115
+ INTERNAL_CRON_TOKEN_SETTING_KEY,
116
+ CRON_NAME_AGGREGATE,
117
+ CRON_NAME_RETENTION,
118
+ };
@@ -0,0 +1,43 @@
1
+ const globalSettingsService = require('./globalSettings.service');
2
+
3
+ const ExperimentEvent = require('../models/ExperimentEvent');
4
+ const ExperimentMetricBucket = require('../models/ExperimentMetricBucket');
5
+
6
+ function toInt(val, fallback) {
7
+ const n = parseInt(String(val), 10);
8
+ return Number.isFinite(n) ? n : fallback;
9
+ }
10
+
11
+ async function runRetentionCleanup() {
12
+ const eventsRetentionDays = toInt(
13
+ await globalSettingsService.getSettingValue('EXPERIMENT_EVENTS_RETENTION_DAYS', '30'),
14
+ 30,
15
+ );
16
+ const metricsRetentionDays = toInt(
17
+ await globalSettingsService.getSettingValue('EXPERIMENT_METRICS_RETENTION_DAYS', '180'),
18
+ 180,
19
+ );
20
+
21
+ const now = Date.now();
22
+ const eventsCutoff = new Date(now - eventsRetentionDays * 24 * 60 * 60 * 1000);
23
+ const metricsCutoff = new Date(now - metricsRetentionDays * 24 * 60 * 60 * 1000);
24
+
25
+ const [eventsRes, bucketsRes] = await Promise.all([
26
+ ExperimentEvent.deleteMany({ ts: { $lt: eventsCutoff } }),
27
+ ExperimentMetricBucket.deleteMany({ bucketStart: { $lt: metricsCutoff } }),
28
+ ]);
29
+
30
+ return {
31
+ eventsRetentionDays,
32
+ metricsRetentionDays,
33
+ cutoffs: { eventsCutoff: eventsCutoff.toISOString(), metricsCutoff: metricsCutoff.toISOString() },
34
+ deleted: {
35
+ events: eventsRes?.deletedCount ?? 0,
36
+ metricBuckets: bucketsRes?.deletedCount ?? 0,
37
+ },
38
+ };
39
+ }
40
+
41
+ module.exports = {
42
+ runRetentionCleanup,
43
+ };
@@ -0,0 +1,134 @@
1
+ const { WebSocketServer } = require('ws');
2
+ const url = require('url');
3
+
4
+ const subscribersByCode = new Map(); // experimentCode -> Set<ws>
5
+
6
+ function safeSend(ws, payload) {
7
+ try {
8
+ ws.send(JSON.stringify(payload));
9
+ } catch {
10
+ }
11
+ }
12
+
13
+ function normalizeCode(v) {
14
+ return String(v || '').trim();
15
+ }
16
+
17
+ function broadcastWinnerChanged({ experimentCode, winnerVariantKey, decidedAt }) {
18
+ const code = normalizeCode(experimentCode);
19
+ if (!code) return;
20
+
21
+ const subs = subscribersByCode.get(code);
22
+ if (!subs || subs.size === 0) return;
23
+
24
+ const msg = {
25
+ type: 'winner',
26
+ experimentCode: code,
27
+ winnerVariantKey: winnerVariantKey || null,
28
+ decidedAt: decidedAt ? new Date(decidedAt).toISOString() : null,
29
+ };
30
+
31
+ for (const ws of subs) {
32
+ if (ws.readyState !== ws.OPEN) continue;
33
+ safeSend(ws, msg);
34
+ }
35
+ }
36
+
37
+ function attachExperimentsWebsocketServer(server) {
38
+ const wsPath = '/api/experiments/ws';
39
+ const wss = new WebSocketServer({ noServer: true });
40
+
41
+ server.on('upgrade', (req, socket, head) => {
42
+ const parsed = url.parse(req.url, true);
43
+ if (!parsed || parsed.pathname !== wsPath) return;
44
+
45
+ wss.handleUpgrade(req, socket, head, (ws) => {
46
+ wss.emit('connection', ws, req, parsed);
47
+ });
48
+ });
49
+
50
+ wss.on('connection', (ws, _req, parsed) => {
51
+ ws._sbExperimentSubs = new Set();
52
+
53
+ safeSend(ws, { type: 'hello' });
54
+
55
+ ws.on('message', (raw) => {
56
+ let msg;
57
+ try {
58
+ msg = JSON.parse(String(raw || ''));
59
+ } catch {
60
+ return;
61
+ }
62
+
63
+ const type = normalizeCode(msg?.type);
64
+ const experimentCode = normalizeCode(msg?.experimentCode);
65
+
66
+ if (type === 'subscribe') {
67
+ if (!experimentCode) return;
68
+
69
+ let set = subscribersByCode.get(experimentCode);
70
+ if (!set) {
71
+ set = new Set();
72
+ subscribersByCode.set(experimentCode, set);
73
+ }
74
+ set.add(ws);
75
+ ws._sbExperimentSubs.add(experimentCode);
76
+ safeSend(ws, { type: 'subscribed', experimentCode });
77
+ return;
78
+ }
79
+
80
+ if (type === 'unsubscribe') {
81
+ if (!experimentCode) return;
82
+
83
+ const set = subscribersByCode.get(experimentCode);
84
+ if (set) {
85
+ set.delete(ws);
86
+ if (set.size === 0) subscribersByCode.delete(experimentCode);
87
+ }
88
+ ws._sbExperimentSubs.delete(experimentCode);
89
+ safeSend(ws, { type: 'unsubscribed', experimentCode });
90
+ }
91
+ });
92
+
93
+ ws.on('close', () => {
94
+ for (const code of ws._sbExperimentSubs || []) {
95
+ const set = subscribersByCode.get(code);
96
+ if (set) {
97
+ set.delete(ws);
98
+ if (set.size === 0) subscribersByCode.delete(code);
99
+ }
100
+ }
101
+ });
102
+
103
+ ws.on('error', () => {
104
+ for (const code of ws._sbExperimentSubs || []) {
105
+ const set = subscribersByCode.get(code);
106
+ if (set) {
107
+ set.delete(ws);
108
+ if (set.size === 0) subscribersByCode.delete(code);
109
+ }
110
+ }
111
+ });
112
+
113
+ // If query provides experimentCode, auto-subscribe.
114
+ const q = parsed && parsed.query ? parsed.query : {};
115
+ const initial = normalizeCode(q.experimentCode);
116
+ if (initial) {
117
+ let set = subscribersByCode.get(initial);
118
+ if (!set) {
119
+ set = new Set();
120
+ subscribersByCode.set(initial, set);
121
+ }
122
+ set.add(ws);
123
+ ws._sbExperimentSubs.add(initial);
124
+ safeSend(ws, { type: 'subscribed', experimentCode: initial });
125
+ }
126
+ });
127
+
128
+ return { wss, wsPath };
129
+ }
130
+
131
+ module.exports = {
132
+ attachExperimentsWebsocketServer,
133
+ broadcastWinnerChanged,
134
+ };
@@ -39,11 +39,26 @@ async function getSettingValue(key, defaultValue = null) {
39
39
  }
40
40
  }
41
41
 
42
+ async function deleteSetting(key) {
43
+ try {
44
+ const setting = await GlobalSetting.findOneAndDelete({ key });
45
+
46
+ // Clear cache for this key
47
+ settingsCache.delete(key);
48
+
49
+ return setting;
50
+ } catch (error) {
51
+ console.error(`Error deleting setting ${key}:`, error);
52
+ throw error;
53
+ }
54
+ }
55
+
42
56
  function clearSettingsCache() {
43
57
  settingsCache.clear();
44
58
  }
45
59
 
46
60
  module.exports = {
47
61
  getSettingValue,
62
+ deleteSetting,
48
63
  clearSettingsCache,
49
64
  };
@@ -2,6 +2,18 @@ const crypto = require('crypto');
2
2
 
3
3
  const JsonConfig = require('../models/JsonConfig');
4
4
 
5
+ const logger = {
6
+ log: (...args) => {
7
+ if (process.env.DEBUG_JSON_CONFIGS === 'true' && !process.env.TUI_MODE) console.log(...args);
8
+ },
9
+ warn: (...args) => {
10
+ if (process.env.DEBUG_JSON_CONFIGS === 'true' && !process.env.TUI_MODE) console.warn(...args);
11
+ },
12
+ error: (...args) => {
13
+ console.error(...args);
14
+ }
15
+ };
16
+
5
17
  const cache = new Map();
6
18
 
7
19
  function normalizeSlugBase(title) {
@@ -122,7 +134,7 @@ async function getJsonConfigById(id) {
122
134
  }
123
135
 
124
136
  async function createJsonConfig({ title, jsonRaw, publicEnabled = false, cacheTtlSeconds = 0, alias }) {
125
- console.log('createJsonConfig called with:', { title, jsonRaw, publicEnabled, cacheTtlSeconds, alias });
137
+ logger.log('createJsonConfig called with:', { title, jsonRaw, publicEnabled, cacheTtlSeconds, alias });
126
138
 
127
139
  const normalizedTitle = String(title || '').trim();
128
140
  if (!normalizedTitle) {
@@ -142,7 +154,7 @@ async function createJsonConfig({ title, jsonRaw, publicEnabled = false, cacheTt
142
154
  let normalizedAlias = null;
143
155
  if (alias !== undefined && alias !== null) {
144
156
  normalizedAlias = normalizeAlias(alias);
145
- console.log('Normalized alias:', normalizedAlias);
157
+ logger.log('Normalized alias:', normalizedAlias);
146
158
  if (normalizedAlias && !(await validateAliasUniqueness(normalizedAlias))) {
147
159
  const err = new Error('Alias must be unique and not conflict with existing slugs or aliases');
148
160
  err.code = 'ALIAS_NOT_UNIQUE';
@@ -162,10 +174,10 @@ async function createJsonConfig({ title, jsonRaw, publicEnabled = false, cacheTt
162
174
  jsonHash: computeJsonHash(String(jsonRaw)),
163
175
  };
164
176
 
165
- console.log('Creating document with data:', createData);
177
+ //console.log('Creating document with data:', createData);
166
178
 
167
179
  const doc = await JsonConfig.create(createData);
168
- console.log('Created document:', doc.toObject());
180
+ //console.log('Created document:', doc.toObject());
169
181
 
170
182
  clearJsonConfigCache(slug);
171
183
  if (normalizedAlias) {
@@ -175,7 +187,7 @@ async function createJsonConfig({ title, jsonRaw, publicEnabled = false, cacheTt
175
187
  }
176
188
 
177
189
  async function updateJsonConfig(id, patch) {
178
- console.log('updateJsonConfig called with id:', id, 'patch:', patch);
190
+ logger.log('updateJsonConfig called with id:', id, 'patch:', patch);
179
191
 
180
192
  const doc = await JsonConfig.findById(id);
181
193
  if (!doc) {
@@ -184,7 +196,7 @@ async function updateJsonConfig(id, patch) {
184
196
  throw err;
185
197
  }
186
198
 
187
- console.log('Found document:', doc.toObject());
199
+ logger.log('Found document:', doc.toObject());
188
200
 
189
201
  const oldSlug = doc.slug;
190
202
  const oldAlias = doc.alias;
@@ -222,14 +234,14 @@ async function updateJsonConfig(id, patch) {
222
234
 
223
235
  if (patch && Object.prototype.hasOwnProperty.call(patch, 'alias')) {
224
236
  const newAlias = patch.alias;
225
- console.log('Processing alias update. newAlias:', newAlias);
237
+ logger.log('Processing alias update. newAlias:', newAlias);
226
238
 
227
239
  if (newAlias === null || newAlias === undefined || newAlias === '') {
228
240
  doc.alias = undefined;
229
- console.log('Setting alias to undefined');
241
+ logger.log('Setting alias to undefined');
230
242
  } else {
231
243
  const normalizedAlias = normalizeAlias(newAlias);
232
- console.log('Normalized alias for update:', normalizedAlias);
244
+ logger.log('Normalized alias for update:', normalizedAlias);
233
245
 
234
246
  if (!normalizedAlias) {
235
247
  const err = new Error('Invalid alias format');
@@ -244,7 +256,7 @@ async function updateJsonConfig(id, patch) {
244
256
  }
245
257
 
246
258
  doc.alias = normalizedAlias;
247
- console.log('Setting alias to:', normalizedAlias);
259
+ logger.log('Setting alias to:', normalizedAlias);
248
260
  }
249
261
  }
250
262
 
@@ -252,9 +264,9 @@ async function updateJsonConfig(id, patch) {
252
264
  doc.slug = await generateUniqueSlugFromTitle(doc.title);
253
265
  }
254
266
 
255
- console.log('Document before save:', doc.toObject());
267
+ logger.log('Document before save:', doc.toObject());
256
268
  await doc.save();
257
- console.log('Document after save:', doc.toObject());
269
+ logger.log('Document after save:', doc.toObject());
258
270
 
259
271
  clearJsonConfigCache(oldSlug);
260
272
  clearJsonConfigCache(doc.slug);