mindsim 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { Command } from "commander";
6
6
  import { login } from "./auth";
7
+ import { loadServiceJsonFromFile, validateServiceJson } from "./config";
7
8
  import { MindSim } from "./index";
8
9
  import { checkForUpdates, getPackageVersion, updateSdk } from "./version";
9
10
 
@@ -88,6 +89,111 @@ const main = async () => {
88
89
  await updateSdk();
89
90
  });
90
91
 
92
+ // ==========================================
93
+ // SERVICE JSON VALIDATION
94
+ // ==========================================
95
+
96
+ program
97
+ .command("validate-service-json")
98
+ .description("Validate a service JSON credentials file")
99
+ .argument("<path>", "Path to the service JSON file")
100
+ .option("-v, --verbose", "Show detailed validation results", false)
101
+ .action(async (filePath: string, options: { verbose: boolean }) => {
102
+ try {
103
+ const resolvedPath = path.resolve(filePath);
104
+
105
+ // Check if file exists
106
+ if (!fs.existsSync(resolvedPath)) {
107
+ console.error(`❌ File not found: ${resolvedPath}`);
108
+ process.exit(1);
109
+ }
110
+
111
+ // Read and parse the file
112
+ let content: string;
113
+ try {
114
+ content = fs.readFileSync(resolvedPath, "utf-8");
115
+ } catch (readError) {
116
+ console.error(
117
+ `❌ Failed to read file: ${readError instanceof Error ? readError.message : readError}`,
118
+ );
119
+ process.exit(1);
120
+ }
121
+
122
+ // Parse JSON
123
+ let json: unknown;
124
+ try {
125
+ json = JSON.parse(content);
126
+ } catch (parseError) {
127
+ console.error("❌ Invalid JSON syntax");
128
+ if (options.verbose) {
129
+ console.error(` ${parseError instanceof Error ? parseError.message : parseError}`);
130
+ }
131
+ process.exit(1);
132
+ }
133
+
134
+ // Validate service JSON structure
135
+ const validation = validateServiceJson(json);
136
+
137
+ if (validation.valid) {
138
+ console.log("✅ Service JSON is valid");
139
+
140
+ if (options.verbose && validation.serviceJson) {
141
+ console.log("\n📋 Credentials Summary:");
142
+ console.log(` Project ID: ${validation.serviceJson.project_id}`);
143
+ console.log(` Project Name: ${validation.serviceJson.project_name}`);
144
+ console.log(` Environment: ${validation.serviceJson.environment}`);
145
+ console.log(` Client Email: ${validation.serviceJson.client_email}`);
146
+ console.log(` API Base URL: ${validation.serviceJson.api_base_url}`);
147
+ console.log(` Scopes: ${validation.serviceJson.scopes.join(", ")}`);
148
+ console.log(` Created At: ${validation.serviceJson.created_at}`);
149
+ console.log("\n🔒 API key is present (not displayed for security)");
150
+ }
151
+
152
+ // Test that it can actually be loaded
153
+ const loaded = loadServiceJsonFromFile(resolvedPath);
154
+ if (loaded) {
155
+ console.log("✅ Service JSON can be loaded successfully");
156
+ }
157
+
158
+ process.exit(0);
159
+ } else {
160
+ console.error("❌ Service JSON validation failed");
161
+ console.error("\nErrors:");
162
+ for (const error of validation.errors) {
163
+ console.error(` • ${error}`);
164
+ }
165
+
166
+ if (options.verbose) {
167
+ console.error("\n💡 Tip: Service JSON should have this structure:");
168
+ console.error(` {
169
+ "type": "mindsim_service_account",
170
+ "version": "1",
171
+ "project_id": "your-app-slug",
172
+ "project_name": "Your App Name",
173
+ "environment": "production",
174
+ "client_email": "service@your-app.mindsim.io",
175
+ "client_id": "uuid",
176
+ "api_key": "dev_xxxxx",
177
+ "api_base_url": "https://api.reasoner.com/api/mindsim",
178
+ "scopes": ["minds:read", "simulate:run"],
179
+ "app_metadata": {
180
+ "app_id": "uuid",
181
+ "workspace_id": "uuid",
182
+ "mindsim_org_id": "uuid",
183
+ "use_case": "customer-facing"
184
+ },
185
+ "created_at": "2025-01-01T00:00:00Z"
186
+ }`);
187
+ }
188
+
189
+ process.exit(1);
190
+ }
191
+ } catch (error) {
192
+ console.error("❌ Unexpected error:", error instanceof Error ? error.message : error);
193
+ process.exit(1);
194
+ }
195
+ });
196
+
91
197
  // ==========================================
92
198
  // MINDS RESOURCES
93
199
  // ==========================================
package/src/config.ts CHANGED
@@ -1,7 +1,33 @@
1
1
  import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import type { DeviceAuthConfig } from "./types";
4
+ import { getLogger } from "./logger";
5
+ import type { DeveloperUseCase, DeviceAuthConfig, MindsimScope, MindsimServiceJson } from "./types";
6
+
7
+ // =============================================================================
8
+ // Gap 7 Fix: Browser Environment Detection
9
+ // =============================================================================
10
+
11
+ /**
12
+ * Check if we're running in a Node.js environment.
13
+ * Returns false in browsers to prevent process.env access errors.
14
+ */
15
+ function isNodeEnvironment(): boolean {
16
+ return (
17
+ typeof process !== "undefined" && process.versions != null && process.versions.node != null
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Safely get an environment variable.
23
+ * Returns undefined in browser environments.
24
+ */
25
+ function getEnvVar(name: string): string | undefined {
26
+ if (!isNodeEnvironment()) {
27
+ return undefined;
28
+ }
29
+ return process.env[name];
30
+ }
5
31
 
6
32
  const API_BASE_URL = "https://api.reasoner.com/api/mindsim";
7
33
  const WORKOS_DEVICE_AUTH_URL = "https://auth.reasoner.com/user_management/authorize/device";
@@ -9,34 +35,390 @@ const WORKOS_TOKEN_URL = "https://auth.reasoner.com/user_management/authenticate
9
35
  const WORKOS_CLIENT_ID = "client_01GPECHM1J9DMY7WQNKTJ195P6";
10
36
  const SDK_KEYS_API_URL = "https://api.reasoner.com/api/sdk/keys";
11
37
 
12
- export function getConfigDir() {
38
+ // Valid scopes for validation (must match rainmaker's ALL_SCOPES)
39
+ const VALID_SCOPES: MindsimScope[] = [
40
+ "minds:read",
41
+ "minds:write",
42
+ "simulate:run",
43
+ "simulate:read",
44
+ "users:read",
45
+ "users:write",
46
+ "org:admin",
47
+ ];
48
+
49
+ // Valid use cases for validation
50
+ const VALID_USE_CASES: DeveloperUseCase[] = ["internal-workflow", "customer-facing", "agentic-ai"];
51
+
52
+ // =============================================================================
53
+ // Gap 12-14 Fix: Validation Helpers
54
+ // =============================================================================
55
+
56
+ /**
57
+ * UUID v4 validation regex
58
+ */
59
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
60
+
61
+ /**
62
+ * Validate a UUID string
63
+ */
64
+ function isValidUUID(value: unknown): boolean {
65
+ return typeof value === "string" && UUID_REGEX.test(value);
66
+ }
67
+
68
+ /**
69
+ * Validate a URL string (must be HTTPS for production)
70
+ */
71
+ function isValidHttpsUrl(value: unknown): boolean {
72
+ if (typeof value !== "string") return false;
73
+ try {
74
+ const url = new URL(value);
75
+ return url.protocol === "https:";
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Validate an ISO 8601 timestamp string
83
+ */
84
+ function isValidISOTimestamp(value: unknown): boolean {
85
+ if (typeof value !== "string") return false;
86
+ const date = new Date(value);
87
+ return !Number.isNaN(date.getTime()) && value.includes("T");
88
+ }
89
+
90
+ /**
91
+ * Gap 8 Fix: Validate an email address format
92
+ */
93
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
94
+ function isValidEmail(value: unknown): boolean {
95
+ return typeof value === "string" && EMAIL_REGEX.test(value);
96
+ }
97
+
98
+ /**
99
+ * Get the MindSim config directory path.
100
+ * @returns Path to ~/.mindsim directory
101
+ */
102
+ export function getConfigDir(): string {
13
103
  return path.join(os.homedir(), ".mindsim");
14
104
  }
15
105
 
16
- export function getConfigFile() {
106
+ /**
107
+ * Get the MindSim config file path.
108
+ * @returns Path to ~/.mindsim/config file
109
+ */
110
+ export function getConfigFile(): string {
17
111
  return path.join(getConfigDir(), "config");
18
112
  }
19
113
 
114
+ // =============================================================================
115
+ // Service JSON Validation (Gap 6 Fix)
116
+ // =============================================================================
117
+
118
+ /**
119
+ * Validation result for service JSON.
120
+ */
121
+ export interface ServiceJsonValidationResult {
122
+ valid: boolean;
123
+ errors: string[];
124
+ serviceJson?: MindsimServiceJson;
125
+ }
126
+
127
+ /**
128
+ * Validate a service JSON object has all required fields with correct types.
129
+ * This provides detailed error messages for debugging.
130
+ *
131
+ * @param json - The parsed JSON object to validate
132
+ * @returns Validation result with errors if invalid
133
+ */
134
+ export function validateServiceJson(json: unknown): ServiceJsonValidationResult {
135
+ const errors: string[] = [];
136
+
137
+ if (!json || typeof json !== "object") {
138
+ return { valid: false, errors: ["Service JSON must be a valid object"] };
139
+ }
140
+
141
+ const obj = json as Record<string, unknown>;
142
+
143
+ // Required string fields
144
+ const requiredStrings: Array<{ key: string; name: string }> = [
145
+ { key: "type", name: "type" },
146
+ { key: "version", name: "version" },
147
+ { key: "project_id", name: "project_id" },
148
+ { key: "project_name", name: "project_name" },
149
+ { key: "environment", name: "environment" },
150
+ { key: "client_email", name: "client_email" },
151
+ { key: "client_id", name: "client_id" },
152
+ { key: "api_key", name: "api_key" },
153
+ { key: "api_base_url", name: "api_base_url" },
154
+ { key: "created_at", name: "created_at" },
155
+ ];
156
+
157
+ for (const { key, name } of requiredStrings) {
158
+ if (typeof obj[key] !== "string" || obj[key] === "") {
159
+ errors.push(`Missing or invalid required field: ${name}`);
160
+ }
161
+ }
162
+
163
+ // Validate type field value
164
+ if (obj.type !== "mindsim_service_account") {
165
+ errors.push('type field must be "mindsim_service_account"');
166
+ }
167
+
168
+ // Validate environment field value
169
+ if (obj.environment !== "production") {
170
+ errors.push('environment field must be "production"');
171
+ }
172
+
173
+ // Validate scopes array
174
+ if (!Array.isArray(obj.scopes)) {
175
+ errors.push("scopes must be an array");
176
+ } else if (obj.scopes.length === 0) {
177
+ errors.push("scopes array cannot be empty");
178
+ } else {
179
+ for (const scope of obj.scopes) {
180
+ if (!VALID_SCOPES.includes(scope as MindsimScope)) {
181
+ errors.push(`Invalid scope: ${scope}. Valid scopes: ${VALID_SCOPES.join(", ")}`);
182
+ }
183
+ }
184
+ }
185
+
186
+ // Validate app_metadata object
187
+ if (!obj.app_metadata || typeof obj.app_metadata !== "object") {
188
+ errors.push("app_metadata must be an object");
189
+ } else {
190
+ const metadata = obj.app_metadata as Record<string, unknown>;
191
+ const metadataFields = ["app_id", "workspace_id", "mindsim_org_id"];
192
+
193
+ for (const field of metadataFields) {
194
+ if (typeof metadata[field] !== "string" || metadata[field] === "") {
195
+ errors.push(`Missing or invalid app_metadata.${field}`);
196
+ }
197
+ }
198
+
199
+ // Validate use_case
200
+ if (!VALID_USE_CASES.includes(metadata.use_case as DeveloperUseCase)) {
201
+ errors.push(
202
+ `Invalid app_metadata.use_case: ${metadata.use_case}. Valid values: ${VALID_USE_CASES.join(", ")}`,
203
+ );
204
+ }
205
+
206
+ // Gap 12 Fix: Validate UUID format for ID fields
207
+ const uuidFields = ["app_id", "workspace_id", "mindsim_org_id"];
208
+ for (const field of uuidFields) {
209
+ if (metadata[field] && !isValidUUID(metadata[field])) {
210
+ errors.push(`Invalid UUID format for app_metadata.${field}`);
211
+ }
212
+ }
213
+ }
214
+
215
+ // Gap 12 Fix: Validate client_id as UUID
216
+ if (obj.client_id && !isValidUUID(obj.client_id)) {
217
+ errors.push("client_id must be a valid UUID");
218
+ }
219
+
220
+ // Gap 8 Fix: Validate client_email as valid email format
221
+ if (obj.client_email && !isValidEmail(obj.client_email)) {
222
+ errors.push("client_email must be a valid email address");
223
+ }
224
+
225
+ // Gap 13 Fix: Validate api_base_url as HTTPS URL
226
+ if (obj.api_base_url && !isValidHttpsUrl(obj.api_base_url)) {
227
+ errors.push("api_base_url must be a valid HTTPS URL");
228
+ }
229
+
230
+ // Gap 14 Fix: Validate timestamps as ISO 8601
231
+ if (obj.created_at && !isValidISOTimestamp(obj.created_at)) {
232
+ errors.push("created_at must be a valid ISO 8601 timestamp");
233
+ }
234
+ if (obj.expires_at && !isValidISOTimestamp(obj.expires_at)) {
235
+ errors.push("expires_at must be a valid ISO 8601 timestamp");
236
+ }
237
+ if (obj.issued_at && !isValidISOTimestamp(obj.issued_at)) {
238
+ errors.push("issued_at must be a valid ISO 8601 timestamp");
239
+ }
240
+
241
+ if (errors.length > 0) {
242
+ return { valid: false, errors };
243
+ }
244
+
245
+ return { valid: true, errors: [], serviceJson: obj as unknown as MindsimServiceJson };
246
+ }
247
+
248
+ // =============================================================================
249
+ // Service JSON Loading Functions
250
+ // =============================================================================
251
+
252
+ /**
253
+ * Load service JSON from a file path.
254
+ *
255
+ * @param filePath - Path to the service JSON file
256
+ * @returns MindsimServiceJson object or null if invalid/not found
257
+ */
258
+ export function loadServiceJsonFromFile(filePath: string): MindsimServiceJson | null {
259
+ try {
260
+ if (!fs.existsSync(filePath)) {
261
+ return null;
262
+ }
263
+ const content = fs.readFileSync(filePath, "utf-8");
264
+
265
+ let json: unknown;
266
+ try {
267
+ json = JSON.parse(content);
268
+ } catch (parseError) {
269
+ getLogger().error("Failed to parse service JSON file (invalid JSON syntax):", parseError);
270
+ return null;
271
+ }
272
+
273
+ // Validate all required fields
274
+ const validation = validateServiceJson(json);
275
+ if (!validation.valid) {
276
+ getLogger().error("Invalid service JSON:", validation.errors.join("; "));
277
+ return null;
278
+ }
279
+
280
+ return validation.serviceJson as MindsimServiceJson;
281
+ } catch (error) {
282
+ // Distinguish between different error types
283
+ if (error instanceof Error && "code" in error) {
284
+ const nodeError = error as NodeJS.ErrnoException;
285
+ if (nodeError.code === "EACCES") {
286
+ getLogger().error("Permission denied reading service JSON file:", filePath);
287
+ } else if (nodeError.code === "ENOENT") {
288
+ // File doesn't exist - this is handled above, but just in case
289
+ return null;
290
+ } else {
291
+ getLogger().error(`Failed to read service JSON file (${nodeError.code}):`, error);
292
+ }
293
+ } else {
294
+ getLogger().error("Failed to load service JSON:", error);
295
+ }
296
+ return null;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Load service JSON from a base64-encoded string.
302
+ * Useful for environment variables in containerized environments.
303
+ *
304
+ * @param base64 - Base64-encoded service JSON string
305
+ * @returns MindsimServiceJson object or null if invalid
306
+ */
307
+ export function loadServiceJsonFromBase64(base64: string): MindsimServiceJson | null {
308
+ try {
309
+ if (!base64 || base64.trim() === "") {
310
+ getLogger().error("Empty base64 string provided");
311
+ return null;
312
+ }
313
+
314
+ const decoded = Buffer.from(base64, "base64").toString("utf-8");
315
+
316
+ if (!decoded || decoded.trim() === "") {
317
+ getLogger().error("Base64 decoded to empty string");
318
+ return null;
319
+ }
320
+
321
+ let json: unknown;
322
+ try {
323
+ json = JSON.parse(decoded);
324
+ } catch (parseError) {
325
+ getLogger().error("Failed to parse decoded service JSON (invalid JSON syntax):", parseError);
326
+ return null;
327
+ }
328
+
329
+ // Validate all required fields
330
+ const validation = validateServiceJson(json);
331
+ if (!validation.valid) {
332
+ getLogger().error("Invalid service JSON:", validation.errors.join("; "));
333
+ return null;
334
+ }
335
+
336
+ return validation.serviceJson as MindsimServiceJson;
337
+ } catch (error) {
338
+ getLogger().error("Failed to decode service JSON from base64:", error);
339
+ return null;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Load service JSON from environment variables.
345
+ * Priority: MINDSIM_SERVICE_JSON (file path) > MINDSIM_SERVICE_JSON_BASE64 (inline)
346
+ *
347
+ * @returns MindsimServiceJson object or null if not configured
348
+ */
349
+ export function loadServiceJson(): MindsimServiceJson | null {
350
+ // Gap 7 Fix: Check Node environment before accessing env vars
351
+ if (!isNodeEnvironment()) {
352
+ return null;
353
+ }
354
+
355
+ // Try file path first
356
+ const filePath = getEnvVar("MINDSIM_SERVICE_JSON");
357
+ if (filePath) {
358
+ const serviceJson = loadServiceJsonFromFile(filePath);
359
+ if (serviceJson) {
360
+ return serviceJson;
361
+ }
362
+ getLogger().warn(`MINDSIM_SERVICE_JSON set but file not found or invalid: ${filePath}`);
363
+ }
364
+
365
+ // Try base64 encoded
366
+ const base64 = getEnvVar("MINDSIM_SERVICE_JSON_BASE64");
367
+ if (base64) {
368
+ const serviceJson = loadServiceJsonFromBase64(base64);
369
+ if (serviceJson) {
370
+ return serviceJson;
371
+ }
372
+ getLogger().warn("MINDSIM_SERVICE_JSON_BASE64 set but failed to decode");
373
+ }
374
+
375
+ return null;
376
+ }
377
+
378
+ // =============================================================================
379
+ // API Key Loading
380
+ // =============================================================================
381
+
382
+ /**
383
+ * Load API key with priority:
384
+ * 1. Service JSON (env vars)
385
+ * 2. MINDSIM_API_KEY env var
386
+ * 3. Config file (~/.mindsim/config)
387
+ */
20
388
  export const loadApiKey = (): string | null => {
21
389
  try {
22
- if (process.env.MINDSIM_API_KEY) {
23
- return process.env.MINDSIM_API_KEY;
390
+ // Priority 1: Service JSON
391
+ const serviceJson = loadServiceJson();
392
+ if (serviceJson?.api_key) {
393
+ return serviceJson.api_key;
24
394
  }
25
395
 
26
- const configFile = getConfigFile();
396
+ // Priority 2: Direct API key env var
397
+ const envApiKey = getEnvVar("MINDSIM_API_KEY");
398
+ if (envApiKey) {
399
+ return envApiKey;
400
+ }
27
401
 
28
- if (fs.existsSync(configFile)) {
29
- const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
30
- return config.apiKey || null;
402
+ // Priority 3: Config file (only in Node environment)
403
+ if (isNodeEnvironment()) {
404
+ const configFile = getConfigFile();
405
+ if (fs.existsSync(configFile)) {
406
+ const config = JSON.parse(fs.readFileSync(configFile, "utf-8"));
407
+ return config.apiKey || null;
408
+ }
31
409
  }
32
410
  } catch (error) {
33
- // TODO: define logging interface so users can control where they are written to
34
- console.error(error);
411
+ getLogger().error("Failed to load API key:", error);
35
412
  }
36
413
  return null;
37
414
  };
38
415
 
39
416
  export const saveApiKey = (apiKey: string): void => {
417
+ // Gap 9 Fix: Check Node environment before accessing fs
418
+ if (!isNodeEnvironment()) {
419
+ throw new Error("saveApiKey is only available in Node.js environments");
420
+ }
421
+
40
422
  const configDir = getConfigDir();
41
423
  const configFile = getConfigFile();
42
424
 
@@ -48,13 +430,23 @@ export const saveApiKey = (apiKey: string): void => {
48
430
 
49
431
  export const getDeviceAuthConfig = (): DeviceAuthConfig => {
50
432
  return {
51
- deviceAuthUrl: process.env.MINDSIM_WORKOS_DEVICE_AUTH_URL || WORKOS_DEVICE_AUTH_URL,
52
- tokenUrl: process.env.MINDSIM_WORKOS_TOKEN_URL || WORKOS_TOKEN_URL,
53
- clientId: process.env.MINDSIM_WORKOS_CLIENT_ID || WORKOS_CLIENT_ID,
54
- sdkKeysApiUrl: process.env.MINDSIM_SDK_KEYS_API_URL || SDK_KEYS_API_URL,
433
+ deviceAuthUrl: getEnvVar("MINDSIM_WORKOS_DEVICE_AUTH_URL") || WORKOS_DEVICE_AUTH_URL,
434
+ tokenUrl: getEnvVar("MINDSIM_WORKOS_TOKEN_URL") || WORKOS_TOKEN_URL,
435
+ clientId: getEnvVar("MINDSIM_WORKOS_CLIENT_ID") || WORKOS_CLIENT_ID,
436
+ sdkKeysApiUrl: getEnvVar("MINDSIM_SDK_KEYS_API_URL") || SDK_KEYS_API_URL,
55
437
  };
56
438
  };
57
439
 
58
- export const getApiBaseUrl = () => {
59
- return process.env.MIND_SIM_API_BASE_URL || API_BASE_URL;
440
+ /**
441
+ * Get the API base URL.
442
+ * Priority: MINDSIM_API_BASE_URL > MIND_SIM_API_BASE_URL (deprecated) > default
443
+ * @returns API base URL
444
+ */
445
+ export const getApiBaseUrl = (): string => {
446
+ // Gap 12 Fix: Use consistent naming (MINDSIM_*) with backwards compatibility
447
+ return (
448
+ getEnvVar("MINDSIM_API_BASE_URL") ||
449
+ getEnvVar("MIND_SIM_API_BASE_URL") || // Deprecated, for backwards compatibility
450
+ API_BASE_URL
451
+ );
60
452
  };