@mongoosejs/studio 0.4.0 → 0.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 (39) hide show
  1. package/README.md +9 -0
  2. package/backend/actions/ChatMessage/executeScript.js +17 -25
  3. package/backend/actions/Model/getDocuments.js +7 -4
  4. package/backend/actions/Model/getDocumentsStream.js +7 -4
  5. package/backend/actions/getCapabilities.js +42 -0
  6. package/backend/actions/index.js +1 -1
  7. package/backend/db/chatMessageSchema.js +2 -1
  8. package/backend/helpers/isBindIPConnection.js +8 -0
  9. package/backend/helpers/isLocalhostConnection.js +8 -0
  10. package/backend/helpers/normalizeBindIPOption.js +16 -0
  11. package/backend/helpers/parseProjectionParam.js +180 -0
  12. package/backend/sandbox/createSandbox.js +83 -0
  13. package/backend/sandbox/createScriptDb.js +201 -0
  14. package/express.js +16 -0
  15. package/frontend/public/app.js +845 -114
  16. package/frontend/public/tw.css +4 -0
  17. package/frontend/src/api.js +6 -0
  18. package/frontend/src/chat/chat-message/chat-message.html +1 -0
  19. package/frontend/src/chat/chat-message/chat-message.js +4 -3
  20. package/frontend/src/chat/chat-message-script/chat-message-script.html +98 -16
  21. package/frontend/src/chat/chat-message-script/chat-message-script.js +77 -17
  22. package/frontend/src/chat/chat.html +5 -1
  23. package/frontend/src/chat/chat.js +11 -2
  24. package/frontend/src/detail-objectid/detail-objectid.html +1 -0
  25. package/frontend/src/detail-objectid/detail-objectid.js +67 -0
  26. package/frontend/src/document-details/document-property/document-property.html +8 -3
  27. package/frontend/src/document-details/document-property/document-property.js +7 -0
  28. package/frontend/src/document-details/objectid-view-mode-picker/objectid-view-mode-picker.html +12 -0
  29. package/frontend/src/document-details/objectid-view-mode-picker/objectid-view-mode-picker.js +19 -0
  30. package/frontend/src/modal/modal.css +0 -1
  31. package/frontend/src/models/document-search/document-search.js +6 -0
  32. package/frontend/src/models/models.html +6 -8
  33. package/frontend/src/models/models.js +257 -85
  34. package/frontend/src/models/projection-search/projection-search.html +27 -0
  35. package/frontend/src/models/projection-search/projection-search.js +226 -0
  36. package/index.d.ts +1 -1
  37. package/package.json +2 -2
  38. package/backend/actions/Script/execute.js +0 -20
  39. package/backend/actions/Script/index.js +0 -3
package/README.md CHANGED
@@ -12,6 +12,7 @@ For local dev, if your app runs on `localhost:3000`, Studio will be on `localhos
12
12
 
13
13
  By default, Mongoose Studio does **not** provide any authentication or authorization.
14
14
  You can use Mongoose Studio for free for local development, but we recommend [Mongoose Studio Pro](https://studio.mongoosejs.io/#pricing) for when you want to go into production.
15
+ When you omit an API key, Mongoose Studio only accepts localhost connections by default.
15
16
 
16
17
  First, `npm install @mongoosejs/studio`.
17
18
 
@@ -43,6 +44,14 @@ opts.googleGeminiAPIKey = process.env.GOOGLE_GEMINI_API_KEY;
43
44
  app.use('/studio', await studio('/studio/api', mongoose, opts));
44
45
  ```
45
46
 
47
+ Without an API key, you can allow access from additional IP addresses with `bindIp`, similar to MongoDB's `bindIp` option. `bindIp` may be a comma-separated string or an array of exact IP addresses. Set `bindIp` to `null` to allow unauthenticated connections from anywhere.
48
+
49
+ ```javascript
50
+ app.use('/studio', await studio('/studio/api', mongoose, {
51
+ bindIp: '127.0.0.1,192.168.0.10'
52
+ }));
53
+ ```
54
+
46
55
  ### Next.js
47
56
 
48
57
  First, add `withMongooseStudio` to your `next.config.js` file:
@@ -2,9 +2,8 @@
2
2
 
3
3
  const Archetype = require('archetype');
4
4
  const authorize = require('../../authorize');
5
- const MongooseStudioChartColors = require('../../constants/mongooseStudioChartColors');
5
+ const createSandbox = require('../../sandbox/createSandbox');
6
6
  const mongoose = require('mongoose');
7
- const vm = require('vm');
8
7
 
9
8
  const ExecuteScriptParams = new Archetype({
10
9
  initiatedById: {
@@ -17,13 +16,16 @@ const ExecuteScriptParams = new Archetype({
17
16
  script: {
18
17
  $type: 'string'
19
18
  },
19
+ dryRun: {
20
+ $type: 'boolean'
21
+ },
20
22
  roles: {
21
23
  $type: ['string']
22
24
  }
23
25
  }).compile('ExecuteScriptParams');
24
26
 
25
27
  module.exports = ({ db, studioConnection }) => async function executeScript(params) {
26
- const { initiatedById, chatMessageId, script, roles } = new ExecuteScriptParams(params);
28
+ const { initiatedById, chatMessageId, script, dryRun, roles } = new ExecuteScriptParams(params);
27
29
  const ChatThread = studioConnection.model('__Studio_ChatThread');
28
30
  const ChatMessage = studioConnection.model('__Studio_ChatMessage');
29
31
 
@@ -39,32 +41,19 @@ module.exports = ({ db, studioConnection }) => async function executeScript(para
39
41
  throw new Error('Unauthorized');
40
42
  }
41
43
 
42
- // Create a sandbox with the db object
43
- const logs = [];
44
- if (!db.Types) {
45
- db.Types = mongoose.Types;
46
- }
47
- const sandbox = { db, mongoose, console: {}, ObjectId: mongoose.Types.ObjectId, MongooseStudioChartColors };
48
-
49
- // Capture console logs
50
- sandbox.console.log = function() {
51
- const args = Array.from(arguments);
52
- logs.push(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : arg).join(' '));
53
- };
54
-
55
- const context = vm.createContext(sandbox);
44
+ const sandbox = createSandbox({ db });
56
45
 
57
46
  let output;
58
47
  let error;
59
48
 
60
49
  try {
61
50
  // Execute the script in the sandbox
62
- output = await vm.runInContext(wrappedScript(script), context);
51
+ output = await sandbox.runScript({ script, dryRun });
63
52
 
64
53
  const updatedContent = updateContentWithScript(chatMessage.content, chatMessage.script, script);
65
54
  chatMessage.script = script;
66
55
  chatMessage.content = updatedContent;
67
- chatMessage.executionResult = { output, logs: logs.join('\n'), error: null };
56
+ chatMessage.executionResult = { output, logs: sandbox.getLogs(), error: null, dryRun: !!dryRun };
68
57
  await chatMessage.save();
69
58
 
70
59
  return { chatMessage };
@@ -80,20 +69,23 @@ module.exports = ({ db, studioConnection }) => async function executeScript(para
80
69
  content: updatedContent,
81
70
  executionResult: {
82
71
  output: null,
83
- logs: logs.join('\n'),
84
- error
72
+ logs: sandbox.getLogs(),
73
+ error,
74
+ dryRun: !!dryRun
85
75
  }
86
76
  }
87
77
  );
88
78
 
89
79
  throw new Error(`Script execution failed: ${error}`);
80
+ } finally {
81
+ try {
82
+ await sandbox.close();
83
+ } catch (_) {
84
+ // Ignore sandbox cleanup errors so they do not mask the primary result.
85
+ }
90
86
  }
91
87
  };
92
88
 
93
- const wrappedScript = script => `(async () => {
94
- ${script}
95
- })()`;
96
-
97
89
  function updateContentWithScript(content, previousScript, newScript) {
98
90
  if (typeof content !== 'string') {
99
91
  return content;
@@ -5,7 +5,7 @@ const removeSpecifiedPaths = require('../../helpers/removeSpecifiedPaths');
5
5
  const evaluateFilter = require('../../helpers/evaluateFilter');
6
6
  const getRefFromSchemaType = require('../../helpers/getRefFromSchemaType');
7
7
  const getSuggestedProjection = require('../../helpers/getSuggestedProjection');
8
- const parseFieldsParam = require('../../helpers/parseFieldsParam');
8
+ const parseProjectionParam = require('../../helpers/parseProjectionParam');
9
9
  const authorize = require('../../authorize');
10
10
 
11
11
  const GetDocumentsParams = new Archetype({
@@ -32,7 +32,7 @@ const GetDocumentsParams = new Archetype({
32
32
  sortDirection: {
33
33
  $type: 'number'
34
34
  },
35
- fields: {
35
+ projectionInput: {
36
36
  $type: 'string'
37
37
  },
38
38
  roles: {
@@ -45,7 +45,7 @@ module.exports = ({ db }) => async function getDocuments(params) {
45
45
  const { roles } = params;
46
46
  await authorize('Model.getDocuments', roles);
47
47
 
48
- const { model, limit, skip, sortKey, sortDirection, searchText, fields } = params;
48
+ const { model, limit, skip, sortKey, sortDirection, searchText, projectionInput } = params;
49
49
 
50
50
  const Model = db.models[model];
51
51
  if (Model == null) {
@@ -68,7 +68,10 @@ module.exports = ({ db }) => async function getDocuments(params) {
68
68
  }
69
69
 
70
70
  let query = Model.find(filter).limit(limit).skip(skip).sort(sortObj);
71
- const projection = parseFieldsParam(fields);
71
+ const projection = parseProjectionParam({
72
+ projectionInput,
73
+ schemaPaths: Object.keys(Model.schema.paths)
74
+ });
72
75
  if (projection != null) {
73
76
  query = query.select(projection);
74
77
  }
@@ -5,7 +5,7 @@ const removeSpecifiedPaths = require('../../helpers/removeSpecifiedPaths');
5
5
  const evaluateFilter = require('../../helpers/evaluateFilter');
6
6
  const getRefFromSchemaType = require('../../helpers/getRefFromSchemaType');
7
7
  const getSuggestedProjection = require('../../helpers/getSuggestedProjection');
8
- const parseFieldsParam = require('../../helpers/parseFieldsParam');
8
+ const parseProjectionParam = require('../../helpers/parseProjectionParam');
9
9
  const authorize = require('../../authorize');
10
10
 
11
11
  const GetDocumentsParams = new Archetype({
@@ -32,7 +32,7 @@ const GetDocumentsParams = new Archetype({
32
32
  sortDirection: {
33
33
  $type: 'number'
34
34
  },
35
- fields: {
35
+ projectionInput: {
36
36
  $type: 'string'
37
37
  },
38
38
  roles: {
@@ -45,7 +45,7 @@ module.exports = ({ db }) => async function* getDocumentsStream(params) {
45
45
  const { roles } = params;
46
46
  await authorize('Model.getDocumentsStream', roles);
47
47
 
48
- const { model, limit, skip, sortKey, sortDirection, searchText, fields } = params;
48
+ const { model, limit, skip, sortKey, sortDirection, searchText, projectionInput } = params;
49
49
 
50
50
  const Model = db.models[model];
51
51
  if (Model == null) {
@@ -68,7 +68,10 @@ module.exports = ({ db }) => async function* getDocumentsStream(params) {
68
68
  }
69
69
 
70
70
  let query = Model.find(filter).limit(limit).skip(skip).sort(sortObj).batchSize(1);
71
- const projection = parseFieldsParam(fields);
71
+ const projection = parseProjectionParam({
72
+ projectionInput,
73
+ schemaPaths: Object.keys(Model.schema.paths)
74
+ });
72
75
  if (projection != null) {
73
76
  query = query.select(projection);
74
77
  }
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ module.exports = ({ db, options }) => async function getCapabilities() {
4
+ const connection = db?.connection ?? db;
5
+ const admin = typeof connection?.db?.admin === 'function' ? connection.db.admin() : null;
6
+ const supportsAI = hasAIProviderKey(options);
7
+
8
+ if (admin == null || typeof admin.command !== 'function') {
9
+ return {
10
+ supportsChangeStreams: false,
11
+ supportsTransactions: false,
12
+ supportsAI
13
+ };
14
+ }
15
+
16
+ let hello;
17
+ try {
18
+ hello = await admin.command({ hello: 1 });
19
+ } catch (err) {
20
+ hello = await admin.command({ isMaster: 1 });
21
+ }
22
+
23
+ const maxWireVersion = hello?.maxWireVersion ?? 0;
24
+ const supportsModernMongoFeatures = maxWireVersion >= 7;
25
+ const isReplicaSet = !!hello?.setName;
26
+ const isMongos = hello?.msg === 'isdbgrid';
27
+ const hasSessions = hello?.logicalSessionTimeoutMinutes != null;
28
+
29
+ return {
30
+ supportsChangeStreams: supportsModernMongoFeatures && (isReplicaSet || isMongos),
31
+ supportsTransactions: supportsModernMongoFeatures && hasSessions && (isReplicaSet || isMongos),
32
+ supportsAI
33
+ };
34
+ };
35
+
36
+ function hasAIProviderKey(options) {
37
+ return [
38
+ options?.openAIAPIKey,
39
+ options?.anthropicAPIKey,
40
+ options?.googleGeminiAPIKey
41
+ ].some(value => typeof value === 'string' ? value.trim().length > 0 : !!value);
42
+ }
@@ -3,7 +3,7 @@
3
3
  exports.ChatMessage = require('./ChatMessage');
4
4
  exports.ChatThread = require('./ChatThread');
5
5
  exports.Dashboard = require('./Dashboard');
6
+ exports.getCapabilities = require('./getCapabilities');
6
7
  exports.Model = require('./Model');
7
- exports.Script = require('./Script');
8
8
  exports.status = require('./status');
9
9
  exports.Task = require('./Task');
@@ -28,7 +28,8 @@ const chatMessageSchema = new mongoose.Schema({
28
28
  executionResult: {
29
29
  output: mongoose.Schema.Types.Mixed,
30
30
  logs: String,
31
- error: String
31
+ error: String,
32
+ dryRun: Boolean
32
33
  }
33
34
  }, { timestamps: true });
34
35
 
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ module.exports = function isBindIPConnection(req, bindIp) {
4
+ if (bindIp == null) {
5
+ return true;
6
+ }
7
+ return bindIp.includes(req.ip || '');
8
+ };
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ module.exports = function isLocalhostConnection(req) {
4
+ const remoteAddress = req.ip || '';
5
+ return remoteAddress === '127.0.0.1' ||
6
+ remoteAddress === '::1' ||
7
+ remoteAddress === '::ffff:127.0.0.1';
8
+ };
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ module.exports = function normalizeBindIPOption(bindIp) {
4
+ if (bindIp === null) {
5
+ return null;
6
+ }
7
+ if (bindIp === undefined) {
8
+ return ['localhost'];
9
+ }
10
+
11
+ const bindIps = Array.isArray(bindIp) ? bindIp : `${bindIp}`.split(',');
12
+ return bindIps.
13
+ flatMap(bindIp => `${bindIp}`.split(',')).
14
+ map(bindIp => bindIp.trim()).
15
+ filter(bindIp => bindIp.length > 0);
16
+ };
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Parse a raw projection input string into a Query.select()-compatible projection.
5
+ *
6
+ * Supports:
7
+ * - `name email`
8
+ * - `-password`
9
+ * - `+email`
10
+ * - `{ deletedAt: 1 }`
11
+ * - `{ password: 0 }`
12
+ *
13
+ * @param {Object} options
14
+ * @param {string|undefined} options.projectionInput
15
+ * @param {string[]} options.schemaPaths
16
+ * @returns {string|object|null}
17
+ */
18
+ function parseProjectionParam({ projectionInput, schemaPaths }) {
19
+ return parseProjectionInput(projectionInput, schemaPaths);
20
+ }
21
+
22
+ function parseProjectionInput(projectionInput, schemaPaths) {
23
+ if (projectionInput == null || typeof projectionInput !== 'string') {
24
+ return null;
25
+ }
26
+ const trimmed = projectionInput.trim();
27
+ if (!trimmed) {
28
+ return null;
29
+ }
30
+
31
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
32
+ return parseProjectionObjectNotation(trimmed, schemaPaths);
33
+ }
34
+
35
+ const tokens = normalizeProjectionTokens(trimmed);
36
+ if (tokens === null || tokens.length === 0) {
37
+ return null;
38
+ }
39
+
40
+ const includeKeys = [];
41
+ const excludeKeys = [];
42
+
43
+ for (const rawToken of tokens) {
44
+ const token = rawToken.trim();
45
+ if (!token) {
46
+ continue;
47
+ }
48
+
49
+ const prefix = token[0];
50
+ if (prefix === '-') {
51
+ const path = token.slice(1).trim();
52
+ if (!path) {
53
+ return null;
54
+ }
55
+ excludeKeys.push(path);
56
+ } else if (prefix === '+') {
57
+ const path = token.slice(1).trim();
58
+ if (!path) {
59
+ return null;
60
+ }
61
+ includeKeys.push(path);
62
+ } else {
63
+ includeKeys.push(token);
64
+ }
65
+ }
66
+
67
+ if (includeKeys.length > 0 && excludeKeys.length > 0) {
68
+ const includeSet = new Set(includeKeys);
69
+ for (const path of excludeKeys) {
70
+ const ex = path;
71
+ for (const key of Array.from(includeSet)) {
72
+ if (key.toLowerCase() === ex.toLowerCase()) {
73
+ includeSet.delete(key);
74
+ }
75
+ }
76
+ }
77
+ if (includeSet.size === 0) {
78
+ return null;
79
+ }
80
+ return Array.from(includeSet).join(' ');
81
+ }
82
+
83
+ if (excludeKeys.length > 0) {
84
+ return excludeKeys.map(path => `-${path}`).join(' ');
85
+ }
86
+
87
+ return includeKeys.join(' ');
88
+ }
89
+
90
+ function normalizeProjectionTokens(trimmed) {
91
+ const rawTokens = trimmed.split(/[,\s]+/).filter(Boolean);
92
+ const tokens = [];
93
+ for (let i = 0; i < rawTokens.length; i++) {
94
+ const token = rawTokens[i];
95
+ if (token === '-' || token === '+') {
96
+ if (i + 1 >= rawTokens.length) {
97
+ return null;
98
+ }
99
+ tokens.push(token + rawTokens[++i]);
100
+ } else {
101
+ tokens.push(token);
102
+ }
103
+ }
104
+ return tokens;
105
+ }
106
+
107
+ function parseProjectionObjectNotation(trimmed, schemaPaths) {
108
+ const body = trimmed.slice(1, -1).trim();
109
+ if (!body) {
110
+ return null;
111
+ }
112
+
113
+ const pairRe = /(?:^|,)\s*(?:"([^"]+)"|'([^']+)'|([a-zA-Z_$][\w.$]*))\s*:\s*("[^"]*"|'[^']*'|[^,]+?)\s*(?=,|$)/g;
114
+ const includeKeys = [];
115
+ const excludeKeys = [];
116
+ let matchedChars = '';
117
+ let match;
118
+
119
+ while ((match = pairRe.exec(body)) !== null) {
120
+ matchedChars += match[0];
121
+ const key = (match[1] || match[2] || match[3] || '').trim();
122
+ const rawValue = (match[4] || '').trim();
123
+ if (!key || !rawValue) {
124
+ return null;
125
+ }
126
+ if (key === '__proto__' || key === 'constructor') {
127
+ throw new Error(`Invalid key: ${key}`);
128
+ }
129
+
130
+ const valueLower = rawValue.replace(/^['"]|['"]$/g, '').trim().toLowerCase();
131
+ const isInclude = valueLower === '1' || valueLower === 'true';
132
+ const isExclude = valueLower === '0' || valueLower === 'false';
133
+ if (!isInclude && !isExclude) {
134
+ return null;
135
+ }
136
+ if (isInclude) {
137
+ includeKeys.push(key);
138
+ } else {
139
+ excludeKeys.push(key);
140
+ }
141
+ }
142
+
143
+ const normalizedBody = body.replace(/\s+/g, '');
144
+ const normalizedMatched = matchedChars.replace(/\s+/g, '').replace(/^,/, '');
145
+ if (!normalizedMatched || normalizedMatched !== normalizedBody) {
146
+ return null;
147
+ }
148
+
149
+ if (includeKeys.length > 0 && excludeKeys.length > 0) {
150
+ const includeSet = new Set(includeKeys);
151
+ for (const path of excludeKeys) {
152
+ const ex = path;
153
+ for (const key of Array.from(includeSet)) {
154
+ if (key.toLowerCase() === ex.toLowerCase()) {
155
+ includeSet.delete(key);
156
+ }
157
+ }
158
+ }
159
+ if (includeSet.size === 0) {
160
+ return null;
161
+ }
162
+ return Array.from(includeSet).join(' ');
163
+ }
164
+
165
+ if (excludeKeys.length > 0) {
166
+ const projection = {};
167
+ for (const path of excludeKeys) {
168
+ projection[path] = 0;
169
+ }
170
+ return Object.keys(projection).length > 0 ? projection : null;
171
+ }
172
+
173
+ const projection = {};
174
+ for (const path of includeKeys) {
175
+ projection[path] = 1;
176
+ }
177
+ return Object.keys(projection).length > 0 ? projection : null;
178
+ }
179
+
180
+ module.exports = parseProjectionParam;
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const MongooseStudioChartColors = require('../constants/mongooseStudioChartColors');
4
+ const mongoose = require('mongoose');
5
+ const vm = require('vm');
6
+ const { createScriptDb } = require('./createScriptDb');
7
+
8
+ module.exports = function createSandbox({ db }) {
9
+ const logs = [];
10
+ const scriptDb = createScriptDb(db);
11
+ if (!scriptDb.db.Types) {
12
+ scriptDb.db.Types = mongoose.Types;
13
+ }
14
+
15
+ const sandbox = {
16
+ db: scriptDb.db,
17
+ mongoose,
18
+ console: {},
19
+ ObjectId: mongoose.Types.ObjectId,
20
+ MongooseStudioChartColors
21
+ };
22
+
23
+ sandbox.console.log = function() {
24
+ const args = Array.from(arguments);
25
+ logs.push(args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : arg).join(' '));
26
+ };
27
+
28
+ return {
29
+ context: vm.createContext(sandbox),
30
+ db: scriptDb.db,
31
+ getLogs() {
32
+ return logs.join('\n');
33
+ },
34
+ setDryRunSession: scriptDb.setDryRunSession,
35
+ async runScript({ script, dryRun }) {
36
+ const wrappedScript = `(async () => {
37
+ ${script}
38
+ })()`;
39
+
40
+ if (!dryRun) {
41
+ return await vm.runInContext(wrappedScript, this.context);
42
+ }
43
+
44
+ return await runDryRunScript({
45
+ db: this.db,
46
+ context: this.context,
47
+ setDryRunSession: this.setDryRunSession,
48
+ wrappedScript
49
+ });
50
+ },
51
+ close: scriptDb.close
52
+ };
53
+ };
54
+
55
+ async function runDryRunScript({ db, context, setDryRunSession, wrappedScript }) {
56
+ if (typeof db?.startSession !== 'function') {
57
+ throw new Error('Dry run mode requires MongoDB sessions support');
58
+ }
59
+
60
+ const session = await db.startSession();
61
+ setDryRunSession(session);
62
+
63
+ try {
64
+ session.startTransaction();
65
+ const result = await vm.runInContext(wrappedScript, context);
66
+ await abortTransaction(session);
67
+ return result;
68
+ } catch (err) {
69
+ await abortTransaction(session);
70
+ throw err;
71
+ } finally {
72
+ setDryRunSession(null);
73
+ await session.endSession().catch(() => {});
74
+ }
75
+ }
76
+
77
+ async function abortTransaction(session) {
78
+ if (typeof session?.inTransaction === 'function' && !session.inTransaction()) {
79
+ return;
80
+ }
81
+
82
+ await session.abortTransaction().catch(() => {});
83
+ }