@intranefr/superbackend 1.4.4 → 1.5.0

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 (42) hide show
  1. package/index.js +16 -1
  2. package/package.json +5 -2
  3. package/public/sdk/ui-components.iife.js +191 -0
  4. package/sdk/ui-components/browser/src/index.js +228 -0
  5. package/src/controllers/admin.controller.js +89 -0
  6. package/src/controllers/adminHeadless.controller.js +82 -0
  7. package/src/controllers/adminScripts.controller.js +229 -0
  8. package/src/controllers/adminTerminals.controller.js +39 -0
  9. package/src/controllers/adminUiComponents.controller.js +315 -0
  10. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  11. package/src/controllers/orgAdmin.controller.js +286 -0
  12. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  13. package/src/middleware/auth.js +7 -0
  14. package/src/middleware.js +115 -0
  15. package/src/models/HeadlessModelDefinition.js +10 -0
  16. package/src/models/ScriptDefinition.js +42 -0
  17. package/src/models/ScriptRun.js +22 -0
  18. package/src/models/UiComponent.js +29 -0
  19. package/src/models/UiComponentProject.js +26 -0
  20. package/src/models/UiComponentProjectComponent.js +18 -0
  21. package/src/routes/admin.routes.js +1 -0
  22. package/src/routes/adminHeadless.routes.js +6 -0
  23. package/src/routes/adminScripts.routes.js +21 -0
  24. package/src/routes/adminTerminals.routes.js +13 -0
  25. package/src/routes/adminUiComponents.routes.js +29 -0
  26. package/src/routes/llmUi.routes.js +26 -0
  27. package/src/routes/orgAdmin.routes.js +5 -0
  28. package/src/routes/uiComponentsPublic.routes.js +9 -0
  29. package/src/services/headlessExternalModels.service.js +292 -0
  30. package/src/services/headlessModels.service.js +26 -6
  31. package/src/services/scriptsRunner.service.js +259 -0
  32. package/src/services/terminals.service.js +152 -0
  33. package/src/services/terminalsWs.service.js +100 -0
  34. package/src/services/uiComponentsAi.service.js +312 -0
  35. package/src/services/uiComponentsCrypto.service.js +39 -0
  36. package/views/admin-headless.ejs +294 -24
  37. package/views/admin-organizations.ejs +365 -9
  38. package/views/admin-scripts.ejs +497 -0
  39. package/views/admin-terminals.ejs +328 -0
  40. package/views/admin-ui-components.ejs +709 -0
  41. package/views/admin-users.ejs +261 -4
  42. package/views/partials/dashboard/nav-items.ejs +3 -0
@@ -3,6 +3,9 @@ const mongoose = require('mongoose');
3
3
  const Organization = require('../models/Organization');
4
4
  const OrganizationMember = require('../models/OrganizationMember');
5
5
  const Invite = require('../models/Invite');
6
+ const User = require('../models/User');
7
+ const Asset = require('../models/Asset');
8
+ const Notification = require('../models/Notification');
6
9
  const emailService = require('../services/email.service');
7
10
  const { isValidOrgRole, getAllowedOrgRoles, getDefaultOrgRole } = require('../utils/orgRoles');
8
11
 
@@ -489,3 +492,286 @@ exports.resendInvite = async (req, res) => {
489
492
  return res.status(500).json({ error: 'Failed to resend invite' });
490
493
  }
491
494
  };
495
+
496
+ // Create organization (admin only)
497
+ exports.createOrganization = async (req, res) => {
498
+ try {
499
+ const { name, description, ownerUserId } = req.body;
500
+
501
+ // Validation
502
+ if (!name || typeof name !== 'string' || name.trim().length < 2) {
503
+ return res.status(400).json({ error: 'Name must be at least 2 characters long' });
504
+ }
505
+
506
+ if (name.trim().length > 100) {
507
+ return res.status(400).json({ error: 'Name must be less than 100 characters' });
508
+ }
509
+
510
+ if (description && description.trim().length > 500) {
511
+ return res.status(400).json({ error: 'Description must be less than 500 characters' });
512
+ }
513
+
514
+ // Validate owner if specified
515
+ let ownerId = null;
516
+ if (ownerUserId) {
517
+ if (!mongoose.Types.ObjectId.isValid(String(ownerUserId))) {
518
+ return res.status(400).json({ error: 'Invalid owner user ID' });
519
+ }
520
+
521
+ const owner = await User.findById(ownerUserId);
522
+ if (!owner) {
523
+ return res.status(400).json({ error: 'Owner user not found' });
524
+ }
525
+ ownerId = owner._id;
526
+ } else {
527
+ // Default to first admin user if no owner specified
528
+ const defaultOwner = await User.findOne({ role: 'admin' });
529
+ if (!defaultOwner) {
530
+ return res.status(400).json({ error: 'No admin user available to assign as owner' });
531
+ }
532
+ ownerId = defaultOwner._id;
533
+ }
534
+
535
+ // Generate unique slug
536
+ let baseSlug = name.trim()
537
+ .toLowerCase()
538
+ .replace(/[^a-z0-9\s-]/g, '')
539
+ .replace(/\s+/g, '-')
540
+ .replace(/-+/g, '-')
541
+ .replace(/^-|-$/g, '');
542
+
543
+ if (!baseSlug || baseSlug.length < 2) {
544
+ return res.status(400).json({ error: 'Name must contain valid characters for slug generation' });
545
+ }
546
+
547
+ let slug = baseSlug;
548
+ let counter = 1;
549
+
550
+ while (await Organization.findOne({ slug })) {
551
+ slug = `${baseSlug}-${counter}`;
552
+ counter++;
553
+ if (counter > 1000) {
554
+ return res.status(500).json({ error: 'Unable to generate unique slug' });
555
+ }
556
+ }
557
+
558
+ // Create organization
559
+ const org = await Organization.create({
560
+ name: name.trim(),
561
+ slug,
562
+ description: description ? description.trim() : '',
563
+ ownerUserId: ownerId,
564
+ status: 'active',
565
+ settings: {}
566
+ });
567
+
568
+ console.log(`Admin created organization: ${org.name} (${org._id}) with owner: ${ownerId}`);
569
+
570
+ res.status(201).json({
571
+ message: 'Organization created successfully',
572
+ org: org.toObject()
573
+ });
574
+ } catch (error) {
575
+ console.error('Create organization error:', error);
576
+ if (error.code === 11000) {
577
+ // Duplicate key error
578
+ return res.status(400).json({ error: 'Organization with this name or slug already exists' });
579
+ }
580
+ return res.status(500).json({ error: 'Failed to create organization' });
581
+ }
582
+ };
583
+
584
+ // Update organization (admin only)
585
+ exports.updateOrganization = async (req, res) => {
586
+ try {
587
+ const { orgId } = req.params;
588
+ const { name, description, ownerUserId, status } = req.body;
589
+
590
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
591
+ return res.status(400).json({ error: 'Invalid organization ID' });
592
+ }
593
+
594
+ const org = await Organization.findById(orgId);
595
+ if (!org) {
596
+ return res.status(404).json({ error: 'Organization not found' });
597
+ }
598
+
599
+ // Update name (but not slug - per requirements)
600
+ if (name !== undefined) {
601
+ if (!name || typeof name !== 'string' || name.trim().length < 2) {
602
+ return res.status(400).json({ error: 'Name must be at least 2 characters long' });
603
+ }
604
+ if (name.trim().length > 100) {
605
+ return res.status(400).json({ error: 'Name must be less than 100 characters' });
606
+ }
607
+ org.name = name.trim();
608
+ }
609
+
610
+ // Update description
611
+ if (description !== undefined) {
612
+ if (description && description.trim().length > 500) {
613
+ return res.status(400).json({ error: 'Description must be less than 500 characters' });
614
+ }
615
+ org.description = description ? description.trim() : '';
616
+ }
617
+
618
+ // Update owner
619
+ if (ownerUserId !== undefined) {
620
+ if (ownerUserId) {
621
+ if (!mongoose.Types.ObjectId.isValid(String(ownerUserId))) {
622
+ return res.status(400).json({ error: 'Invalid owner user ID' });
623
+ }
624
+ const owner = await User.findById(ownerUserId);
625
+ if (!owner) {
626
+ return res.status(400).json({ error: 'Owner user not found' });
627
+ }
628
+ org.ownerUserId = owner._id;
629
+ } else {
630
+ return res.status(400).json({ error: 'Owner cannot be empty' });
631
+ }
632
+ }
633
+
634
+ // Update status
635
+ if (status !== undefined) {
636
+ if (!['active', 'disabled'].includes(status)) {
637
+ return res.status(400).json({ error: 'Status must be either "active" or "disabled"' });
638
+ }
639
+ org.status = status;
640
+ }
641
+
642
+ await org.save();
643
+
644
+ console.log(`Admin updated organization: ${org.name} (${org._id})`);
645
+
646
+ res.json({
647
+ message: 'Organization updated successfully',
648
+ org: org.toObject()
649
+ });
650
+ } catch (error) {
651
+ console.error('Update organization error:', error);
652
+ return res.status(500).json({ error: 'Failed to update organization' });
653
+ }
654
+ };
655
+
656
+ // Disable organization (admin only)
657
+ exports.disableOrganization = async (req, res) => {
658
+ try {
659
+ const { orgId } = req.params;
660
+
661
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
662
+ return res.status(400).json({ error: 'Invalid organization ID' });
663
+ }
664
+
665
+ const org = await Organization.findById(orgId);
666
+ if (!org) {
667
+ return res.status(404).json({ error: 'Organization not found' });
668
+ }
669
+
670
+ if (org.status === 'disabled') {
671
+ return res.status(400).json({ error: 'Organization is already disabled' });
672
+ }
673
+
674
+ org.status = 'disabled';
675
+ await org.save();
676
+
677
+ console.log(`Admin disabled organization: ${org.name} (${org._id})`);
678
+
679
+ res.json({
680
+ message: 'Organization disabled successfully',
681
+ org: org.toObject()
682
+ });
683
+ } catch (error) {
684
+ console.error('Disable organization error:', error);
685
+ return res.status(500).json({ error: 'Failed to disable organization' });
686
+ }
687
+ };
688
+
689
+ // Enable organization (admin only)
690
+ exports.enableOrganization = async (req, res) => {
691
+ try {
692
+ const { orgId } = req.params;
693
+
694
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
695
+ return res.status(400).json({ error: 'Invalid organization ID' });
696
+ }
697
+
698
+ const org = await Organization.findById(orgId);
699
+ if (!org) {
700
+ return res.status(404).json({ error: 'Organization not found' });
701
+ }
702
+
703
+ if (org.status === 'active') {
704
+ return res.status(400).json({ error: 'Organization is already active' });
705
+ }
706
+
707
+ org.status = 'active';
708
+ await org.save();
709
+
710
+ console.log(`Admin enabled organization: ${org.name} (${org._id})`);
711
+
712
+ res.json({
713
+ message: 'Organization enabled successfully',
714
+ org: org.toObject()
715
+ });
716
+ } catch (error) {
717
+ console.error('Enable organization error:', error);
718
+ return res.status(500).json({ error: 'Failed to enable organization' });
719
+ }
720
+ };
721
+
722
+ // Delete organization (admin only)
723
+ exports.deleteOrganization = async (req, res) => {
724
+ try {
725
+ const { orgId } = req.params;
726
+
727
+ if (!orgId || !mongoose.Types.ObjectId.isValid(String(orgId))) {
728
+ return res.status(400).json({ error: 'Invalid organization ID' });
729
+ }
730
+
731
+ const org = await Organization.findById(orgId);
732
+ if (!org) {
733
+ return res.status(404).json({ error: 'Organization not found' });
734
+ }
735
+
736
+ // Cascade cleanup
737
+ await cleanupOrganizationData(orgId);
738
+
739
+ // Delete organization
740
+ await Organization.findByIdAndDelete(orgId);
741
+
742
+ console.log(`Admin deleted organization: ${org.name} (${org._id})`);
743
+
744
+ res.json({ message: 'Organization deleted successfully' });
745
+ } catch (error) {
746
+ console.error('Delete organization error:', error);
747
+ return res.status(500).json({ error: 'Failed to delete organization' });
748
+ }
749
+ };
750
+
751
+ // Helper function to clean up organization data
752
+ async function cleanupOrganizationData(orgId) {
753
+ try {
754
+ // Delete organization members
755
+ await OrganizationMember.deleteMany({ orgId });
756
+
757
+ // Delete organization invites
758
+ await Invite.deleteMany({ orgId });
759
+
760
+ // Delete organization assets
761
+ await Asset.deleteMany({ ownerUserId: { $in: await getOrganizationUserIds(orgId) } });
762
+
763
+ // Delete organization notifications
764
+ await Notification.deleteMany({ userId: { $in: await getOrganizationUserIds(orgId) } });
765
+
766
+ console.log(`Completed cleanup for organization ${orgId}`);
767
+ } catch (error) {
768
+ console.error('Error during organization cleanup:', error);
769
+ throw error;
770
+ }
771
+ }
772
+
773
+ // Helper function to get all user IDs in an organization
774
+ async function getOrganizationUserIds(orgId) {
775
+ const members = await OrganizationMember.find({ orgId }).distinct('userId');
776
+ return members;
777
+ }
@@ -0,0 +1,118 @@
1
+ const UiComponent = require('../models/UiComponent');
2
+ const UiComponentProject = require('../models/UiComponentProject');
3
+ const UiComponentProjectComponent = require('../models/UiComponentProjectComponent');
4
+
5
+ const { verifyKey } = require('../services/uiComponentsCrypto.service');
6
+
7
+ function extractProjectKey(req) {
8
+ const headerToken = req.headers['x-project-key'] || req.headers['x-api-key'];
9
+ if (headerToken) return String(headerToken).trim();
10
+ return null;
11
+ }
12
+
13
+ async function loadAndAuthorizeProject(req, res, projectId) {
14
+ const project = await UiComponentProject.findOne({ projectId: String(projectId), isActive: true }).lean();
15
+ if (!project) {
16
+ res.status(404).json({ error: 'Project not found' });
17
+ return null;
18
+ }
19
+
20
+ if (!project.isPublic) {
21
+ const key = extractProjectKey(req);
22
+ const ok = project.apiKeyHash && verifyKey(key, project.apiKeyHash);
23
+ if (!ok) {
24
+ res.status(401).json({ error: 'Invalid project key' });
25
+ return null;
26
+ }
27
+ }
28
+
29
+ return project;
30
+ }
31
+
32
+ exports.getManifest = async (req, res) => {
33
+ try {
34
+ const { projectId } = req.params;
35
+ const project = await loadAndAuthorizeProject(req, res, projectId);
36
+ if (!project) return;
37
+
38
+ const assignments = await UiComponentProjectComponent.find({ projectId: project.projectId, enabled: true }).lean();
39
+ const codes = assignments.map((a) => a.componentCode);
40
+
41
+ const components = await UiComponent.find({
42
+ code: { $in: codes },
43
+ isActive: true,
44
+ })
45
+ .sort({ code: 1 })
46
+ .lean();
47
+
48
+ const docsOnly = String(req.query.docs || '').toLowerCase() === 'true';
49
+
50
+ const out = components.map((c) => {
51
+ const base = {
52
+ code: c.code,
53
+ name: c.name,
54
+ version: c.version,
55
+ };
56
+ if (docsOnly) {
57
+ base.usageMarkdown = c.usageMarkdown;
58
+ } else {
59
+ base.html = c.html;
60
+ base.js = c.js;
61
+ base.css = c.css;
62
+ }
63
+ return base;
64
+ });
65
+
66
+ return res.json({
67
+ project: {
68
+ projectId: project.projectId,
69
+ name: project.name,
70
+ isPublic: project.isPublic,
71
+ allowedOrigins: project.allowedOrigins || [],
72
+ },
73
+ components: out,
74
+ });
75
+ } catch (error) {
76
+ console.error('UI Components getManifest error:', error);
77
+ return res.status(500).json({ error: 'Failed to load manifest' });
78
+ }
79
+ };
80
+
81
+ exports.getComponent = async (req, res) => {
82
+ try {
83
+ const { projectId, code } = req.params;
84
+ const project = await loadAndAuthorizeProject(req, res, projectId);
85
+ if (!project) return;
86
+
87
+ const componentCode = String(code || '').trim().toLowerCase();
88
+
89
+ const assignment = await UiComponentProjectComponent.findOne({
90
+ projectId: project.projectId,
91
+ componentCode,
92
+ enabled: true,
93
+ }).lean();
94
+
95
+ if (!assignment) return res.status(404).json({ error: 'Component not enabled for this project' });
96
+
97
+ const component = await UiComponent.findOne({ code: componentCode, isActive: true }).lean();
98
+ if (!component) return res.status(404).json({ error: 'Component not found' });
99
+
100
+ return res.json({
101
+ project: {
102
+ projectId: project.projectId,
103
+ isPublic: project.isPublic,
104
+ },
105
+ component: {
106
+ code: component.code,
107
+ name: component.name,
108
+ version: component.version,
109
+ html: component.html,
110
+ js: component.js,
111
+ css: component.css,
112
+ },
113
+ });
114
+ } catch (error) {
115
+ console.error('UI Components getComponent error:', error);
116
+ return res.status(500).json({ error: 'Failed to load component' });
117
+ }
118
+ };
@@ -19,6 +19,7 @@ const authenticate = async (req, res, next) => {
19
19
  req.user = user;
20
20
  next();
21
21
  } catch (error) {
22
+ console.error("api authenticate error:", error);
22
23
  return res
23
24
  .status(401)
24
25
  .json({ error: error.message || "Authentication failed" });
@@ -45,6 +46,12 @@ const basicAuth = (req, res, next) => {
45
46
  if (username === adminUsername && password === adminPassword) {
46
47
  next();
47
48
  } else {
49
+ console.error("api basicAuth error:", {
50
+ username,
51
+ password,
52
+ adminUsername,
53
+ adminPassword,
54
+ });
48
55
  res.setHeader("WWW-Authenticate", 'Basic realm="Admin Area"');
49
56
  return res.status(401).json({ error: "Invalid credentials" });
50
57
  }
package/src/middleware.js CHANGED
@@ -31,6 +31,13 @@ function createMiddleware(options = {}) {
31
31
  const router = express.Router();
32
32
  const adminPath = options.adminPath || "/admin";
33
33
 
34
+ // Expose adminPath and WS attachment helper
35
+ router.adminPath = adminPath;
36
+ router.attachWs = (server) => {
37
+ const { attachTerminalWebsocketServer } = require('./services/terminalsWs.service');
38
+ attachTerminalWebsocketServer(server, { basePathPrefix: adminPath });
39
+ };
40
+
34
41
  // Initialize console override service early to capture all logs
35
42
  consoleOverride.init();
36
43
 
@@ -142,6 +149,12 @@ function createMiddleware(options = {}) {
142
149
  // Serve public static files (e.g. /og/og-default.png)
143
150
  router.use(express.static(path.join(__dirname, "..", "public")));
144
151
 
152
+ // Serve browser SDK bundles
153
+ router.use(
154
+ "/public/sdk",
155
+ express.static(path.join(__dirname, "..", "public", "sdk")),
156
+ );
157
+
145
158
  // Serve static files for admin views
146
159
  router.use(
147
160
  `${adminPath}/assets`,
@@ -188,6 +201,70 @@ function createMiddleware(options = {}) {
188
201
  });
189
202
  });
190
203
 
204
+ router.get(`${adminPath}/terminals`, basicAuth, (req, res) => {
205
+ const templatePath = path.join(
206
+ __dirname,
207
+ "..",
208
+ "views",
209
+ "admin-terminals.ejs",
210
+ );
211
+ fs.readFile(templatePath, "utf8", (err, template) => {
212
+ if (err) {
213
+ console.error("Error reading template:", err);
214
+ return res.status(500).send("Error loading page");
215
+ }
216
+ try {
217
+ const html = ejs.render(
218
+ template,
219
+ {
220
+ baseUrl: req.baseUrl,
221
+ adminPath,
222
+ endpointRegistry,
223
+ },
224
+ {
225
+ filename: templatePath,
226
+ },
227
+ );
228
+ res.send(html);
229
+ } catch (renderErr) {
230
+ console.error("Error rendering template:", renderErr);
231
+ res.status(500).send("Error rendering page");
232
+ }
233
+ });
234
+ });
235
+
236
+ router.get(`${adminPath}/scripts`, basicAuth, (req, res) => {
237
+ const templatePath = path.join(
238
+ __dirname,
239
+ "..",
240
+ "views",
241
+ "admin-scripts.ejs",
242
+ );
243
+ fs.readFile(templatePath, "utf8", (err, template) => {
244
+ if (err) {
245
+ console.error("Error reading template:", err);
246
+ return res.status(500).send("Error loading page");
247
+ }
248
+ try {
249
+ const html = ejs.render(
250
+ template,
251
+ {
252
+ baseUrl: req.baseUrl,
253
+ adminPath,
254
+ endpointRegistry,
255
+ },
256
+ {
257
+ filename: templatePath,
258
+ },
259
+ );
260
+ res.send(html);
261
+ } catch (renderErr) {
262
+ console.error("Error rendering template:", renderErr);
263
+ res.status(500).send("Error rendering page");
264
+ }
265
+ });
266
+ });
267
+
191
268
  router.use("/api/admin", require("./routes/admin.routes"));
192
269
  router.use("/api/admin/settings", require("./routes/globalSettings.routes"));
193
270
  router.use(
@@ -204,11 +281,14 @@ function createMiddleware(options = {}) {
204
281
  );
205
282
  router.use("/api/admin/i18n", require("./routes/adminI18n.routes"));
206
283
  router.use("/api/admin/headless", require("./routes/adminHeadless.routes"));
284
+ router.use("/api/admin/scripts", require("./routes/adminScripts.routes"));
285
+ router.use("/api/admin/terminals", require("./routes/adminTerminals.routes"));
207
286
  router.use("/api/admin/assets", require("./routes/adminAssets.routes"));
208
287
  router.use(
209
288
  "/api/admin/upload-namespaces",
210
289
  require("./routes/adminUploadNamespaces.routes"),
211
290
  );
291
+ router.use("/api/admin/ui-components", require("./routes/adminUiComponents.routes"));
212
292
  router.use("/api/admin/migration", require("./routes/adminMigration.routes"));
213
293
  router.use("/api/admin/errors", basicAuth, require("./routes/adminErrors.routes"));
214
294
  router.use("/api/admin/audit", basicAuth, require("./routes/adminAudit.routes"));
@@ -229,6 +309,8 @@ function createMiddleware(options = {}) {
229
309
  router.use("/api/invites", require("./routes/invite.routes"));
230
310
  router.use("/api/log", require("./routes/log.routes"));
231
311
  router.use("/api/error-tracking", require("./routes/errorTracking.routes"));
312
+ router.use("/api/ui-components", require("./routes/uiComponentsPublic.routes"));
313
+ router.use("/api/llm/ui", require("./routes/llmUi.routes"));
232
314
 
233
315
  // Public assets proxy
234
316
  router.use("/public/assets", require("./routes/publicAssets.routes"));
@@ -541,6 +623,39 @@ function createMiddleware(options = {}) {
541
623
  });
542
624
  });
543
625
 
626
+ // Admin UI Components page (protected by basic auth)
627
+ router.get(`${adminPath}/ui-components`, basicAuth, (req, res) => {
628
+ const templatePath = path.join(
629
+ __dirname,
630
+ "..",
631
+ "views",
632
+ "admin-ui-components.ejs",
633
+ );
634
+ fs.readFile(templatePath, "utf8", (err, template) => {
635
+ if (err) {
636
+ console.error("Error reading template:", err);
637
+ return res.status(500).send("Error loading page");
638
+ }
639
+ try {
640
+ const html = ejs.render(
641
+ template,
642
+ {
643
+ baseUrl: req.baseUrl,
644
+ adminPath,
645
+ endpointRegistry,
646
+ },
647
+ {
648
+ filename: templatePath,
649
+ },
650
+ );
651
+ res.send(html);
652
+ } catch (renderErr) {
653
+ console.error("Error rendering template:", renderErr);
654
+ res.status(500).send("Error rendering page");
655
+ }
656
+ });
657
+ });
658
+
544
659
  // Admin JSON configs page (protected by basic auth)
545
660
  router.get(`${adminPath}/json-configs`, basicAuth, (req, res) => {
546
661
  const templatePath = path.join(
@@ -28,6 +28,16 @@ const headlessModelDefinitionSchema = new mongoose.Schema(
28
28
  description: { type: String, default: '' },
29
29
  fields: { type: [headlessFieldSchema], default: [] },
30
30
  indexes: { type: [headlessIndexSchema], default: [] },
31
+ sourceType: { type: String, enum: ['internal', 'external'], default: 'internal', index: true },
32
+ sourceCollectionName: { type: String, default: null, index: true },
33
+ isExternal: { type: Boolean, default: false, index: true },
34
+ inference: {
35
+ enabled: { type: Boolean, default: false },
36
+ lastInferredAt: { type: Date, default: null },
37
+ sampleSize: { type: Number, default: null },
38
+ warnings: { type: [String], default: [] },
39
+ stats: { type: mongoose.Schema.Types.Mixed, default: null },
40
+ },
31
41
  isActive: { type: Boolean, default: true, index: true },
32
42
  version: { type: Number, default: 1 },
33
43
  fieldsHash: { type: String, default: null },
@@ -0,0 +1,42 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const envVarSchema = new mongoose.Schema(
4
+ {
5
+ key: { type: String, required: true },
6
+ value: { type: String, required: true },
7
+ },
8
+ { _id: false },
9
+ );
10
+
11
+ const scriptDefinitionSchema = new mongoose.Schema(
12
+ {
13
+ name: { type: String, required: true },
14
+ codeIdentifier: { type: String, required: true, unique: true, index: true },
15
+ description: { type: String, default: '' },
16
+ type: { type: String, enum: ['bash', 'node', 'browser'], required: true },
17
+ runner: { type: String, enum: ['host', 'vm2', 'browser'], required: true },
18
+ script: { type: String, required: true },
19
+ defaultWorkingDirectory: { type: String, default: '' },
20
+ env: { type: [envVarSchema], default: [] },
21
+ timeoutMs: { type: Number, default: 5 * 60 * 1000 },
22
+ enabled: { type: Boolean, default: true, index: true },
23
+ },
24
+ { timestamps: true, collection: 'script_definitions' },
25
+ );
26
+
27
+ function normalizeCodeIdentifier(codeIdentifier) {
28
+ return String(codeIdentifier || '')
29
+ .trim()
30
+ .toLowerCase()
31
+ .replace(/\s+/g, '-')
32
+ .replace(/[^a-z0-9_-]/g, '');
33
+ }
34
+
35
+ scriptDefinitionSchema.pre('validate', function preValidate(next) {
36
+ this.codeIdentifier = normalizeCodeIdentifier(this.codeIdentifier);
37
+ next();
38
+ });
39
+
40
+ module.exports =
41
+ mongoose.models.ScriptDefinition ||
42
+ mongoose.model('ScriptDefinition', scriptDefinitionSchema);
@@ -0,0 +1,22 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const scriptRunSchema = new mongoose.Schema(
4
+ {
5
+ scriptId: { type: mongoose.Schema.Types.ObjectId, ref: 'ScriptDefinition', required: true, index: true },
6
+ status: {
7
+ type: String,
8
+ enum: ['queued', 'running', 'succeeded', 'failed', 'canceled', 'timed_out'],
9
+ default: 'queued',
10
+ index: true,
11
+ },
12
+ trigger: { type: String, enum: ['manual', 'schedule', 'api'], default: 'manual', index: true },
13
+ startedAt: { type: Date, default: null },
14
+ finishedAt: { type: Date, default: null },
15
+ exitCode: { type: Number, default: null },
16
+ outputTail: { type: String, default: '' },
17
+ meta: { type: mongoose.Schema.Types.Mixed, default: null },
18
+ },
19
+ { timestamps: true, collection: 'script_runs' },
20
+ );
21
+
22
+ module.exports = mongoose.models.ScriptRun || mongoose.model('ScriptRun', scriptRunSchema);