@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.
Files changed (53) hide show
  1. package/.env.example +4 -0
  2. package/README.md +18 -0
  3. package/package.json +6 -1
  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 +108 -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/llm.service.js +1 -0
  29. package/src/services/plugins.service.js +50 -16
  30. package/src/services/superDemosAuthoringSessions.service.js +132 -0
  31. package/src/services/superDemosWs.service.js +164 -0
  32. package/src/services/terminalsWs.service.js +35 -3
  33. package/src/utils/rbac/rightsRegistry.js +2 -0
  34. package/views/admin-agents.ejs +261 -11
  35. package/views/admin-dashboard.ejs +78 -8
  36. package/views/admin-superdemos.ejs +335 -0
  37. package/views/admin-terminals.ejs +462 -34
  38. package/views/partials/admin/agents-chat.ejs +80 -0
  39. package/views/partials/dashboard/nav-items.ejs +1 -0
  40. package/views/partials/dashboard/tab-bar.ejs +6 -0
  41. package/cookies.txt +0 -6
  42. package/cookies1.txt +0 -6
  43. package/cookies2.txt +0 -6
  44. package/cookies3.txt +0 -6
  45. package/cookies4.txt +0 -5
  46. package/cookies_old.txt +0 -5
  47. package/cookies_old_test.txt +0 -6
  48. package/cookies_super.txt +0 -5
  49. package/cookies_super_test.txt +0 -6
  50. package/cookies_test.txt +0 -6
  51. package/test-access.js +0 -63
  52. package/test-iframe-fix.html +0 -63
  53. 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(
@@ -249,6 +249,7 @@ async function logAuditEntry({
249
249
  model,
250
250
  variables: variables || {},
251
251
  requestOptions: requestOptions || {},
252
+ auditContext: requestOptions?.auditContext || null,
252
253
  errorMessage,
253
254
  usage: usage || null,
254
255
  },
@@ -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 root = resolvePluginsRoot(pluginsRoot);
135
- if (!fs.existsSync(root)) return [];
136
-
137
- const entries = fs.readdirSync(root, { withFileTypes: true });
138
- const plugins = [];
139
-
140
- for (const entry of entries) {
141
- if (!entry.isDirectory()) continue;
142
- const pluginId = entry.name;
143
- const absoluteDir = path.join(root, pluginId);
144
- const loaded = readPluginModule(absoluteDir);
145
- if (!loaded) continue;
146
-
147
- const plugin = normalizePlugin(loaded, pluginId, absoluteDir);
148
- if (!plugin.id) continue;
149
- plugins.push(plugin);
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
+ };