@intranefr/superbackend 1.6.6 → 1.7.7

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 (51) hide show
  1. package/.env.example +4 -0
  2. package/README.md +18 -0
  3. package/package.json +8 -2
  4. package/public/js/admin-superdemos.js +396 -0
  5. package/public/sdk/superdemos.iife.js +614 -0
  6. package/public/superdemos-qa.html +324 -0
  7. package/sdk/superdemos/browser/src/index.js +719 -0
  8. package/src/cli/agent-chat.js +369 -0
  9. package/src/cli/agent-list.js +42 -0
  10. package/src/controllers/adminAgentsChat.controller.js +172 -0
  11. package/src/controllers/adminSuperDemos.controller.js +382 -0
  12. package/src/controllers/superDemosPublic.controller.js +126 -0
  13. package/src/middleware.js +102 -19
  14. package/src/models/BlogAutomationLock.js +4 -4
  15. package/src/models/BlogPost.js +16 -16
  16. package/src/models/CacheEntry.js +17 -6
  17. package/src/models/JsonConfig.js +2 -4
  18. package/src/models/RateLimitMetricBucket.js +10 -5
  19. package/src/models/SuperDemo.js +38 -0
  20. package/src/models/SuperDemoProject.js +32 -0
  21. package/src/models/SuperDemoStep.js +27 -0
  22. package/src/routes/adminAgents.routes.js +10 -0
  23. package/src/routes/adminMarkdowns.routes.js +3 -0
  24. package/src/routes/adminSuperDemos.routes.js +31 -0
  25. package/src/routes/superDemos.routes.js +9 -0
  26. package/src/services/auditLogger.js +75 -37
  27. package/src/services/email.service.js +18 -3
  28. package/src/services/superDemosAuthoringSessions.service.js +132 -0
  29. package/src/services/superDemosWs.service.js +164 -0
  30. package/src/services/terminalsWs.service.js +35 -3
  31. package/src/utils/rbac/rightsRegistry.js +2 -0
  32. package/views/admin-agents.ejs +261 -11
  33. package/views/admin-dashboard.ejs +78 -8
  34. package/views/admin-superdemos.ejs +335 -0
  35. package/views/admin-terminals.ejs +462 -34
  36. package/views/partials/admin/agents-chat.ejs +80 -0
  37. package/views/partials/dashboard/nav-items.ejs +1 -0
  38. package/views/partials/dashboard/tab-bar.ejs +6 -0
  39. package/cookies.txt +0 -6
  40. package/cookies1.txt +0 -6
  41. package/cookies2.txt +0 -6
  42. package/cookies3.txt +0 -6
  43. package/cookies4.txt +0 -5
  44. package/cookies_old.txt +0 -5
  45. package/cookies_old_test.txt +0 -6
  46. package/cookies_super.txt +0 -5
  47. package/cookies_super_test.txt +0 -6
  48. package/cookies_test.txt +0 -6
  49. package/test-access.js +0 -63
  50. package/test-iframe-fix.html +0 -63
  51. package/test-iframe.html +0 -14
@@ -1,22 +1,24 @@
1
- const AuditEvent = require('../models/AuditEvent');
1
+ const AuditEvent = require("../models/AuditEvent");
2
2
 
3
3
  const SENSITIVE_KEYS = [
4
- 'password',
5
- 'token',
6
- 'secret',
7
- 'authorization',
8
- 'cookie',
9
- 'apikey',
10
- 'api_key',
11
- 'accesstoken',
12
- 'refreshtoken',
13
- 'passwordhash',
4
+ "password",
5
+ "token",
6
+ "secret",
7
+ "authorization",
8
+ "cookie",
9
+ "apikey",
10
+ "api_key",
11
+ "accesstoken",
12
+ "refreshtoken",
13
+ "passwordhash",
14
14
  ];
15
15
 
16
+ const FALLBACK_AUDIT_ACTION = "system.unknown_action";
17
+
16
18
  async function getConfig() {
17
19
  return {
18
- auditTrackingEnabled: process.env.AUDIT_TRACKING_ENABLED !== 'false',
19
- auditLogFailedAttempts: process.env.AUDIT_LOG_FAILED_ATTEMPTS !== 'false',
20
+ auditTrackingEnabled: process.env.AUDIT_TRACKING_ENABLED !== "false",
21
+ auditLogFailedAttempts: process.env.AUDIT_LOG_FAILED_ATTEMPTS !== "false",
20
22
  auditRetentionDays: parseInt(process.env.AUDIT_RETENTION_DAYS, 10) || 90,
21
23
  };
22
24
  }
@@ -25,20 +27,20 @@ function scrubValue(key, value) {
25
27
  const lowerKey = String(key).toLowerCase();
26
28
  for (const sensitive of SENSITIVE_KEYS) {
27
29
  if (lowerKey.includes(sensitive)) {
28
- return '[REDACTED]';
30
+ return "[REDACTED]";
29
31
  }
30
32
  }
31
33
  return value;
32
34
  }
33
35
 
34
36
  function scrubObject(obj, depth = 0) {
35
- if (depth > 5 || !obj || typeof obj !== 'object') return obj;
37
+ if (depth > 5 || !obj || typeof obj !== "object") return obj;
36
38
  if (Array.isArray(obj)) {
37
39
  return obj.slice(0, 10).map((item) => scrubObject(item, depth + 1));
38
40
  }
39
41
  const result = {};
40
42
  for (const [key, value] of Object.entries(obj)) {
41
- if (typeof value === 'object' && value !== null) {
43
+ if (typeof value === "object" && value !== null) {
42
44
  result[key] = scrubObject(value, depth + 1);
43
45
  } else {
44
46
  result[key] = scrubValue(key, value);
@@ -50,9 +52,12 @@ function scrubObject(obj, depth = 0) {
50
52
  function extractContext(req) {
51
53
  if (!req) return {};
52
54
  return {
53
- ip: req.ip || req.headers?.['x-forwarded-for']?.split(',')[0]?.trim() || req.connection?.remoteAddress,
54
- userAgent: req.headers?.['user-agent']?.slice(0, 500),
55
- requestId: req.headers?.['x-request-id'] || req.requestId,
55
+ ip:
56
+ req.ip ||
57
+ req.headers?.["x-forwarded-for"]?.split(",")[0]?.trim() ||
58
+ req.connection?.remoteAddress,
59
+ userAgent: req.headers?.["user-agent"]?.slice(0, 500),
60
+ requestId: req.headers?.["x-request-id"] || req.requestId,
56
61
  path: req.path || req.url,
57
62
  method: req.method,
58
63
  };
@@ -60,28 +65,47 @@ function extractContext(req) {
60
65
 
61
66
  function extractActor(req) {
62
67
  if (!req) {
63
- return { actorType: 'system', actorId: null };
68
+ return { actorType: "system", actorId: null };
64
69
  }
65
70
 
66
71
  if (req.user) {
67
72
  return {
68
- actorType: 'user',
73
+ actorType: "user",
69
74
  actorId: String(req.user._id || req.user.id),
70
75
  };
71
76
  }
72
77
 
73
- const authHeader = req.headers?.authorization || '';
74
- if (authHeader.startsWith('Basic ')) {
78
+ const authHeader = req.headers?.authorization || "";
79
+ if (authHeader.startsWith("Basic ")) {
75
80
  try {
76
- const credentials = Buffer.from(authHeader.substring(6), 'base64').toString('utf-8');
77
- const [username] = credentials.split(':');
78
- return { actorType: 'admin', actorId: username || null };
81
+ const credentials = Buffer.from(
82
+ authHeader.substring(6),
83
+ "base64",
84
+ ).toString("utf-8");
85
+ const [username] = credentials.split(":");
86
+ return { actorType: "admin", actorId: username || null };
79
87
  } catch (e) {
80
- return { actorType: 'admin', actorId: null };
88
+ return { actorType: "admin", actorId: null };
81
89
  }
82
90
  }
83
91
 
84
- return { actorType: 'system', actorId: null };
92
+ return { actorType: "system", actorId: null };
93
+ }
94
+
95
+ function normalizeAction(action) {
96
+ if (typeof action === "string" && action.trim().length > 0) {
97
+ return {
98
+ value: action,
99
+ normalized: false,
100
+ original: null,
101
+ };
102
+ }
103
+
104
+ return {
105
+ value: FALLBACK_AUDIT_ACTION,
106
+ normalized: true,
107
+ original: action,
108
+ };
85
109
  }
86
110
 
87
111
  async function logAudit(event) {
@@ -91,28 +115,39 @@ async function logAudit(event) {
91
115
  return null;
92
116
  }
93
117
 
94
- if (event.outcome === 'failure' && !config.auditLogFailedAttempts) {
118
+ if (event.outcome === "failure" && !config.auditLogFailedAttempts) {
95
119
  return null;
96
120
  }
97
121
 
98
122
  const actor = event.actor || extractActor(event.req);
99
123
  const context = event.context || extractContext(event.req);
124
+ const normalizedAction = normalizeAction(event.action);
125
+ const actionNormalizationMeta = normalizedAction.normalized
126
+ ? {
127
+ actionNormalized: true,
128
+ normalizedFromAction:
129
+ normalizedAction.original === undefined
130
+ ? null
131
+ : String(normalizedAction.original),
132
+ }
133
+ : {};
100
134
 
101
135
  const auditEvent = await AuditEvent.create({
102
- actorType: actor.actorType || 'system',
136
+ actorType: actor.actorType || "system",
103
137
  actorId: actor.actorId || null,
104
- action: event.action,
105
- entityType: event.entityType || event.targetType || 'unknown',
138
+ action: normalizedAction.value,
139
+ entityType: event.entityType || event.targetType || "unknown",
106
140
  entityId: event.entityId || event.targetId || null,
107
141
  before: event.before || null,
108
142
  after: event.after || null,
109
143
  meta: scrubObject({
110
144
  ...(event.meta || {}),
111
- outcome: event.outcome || 'success',
145
+ ...actionNormalizationMeta,
146
+ outcome: event.outcome || "success",
112
147
  context,
113
148
  details: scrubObject(event.details || {}),
114
149
  }),
115
- outcome: event.outcome || 'success',
150
+ outcome: event.outcome || "success",
116
151
  context,
117
152
  targetType: event.targetType || event.entityType,
118
153
  targetId: event.targetId || event.entityId,
@@ -121,7 +156,7 @@ async function logAudit(event) {
121
156
  return auditEvent;
122
157
  } catch (err) {
123
158
  try {
124
- console.log('[AuditLogger] Failed to log audit:', err.message);
159
+ console.log("[AuditLogger] Failed to log audit:", err.message);
125
160
  } catch (e) {
126
161
  // ignore
127
162
  }
@@ -139,13 +174,15 @@ function auditMiddleware(action, options = {}) {
139
174
  return (req, res, next) => {
140
175
  const originalEnd = res.end;
141
176
  res.end = function (...args) {
142
- const outcome = res.statusCode >= 400 ? 'failure' : 'success';
177
+ const outcome = res.statusCode >= 400 ? "failure" : "success";
143
178
  logAuditSync({
144
179
  req,
145
180
  action,
146
181
  outcome,
147
182
  entityType: options.entityType || options.targetType,
148
- entityId: options.getEntityId ? options.getEntityId(req) : (req.params?.id || req.params?.key),
183
+ entityId: options.getEntityId
184
+ ? options.getEntityId(req)
185
+ : req.params?.id || req.params?.key,
149
186
  details: options.getDetails ? options.getDetails(req, res) : undefined,
150
187
  });
151
188
  return originalEnd.apply(this, args);
@@ -161,5 +198,6 @@ module.exports = {
161
198
  getConfig,
162
199
  extractActor,
163
200
  extractContext,
201
+ normalizeAction,
164
202
  scrubObject,
165
203
  };
@@ -12,12 +12,22 @@ const CACHE_TTL = 60000; // 1 minute
12
12
 
13
13
  // Helper to get setting with cache
14
14
  const getSetting = async (key, defaultValue) => {
15
+ // Try to initialize Resend if not already initialized
16
+ if (!resendClient && key === "RESEND_API_KEY") {
17
+ // We'll let the lazy init handle it below to avoid recursion
18
+ }
19
+
15
20
  const cached = settingsCache.get(key);
16
21
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
17
22
  return cached.value;
18
23
  }
19
24
 
20
25
  try {
26
+ // If we're not connected yet, avoid buffering and return default
27
+ if (mongoose.connection.readyState !== 1) {
28
+ return defaultValue;
29
+ }
30
+
21
31
  const setting = await GlobalSetting.findOne({ key }).lean();
22
32
  const value = setting ? setting.value : defaultValue;
23
33
  settingsCache.set(key, { value, timestamp: Date.now() });
@@ -40,6 +50,9 @@ const replaceTemplateVars = (template, variables) => {
40
50
 
41
51
  // Initialize Resend client if API key is available
42
52
  const initResend = async () => {
53
+ // Only init if we have a connection
54
+ if (mongoose.connection.readyState !== 1) return;
55
+
43
56
  // Try to get API key from settings first, then fall back to env
44
57
  const apiKey = await getSetting("RESEND_API_KEY", process.env.RESEND_API_KEY);
45
58
 
@@ -57,9 +70,6 @@ const initResend = async () => {
57
70
  }
58
71
  };
59
72
 
60
- // Initialize on module load
61
- initResend().catch((err) => console.error("Error initializing Resend:", err));
62
-
63
73
  const sendEmail = async ({
64
74
  to,
65
75
  subject,
@@ -70,6 +80,11 @@ const sendEmail = async ({
70
80
  type = "other",
71
81
  metadata,
72
82
  }) => {
83
+ // Lazy initialize Resend if needed
84
+ if (!resendClient) {
85
+ await initResend();
86
+ }
87
+
73
88
  const defaultFrom =
74
89
  from ||
75
90
  (await getSetting(
@@ -0,0 +1,132 @@
1
+ const crypto = require('crypto');
2
+
3
+ const DEFAULT_TTL_MS = 10 * 60 * 1000;
4
+
5
+ function base64UrlEncode(buf) {
6
+ return buf
7
+ .toString('base64')
8
+ .replace(/\+/g, '-')
9
+ .replace(/\//g, '_')
10
+ .replace(/=+$/g, '');
11
+ }
12
+
13
+ function newId(prefix) {
14
+ const raw = crypto.randomBytes(16);
15
+ return `${prefix}_${base64UrlEncode(raw)}`.toLowerCase();
16
+ }
17
+
18
+ function newToken() {
19
+ const raw = crypto.randomBytes(32);
20
+ return `sdt_${base64UrlEncode(raw)}`;
21
+ }
22
+
23
+ const sessions = new Map(); // sessionId -> session
24
+
25
+ function nowMs() {
26
+ return Date.now();
27
+ }
28
+
29
+ function cleanupExpired() {
30
+ const t = nowMs();
31
+ for (const [id, s] of sessions.entries()) {
32
+ if (s.expiresAtMs <= t) {
33
+ sessions.delete(id);
34
+ try {
35
+ if (s.adminWs && s.adminWs.close) s.adminWs.close();
36
+ } catch {}
37
+ try {
38
+ if (s.sdkWs && s.sdkWs.close) s.sdkWs.close();
39
+ } catch {}
40
+ }
41
+ }
42
+ }
43
+
44
+ function createSession({ projectId = null, demoId = null, ttlMs = DEFAULT_TTL_MS } = {}) {
45
+ cleanupExpired();
46
+
47
+ const sessionId = newId('sd_sess');
48
+ const token = newToken();
49
+
50
+ const createdAtMs = nowMs();
51
+ const expiresAtMs = createdAtMs + (Number(ttlMs) > 0 ? Number(ttlMs) : DEFAULT_TTL_MS);
52
+
53
+ const session = {
54
+ sessionId,
55
+ token,
56
+ projectId: projectId ? String(projectId) : null,
57
+ demoId: demoId ? String(demoId) : null,
58
+ createdAtMs,
59
+ expiresAtMs,
60
+ adminWs: null,
61
+ sdkWs: null,
62
+ };
63
+
64
+ sessions.set(sessionId, session);
65
+ return { sessionId, token, expiresAtMs };
66
+ }
67
+
68
+ function getSession(sessionId) {
69
+ cleanupExpired();
70
+ const id = String(sessionId || '').trim();
71
+ if (!id) return null;
72
+ const s = sessions.get(id);
73
+ if (!s) return null;
74
+ if (s.expiresAtMs <= nowMs()) {
75
+ sessions.delete(id);
76
+ return null;
77
+ }
78
+ return s;
79
+ }
80
+
81
+ function validateToken(sessionId, token) {
82
+ const s = getSession(sessionId);
83
+ if (!s) return false;
84
+ return String(s.token) === String(token || '');
85
+ }
86
+
87
+ function attachClient(sessionId, role, ws) {
88
+ const s = getSession(sessionId);
89
+ if (!s) return null;
90
+ if (role === 'admin') s.adminWs = ws;
91
+ if (role === 'sdk') s.sdkWs = ws;
92
+ return s;
93
+ }
94
+
95
+ function detachClient(sessionId, role, ws) {
96
+ const s = getSession(sessionId);
97
+ if (!s) return null;
98
+ if (role === 'admin' && s.adminWs === ws) s.adminWs = null;
99
+ if (role === 'sdk' && s.sdkWs === ws) s.sdkWs = null;
100
+ return s;
101
+ }
102
+
103
+ function destroySession(sessionId) {
104
+ const id = String(sessionId || '').trim();
105
+ if (!id) return false;
106
+ const s = sessions.get(id);
107
+ sessions.delete(id);
108
+ if (s) {
109
+ try {
110
+ if (s.adminWs && s.adminWs.close) s.adminWs.close();
111
+ } catch {}
112
+ try {
113
+ if (s.sdkWs && s.sdkWs.close) s.sdkWs.close();
114
+ } catch {}
115
+ }
116
+ return Boolean(s);
117
+ }
118
+
119
+ function _resetForTests() {
120
+ sessions.clear();
121
+ }
122
+
123
+ module.exports = {
124
+ DEFAULT_TTL_MS,
125
+ createSession,
126
+ getSession,
127
+ validateToken,
128
+ attachClient,
129
+ detachClient,
130
+ destroySession,
131
+ _resetForTests,
132
+ };
@@ -0,0 +1,164 @@
1
+ const { WebSocketServer } = require('ws');
2
+ const url = require('url');
3
+
4
+ const SuperDemoProject = require('../models/SuperDemoProject');
5
+ const sessions = require('./superDemosAuthoringSessions.service');
6
+
7
+ function toStr(v) {
8
+ return v === undefined || v === null ? '' : String(v);
9
+ }
10
+
11
+ function normalizeOrigin(o) {
12
+ const s = toStr(o).trim();
13
+ return s.replace(/\/$/, '');
14
+ }
15
+
16
+ function safeSend(ws, payload) {
17
+ try {
18
+ ws.send(JSON.stringify(payload));
19
+ } catch {}
20
+ }
21
+
22
+ function parseAndValidateQuery(parsed) {
23
+ const q = parsed && parsed.query ? parsed.query : {};
24
+ const sessionId = toStr(q.sessionId).trim();
25
+ const role = toStr(q.role).trim().toLowerCase();
26
+ const token = toStr(q.token).trim();
27
+
28
+ if (!sessionId || !token) return { ok: false, error: 'Missing sessionId/token' };
29
+ if (role !== 'admin' && role !== 'sdk') return { ok: false, error: 'Invalid role' };
30
+ if (!sessions.validateToken(sessionId, token)) return { ok: false, error: 'Invalid session' };
31
+
32
+ return { ok: true, sessionId, role, token };
33
+ }
34
+
35
+ async function isOriginAllowedForSession(req, session) {
36
+ // If no projectId, skip allowlist enforcement.
37
+ if (!session || !session.projectId) return true;
38
+
39
+ const origin = normalizeOrigin(req.headers?.origin);
40
+ // If origin header is missing (some WS clients), allow.
41
+ if (!origin) return true;
42
+
43
+ let project;
44
+ try {
45
+ project = await SuperDemoProject.findOne({ projectId: session.projectId, isActive: true }).lean();
46
+ } catch {
47
+ // If DB lookup fails, fail open for now (v1).
48
+ return true;
49
+ }
50
+ if (!project) return true;
51
+
52
+ const allowed = Array.isArray(project.allowedOrigins) ? project.allowedOrigins : [];
53
+ if (allowed.length === 0) return true;
54
+
55
+ const allowedNorm = allowed.map(normalizeOrigin).filter(Boolean);
56
+ return allowedNorm.includes(origin);
57
+ }
58
+
59
+ function attachSuperDemosWebsocketServer(server) {
60
+ const wsPath = '/api/superdemos/ws';
61
+ const wss = new WebSocketServer({ noServer: true });
62
+
63
+ server.on('upgrade', async (req, socket, head) => {
64
+ const parsed = url.parse(req.url, true);
65
+ if (!parsed || parsed.pathname !== wsPath) return;
66
+
67
+ const validated = parseAndValidateQuery(parsed);
68
+ if (!validated.ok) {
69
+ try {
70
+ socket.destroy();
71
+ } catch {}
72
+ return;
73
+ }
74
+
75
+ const session = sessions.getSession(validated.sessionId);
76
+ if (!session) {
77
+ try {
78
+ socket.destroy();
79
+ } catch {}
80
+ return;
81
+ }
82
+
83
+ if (validated.role === 'sdk') {
84
+ const okOrigin = await isOriginAllowedForSession(req, session);
85
+ if (!okOrigin) {
86
+ try {
87
+ socket.destroy();
88
+ } catch {}
89
+ return;
90
+ }
91
+ }
92
+
93
+ wss.handleUpgrade(req, socket, head, (ws) => {
94
+ wss.emit('connection', ws, req, parsed);
95
+ });
96
+ });
97
+
98
+ wss.on('connection', (ws, _req, parsed) => {
99
+ const validated = parseAndValidateQuery(parsed);
100
+ if (!validated.ok) {
101
+ safeSend(ws, { type: 'error', error: validated.error });
102
+ ws.close();
103
+ return;
104
+ }
105
+
106
+ const { sessionId, role } = validated;
107
+ const session = sessions.attachClient(sessionId, role, ws);
108
+ if (!session) {
109
+ safeSend(ws, { type: 'error', error: 'Session expired' });
110
+ ws.close();
111
+ return;
112
+ }
113
+
114
+ ws._sbSuperDemos = { sessionId, role };
115
+
116
+ safeSend(ws, { type: 'hello', sessionId, role, expiresAtMs: session.expiresAtMs });
117
+
118
+ const peer = role === 'admin' ? session.sdkWs : session.adminWs;
119
+ if (peer && peer.readyState === peer.OPEN) {
120
+ safeSend(ws, { type: 'peer_status', peerRole: role === 'admin' ? 'sdk' : 'admin', status: 'connected' });
121
+ } else {
122
+ safeSend(ws, { type: 'peer_status', peerRole: role === 'admin' ? 'sdk' : 'admin', status: 'disconnected' });
123
+ }
124
+
125
+ // Notify other side.
126
+ if (peer && peer.readyState === peer.OPEN) {
127
+ safeSend(peer, { type: 'peer_status', peerRole: role, status: 'connected' });
128
+ }
129
+
130
+ ws.on('message', (raw) => {
131
+ const s = sessions.getSession(sessionId);
132
+ if (!s) return;
133
+
134
+ const other = role === 'admin' ? s.sdkWs : s.adminWs;
135
+ if (!other || other.readyState !== other.OPEN) return;
136
+
137
+ // Relay as-is if it is valid JSON.
138
+ try {
139
+ const msg = JSON.parse(toStr(raw));
140
+ safeSend(other, msg);
141
+ } catch {
142
+ // ignore invalid frames
143
+ }
144
+ });
145
+
146
+ function cleanup() {
147
+ const s = sessions.detachClient(sessionId, role, ws);
148
+ if (!s) return;
149
+ const other = role === 'admin' ? s.sdkWs : s.adminWs;
150
+ if (other && other.readyState === other.OPEN) {
151
+ safeSend(other, { type: 'peer_status', peerRole: role, status: 'disconnected' });
152
+ }
153
+ }
154
+
155
+ ws.on('close', cleanup);
156
+ ws.on('error', cleanup);
157
+ });
158
+
159
+ return { wss, wsPath };
160
+ }
161
+
162
+ module.exports = {
163
+ attachSuperDemosWebsocketServer,
164
+ };
@@ -29,7 +29,7 @@ function attachTerminalWebsocketServer(server, options = {}) {
29
29
  const parsed = url.parse(req.url, true);
30
30
  if (!parsed || parsed.pathname !== wsPath) return;
31
31
 
32
- console.log(`[Terminals] WebSocket upgrade request for ${parsed.pathname}`);
32
+ //console.log(`[Terminals] WebSocket upgrade request for ${parsed.pathname}`);
33
33
  wss.handleUpgrade(req, socket, head, (ws) => {
34
34
  wss.emit('connection', ws, req, parsed);
35
35
  });
@@ -62,6 +62,7 @@ function attachTerminalWebsocketServer(server, options = {}) {
62
62
 
63
63
  s.pty.onData(onData);
64
64
 
65
+ // Enhanced message handler with ping/pong support
65
66
  ws.on('message', (raw) => {
66
67
  touch(sessionId);
67
68
  let msg;
@@ -79,19 +80,50 @@ function attachTerminalWebsocketServer(server, options = {}) {
79
80
  if (msg.type === 'resize') {
80
81
  resizeSession(sessionId, msg.cols, msg.rows);
81
82
  }
83
+
84
+ // Handle ping messages with pong response
85
+ if (msg.type === 'ping') {
86
+ try {
87
+ ws.send(JSON.stringify({ type: 'pong' }));
88
+ console.log(`[Terminals] Pong sent for session ${sessionId}`);
89
+ } catch (error) {
90
+ console.error(`[Terminals] Failed to send pong for session ${sessionId}:`, error);
91
+ }
92
+ }
82
93
  });
83
94
 
84
- ws.on('close', () => {
95
+ ws.on('close', (code, reason) => {
96
+ console.log(`[Terminals] WebSocket closed for session ${sessionId}, code: ${code}, reason: ${reason}`);
85
97
  try {
86
98
  s.pty.offData(onData);
87
99
  } catch {}
88
100
  });
89
101
 
90
- ws.on('error', () => {
102
+ ws.on('error', (error) => {
103
+ console.error(`[Terminals] WebSocket error for session ${sessionId}:`, error);
91
104
  try {
92
105
  s.pty.offData(onData);
93
106
  } catch {}
94
107
  });
108
+
109
+ // Set up connection timeout detection
110
+ const pingInterval = setInterval(() => {
111
+ if (ws.readyState === WebSocket.OPEN) {
112
+ try {
113
+ ws.send(JSON.stringify({ type: 'ping' }));
114
+ } catch (error) {
115
+ console.error(`[Terminals] Failed to send server ping for session ${sessionId}:`, error);
116
+ clearInterval(pingInterval);
117
+ ws.close();
118
+ }
119
+ } else {
120
+ clearInterval(pingInterval);
121
+ }
122
+ }, 45000); // Server ping every 45 seconds
123
+
124
+ ws.on('close', () => {
125
+ clearInterval(pingInterval);
126
+ });
95
127
  });
96
128
 
97
129
  return { wss, wsPath };
@@ -61,6 +61,8 @@ const DEFAULT_RIGHTS = [
61
61
  'admin_panel__file-manager:write',
62
62
  'admin_panel__ui-components:read',
63
63
  'admin_panel__ui-components:write',
64
+ 'admin_panel__superdemos:read',
65
+ 'admin_panel__superdemos:write',
64
66
  'admin_panel__headless:read',
65
67
  'admin_panel__headless:write',
66
68
  'admin_panel__pages:read',