@inneropen/marvin-sdk 2.0.0-next.13 → 2.0.0-next.15

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/dist/index.cjs CHANGED
@@ -30,19 +30,97 @@ function validateConfig(config) {
30
30
  const receivedKeys = Object.keys(config).join(", ");
31
31
  if (!config.apiUrl) {
32
32
  throw new Error(
33
- `Missing required config parameter 'apiUrl'. Received config keys: ${receivedKeys || "(none)"}. Set this to your MARVIN_API_URL environment variable value.`
33
+ `Missing required config: 'apiUrl'
34
+
35
+ The API URL is the base URL of your Marvin server.
36
+
37
+ Examples:
38
+ - Production: https://marvin.example.com
39
+ - Development: http://localhost:8080
40
+
41
+ Solutions:
42
+ 1. Set environment variable: MARVIN_API_URL=https://marvin.example.com
43
+ 2. Pass directly: createMarvinClient({ apiUrl: 'https://marvin.example.com' })
44
+
45
+ Received config keys: ${receivedKeys || "(none)"}`
34
46
  );
35
47
  }
36
48
  if (!config.siteClientToken) {
37
49
  throw new Error(
38
- `Missing required config parameter 'siteClientToken'. Received config keys: ${receivedKeys}. Set this to your MARVIN_SITE_CLIENT_TOKEN environment variable value.`
50
+ `Missing required config: 'siteClientToken'
51
+
52
+ The site client token provides read-only access to published content.
53
+
54
+ How to get it:
55
+ 1. Log into Marvin admin panel
56
+ 2. Navigate to: Settings \u2192 API Clients
57
+ 3. Copy the token for your workspace
58
+
59
+ Examples:
60
+ - site_client_abc123xyz
61
+ - sc_1234567890abcdef
62
+
63
+ Solutions:
64
+ 1. Set environment variable: MARVIN_SITE_CLIENT_TOKEN=site_client_abc123
65
+ 2. Pass directly: createMarvinClient({ siteClientToken: 'site_client_abc123' })
66
+
67
+ Received config keys: ${receivedKeys}`
39
68
  );
40
69
  }
41
70
  if (!config.workspaceSlug) {
42
71
  throw new Error(
43
- `Missing required config parameter 'workspaceSlug'. Received config keys: ${receivedKeys}. Set this to your MARVIN_WORKSPACE_SLUG environment variable value.`
72
+ `Missing required config: 'workspaceSlug'
73
+
74
+ The workspace slug identifies which workspace to access.
75
+
76
+ Examples:
77
+ - my-blog
78
+ - company-website
79
+ - acme-corp
80
+
81
+ How to find it:
82
+ 1. Check your workspace URL: https://marvin.example.com/workspace/[slug]
83
+ 2. Or find it in: Settings \u2192 General \u2192 Workspace Slug
84
+
85
+ Solutions:
86
+ 1. Set environment variable: MARVIN_WORKSPACE_SLUG=my-blog
87
+ 2. Pass directly: createMarvinClient({ workspaceSlug: 'my-blog' })
88
+
89
+ Received config keys: ${receivedKeys}`
44
90
  );
45
91
  }
92
+ if (config.apiUrl) {
93
+ try {
94
+ new URL(config.apiUrl);
95
+ } catch {
96
+ throw new Error(
97
+ `Invalid 'apiUrl' format: "${config.apiUrl}"
98
+
99
+ The API URL must be a valid HTTP or HTTPS URL.
100
+
101
+ Valid examples:
102
+ \u2713 https://marvin.example.com
103
+ \u2713 http://localhost:8080
104
+ \u2713 https://api.marvin.io
105
+
106
+ Invalid examples:
107
+ \u2717 marvin.example.com (missing protocol)
108
+ \u2717 ftp://marvin.example.com (wrong protocol)
109
+ \u2717 /api (not a full URL)`
110
+ );
111
+ }
112
+ const url = new URL(config.apiUrl);
113
+ if (!["http:", "https:"].includes(url.protocol)) {
114
+ throw new Error(
115
+ `Invalid 'apiUrl' protocol: "${url.protocol}"
116
+
117
+ Only HTTP and HTTPS protocols are supported.
118
+
119
+ Your URL: ${config.apiUrl}
120
+ Change to: ${config.apiUrl.replace(url.protocol, "https:")}`
121
+ );
122
+ }
123
+ }
46
124
  }
47
125
  function createConfigFromEnv(overrides) {
48
126
  return {
@@ -170,15 +248,46 @@ var init_HttpClient = __esm({
170
248
  credentials;
171
249
  timeout;
172
250
  debug;
251
+ retryConfig;
173
252
  logger;
174
253
  constructor(config) {
175
254
  this.baseUrl = config.baseUrl;
176
255
  this.auth = config.auth;
177
256
  this.defaultHeaders = config.defaultHeaders || {};
178
257
  this.credentials = config.credentials || "same-origin";
179
- this.timeout = config.timeout || 3e4;
258
+ const MAX_TIMEOUT = 12e4;
259
+ this.timeout = Math.min(config.timeout || 3e4, MAX_TIMEOUT);
180
260
  this.debug = config.debug ?? false;
181
261
  this.logger = config.logger ?? console;
262
+ this.retryConfig = {
263
+ maxRetries: config.retry?.maxRetries ?? 3,
264
+ initialDelay: config.retry?.initialDelay ?? 1e3,
265
+ maxDelay: config.retry?.maxDelay ?? 1e4,
266
+ retryableStatuses: config.retry?.retryableStatuses ?? [408, 429, 500, 502, 503, 504]
267
+ };
268
+ }
269
+ /**
270
+ * Validate path parameter to prevent path traversal attacks
271
+ */
272
+ validatePathParam(param, paramName = "parameter") {
273
+ if (typeof param === "number") {
274
+ return String(param);
275
+ }
276
+ if (!param || typeof param !== "string") {
277
+ throw new MarvinValidationError(`Invalid ${paramName}: must be a non-empty string or number`);
278
+ }
279
+ if (param.includes("..") || param.includes("/") || param.includes("\\")) {
280
+ throw new MarvinValidationError(
281
+ `Invalid ${paramName}: cannot contain path traversal characters (.. / \\)`
282
+ );
283
+ }
284
+ if (param.includes("\0")) {
285
+ throw new MarvinValidationError(`Invalid ${paramName}: cannot contain null bytes`);
286
+ }
287
+ if (param.length > 255) {
288
+ throw new MarvinValidationError(`Invalid ${paramName}: exceeds maximum length of 255 characters`);
289
+ }
290
+ return param;
182
291
  }
183
292
  /**
184
293
  * Build full URL from endpoint
@@ -212,9 +321,69 @@ var init_HttpClient = __esm({
212
321
  return filtered.length > 0 ? `?${filtered.join("&")}` : "";
213
322
  }
214
323
  /**
215
- * Perform HTTP request
324
+ * Sanitize data for logging by redacting sensitive fields
325
+ */
326
+ sanitizeForLogging(data) {
327
+ if (!data || typeof data !== "object") {
328
+ return data;
329
+ }
330
+ const sensitiveFields = [
331
+ "token",
332
+ "access_token",
333
+ "refresh_token",
334
+ "password",
335
+ "secret",
336
+ "apiKey",
337
+ "api_key",
338
+ "bearer",
339
+ "authorization",
340
+ "csrf_token",
341
+ "session",
342
+ "cookie"
343
+ ];
344
+ const sanitize = (obj) => {
345
+ const sanitized = {};
346
+ for (const [key, value] of Object.entries(obj)) {
347
+ const keyLower = key.toLowerCase();
348
+ const isSensitive = sensitiveFields.some((field) => keyLower.includes(field));
349
+ if (isSensitive) {
350
+ sanitized[key] = "[REDACTED]";
351
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
352
+ sanitized[key] = sanitize(value);
353
+ } else if (Array.isArray(value)) {
354
+ sanitized[key] = value.map(
355
+ (item) => item && typeof item === "object" ? sanitize(item) : item
356
+ );
357
+ } else {
358
+ sanitized[key] = value;
359
+ }
360
+ }
361
+ return sanitized;
362
+ };
363
+ if (Array.isArray(data)) {
364
+ return data.map(
365
+ (item) => item && typeof item === "object" ? sanitize(item) : item
366
+ );
367
+ }
368
+ return sanitize(data);
369
+ }
370
+ /**
371
+ * Calculate delay for retry with exponential backoff
216
372
  */
217
- async request(method, endpoint, options) {
373
+ calculateRetryDelay(attempt) {
374
+ const delay = this.retryConfig.initialDelay * Math.pow(2, attempt);
375
+ return Math.min(delay, this.retryConfig.maxDelay);
376
+ }
377
+ /**
378
+ * Sleep for specified milliseconds
379
+ */
380
+ sleep(ms) {
381
+ return new Promise((resolve) => setTimeout(resolve, ms));
382
+ }
383
+ /**
384
+ * Perform HTTP request with automatic retry on transient failures
385
+ */
386
+ async request(method, endpoint, options, retryAttempt = 0) {
218
387
  const url = this.buildUrl(endpoint) + (options?.params ? this.buildQueryString(options.params) : "");
219
388
  const isFormData = options?.body instanceof FormData;
220
389
  const headers = this.buildHeaders(options?.headers, isFormData);
@@ -250,6 +419,18 @@ var init_HttpClient = __esm({
250
419
  errorText ? `Body: ${errorText}` : ""
251
420
  );
252
421
  }
422
+ const isRetryable = this.retryConfig.retryableStatuses.includes(response.status);
423
+ const canRetry = retryAttempt < this.retryConfig.maxRetries;
424
+ if (isRetryable && canRetry) {
425
+ const delay = this.calculateRetryDelay(retryAttempt);
426
+ if (this.debug) {
427
+ this.logger.warn(
428
+ `[Marvin] Retry ${retryAttempt + 1}/${this.retryConfig.maxRetries} after ${delay}ms (status: ${response.status})`
429
+ );
430
+ }
431
+ await this.sleep(delay);
432
+ return this.request(method, endpoint, options, retryAttempt + 1);
433
+ }
253
434
  if (response.status === 404) {
254
435
  throw new MarvinNotFoundError(
255
436
  `Resource not found: ${endpoint}`,
@@ -282,8 +463,8 @@ var init_HttpClient = __esm({
282
463
  }
283
464
  const data = await response.json();
284
465
  if (this.debug) {
285
- const preview = typeof data === "object" && data !== null ? Array.isArray(data) ? `Array(${data.length})` : `Object with keys: ${Object.keys(data).join(", ")}` : String(data);
286
- this.logger.log(`[Marvin] Response: ${preview}`);
466
+ const sanitized = this.sanitizeForLogging(data);
467
+ this.logger.log("[Marvin] Response:", JSON.stringify(sanitized, null, 2));
287
468
  }
288
469
  return data;
289
470
  } catch (error) {
@@ -292,6 +473,17 @@ var init_HttpClient = __esm({
292
473
  throw error;
293
474
  }
294
475
  if (error instanceof Error) {
476
+ const canRetry = retryAttempt < this.retryConfig.maxRetries;
477
+ if (canRetry && error.name !== "AbortError") {
478
+ const delay = this.calculateRetryDelay(retryAttempt);
479
+ if (this.debug) {
480
+ this.logger.warn(
481
+ `[Marvin] Retry ${retryAttempt + 1}/${this.retryConfig.maxRetries} after ${delay}ms (network error)`
482
+ );
483
+ }
484
+ await this.sleep(delay);
485
+ return this.request(method, endpoint, options, retryAttempt + 1);
486
+ }
295
487
  if (error.name === "AbortError") {
296
488
  throw new MarvinNetworkError(
297
489
  `Request timeout after ${this.timeout}ms`,
@@ -372,10 +564,27 @@ var init_AuthStrategy = __esm({
372
564
  }
373
565
  sessionToken;
374
566
  onUnauthorized;
567
+ csrfToken;
568
+ /**
569
+ * Set CSRF token for cross-site request forgery protection
570
+ * The token should be obtained from the server after login
571
+ */
572
+ setCsrfToken(token) {
573
+ this.csrfToken = token;
574
+ }
575
+ /**
576
+ * Get current CSRF token
577
+ */
578
+ getCsrfToken() {
579
+ return this.csrfToken;
580
+ }
375
581
  injectAuth(headers) {
376
582
  if (this.sessionToken) {
377
583
  headers.set("Authorization", `Bearer ${this.sessionToken}`);
378
584
  }
585
+ if (this.csrfToken) {
586
+ headers.set("X-CSRF-Token", this.csrfToken);
587
+ }
379
588
  }
380
589
  async handleUnauthorized() {
381
590
  if (this.onUnauthorized) {
@@ -383,7 +592,9 @@ var init_AuthStrategy = __esm({
383
592
  }
384
593
  }
385
594
  getDescription() {
386
- return this.sessionToken ? "Session authentication (explicit token)" : "Session authentication (cookies)";
595
+ const auth = this.sessionToken ? "Session authentication (explicit token)" : "Session authentication (cookies)";
596
+ const csrf = this.csrfToken ? " with CSRF protection" : "";
597
+ return auth + csrf;
387
598
  }
388
599
  };
389
600
  NoAuth = class {
@@ -443,11 +654,17 @@ var init_cache = __esm({
443
654
  "src/core/cache.ts"() {
444
655
  "use strict";
445
656
  MarvinCache = class {
446
- constructor(duration = 5 * 60 * 1e3) {
657
+ constructor(duration = 5 * 60 * 1e3, maxEntries = 1e3) {
447
658
  this.duration = duration;
659
+ this.maxEntries = maxEntries;
448
660
  }
449
661
  duration;
662
+ maxEntries;
450
663
  cache = /* @__PURE__ */ new Map();
664
+ /**
665
+ * Get cached data by key
666
+ * Returns undefined if not found or expired
667
+ */
451
668
  get(key) {
452
669
  const entry = this.cache.get(key);
453
670
  if (!entry) return void 0;
@@ -458,25 +675,149 @@ var init_cache = __esm({
458
675
  }
459
676
  return entry.data;
460
677
  }
461
- set(key, data) {
678
+ /**
679
+ * Set cached data with optional tags for invalidation
680
+ */
681
+ set(key, data, tags) {
682
+ if (this.cache.size >= this.maxEntries) {
683
+ const oldestKey = this.cache.keys().next().value;
684
+ if (oldestKey) {
685
+ this.cache.delete(oldestKey);
686
+ }
687
+ }
462
688
  this.cache.set(key, {
463
689
  data,
464
- timestamp: Date.now()
690
+ timestamp: Date.now(),
691
+ tags
465
692
  });
466
693
  }
694
+ /**
695
+ * Check if key exists and is not expired
696
+ */
467
697
  has(key) {
468
698
  return this.get(key) !== void 0;
469
699
  }
700
+ /**
701
+ * Clear all cached entries
702
+ */
470
703
  clear() {
471
704
  this.cache.clear();
472
705
  }
706
+ /**
707
+ * Delete a specific cache entry
708
+ */
473
709
  delete(key) {
474
710
  this.cache.delete(key);
475
711
  }
712
+ /**
713
+ * Invalidate all cache entries with a specific tag
714
+ * Useful for invalidating related data after mutations
715
+ *
716
+ * @example
717
+ * ```typescript
718
+ * // Cache entries with tags
719
+ * cache.set('entry:1', data, ['entries', 'entry:1']);
720
+ * cache.set('entries:list', data, ['entries']);
721
+ *
722
+ * // Invalidate all entries
723
+ * cache.invalidateByTag('entries');
724
+ * ```
725
+ */
726
+ invalidateByTag(tag) {
727
+ let count = 0;
728
+ for (const [key, entry] of this.cache.entries()) {
729
+ if (entry.tags?.includes(tag)) {
730
+ this.cache.delete(key);
731
+ count++;
732
+ }
733
+ }
734
+ return count;
735
+ }
736
+ /**
737
+ * Get cache statistics
738
+ */
739
+ getStats() {
740
+ let oldest = null;
741
+ let newest = null;
742
+ for (const entry of this.cache.values()) {
743
+ if (oldest === null || entry.timestamp < oldest) {
744
+ oldest = entry.timestamp;
745
+ }
746
+ if (newest === null || entry.timestamp > newest) {
747
+ newest = entry.timestamp;
748
+ }
749
+ }
750
+ return {
751
+ size: this.cache.size,
752
+ maxEntries: this.maxEntries,
753
+ oldestEntry: oldest,
754
+ newestEntry: newest
755
+ };
756
+ }
757
+ /**
758
+ * Remove expired entries (garbage collection)
759
+ * Returns number of entries removed
760
+ */
761
+ prune() {
762
+ let count = 0;
763
+ const now = Date.now();
764
+ for (const [key, entry] of this.cache.entries()) {
765
+ const age = now - entry.timestamp;
766
+ if (age > this.duration) {
767
+ this.cache.delete(key);
768
+ count++;
769
+ }
770
+ }
771
+ return count;
772
+ }
476
773
  };
477
774
  }
478
775
  });
479
776
 
777
+ // src/core/validation.ts
778
+ function validateEmail(email, fieldName = "Email") {
779
+ if (!email || typeof email !== "string") {
780
+ throw new MarvinValidationError(`${fieldName} is required and must be a string`);
781
+ }
782
+ const trimmed = email.trim();
783
+ if (trimmed.length === 0) {
784
+ throw new MarvinValidationError(`${fieldName} cannot be empty`);
785
+ }
786
+ if (trimmed.length < 5) {
787
+ throw new MarvinValidationError(`${fieldName} is too short`);
788
+ }
789
+ if (trimmed.length > 254) {
790
+ throw new MarvinValidationError(`${fieldName} exceeds maximum length of 254 characters`);
791
+ }
792
+ if (!EMAIL_REGEX.test(trimmed)) {
793
+ throw new MarvinValidationError(
794
+ `${fieldName} format is invalid. Expected format: user@example.com`
795
+ );
796
+ }
797
+ const [localPart, domain] = trimmed.split("@");
798
+ if (localPart.length > 64) {
799
+ throw new MarvinValidationError(`${fieldName} local part exceeds 64 characters`);
800
+ }
801
+ if (domain.length > 253) {
802
+ throw new MarvinValidationError(`${fieldName} domain exceeds 253 characters`);
803
+ }
804
+ if (trimmed.includes("..")) {
805
+ throw new MarvinValidationError(`${fieldName} cannot contain consecutive dots`);
806
+ }
807
+ if (localPart.startsWith(".") || localPart.endsWith(".")) {
808
+ throw new MarvinValidationError(`${fieldName} local part cannot start or end with a dot`);
809
+ }
810
+ return trimmed;
811
+ }
812
+ var EMAIL_REGEX;
813
+ var init_validation = __esm({
814
+ "src/core/validation.ts"() {
815
+ "use strict";
816
+ init_errors();
817
+ EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
818
+ }
819
+ });
820
+
480
821
  // src/core/index.ts
481
822
  var init_core = __esm({
482
823
  "src/core/index.ts"() {
@@ -486,6 +827,7 @@ var init_core = __esm({
486
827
  init_errors();
487
828
  init_pagination();
488
829
  init_cache();
830
+ init_validation();
489
831
  }
490
832
  });
491
833
 
@@ -1373,12 +1715,14 @@ var AuthClient = class extends HttpClient {
1373
1715
  * Returns the created user object
1374
1716
  */
1375
1717
  async register(data) {
1718
+ validateEmail(data.email, "Email");
1376
1719
  return this.post("/api/users/register", data);
1377
1720
  }
1378
1721
  /**
1379
1722
  * Request password reset (sends email with token)
1380
1723
  */
1381
1724
  async forgotPassword(data) {
1725
+ validateEmail(data.email, "Email");
1382
1726
  return this.post("/api/users/forgot-password", data);
1383
1727
  }
1384
1728
  /**