@intranefr/superbackend 1.6.5 → 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 +108 -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/llm.service.js +1 -0
- package/src/services/plugins.service.js +50 -16
- 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(
|
|
@@ -12,6 +12,8 @@ const DEFAULT_REGISTRY_ID = 'plugins';
|
|
|
12
12
|
const exposedServices = {};
|
|
13
13
|
const exposedHelpers = {};
|
|
14
14
|
|
|
15
|
+
const additionalPluginsRoots = new Set();
|
|
16
|
+
|
|
15
17
|
function sha256(value) {
|
|
16
18
|
return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
|
|
17
19
|
}
|
|
@@ -24,6 +26,24 @@ function resolvePluginsRoot(customRoot) {
|
|
|
24
26
|
return customRoot || path.join(process.cwd(), 'plugins');
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
function listPluginsRoots({ pluginsRoot } = {}) {
|
|
30
|
+
const roots = [];
|
|
31
|
+
const primary = resolvePluginsRoot(pluginsRoot);
|
|
32
|
+
if (primary) roots.push(primary);
|
|
33
|
+
for (const extra of additionalPluginsRoots) {
|
|
34
|
+
if (extra && typeof extra === 'string') roots.push(extra);
|
|
35
|
+
}
|
|
36
|
+
// de-dup while preserving order
|
|
37
|
+
return Array.from(new Set(roots));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function registerPluginsRoot(absolutePath) {
|
|
41
|
+
const root = String(absolutePath || '').trim();
|
|
42
|
+
if (!root) return { ok: false, reason: 'empty_path' };
|
|
43
|
+
additionalPluginsRoots.add(root);
|
|
44
|
+
return { ok: true, root };
|
|
45
|
+
}
|
|
46
|
+
|
|
27
47
|
function normalizePlugin(rawModule, pluginId, absoluteDir) {
|
|
28
48
|
const candidate = rawModule && typeof rawModule === 'object' ? rawModule : {};
|
|
29
49
|
const hooks = candidate.hooks && typeof candidate.hooks === 'object' ? candidate.hooks : {};
|
|
@@ -131,28 +151,40 @@ function readPluginModule(pluginDir) {
|
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
async function discoverPlugins({ pluginsRoot } = {}) {
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
const roots = listPluginsRoots({ pluginsRoot });
|
|
155
|
+
const pluginById = new Map();
|
|
156
|
+
|
|
157
|
+
for (const root of roots) {
|
|
158
|
+
if (!fs.existsSync(root)) continue;
|
|
159
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
160
|
+
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
if (!entry.isDirectory()) continue;
|
|
163
|
+
const pluginId = entry.name;
|
|
164
|
+
const absoluteDir = path.join(root, pluginId);
|
|
165
|
+
const loaded = readPluginModule(absoluteDir);
|
|
166
|
+
if (!loaded) continue;
|
|
167
|
+
|
|
168
|
+
const plugin = normalizePlugin(loaded, pluginId, absoluteDir);
|
|
169
|
+
if (!plugin.id) continue;
|
|
170
|
+
|
|
171
|
+
// First discovered wins (stable and avoids unexpected overrides)
|
|
172
|
+
if (!pluginById.has(plugin.id)) {
|
|
173
|
+
pluginById.set(plugin.id, plugin);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
150
176
|
}
|
|
151
177
|
|
|
178
|
+
const plugins = Array.from(pluginById.values());
|
|
152
179
|
plugins.sort((a, b) => a.id.localeCompare(b.id));
|
|
153
180
|
return plugins;
|
|
154
181
|
}
|
|
155
182
|
|
|
183
|
+
async function loadAllPluginsFromFolder(absolutePath, { context } = {}) {
|
|
184
|
+
registerPluginsRoot(absolutePath);
|
|
185
|
+
return bootstrap({ context });
|
|
186
|
+
}
|
|
187
|
+
|
|
156
188
|
async function ensurePluginsRegistry() {
|
|
157
189
|
return registryService.ensureRegistry({
|
|
158
190
|
id: DEFAULT_REGISTRY_ID,
|
|
@@ -337,6 +369,8 @@ module.exports = {
|
|
|
337
369
|
PLUGINS_STATE_KEY,
|
|
338
370
|
DEFAULT_REGISTRY_ID,
|
|
339
371
|
ensurePluginsRegistry,
|
|
372
|
+
registerPluginsRoot,
|
|
373
|
+
loadAllPluginsFromFolder,
|
|
340
374
|
discoverPlugins,
|
|
341
375
|
bootstrap,
|
|
342
376
|
listPlugins,
|
|
@@ -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
|
+
};
|