@intranefr/superbackend 1.6.6 → 1.6.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.
- package/.env.example +4 -0
- package/README.md +18 -0
- package/package.json +6 -1
- package/public/js/admin-superdemos.js +396 -0
- package/public/sdk/superdemos.iife.js +614 -0
- package/public/superdemos-qa.html +324 -0
- package/sdk/superdemos/browser/src/index.js +719 -0
- package/src/cli/agent-chat.js +369 -0
- package/src/cli/agent-list.js +42 -0
- package/src/controllers/adminAgentsChat.controller.js +172 -0
- package/src/controllers/adminSuperDemos.controller.js +382 -0
- package/src/controllers/superDemosPublic.controller.js +126 -0
- package/src/middleware.js +102 -19
- package/src/models/BlogAutomationLock.js +4 -4
- package/src/models/BlogPost.js +16 -16
- package/src/models/CacheEntry.js +17 -6
- package/src/models/JsonConfig.js +2 -4
- package/src/models/RateLimitMetricBucket.js +10 -5
- package/src/models/SuperDemo.js +38 -0
- package/src/models/SuperDemoProject.js +32 -0
- package/src/models/SuperDemoStep.js +27 -0
- package/src/routes/adminAgents.routes.js +10 -0
- package/src/routes/adminMarkdowns.routes.js +3 -0
- package/src/routes/adminSuperDemos.routes.js +31 -0
- package/src/routes/superDemos.routes.js +9 -0
- package/src/services/auditLogger.js +75 -37
- package/src/services/email.service.js +18 -3
- package/src/services/superDemosAuthoringSessions.service.js +132 -0
- package/src/services/superDemosWs.service.js +164 -0
- package/src/services/terminalsWs.service.js +35 -3
- package/src/utils/rbac/rightsRegistry.js +2 -0
- package/views/admin-agents.ejs +261 -11
- package/views/admin-dashboard.ejs +78 -8
- package/views/admin-superdemos.ejs +335 -0
- package/views/admin-terminals.ejs +462 -34
- package/views/partials/admin/agents-chat.ejs +80 -0
- package/views/partials/dashboard/nav-items.ejs +1 -0
- package/views/partials/dashboard/tab-bar.ejs +6 -0
- package/cookies.txt +0 -6
- package/cookies1.txt +0 -6
- package/cookies2.txt +0 -6
- package/cookies3.txt +0 -6
- package/cookies4.txt +0 -5
- package/cookies_old.txt +0 -5
- package/cookies_old_test.txt +0 -6
- package/cookies_super.txt +0 -5
- package/cookies_super_test.txt +0 -6
- package/cookies_test.txt +0 -6
- package/test-access.js +0 -63
- package/test-iframe-fix.html +0 -63
- package/test-iframe.html +0 -14
|
@@ -1,22 +1,24 @@
|
|
|
1
|
-
const AuditEvent = require(
|
|
1
|
+
const AuditEvent = require("../models/AuditEvent");
|
|
2
2
|
|
|
3
3
|
const SENSITIVE_KEYS = [
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 !==
|
|
19
|
-
auditLogFailedAttempts: process.env.AUDIT_LOG_FAILED_ATTEMPTS !==
|
|
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
|
|
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 !==
|
|
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 ===
|
|
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:
|
|
54
|
-
|
|
55
|
-
|
|
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:
|
|
68
|
+
return { actorType: "system", actorId: null };
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
if (req.user) {
|
|
67
72
|
return {
|
|
68
|
-
actorType:
|
|
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(
|
|
78
|
+
const authHeader = req.headers?.authorization || "";
|
|
79
|
+
if (authHeader.startsWith("Basic ")) {
|
|
75
80
|
try {
|
|
76
|
-
const credentials = Buffer.from(
|
|
77
|
-
|
|
78
|
-
|
|
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:
|
|
88
|
+
return { actorType: "admin", actorId: null };
|
|
81
89
|
}
|
|
82
90
|
}
|
|
83
91
|
|
|
84
|
-
return { actorType:
|
|
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 ===
|
|
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 ||
|
|
136
|
+
actorType: actor.actorType || "system",
|
|
103
137
|
actorId: actor.actorId || null,
|
|
104
|
-
action:
|
|
105
|
-
entityType: event.entityType || event.targetType ||
|
|
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
|
-
|
|
145
|
+
...actionNormalizationMeta,
|
|
146
|
+
outcome: event.outcome || "success",
|
|
112
147
|
context,
|
|
113
148
|
details: scrubObject(event.details || {}),
|
|
114
149
|
}),
|
|
115
|
-
outcome: event.outcome ||
|
|
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(
|
|
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 ?
|
|
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
|
|
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',
|