@intranefr/superbackend 1.4.4 → 1.5.1

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 (195) hide show
  1. package/.env.example +5 -0
  2. package/README.md +11 -0
  3. package/index.js +39 -1
  4. package/package.json +11 -3
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/ui-components/browser/src/index.js +228 -0
  7. package/src/admin/endpointRegistry.js +120 -0
  8. package/src/controllers/admin.controller.js +111 -5
  9. package/src/controllers/adminBlockDefinitions.controller.js +127 -0
  10. package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
  11. package/src/controllers/adminCache.controller.js +342 -0
  12. package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
  13. package/src/controllers/adminCrons.controller.js +388 -0
  14. package/src/controllers/adminDbBrowser.controller.js +124 -0
  15. package/src/controllers/adminEjsVirtual.controller.js +13 -3
  16. package/src/controllers/adminHeadless.controller.js +91 -2
  17. package/src/controllers/adminHealthChecks.controller.js +570 -0
  18. package/src/controllers/adminI18n.controller.js +51 -29
  19. package/src/controllers/adminLlm.controller.js +126 -2
  20. package/src/controllers/adminPages.controller.js +720 -0
  21. package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
  22. package/src/controllers/adminProxy.controller.js +113 -0
  23. package/src/controllers/adminRateLimits.controller.js +138 -0
  24. package/src/controllers/adminRbac.controller.js +803 -0
  25. package/src/controllers/adminScripts.controller.js +320 -0
  26. package/src/controllers/adminSeoConfig.controller.js +71 -48
  27. package/src/controllers/adminTerminals.controller.js +39 -0
  28. package/src/controllers/adminUiComponents.controller.js +315 -0
  29. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  30. package/src/controllers/blogAdmin.controller.js +279 -0
  31. package/src/controllers/blogAiAdmin.controller.js +224 -0
  32. package/src/controllers/blogAutomationAdmin.controller.js +141 -0
  33. package/src/controllers/blogInternal.controller.js +26 -0
  34. package/src/controllers/blogPublic.controller.js +89 -0
  35. package/src/controllers/fileManager.controller.js +190 -0
  36. package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
  37. package/src/controllers/healthChecksPublic.controller.js +196 -0
  38. package/src/controllers/metrics.controller.js +64 -4
  39. package/src/controllers/orgAdmin.controller.js +366 -0
  40. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  41. package/src/middleware/auth.js +7 -0
  42. package/src/middleware/internalCronAuth.js +29 -0
  43. package/src/middleware/rbac.js +62 -0
  44. package/src/middleware.js +879 -56
  45. package/src/models/BlockDefinition.js +27 -0
  46. package/src/models/BlogAutomationLock.js +14 -0
  47. package/src/models/BlogAutomationRun.js +39 -0
  48. package/src/models/BlogPost.js +42 -0
  49. package/src/models/CacheEntry.js +26 -0
  50. package/src/models/ConsoleEntry.js +32 -0
  51. package/src/models/ConsoleLog.js +23 -0
  52. package/src/models/ContextBlockDefinition.js +33 -0
  53. package/src/models/CronExecution.js +47 -0
  54. package/src/models/CronJob.js +70 -0
  55. package/src/models/ExternalDbConnection.js +49 -0
  56. package/src/models/FileEntry.js +22 -0
  57. package/src/models/HeadlessModelDefinition.js +10 -0
  58. package/src/models/HealthAutoHealAttempt.js +57 -0
  59. package/src/models/HealthCheck.js +132 -0
  60. package/src/models/HealthCheckRun.js +51 -0
  61. package/src/models/HealthIncident.js +49 -0
  62. package/src/models/Page.js +95 -0
  63. package/src/models/PageCollection.js +42 -0
  64. package/src/models/ProxyEntry.js +66 -0
  65. package/src/models/RateLimitCounter.js +19 -0
  66. package/src/models/RateLimitMetricBucket.js +20 -0
  67. package/src/models/RbacGrant.js +25 -0
  68. package/src/models/RbacGroup.js +16 -0
  69. package/src/models/RbacGroupMember.js +13 -0
  70. package/src/models/RbacGroupRole.js +13 -0
  71. package/src/models/RbacRole.js +25 -0
  72. package/src/models/RbacUserRole.js +13 -0
  73. package/src/models/ScriptDefinition.js +42 -0
  74. package/src/models/ScriptRun.js +22 -0
  75. package/src/models/UiComponent.js +29 -0
  76. package/src/models/UiComponentProject.js +26 -0
  77. package/src/models/UiComponentProjectComponent.js +18 -0
  78. package/src/routes/admin.routes.js +1 -0
  79. package/src/routes/adminBlog.routes.js +21 -0
  80. package/src/routes/adminBlogAi.routes.js +16 -0
  81. package/src/routes/adminBlogAutomation.routes.js +27 -0
  82. package/src/routes/adminCache.routes.js +20 -0
  83. package/src/routes/adminConsoleManager.routes.js +302 -0
  84. package/src/routes/adminCrons.routes.js +25 -0
  85. package/src/routes/adminDbBrowser.routes.js +65 -0
  86. package/src/routes/adminEjsVirtual.routes.js +2 -1
  87. package/src/routes/adminHeadless.routes.js +8 -1
  88. package/src/routes/adminHealthChecks.routes.js +28 -0
  89. package/src/routes/adminI18n.routes.js +4 -3
  90. package/src/routes/adminLlm.routes.js +4 -2
  91. package/src/routes/adminPages.routes.js +55 -0
  92. package/src/routes/adminProxy.routes.js +15 -0
  93. package/src/routes/adminRateLimits.routes.js +17 -0
  94. package/src/routes/adminRbac.routes.js +38 -0
  95. package/src/routes/adminScripts.routes.js +21 -0
  96. package/src/routes/adminSeoConfig.routes.js +5 -4
  97. package/src/routes/adminTerminals.routes.js +13 -0
  98. package/src/routes/adminUiComponents.routes.js +30 -0
  99. package/src/routes/blogInternal.routes.js +14 -0
  100. package/src/routes/blogPublic.routes.js +9 -0
  101. package/src/routes/fileManager.routes.js +62 -0
  102. package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
  103. package/src/routes/healthChecksPublic.routes.js +9 -0
  104. package/src/routes/log.routes.js +43 -60
  105. package/src/routes/metrics.routes.js +4 -2
  106. package/src/routes/orgAdmin.routes.js +6 -0
  107. package/src/routes/pages.routes.js +123 -0
  108. package/src/routes/proxy.routes.js +46 -0
  109. package/src/routes/rbac.routes.js +47 -0
  110. package/src/routes/uiComponentsPublic.routes.js +9 -0
  111. package/src/routes/webhook.routes.js +2 -1
  112. package/src/routes/workflows.routes.js +4 -0
  113. package/src/services/blockDefinitionsAi.service.js +247 -0
  114. package/src/services/blog.service.js +99 -0
  115. package/src/services/blogAutomation.service.js +978 -0
  116. package/src/services/blogCronsBootstrap.service.js +184 -0
  117. package/src/services/blogPublishing.service.js +58 -0
  118. package/src/services/cacheLayer.service.js +696 -0
  119. package/src/services/consoleManager.service.js +700 -0
  120. package/src/services/consoleOverride.service.js +6 -1
  121. package/src/services/cronScheduler.service.js +350 -0
  122. package/src/services/dbBrowser.service.js +536 -0
  123. package/src/services/ejsVirtual.service.js +102 -32
  124. package/src/services/fileManager.service.js +475 -0
  125. package/src/services/fileManagerStoragePolicy.service.js +285 -0
  126. package/src/services/headlessExternalModels.service.js +292 -0
  127. package/src/services/headlessModels.service.js +26 -6
  128. package/src/services/healthChecks.service.js +650 -0
  129. package/src/services/healthChecksBootstrap.service.js +109 -0
  130. package/src/services/healthChecksScheduler.service.js +106 -0
  131. package/src/services/llmDefaults.service.js +190 -0
  132. package/src/services/migrationAssets/s3.js +2 -2
  133. package/src/services/pages.service.js +602 -0
  134. package/src/services/pagesContext.service.js +331 -0
  135. package/src/services/pagesContextBlocksAi.service.js +349 -0
  136. package/src/services/proxy.service.js +535 -0
  137. package/src/services/rateLimiter.service.js +623 -0
  138. package/src/services/rbac.service.js +212 -0
  139. package/src/services/scriptsRunner.service.js +259 -0
  140. package/src/services/terminals.service.js +152 -0
  141. package/src/services/terminalsWs.service.js +100 -0
  142. package/src/services/uiComponentsAi.service.js +299 -0
  143. package/src/services/uiComponentsCrypto.service.js +39 -0
  144. package/src/services/workflow.service.js +23 -8
  145. package/src/utils/orgRoles.js +14 -0
  146. package/src/utils/rbac/engine.js +60 -0
  147. package/src/utils/rbac/rightsRegistry.js +29 -0
  148. package/views/admin-blog-automation.ejs +877 -0
  149. package/views/admin-blog-edit.ejs +542 -0
  150. package/views/admin-blog.ejs +399 -0
  151. package/views/admin-cache.ejs +681 -0
  152. package/views/admin-console-manager.ejs +680 -0
  153. package/views/admin-crons.ejs +645 -0
  154. package/views/admin-db-browser.ejs +445 -0
  155. package/views/admin-ejs-virtual.ejs +16 -10
  156. package/views/admin-file-manager.ejs +942 -0
  157. package/views/admin-headless.ejs +294 -24
  158. package/views/admin-health-checks.ejs +725 -0
  159. package/views/admin-i18n.ejs +59 -5
  160. package/views/admin-llm.ejs +99 -1
  161. package/views/admin-organizations.ejs +528 -10
  162. package/views/admin-pages.ejs +2424 -0
  163. package/views/admin-proxy.ejs +491 -0
  164. package/views/admin-rate-limiter.ejs +625 -0
  165. package/views/admin-rbac.ejs +1331 -0
  166. package/views/admin-scripts.ejs +497 -0
  167. package/views/admin-seo-config.ejs +61 -7
  168. package/views/admin-terminals.ejs +328 -0
  169. package/views/admin-ui-components.ejs +741 -0
  170. package/views/admin-users.ejs +261 -4
  171. package/views/admin-workflows.ejs +7 -7
  172. package/views/file-manager.ejs +866 -0
  173. package/views/pages/blocks/contact.ejs +27 -0
  174. package/views/pages/blocks/cta.ejs +18 -0
  175. package/views/pages/blocks/faq.ejs +20 -0
  176. package/views/pages/blocks/features.ejs +19 -0
  177. package/views/pages/blocks/hero.ejs +13 -0
  178. package/views/pages/blocks/html.ejs +5 -0
  179. package/views/pages/blocks/image.ejs +14 -0
  180. package/views/pages/blocks/testimonials.ejs +26 -0
  181. package/views/pages/blocks/text.ejs +10 -0
  182. package/views/pages/layouts/default.ejs +51 -0
  183. package/views/pages/layouts/minimal.ejs +42 -0
  184. package/views/pages/layouts/sidebar.ejs +54 -0
  185. package/views/pages/partials/footer.ejs +13 -0
  186. package/views/pages/partials/header.ejs +12 -0
  187. package/views/pages/partials/sidebar.ejs +8 -0
  188. package/views/pages/runtime/page.ejs +10 -0
  189. package/views/pages/templates/article.ejs +20 -0
  190. package/views/pages/templates/default.ejs +12 -0
  191. package/views/pages/templates/landing.ejs +14 -0
  192. package/views/pages/templates/listing.ejs +15 -0
  193. package/views/partials/admin-image-upload-modal.ejs +221 -0
  194. package/views/partials/dashboard/nav-items.ejs +14 -0
  195. package/views/partials/llm-provider-model-picker.ejs +183 -0
@@ -0,0 +1,212 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const OrganizationMember = require('../models/OrganizationMember');
4
+ const RbacUserRole = require('../models/RbacUserRole');
5
+ const RbacGroup = require('../models/RbacGroup');
6
+ const RbacGroupMember = require('../models/RbacGroupMember');
7
+ const RbacGroupRole = require('../models/RbacGroupRole');
8
+ const RbacGrant = require('../models/RbacGrant');
9
+ const { matches } = require('../utils/rbac/engine');
10
+
11
+ function normalizeId(input) {
12
+ if (!input) return null;
13
+ const str = String(input);
14
+ if (!mongoose.Types.ObjectId.isValid(str)) return null;
15
+ return new mongoose.Types.ObjectId(str);
16
+ }
17
+
18
+ function normalizeRight(input) {
19
+ return String(input || '').trim();
20
+ }
21
+
22
+ function buildExplainItem(grant, source) {
23
+ if (!grant) return null;
24
+ return {
25
+ source,
26
+ effect: grant.effect,
27
+ right: grant.right,
28
+ subjectType: grant.subjectType,
29
+ subjectId: String(grant.subjectId),
30
+ scopeType: grant.scopeType,
31
+ scopeId: grant.scopeId ? String(grant.scopeId) : null,
32
+ id: String(grant._id),
33
+ };
34
+ }
35
+
36
+ function extractMatches(grants, requiredRight) {
37
+ const denies = [];
38
+ const allows = [];
39
+
40
+ for (const g of grants || []) {
41
+ if (!g) continue;
42
+ if (!matches(requiredRight, g.right)) continue;
43
+
44
+ if (g.effect === 'deny') {
45
+ denies.push(g);
46
+ } else {
47
+ allows.push(g);
48
+ }
49
+ }
50
+
51
+ return { denies, allows };
52
+ }
53
+
54
+ async function getUserOrgIds(userId) {
55
+ const uid = normalizeId(userId);
56
+ if (!uid) return [];
57
+ const rows = await OrganizationMember.find({ userId: uid, status: 'active' }).select('orgId').lean();
58
+ return rows.map((r) => String(r.orgId));
59
+ }
60
+
61
+ async function getEffectiveGrants({ userId, orgId }) {
62
+ const uid = normalizeId(userId);
63
+ const oid = normalizeId(orgId);
64
+ if (!uid || !oid) {
65
+ return {
66
+ grants: [],
67
+ layers: { org: [], group: [], role: [], user: [] },
68
+ explain: [],
69
+ context: { roles: [], groups: [] },
70
+ orgMember: null,
71
+ };
72
+ }
73
+
74
+ const orgMember = await OrganizationMember.findOne({ userId: uid, orgId: oid, status: 'active' }).lean();
75
+ if (!orgMember) {
76
+ return {
77
+ grants: [],
78
+ layers: { org: [], group: [], role: [], user: [] },
79
+ explain: [],
80
+ context: { roles: [], groups: [] },
81
+ orgMember: null,
82
+ };
83
+ }
84
+
85
+ const [userRoleLinks, groupLinks, orgGrantsGlobal, orgGrantsOrg] = await Promise.all([
86
+ RbacUserRole.find({ userId: uid }).select('roleId').lean(),
87
+ RbacGroupMember.find({ userId: uid }).select('groupId').lean(),
88
+ RbacGrant.find({ subjectType: 'org', subjectId: oid, scopeType: 'global' }).lean(),
89
+ RbacGrant.find({ subjectType: 'org', subjectId: oid, scopeType: 'org', scopeId: oid }).lean(),
90
+ ]);
91
+
92
+ const directRoleIds = userRoleLinks.map((r) => r.roleId).filter(Boolean);
93
+ const groupIds = groupLinks.map((g) => g.groupId).filter(Boolean);
94
+
95
+ const groups = groupIds.length
96
+ ? await RbacGroup.find({ _id: { $in: groupIds }, status: 'active' }).select('_id isGlobal orgId').lean()
97
+ : [];
98
+
99
+ const allowedGroupIds = [];
100
+ for (const g of groups) {
101
+ if (g.isGlobal) {
102
+ allowedGroupIds.push(g._id);
103
+ continue;
104
+ }
105
+ if (g.orgId && String(g.orgId) === String(oid)) {
106
+ allowedGroupIds.push(g._id);
107
+ }
108
+ }
109
+
110
+ const groupRoleLinks = allowedGroupIds.length
111
+ ? await RbacGroupRole.find({ groupId: { $in: allowedGroupIds } }).select('groupId roleId').lean()
112
+ : [];
113
+
114
+ const groupRoleIds = groupRoleLinks.map((l) => l.roleId).filter(Boolean);
115
+ const effectiveRoleIds = Array.from(new Set([...directRoleIds, ...groupRoleIds]));
116
+
117
+ const [userGrantsGlobal, userGrantsOrg, roleGrantsGlobal, roleGrantsOrg, groupGrantsGlobal, groupGrantsOrg] = await Promise.all([
118
+ RbacGrant.find({ subjectType: 'user', subjectId: uid, scopeType: 'global' }).lean(),
119
+ RbacGrant.find({ subjectType: 'user', subjectId: uid, scopeType: 'org', scopeId: oid }).lean(),
120
+ effectiveRoleIds.length ? RbacGrant.find({ subjectType: 'role', subjectId: { $in: effectiveRoleIds }, scopeType: 'global' }).lean() : [],
121
+ effectiveRoleIds.length ? RbacGrant.find({ subjectType: 'role', subjectId: { $in: effectiveRoleIds }, scopeType: 'org', scopeId: oid }).lean() : [],
122
+ allowedGroupIds.length ? RbacGrant.find({ subjectType: 'group', subjectId: { $in: allowedGroupIds }, scopeType: 'global' }).lean() : [],
123
+ allowedGroupIds.length ? RbacGrant.find({ subjectType: 'group', subjectId: { $in: allowedGroupIds }, scopeType: 'org', scopeId: oid }).lean() : [],
124
+ ]);
125
+
126
+ const layers = {
127
+ org: [...orgGrantsGlobal, ...orgGrantsOrg],
128
+ group: [...groupGrantsGlobal, ...groupGrantsOrg],
129
+ role: [...roleGrantsGlobal, ...roleGrantsOrg],
130
+ user: [...userGrantsGlobal, ...userGrantsOrg],
131
+ };
132
+
133
+ const all = [...layers.org, ...layers.group, ...layers.role, ...layers.user];
134
+
135
+ const explain = [];
136
+ for (const g of userGrantsGlobal) explain.push(buildExplainItem(g, 'user:global'));
137
+ for (const g of userGrantsOrg) explain.push(buildExplainItem(g, 'user:org'));
138
+ for (const g of roleGrantsGlobal) explain.push(buildExplainItem(g, 'role:global'));
139
+ for (const g of roleGrantsOrg) explain.push(buildExplainItem(g, 'role:org'));
140
+ for (const g of groupGrantsGlobal) explain.push(buildExplainItem(g, 'group:global'));
141
+ for (const g of groupGrantsOrg) explain.push(buildExplainItem(g, 'group:org'));
142
+ for (const g of orgGrantsGlobal) explain.push(buildExplainItem(g, 'org:global'));
143
+ for (const g of orgGrantsOrg) explain.push(buildExplainItem(g, 'org:org'));
144
+
145
+ const context = {
146
+ groups: groups.map((g) => ({
147
+ id: String(g._id),
148
+ isGlobal: !!g.isGlobal,
149
+ orgId: g.orgId ? String(g.orgId) : null,
150
+ })),
151
+ roles: [
152
+ ...directRoleIds.map((rid) => ({ roleId: String(rid), source: 'user' })),
153
+ ...groupRoleLinks.map((l) => ({ roleId: String(l.roleId), source: 'group', groupId: String(l.groupId) })),
154
+ ],
155
+ };
156
+
157
+ return { grants: all, layers, explain: explain.filter(Boolean), context, orgMember };
158
+ }
159
+
160
+ async function checkRight({ userId, orgId, right }) {
161
+ const r = normalizeRight(right);
162
+ if (!r) {
163
+ return { allowed: false, reason: 'invalid_right', explain: [], context: null, decisionLayer: null };
164
+ }
165
+
166
+ const { layers, explain, context, orgMember } = await getEffectiveGrants({ userId, orgId });
167
+ if (!orgMember) {
168
+ return { allowed: false, reason: 'not_org_member', explain: [], context: null, decisionLayer: null };
169
+ }
170
+
171
+ const denyMatches = [];
172
+ for (const [layerName, grants] of Object.entries(layers || {})) {
173
+ const { denies } = extractMatches(grants, r);
174
+ for (const d of denies) denyMatches.push({ layerName, grant: d });
175
+ }
176
+
177
+ if (denyMatches.length) {
178
+ const matchedIds = new Set(denyMatches.map((m) => String(m.grant._id)));
179
+ const filteredExplain = explain.filter((e) => matchedIds.has(String(e.id)));
180
+ return {
181
+ allowed: false,
182
+ reason: 'denied',
183
+ decisionLayer: 'deny',
184
+ explain: filteredExplain,
185
+ context,
186
+ };
187
+ }
188
+
189
+ const allowPriority = ['org', 'group', 'role', 'user'];
190
+ for (const layer of allowPriority) {
191
+ const { allows } = extractMatches(layers?.[layer] || [], r);
192
+ if (!allows.length) continue;
193
+
194
+ const matchedIds = new Set(allows.map((a) => String(a._id)));
195
+ const filteredExplain = explain.filter((e) => matchedIds.has(String(e.id)));
196
+ return {
197
+ allowed: true,
198
+ reason: 'allowed',
199
+ decisionLayer: layer,
200
+ explain: filteredExplain,
201
+ context,
202
+ };
203
+ }
204
+
205
+ return { allowed: false, reason: 'no_match', explain: [], context, decisionLayer: null };
206
+ }
207
+
208
+ module.exports = {
209
+ getUserOrgIds,
210
+ getEffectiveGrants,
211
+ checkRight,
212
+ };
@@ -0,0 +1,259 @@
1
+ const { EventEmitter } = require('events');
2
+ const { spawn } = require('child_process');
3
+ const { NodeVM } = require('vm2');
4
+
5
+ const ScriptRun = require('../models/ScriptRun');
6
+
7
+ const MAX_TAIL_BYTES = 64 * 1024;
8
+
9
+ function nowIso() {
10
+ return new Date().toISOString();
11
+ }
12
+
13
+ function appendTail(prev, chunk) {
14
+ const next = String(prev || '') + String(chunk || '');
15
+ const buf = Buffer.from(next, 'utf8');
16
+ if (buf.length <= MAX_TAIL_BYTES) return next;
17
+ return buf.slice(buf.length - MAX_TAIL_BYTES).toString('utf8');
18
+ }
19
+
20
+ function safeJsonParse(str) {
21
+ try {
22
+ return JSON.parse(String(str || ''));
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function buildEnvPairs(env) {
29
+ const pairs = Array.isArray(env) ? env : [];
30
+ const out = {};
31
+ for (const item of pairs) {
32
+ if (!item || typeof item !== 'object') continue;
33
+ const key = String(item.key || '').trim();
34
+ if (!key) continue;
35
+ const value = String(item.value || '');
36
+ out[key] = value;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ class RunBus {
42
+ constructor(runId) {
43
+ this.runId = String(runId);
44
+ this.emitter = new EventEmitter();
45
+ this.seq = 0;
46
+ this.buffer = [];
47
+ this.closed = false;
48
+ }
49
+
50
+ push(event) {
51
+ if (this.closed) return;
52
+ this.seq += 1;
53
+ const payload = { seq: this.seq, ...event };
54
+ this.buffer.push(payload);
55
+ if (this.buffer.length > 2000) this.buffer.shift();
56
+ this.emitter.emit('event', payload);
57
+ }
58
+
59
+ close() {
60
+ this.closed = true;
61
+ this.emitter.emit('close');
62
+ }
63
+
64
+ snapshot(sinceSeq) {
65
+ const since = Number(sinceSeq || 0);
66
+ return this.buffer.filter((e) => Number(e.seq) > since);
67
+ }
68
+ }
69
+
70
+ const runs = new Map();
71
+
72
+ function getRunBus(runId) {
73
+ return runs.get(String(runId)) || null;
74
+ }
75
+
76
+ async function startRun(scriptDef, options) {
77
+ const trigger = options?.trigger || 'manual';
78
+ const meta = options?.meta || null;
79
+
80
+ const runDoc = await ScriptRun.create({
81
+ scriptId: scriptDef._id,
82
+ status: 'queued',
83
+ trigger,
84
+ startedAt: null,
85
+ finishedAt: null,
86
+ exitCode: null,
87
+ outputTail: '',
88
+ meta,
89
+ });
90
+
91
+ const bus = new RunBus(runDoc._id);
92
+ runs.set(String(runDoc._id), bus);
93
+
94
+ setImmediate(async () => {
95
+ try {
96
+ await ScriptRun.updateOne(
97
+ { _id: runDoc._id },
98
+ { $set: { status: 'running', startedAt: new Date() } },
99
+ );
100
+
101
+ bus.push({ type: 'status', ts: nowIso(), status: 'running' });
102
+
103
+ const timeoutMs = Number(scriptDef.timeoutMs || 0) || 5 * 60 * 1000;
104
+ const env = { ...process.env, ...buildEnvPairs(scriptDef.env) };
105
+ const cwd = String(scriptDef.defaultWorkingDirectory || '').trim() || undefined;
106
+
107
+ let exitCode = 0;
108
+
109
+ if (scriptDef.type === 'bash') {
110
+ if (scriptDef.runner !== 'host') {
111
+ throw Object.assign(new Error('bash scripts only support host runner'), { code: 'VALIDATION' });
112
+ }
113
+ exitCode = await runSpawned({
114
+ runId: runDoc._id,
115
+ bus,
116
+ command: 'bash',
117
+ args: ['-lc', scriptDef.script],
118
+ env,
119
+ cwd,
120
+ timeoutMs,
121
+ });
122
+ } else if (scriptDef.type === 'node') {
123
+ if (scriptDef.runner === 'vm2') {
124
+ exitCode = await runVm2({ runId: runDoc._id, bus, code: scriptDef.script, timeoutMs });
125
+ } else if (scriptDef.runner === 'host') {
126
+ exitCode = await runSpawned({
127
+ runId: runDoc._id,
128
+ bus,
129
+ command: 'node',
130
+ args: ['-e', scriptDef.script],
131
+ env,
132
+ cwd,
133
+ timeoutMs,
134
+ });
135
+ } else {
136
+ throw Object.assign(new Error('Invalid runner for node script'), { code: 'VALIDATION' });
137
+ }
138
+ } else if (scriptDef.type === 'browser') {
139
+ throw Object.assign(new Error('browser scripts run in the UI only'), { code: 'VALIDATION' });
140
+ } else {
141
+ throw Object.assign(new Error('Unsupported script type'), { code: 'VALIDATION' });
142
+ }
143
+
144
+ const finalStatus = exitCode === 0 ? 'succeeded' : 'failed';
145
+ await ScriptRun.updateOne(
146
+ { _id: runDoc._id },
147
+ { $set: { status: finalStatus, finishedAt: new Date(), exitCode } },
148
+ );
149
+
150
+ bus.push({ type: 'status', ts: nowIso(), status: finalStatus, exitCode });
151
+ bus.push({ type: 'done', ts: nowIso(), status: finalStatus, exitCode });
152
+ bus.close();
153
+
154
+ setTimeout(() => {
155
+ runs.delete(String(runDoc._id));
156
+ }, 5 * 60 * 1000).unref();
157
+ } catch (err) {
158
+ const msg = err?.message || 'Run failed';
159
+ await ScriptRun.updateOne(
160
+ { _id: runDoc._id },
161
+ { $set: { status: 'failed', finishedAt: new Date(), exitCode: 1 }, $setOnInsert: {} },
162
+ );
163
+ bus.push({ type: 'log', ts: nowIso(), stream: 'stderr', line: msg + '\n' });
164
+ bus.push({ type: 'status', ts: nowIso(), status: 'failed', exitCode: 1 });
165
+ bus.push({ type: 'done', ts: nowIso(), status: 'failed', exitCode: 1 });
166
+ bus.close();
167
+
168
+ setTimeout(() => {
169
+ runs.delete(String(runDoc._id));
170
+ }, 5 * 60 * 1000).unref();
171
+ }
172
+ });
173
+
174
+ return runDoc;
175
+ }
176
+
177
+ async function runSpawned({ runId, bus, command, args, env, cwd, timeoutMs }) {
178
+ return await new Promise((resolve, reject) => {
179
+ const child = spawn(command, args, {
180
+ env,
181
+ cwd,
182
+ stdio: ['ignore', 'pipe', 'pipe'],
183
+ });
184
+
185
+ let tail = '';
186
+
187
+ const killTimer = setTimeout(() => {
188
+ try {
189
+ child.kill('SIGKILL');
190
+ } catch {}
191
+ }, timeoutMs);
192
+ killTimer.unref();
193
+
194
+ const onData = async (stream, chunk) => {
195
+ const s = chunk.toString('utf8');
196
+ tail = appendTail(tail, s);
197
+ bus.push({ type: 'log', ts: nowIso(), stream, line: s });
198
+ await ScriptRun.updateOne({ _id: runId }, { $set: { outputTail: tail } });
199
+ };
200
+
201
+ child.stdout.on('data', (c) => onData('stdout', c));
202
+ child.stderr.on('data', (c) => onData('stderr', c));
203
+
204
+ child.on('error', (err) => {
205
+ clearTimeout(killTimer);
206
+ reject(err);
207
+ });
208
+
209
+ child.on('close', (code) => {
210
+ clearTimeout(killTimer);
211
+ resolve(Number(code || 0));
212
+ });
213
+ });
214
+ }
215
+
216
+ async function runVm2({ runId, bus, code, timeoutMs }) {
217
+ let tail = '';
218
+
219
+ function pushLog(stream, line) {
220
+ const s = String(line || '');
221
+ tail = appendTail(tail, s);
222
+ bus.push({ type: 'log', ts: nowIso(), stream, line: s });
223
+ return ScriptRun.updateOne({ _id: runId }, { $set: { outputTail: tail } });
224
+ }
225
+
226
+ const vm = new NodeVM({
227
+ console: 'redirect',
228
+ sandbox: {},
229
+ require: {
230
+ external: false,
231
+ builtin: [],
232
+ },
233
+ timeout: timeoutMs,
234
+ eval: false,
235
+ wasm: false,
236
+ });
237
+
238
+ vm.on('console.log', (...args) => {
239
+ pushLog('stdout', args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
240
+ });
241
+ vm.on('console.error', (...args) => {
242
+ pushLog('stderr', args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') + '\n');
243
+ });
244
+
245
+ try {
246
+ vm.run(code, 'script.vm2.js');
247
+ return 0;
248
+ } catch (err) {
249
+ const msg = err?.message || 'vm2 error';
250
+ await pushLog('stderr', msg + '\n');
251
+ return 1;
252
+ }
253
+ }
254
+
255
+ module.exports = {
256
+ startRun,
257
+ getRunBus,
258
+ safeJsonParse,
259
+ };
@@ -0,0 +1,152 @@
1
+ const crypto = require('crypto');
2
+ const pty = require('node-pty');
3
+
4
+ const sessions = new Map();
5
+
6
+ const MAX_SESSIONS = 20;
7
+ const IDLE_TTL_MS = 15 * 60 * 1000;
8
+
9
+ function now() {
10
+ return Date.now();
11
+ }
12
+
13
+ function newId() {
14
+ return crypto.randomBytes(16).toString('hex');
15
+ }
16
+
17
+ function listSessions() {
18
+ return Array.from(sessions.values())
19
+ .map((s) => ({
20
+ sessionId: s.sessionId,
21
+ status: s.status,
22
+ createdAt: s.createdAt,
23
+ lastActivityAt: s.lastActivityAt,
24
+ cols: s.cols,
25
+ rows: s.rows,
26
+ }))
27
+ .sort((a, b) => b.createdAt - a.createdAt);
28
+ }
29
+
30
+ function getSession(sessionId) {
31
+ return sessions.get(String(sessionId)) || null;
32
+ }
33
+
34
+ function createSession(options = {}) {
35
+ if (sessions.size >= MAX_SESSIONS) {
36
+ const err = new Error('Too many active terminal sessions');
37
+ err.code = 'LIMIT';
38
+ throw err;
39
+ }
40
+
41
+ const cols = Number(options.cols || 120);
42
+ const rows = Number(options.rows || 30);
43
+
44
+ const shell = process.env.SHELL || 'bash';
45
+
46
+ const p = pty.spawn(shell, [], {
47
+ name: 'xterm-256color',
48
+ cols,
49
+ rows,
50
+ cwd: process.cwd(),
51
+ env: process.env,
52
+ });
53
+
54
+ const sessionId = newId();
55
+ const s = {
56
+ sessionId,
57
+ pty: p,
58
+ status: 'running',
59
+ createdAt: now(),
60
+ lastActivityAt: now(),
61
+ cols,
62
+ rows,
63
+ };
64
+
65
+ p.onExit(() => {
66
+ const cur = sessions.get(sessionId);
67
+ if (cur) {
68
+ cur.status = 'closed';
69
+ cur.lastActivityAt = now();
70
+ }
71
+ });
72
+
73
+ sessions.set(sessionId, s);
74
+
75
+ return { sessionId };
76
+ }
77
+
78
+ function touch(sessionId) {
79
+ const s = sessions.get(String(sessionId));
80
+ if (!s) return;
81
+ s.lastActivityAt = now();
82
+ }
83
+
84
+ function resizeSession(sessionId, cols, rows) {
85
+ const s = getSession(sessionId);
86
+ if (!s || s.status !== 'running') return;
87
+ const c = Number(cols || 0);
88
+ const r = Number(rows || 0);
89
+ if (!c || !r) return;
90
+ s.cols = c;
91
+ s.rows = r;
92
+ s.lastActivityAt = now();
93
+ try {
94
+ s.pty.resize(c, r);
95
+ } catch {}
96
+ }
97
+
98
+ function writeSession(sessionId, data) {
99
+ const s = getSession(sessionId);
100
+ if (!s || s.status !== 'running') return;
101
+ s.lastActivityAt = now();
102
+ try {
103
+ s.pty.write(String(data || ''));
104
+ } catch {}
105
+ }
106
+
107
+ function killSession(sessionId) {
108
+ const s = getSession(sessionId);
109
+ if (!s) {
110
+ const err = new Error('Session not found');
111
+ err.code = 'NOT_FOUND';
112
+ throw err;
113
+ }
114
+
115
+ try {
116
+ s.pty.kill();
117
+ } catch {}
118
+
119
+ sessions.delete(String(sessionId));
120
+ return { ok: true };
121
+ }
122
+
123
+ function cleanupIdleSessions() {
124
+ const cutoff = now() - IDLE_TTL_MS;
125
+ for (const [id, s] of sessions.entries()) {
126
+ if (s.lastActivityAt < cutoff) {
127
+ try {
128
+ s.pty.kill();
129
+ } catch {}
130
+ sessions.delete(id);
131
+ }
132
+ }
133
+ }
134
+
135
+ let cleanupTimer = null;
136
+ function ensureCleanupTimer() {
137
+ if (cleanupTimer) return;
138
+ cleanupTimer = setInterval(cleanupIdleSessions, 60 * 1000);
139
+ cleanupTimer.unref();
140
+ }
141
+
142
+ ensureCleanupTimer();
143
+
144
+ module.exports = {
145
+ createSession,
146
+ listSessions,
147
+ getSession,
148
+ killSession,
149
+ writeSession,
150
+ resizeSession,
151
+ touch,
152
+ };