@intranefr/superbackend 1.4.4 → 1.5.0

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 (42) hide show
  1. package/index.js +16 -1
  2. package/package.json +5 -2
  3. package/public/sdk/ui-components.iife.js +191 -0
  4. package/sdk/ui-components/browser/src/index.js +228 -0
  5. package/src/controllers/admin.controller.js +89 -0
  6. package/src/controllers/adminHeadless.controller.js +82 -0
  7. package/src/controllers/adminScripts.controller.js +229 -0
  8. package/src/controllers/adminTerminals.controller.js +39 -0
  9. package/src/controllers/adminUiComponents.controller.js +315 -0
  10. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  11. package/src/controllers/orgAdmin.controller.js +286 -0
  12. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  13. package/src/middleware/auth.js +7 -0
  14. package/src/middleware.js +115 -0
  15. package/src/models/HeadlessModelDefinition.js +10 -0
  16. package/src/models/ScriptDefinition.js +42 -0
  17. package/src/models/ScriptRun.js +22 -0
  18. package/src/models/UiComponent.js +29 -0
  19. package/src/models/UiComponentProject.js +26 -0
  20. package/src/models/UiComponentProjectComponent.js +18 -0
  21. package/src/routes/admin.routes.js +1 -0
  22. package/src/routes/adminHeadless.routes.js +6 -0
  23. package/src/routes/adminScripts.routes.js +21 -0
  24. package/src/routes/adminTerminals.routes.js +13 -0
  25. package/src/routes/adminUiComponents.routes.js +29 -0
  26. package/src/routes/llmUi.routes.js +26 -0
  27. package/src/routes/orgAdmin.routes.js +5 -0
  28. package/src/routes/uiComponentsPublic.routes.js +9 -0
  29. package/src/services/headlessExternalModels.service.js +292 -0
  30. package/src/services/headlessModels.service.js +26 -6
  31. package/src/services/scriptsRunner.service.js +259 -0
  32. package/src/services/terminals.service.js +152 -0
  33. package/src/services/terminalsWs.service.js +100 -0
  34. package/src/services/uiComponentsAi.service.js +312 -0
  35. package/src/services/uiComponentsCrypto.service.js +39 -0
  36. package/views/admin-headless.ejs +294 -24
  37. package/views/admin-organizations.ejs +365 -9
  38. package/views/admin-scripts.ejs +497 -0
  39. package/views/admin-terminals.ejs +328 -0
  40. package/views/admin-ui-components.ejs +709 -0
  41. package/views/admin-users.ejs +261 -4
  42. package/views/partials/dashboard/nav-items.ejs +3 -0
@@ -0,0 +1,259 @@
1
+ const { EventEmitter } = require('events');
2
+ const { spawn } = require('child_process');
3
+ const { NodeVM } = require('vm2');
4
+
5
+ const ScriptRun = require('../models/ScriptRun');
6
+
7
+ const MAX_TAIL_BYTES = 64 * 1024;
8
+
9
+ function nowIso() {
10
+ return new Date().toISOString();
11
+ }
12
+
13
+ function appendTail(prev, chunk) {
14
+ const next = String(prev || '') + String(chunk || '');
15
+ const buf = Buffer.from(next, 'utf8');
16
+ if (buf.length <= MAX_TAIL_BYTES) return next;
17
+ return buf.slice(buf.length - MAX_TAIL_BYTES).toString('utf8');
18
+ }
19
+
20
+ function safeJsonParse(str) {
21
+ try {
22
+ return JSON.parse(String(str || ''));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function buildEnvPairs(env) {
29
+ const pairs = Array.isArray(env) ? env : [];
30
+ const out = {};
31
+ for (const item of pairs) {
32
+ if (!item || typeof item !== 'object') continue;
33
+ const key = String(item.key || '').trim();
34
+ if (!key) continue;
35
+ const value = String(item.value || '');
36
+ out[key] = value;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ class RunBus {
42
+ constructor(runId) {
43
+ this.runId = String(runId);
44
+ this.emitter = new EventEmitter();
45
+ this.seq = 0;
46
+ this.buffer = [];
47
+ this.closed = false;
48
+ }
49
+
50
+ push(event) {
51
+ if (this.closed) return;
52
+ this.seq += 1;
53
+ const payload = { seq: this.seq, ...event };
54
+ this.buffer.push(payload);
55
+ if (this.buffer.length > 2000) this.buffer.shift();
56
+ this.emitter.emit('event', payload);
57
+ }
58
+
59
+ close() {
60
+ this.closed = true;
61
+ this.emitter.emit('close');
62
+ }
63
+
64
+ snapshot(sinceSeq) {
65
+ const since = Number(sinceSeq || 0);
66
+ return this.buffer.filter((e) => Number(e.seq) > since);
67
+ }
68
+ }
69
+
70
+ const runs = new Map();
71
+
72
+ function getRunBus(runId) {
73
+ return runs.get(String(runId)) || null;
74
+ }
75
+
76
+ async function startRun(scriptDef, options) {
77
+ const trigger = options?.trigger || 'manual';
78
+ const meta = options?.meta || null;
79
+
80
+ const runDoc = await ScriptRun.create({
81
+ scriptId: scriptDef._id,
82
+ status: 'queued',
83
+ trigger,
84
+ startedAt: null,
85
+ finishedAt: null,
86
+ exitCode: null,
87
+ outputTail: '',
88
+ meta,
89
+ });
90
+
91
+ const bus = new RunBus(runDoc._id);
92
+ runs.set(String(runDoc._id), bus);
93
+
94
+ setImmediate(async () => {
95
+ try {
96
+ await ScriptRun.updateOne(
97
+ { _id: runDoc._id },
98
+ { $set: { status: 'running', startedAt: new Date() } },
99
+ );
100
+
101
+ bus.push({ type: 'status', ts: nowIso(), status: 'running' });
102
+
103
+ const timeoutMs = Number(scriptDef.timeoutMs || 0) || 5 * 60 * 1000;
104
+ const env = { ...process.env, ...buildEnvPairs(scriptDef.env) };
105
+ const cwd = String(scriptDef.defaultWorkingDirectory || '').trim() || undefined;
106
+
107
+ let exitCode = 0;
108
+
109
+ if (scriptDef.type === 'bash') {
110
+ if (scriptDef.runner !== 'host') {
111
+ throw Object.assign(new Error('bash scripts only support host runner'), { code: 'VALIDATION' });
112
+ }
113
+ exitCode = await runSpawned({
114
+ runId: runDoc._id,
115
+ bus,
116
+ command: 'bash',
117
+ args: ['-lc', scriptDef.script],
118
+ env,
119
+ cwd,
120
+ timeoutMs,
121
+ });
122
+ } else if (scriptDef.type === 'node') {
123
+ if (scriptDef.runner === 'vm2') {
124
+ exitCode = await runVm2({ runId: runDoc._id, bus, code: scriptDef.script, timeoutMs });
125
+ } 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
+ });
135
+ } else {
136
+ throw Object.assign(new Error('Invalid runner for node script'), { code: 'VALIDATION' });
137
+ }
138
+ } else if (scriptDef.type === 'browser') {
139
+ throw Object.assign(new Error('browser scripts run in the UI only'), { code: 'VALIDATION' });
140
+ } else {
141
+ throw Object.assign(new Error('Unsupported script type'), { code: 'VALIDATION' });
142
+ }
143
+
144
+ const finalStatus = exitCode === 0 ? 'succeeded' : 'failed';
145
+ await ScriptRun.updateOne(
146
+ { _id: runDoc._id },
147
+ { $set: { status: finalStatus, finishedAt: new Date(), exitCode } },
148
+ );
149
+
150
+ bus.push({ type: 'status', ts: nowIso(), status: finalStatus, exitCode });
151
+ bus.push({ type: 'done', ts: nowIso(), status: finalStatus, exitCode });
152
+ bus.close();
153
+
154
+ setTimeout(() => {
155
+ runs.delete(String(runDoc._id));
156
+ }, 5 * 60 * 1000).unref();
157
+ } catch (err) {
158
+ const msg = err?.message || 'Run failed';
159
+ await ScriptRun.updateOne(
160
+ { _id: runDoc._id },
161
+ { $set: { status: 'failed', finishedAt: new Date(), exitCode: 1 }, $setOnInsert: {} },
162
+ );
163
+ bus.push({ type: 'log', ts: nowIso(), stream: 'stderr', line: msg + '\n' });
164
+ bus.push({ type: 'status', ts: nowIso(), status: 'failed', exitCode: 1 });
165
+ bus.push({ type: 'done', ts: nowIso(), status: 'failed', exitCode: 1 });
166
+ bus.close();
167
+
168
+ setTimeout(() => {
169
+ runs.delete(String(runDoc._id));
170
+ }, 5 * 60 * 1000).unref();
171
+ }
172
+ });
173
+
174
+ return { runId: String(runDoc._id) };
175
+ }
176
+
177
+ async function runSpawned({ runId, bus, command, args, env, cwd, timeoutMs }) {
178
+ return await new Promise((resolve, reject) => {
179
+ const child = spawn(command, args, {
180
+ env,
181
+ cwd,
182
+ stdio: ['ignore', 'pipe', 'pipe'],
183
+ });
184
+
185
+ let tail = '';
186
+
187
+ const killTimer = setTimeout(() => {
188
+ try {
189
+ child.kill('SIGKILL');
190
+ } catch {}
191
+ }, timeoutMs);
192
+ killTimer.unref();
193
+
194
+ const onData = async (stream, chunk) => {
195
+ const s = chunk.toString('utf8');
196
+ tail = appendTail(tail, s);
197
+ bus.push({ type: 'log', ts: nowIso(), stream, line: s });
198
+ await ScriptRun.updateOne({ _id: runId }, { $set: { outputTail: tail } });
199
+ };
200
+
201
+ child.stdout.on('data', (c) => onData('stdout', c));
202
+ child.stderr.on('data', (c) => onData('stderr', c));
203
+
204
+ child.on('error', (err) => {
205
+ clearTimeout(killTimer);
206
+ reject(err);
207
+ });
208
+
209
+ child.on('close', (code) => {
210
+ clearTimeout(killTimer);
211
+ resolve(Number(code || 0));
212
+ });
213
+ });
214
+ }
215
+
216
+ async function runVm2({ runId, bus, code, timeoutMs }) {
217
+ let tail = '';
218
+
219
+ function pushLog(stream, line) {
220
+ const s = String(line || '');
221
+ tail = appendTail(tail, s);
222
+ bus.push({ type: 'log', ts: nowIso(), stream, line: s });
223
+ return ScriptRun.updateOne({ _id: runId }, { $set: { outputTail: tail } });
224
+ }
225
+
226
+ const vm = new NodeVM({
227
+ console: 'redirect',
228
+ sandbox: {},
229
+ require: {
230
+ external: false,
231
+ builtin: [],
232
+ },
233
+ timeout: timeoutMs,
234
+ eval: false,
235
+ wasm: false,
236
+ });
237
+
238
+ vm.on('console.log', (...args) => {
239
+ pushLog('stdout', args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
240
+ });
241
+ vm.on('console.error', (...args) => {
242
+ pushLog('stderr', args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
243
+ });
244
+
245
+ try {
246
+ vm.run(code, 'script.vm2.js');
247
+ return 0;
248
+ } catch (err) {
249
+ const msg = err?.message || 'vm2 error';
250
+ await pushLog('stderr', msg + '\n');
251
+ return 1;
252
+ }
253
+ }
254
+
255
+ module.exports = {
256
+ startRun,
257
+ getRunBus,
258
+ safeJsonParse,
259
+ };
@@ -0,0 +1,152 @@
1
+ const crypto = require('crypto');
2
+ const pty = require('node-pty');
3
+
4
+ const sessions = new Map();
5
+
6
+ const MAX_SESSIONS = 20;
7
+ const IDLE_TTL_MS = 15 * 60 * 1000;
8
+
9
+ function now() {
10
+ return Date.now();
11
+ }
12
+
13
+ function newId() {
14
+ return crypto.randomBytes(16).toString('hex');
15
+ }
16
+
17
+ function listSessions() {
18
+ return Array.from(sessions.values())
19
+ .map((s) => ({
20
+ sessionId: s.sessionId,
21
+ status: s.status,
22
+ createdAt: s.createdAt,
23
+ lastActivityAt: s.lastActivityAt,
24
+ cols: s.cols,
25
+ rows: s.rows,
26
+ }))
27
+ .sort((a, b) => b.createdAt - a.createdAt);
28
+ }
29
+
30
+ function getSession(sessionId) {
31
+ return sessions.get(String(sessionId)) || null;
32
+ }
33
+
34
+ function createSession(options = {}) {
35
+ if (sessions.size >= MAX_SESSIONS) {
36
+ const err = new Error('Too many active terminal sessions');
37
+ err.code = 'LIMIT';
38
+ throw err;
39
+ }
40
+
41
+ const cols = Number(options.cols || 120);
42
+ const rows = Number(options.rows || 30);
43
+
44
+ const shell = process.env.SHELL || 'bash';
45
+
46
+ const p = pty.spawn(shell, [], {
47
+ name: 'xterm-256color',
48
+ cols,
49
+ rows,
50
+ cwd: process.cwd(),
51
+ env: process.env,
52
+ });
53
+
54
+ const sessionId = newId();
55
+ const s = {
56
+ sessionId,
57
+ pty: p,
58
+ status: 'running',
59
+ createdAt: now(),
60
+ lastActivityAt: now(),
61
+ cols,
62
+ rows,
63
+ };
64
+
65
+ p.onExit(() => {
66
+ const cur = sessions.get(sessionId);
67
+ if (cur) {
68
+ cur.status = 'closed';
69
+ cur.lastActivityAt = now();
70
+ }
71
+ });
72
+
73
+ sessions.set(sessionId, s);
74
+
75
+ return { sessionId };
76
+ }
77
+
78
+ function touch(sessionId) {
79
+ const s = sessions.get(String(sessionId));
80
+ if (!s) return;
81
+ s.lastActivityAt = now();
82
+ }
83
+
84
+ function resizeSession(sessionId, cols, rows) {
85
+ const s = getSession(sessionId);
86
+ if (!s || s.status !== 'running') return;
87
+ const c = Number(cols || 0);
88
+ const r = Number(rows || 0);
89
+ if (!c || !r) return;
90
+ s.cols = c;
91
+ s.rows = r;
92
+ s.lastActivityAt = now();
93
+ try {
94
+ s.pty.resize(c, r);
95
+ } catch {}
96
+ }
97
+
98
+ function writeSession(sessionId, data) {
99
+ const s = getSession(sessionId);
100
+ if (!s || s.status !== 'running') return;
101
+ s.lastActivityAt = now();
102
+ try {
103
+ s.pty.write(String(data || ''));
104
+ } catch {}
105
+ }
106
+
107
+ function killSession(sessionId) {
108
+ const s = getSession(sessionId);
109
+ if (!s) {
110
+ const err = new Error('Session not found');
111
+ err.code = 'NOT_FOUND';
112
+ throw err;
113
+ }
114
+
115
+ try {
116
+ s.pty.kill();
117
+ } catch {}
118
+
119
+ sessions.delete(String(sessionId));
120
+ return { ok: true };
121
+ }
122
+
123
+ function cleanupIdleSessions() {
124
+ const cutoff = now() - IDLE_TTL_MS;
125
+ for (const [id, s] of sessions.entries()) {
126
+ if (s.lastActivityAt < cutoff) {
127
+ try {
128
+ s.pty.kill();
129
+ } catch {}
130
+ sessions.delete(id);
131
+ }
132
+ }
133
+ }
134
+
135
+ let cleanupTimer = null;
136
+ function ensureCleanupTimer() {
137
+ if (cleanupTimer) return;
138
+ cleanupTimer = setInterval(cleanupIdleSessions, 60 * 1000);
139
+ cleanupTimer.unref();
140
+ }
141
+
142
+ ensureCleanupTimer();
143
+
144
+ module.exports = {
145
+ createSession,
146
+ listSessions,
147
+ getSession,
148
+ killSession,
149
+ writeSession,
150
+ resizeSession,
151
+ touch,
152
+ };
@@ -0,0 +1,100 @@
1
+ const { WebSocketServer } = require('ws');
2
+ const url = require('url');
3
+
4
+ const { createSession, getSession, writeSession, resizeSession, touch } = require('./terminals.service');
5
+
6
+ function isBasicAuthValid(req, options) {
7
+ const authHeader = req.headers['authorization'] || '';
8
+ if (!String(authHeader).startsWith('Basic ')) return false;
9
+
10
+ const decoded = Buffer.from(String(authHeader).slice(6), 'base64').toString('utf-8');
11
+ const parts = decoded.split(':');
12
+ const username = parts[0] || '';
13
+ const password = parts.slice(1).join(':') || '';
14
+
15
+ const adminUsername = (options && options.adminUsername) || process.env.ADMIN_USERNAME || 'admin';
16
+ const adminPassword = (options && options.adminPassword) || process.env.ADMIN_PASSWORD || 'admin';
17
+
18
+ return username === adminUsername && password === adminPassword;
19
+ }
20
+
21
+ function attachTerminalWebsocketServer(server, options = {}) {
22
+ const wsPath = '/api/admin/terminals/ws';
23
+
24
+ const wss = new WebSocketServer({ noServer: true });
25
+
26
+ console.log(`[Terminals] WebSocket upgrade path: ${wsPath}`);
27
+
28
+ server.on('upgrade', (req, socket, head) => {
29
+ const parsed = url.parse(req.url, true);
30
+ if (!parsed || parsed.pathname !== wsPath) return;
31
+
32
+ console.log(`[Terminals] WebSocket upgrade request for ${parsed.pathname}`);
33
+ wss.handleUpgrade(req, socket, head, (ws) => {
34
+ wss.emit('connection', ws, req, parsed);
35
+ });
36
+ });
37
+
38
+ wss.on('connection', (ws, req, parsed) => {
39
+ const q = parsed && parsed.query ? parsed.query : {};
40
+ let sessionId = q.sessionId ? String(q.sessionId) : null;
41
+
42
+ if (!sessionId) {
43
+ const created = createSession({ cols: 120, rows: 30 });
44
+ sessionId = created.sessionId;
45
+ ws.send(JSON.stringify({ type: 'session', sessionId }));
46
+ }
47
+
48
+ const s = getSession(sessionId);
49
+ if (!s || s.status !== 'running') {
50
+ ws.send(JSON.stringify({ type: 'error', error: 'Session not found' }));
51
+ ws.close();
52
+ return;
53
+ }
54
+
55
+ ws.send(JSON.stringify({ type: 'status', status: 'running', sessionId }));
56
+
57
+ const onData = (data) => {
58
+ try {
59
+ ws.send(JSON.stringify({ type: 'output', data: String(data || '') }));
60
+ } catch {}
61
+ };
62
+
63
+ s.pty.onData(onData);
64
+
65
+ ws.on('message', (raw) => {
66
+ touch(sessionId);
67
+ let msg;
68
+ try {
69
+ msg = JSON.parse(String(raw || ''));
70
+ } catch {
71
+ return;
72
+ }
73
+ if (!msg || typeof msg !== 'object') return;
74
+
75
+ if (msg.type === 'input') {
76
+ writeSession(sessionId, msg.data);
77
+ }
78
+
79
+ if (msg.type === 'resize') {
80
+ resizeSession(sessionId, msg.cols, msg.rows);
81
+ }
82
+ });
83
+
84
+ ws.on('close', () => {
85
+ try {
86
+ s.pty.offData(onData);
87
+ } catch {}
88
+ });
89
+
90
+ ws.on('error', () => {
91
+ try {
92
+ s.pty.offData(onData);
93
+ } catch {}
94
+ });
95
+ });
96
+
97
+ return { wss, wsPath };
98
+ }
99
+
100
+ module.exports = { attachTerminalWebsocketServer };