@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.
@@ -127,15 +127,46 @@ var init_HttpClient = __esm({
127
127
  credentials;
128
128
  timeout;
129
129
  debug;
130
+ retryConfig;
130
131
  logger;
131
132
  constructor(config) {
132
133
  this.baseUrl = config.baseUrl;
133
134
  this.auth = config.auth;
134
135
  this.defaultHeaders = config.defaultHeaders || {};
135
136
  this.credentials = config.credentials || "same-origin";
136
- this.timeout = config.timeout || 3e4;
137
+ const MAX_TIMEOUT = 12e4;
138
+ this.timeout = Math.min(config.timeout || 3e4, MAX_TIMEOUT);
137
139
  this.debug = config.debug ?? false;
138
140
  this.logger = config.logger ?? console;
141
+ this.retryConfig = {
142
+ maxRetries: config.retry?.maxRetries ?? 3,
143
+ initialDelay: config.retry?.initialDelay ?? 1e3,
144
+ maxDelay: config.retry?.maxDelay ?? 1e4,
145
+ retryableStatuses: config.retry?.retryableStatuses ?? [408, 429, 500, 502, 503, 504]
146
+ };
147
+ }
148
+ /**
149
+ * Validate path parameter to prevent path traversal attacks
150
+ */
151
+ validatePathParam(param, paramName = "parameter") {
152
+ if (typeof param === "number") {
153
+ return String(param);
154
+ }
155
+ if (!param || typeof param !== "string") {
156
+ throw new MarvinValidationError(`Invalid ${paramName}: must be a non-empty string or number`);
157
+ }
158
+ if (param.includes("..") || param.includes("/") || param.includes("\\")) {
159
+ throw new MarvinValidationError(
160
+ `Invalid ${paramName}: cannot contain path traversal characters (.. / \\)`
161
+ );
162
+ }
163
+ if (param.includes("\0")) {
164
+ throw new MarvinValidationError(`Invalid ${paramName}: cannot contain null bytes`);
165
+ }
166
+ if (param.length > 255) {
167
+ throw new MarvinValidationError(`Invalid ${paramName}: exceeds maximum length of 255 characters`);
168
+ }
169
+ return param;
139
170
  }
140
171
  /**
141
172
  * Build full URL from endpoint
@@ -169,9 +200,69 @@ var init_HttpClient = __esm({
169
200
  return filtered.length > 0 ? `?${filtered.join("&")}` : "";
170
201
  }
171
202
  /**
172
- * Perform HTTP request
203
+ * Sanitize data for logging by redacting sensitive fields
204
+ */
205
+ sanitizeForLogging(data) {
206
+ if (!data || typeof data !== "object") {
207
+ return data;
208
+ }
209
+ const sensitiveFields = [
210
+ "token",
211
+ "access_token",
212
+ "refresh_token",
213
+ "password",
214
+ "secret",
215
+ "apiKey",
216
+ "api_key",
217
+ "bearer",
218
+ "authorization",
219
+ "csrf_token",
220
+ "session",
221
+ "cookie"
222
+ ];
223
+ const sanitize = (obj) => {
224
+ const sanitized = {};
225
+ for (const [key, value] of Object.entries(obj)) {
226
+ const keyLower = key.toLowerCase();
227
+ const isSensitive = sensitiveFields.some((field) => keyLower.includes(field));
228
+ if (isSensitive) {
229
+ sanitized[key] = "[REDACTED]";
230
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
231
+ sanitized[key] = sanitize(value);
232
+ } else if (Array.isArray(value)) {
233
+ sanitized[key] = value.map(
234
+ (item) => item && typeof item === "object" ? sanitize(item) : item
235
+ );
236
+ } else {
237
+ sanitized[key] = value;
238
+ }
239
+ }
240
+ return sanitized;
241
+ };
242
+ if (Array.isArray(data)) {
243
+ return data.map(
244
+ (item) => item && typeof item === "object" ? sanitize(item) : item
245
+ );
246
+ }
247
+ return sanitize(data);
248
+ }
249
+ /**
250
+ * Calculate delay for retry with exponential backoff
251
+ */
252
+ calculateRetryDelay(attempt) {
253
+ const delay = this.retryConfig.initialDelay * Math.pow(2, attempt);
254
+ return Math.min(delay, this.retryConfig.maxDelay);
255
+ }
256
+ /**
257
+ * Sleep for specified milliseconds
173
258
  */
174
- async request(method, endpoint, options) {
259
+ sleep(ms) {
260
+ return new Promise((resolve) => setTimeout(resolve, ms));
261
+ }
262
+ /**
263
+ * Perform HTTP request with automatic retry on transient failures
264
+ */
265
+ async request(method, endpoint, options, retryAttempt = 0) {
175
266
  const url = this.buildUrl(endpoint) + (options?.params ? this.buildQueryString(options.params) : "");
176
267
  const isFormData = options?.body instanceof FormData;
177
268
  const headers = this.buildHeaders(options?.headers, isFormData);
@@ -207,6 +298,18 @@ var init_HttpClient = __esm({
207
298
  errorText ? `Body: ${errorText}` : ""
208
299
  );
209
300
  }
301
+ const isRetryable = this.retryConfig.retryableStatuses.includes(response.status);
302
+ const canRetry = retryAttempt < this.retryConfig.maxRetries;
303
+ if (isRetryable && canRetry) {
304
+ const delay = this.calculateRetryDelay(retryAttempt);
305
+ if (this.debug) {
306
+ this.logger.warn(
307
+ `[Marvin] Retry ${retryAttempt + 1}/${this.retryConfig.maxRetries} after ${delay}ms (status: ${response.status})`
308
+ );
309
+ }
310
+ await this.sleep(delay);
311
+ return this.request(method, endpoint, options, retryAttempt + 1);
312
+ }
210
313
  if (response.status === 404) {
211
314
  throw new MarvinNotFoundError(
212
315
  `Resource not found: ${endpoint}`,
@@ -239,8 +342,8 @@ var init_HttpClient = __esm({
239
342
  }
240
343
  const data = await response.json();
241
344
  if (this.debug) {
242
- const preview = typeof data === "object" && data !== null ? Array.isArray(data) ? `Array(${data.length})` : `Object with keys: ${Object.keys(data).join(", ")}` : String(data);
243
- this.logger.log(`[Marvin] Response: ${preview}`);
345
+ const sanitized = this.sanitizeForLogging(data);
346
+ this.logger.log("[Marvin] Response:", JSON.stringify(sanitized, null, 2));
244
347
  }
245
348
  return data;
246
349
  } catch (error) {
@@ -249,6 +352,17 @@ var init_HttpClient = __esm({
249
352
  throw error;
250
353
  }
251
354
  if (error instanceof Error) {
355
+ const canRetry = retryAttempt < this.retryConfig.maxRetries;
356
+ if (canRetry && error.name !== "AbortError") {
357
+ const delay = this.calculateRetryDelay(retryAttempt);
358
+ if (this.debug) {
359
+ this.logger.warn(
360
+ `[Marvin] Retry ${retryAttempt + 1}/${this.retryConfig.maxRetries} after ${delay}ms (network error)`
361
+ );
362
+ }
363
+ await this.sleep(delay);
364
+ return this.request(method, endpoint, options, retryAttempt + 1);
365
+ }
252
366
  if (error.name === "AbortError") {
253
367
  throw new MarvinNetworkError(
254
368
  `Request timeout after ${this.timeout}ms`,
@@ -329,10 +443,27 @@ var init_AuthStrategy = __esm({
329
443
  }
330
444
  sessionToken;
331
445
  onUnauthorized;
446
+ csrfToken;
447
+ /**
448
+ * Set CSRF token for cross-site request forgery protection
449
+ * The token should be obtained from the server after login
450
+ */
451
+ setCsrfToken(token) {
452
+ this.csrfToken = token;
453
+ }
454
+ /**
455
+ * Get current CSRF token
456
+ */
457
+ getCsrfToken() {
458
+ return this.csrfToken;
459
+ }
332
460
  injectAuth(headers) {
333
461
  if (this.sessionToken) {
334
462
  headers.set("Authorization", `Bearer ${this.sessionToken}`);
335
463
  }
464
+ if (this.csrfToken) {
465
+ headers.set("X-CSRF-Token", this.csrfToken);
466
+ }
336
467
  }
337
468
  async handleUnauthorized() {
338
469
  if (this.onUnauthorized) {
@@ -340,7 +471,9 @@ var init_AuthStrategy = __esm({
340
471
  }
341
472
  }
342
473
  getDescription() {
343
- return this.sessionToken ? "Session authentication (explicit token)" : "Session authentication (cookies)";
474
+ const auth = this.sessionToken ? "Session authentication (explicit token)" : "Session authentication (cookies)";
475
+ const csrf = this.csrfToken ? " with CSRF protection" : "";
476
+ return auth + csrf;
344
477
  }
345
478
  };
346
479
  NoAuth = class {
@@ -400,11 +533,17 @@ var init_cache = __esm({
400
533
  "src/core/cache.ts"() {
401
534
  "use strict";
402
535
  MarvinCache = class {
403
- constructor(duration = 5 * 60 * 1e3) {
536
+ constructor(duration = 5 * 60 * 1e3, maxEntries = 1e3) {
404
537
  this.duration = duration;
538
+ this.maxEntries = maxEntries;
405
539
  }
406
540
  duration;
541
+ maxEntries;
407
542
  cache = /* @__PURE__ */ new Map();
543
+ /**
544
+ * Get cached data by key
545
+ * Returns undefined if not found or expired
546
+ */
408
547
  get(key) {
409
548
  const entry = this.cache.get(key);
410
549
  if (!entry) return void 0;
@@ -415,25 +554,149 @@ var init_cache = __esm({
415
554
  }
416
555
  return entry.data;
417
556
  }
418
- set(key, data) {
557
+ /**
558
+ * Set cached data with optional tags for invalidation
559
+ */
560
+ set(key, data, tags) {
561
+ if (this.cache.size >= this.maxEntries) {
562
+ const oldestKey = this.cache.keys().next().value;
563
+ if (oldestKey) {
564
+ this.cache.delete(oldestKey);
565
+ }
566
+ }
419
567
  this.cache.set(key, {
420
568
  data,
421
- timestamp: Date.now()
569
+ timestamp: Date.now(),
570
+ tags
422
571
  });
423
572
  }
573
+ /**
574
+ * Check if key exists and is not expired
575
+ */
424
576
  has(key) {
425
577
  return this.get(key) !== void 0;
426
578
  }
579
+ /**
580
+ * Clear all cached entries
581
+ */
427
582
  clear() {
428
583
  this.cache.clear();
429
584
  }
585
+ /**
586
+ * Delete a specific cache entry
587
+ */
430
588
  delete(key) {
431
589
  this.cache.delete(key);
432
590
  }
591
+ /**
592
+ * Invalidate all cache entries with a specific tag
593
+ * Useful for invalidating related data after mutations
594
+ *
595
+ * @example
596
+ * ```typescript
597
+ * // Cache entries with tags
598
+ * cache.set('entry:1', data, ['entries', 'entry:1']);
599
+ * cache.set('entries:list', data, ['entries']);
600
+ *
601
+ * // Invalidate all entries
602
+ * cache.invalidateByTag('entries');
603
+ * ```
604
+ */
605
+ invalidateByTag(tag) {
606
+ let count = 0;
607
+ for (const [key, entry] of this.cache.entries()) {
608
+ if (entry.tags?.includes(tag)) {
609
+ this.cache.delete(key);
610
+ count++;
611
+ }
612
+ }
613
+ return count;
614
+ }
615
+ /**
616
+ * Get cache statistics
617
+ */
618
+ getStats() {
619
+ let oldest = null;
620
+ let newest = null;
621
+ for (const entry of this.cache.values()) {
622
+ if (oldest === null || entry.timestamp < oldest) {
623
+ oldest = entry.timestamp;
624
+ }
625
+ if (newest === null || entry.timestamp > newest) {
626
+ newest = entry.timestamp;
627
+ }
628
+ }
629
+ return {
630
+ size: this.cache.size,
631
+ maxEntries: this.maxEntries,
632
+ oldestEntry: oldest,
633
+ newestEntry: newest
634
+ };
635
+ }
636
+ /**
637
+ * Remove expired entries (garbage collection)
638
+ * Returns number of entries removed
639
+ */
640
+ prune() {
641
+ let count = 0;
642
+ const now = Date.now();
643
+ for (const [key, entry] of this.cache.entries()) {
644
+ const age = now - entry.timestamp;
645
+ if (age > this.duration) {
646
+ this.cache.delete(key);
647
+ count++;
648
+ }
649
+ }
650
+ return count;
651
+ }
433
652
  };
434
653
  }
435
654
  });
436
655
 
656
+ // src/core/validation.ts
657
+ function validateEmail(email, fieldName = "Email") {
658
+ if (!email || typeof email !== "string") {
659
+ throw new MarvinValidationError(`${fieldName} is required and must be a string`);
660
+ }
661
+ const trimmed = email.trim();
662
+ if (trimmed.length === 0) {
663
+ throw new MarvinValidationError(`${fieldName} cannot be empty`);
664
+ }
665
+ if (trimmed.length < 5) {
666
+ throw new MarvinValidationError(`${fieldName} is too short`);
667
+ }
668
+ if (trimmed.length > 254) {
669
+ throw new MarvinValidationError(`${fieldName} exceeds maximum length of 254 characters`);
670
+ }
671
+ if (!EMAIL_REGEX.test(trimmed)) {
672
+ throw new MarvinValidationError(
673
+ `${fieldName} format is invalid. Expected format: user@example.com`
674
+ );
675
+ }
676
+ const [localPart, domain] = trimmed.split("@");
677
+ if (localPart.length > 64) {
678
+ throw new MarvinValidationError(`${fieldName} local part exceeds 64 characters`);
679
+ }
680
+ if (domain.length > 253) {
681
+ throw new MarvinValidationError(`${fieldName} domain exceeds 253 characters`);
682
+ }
683
+ if (trimmed.includes("..")) {
684
+ throw new MarvinValidationError(`${fieldName} cannot contain consecutive dots`);
685
+ }
686
+ if (localPart.startsWith(".") || localPart.endsWith(".")) {
687
+ throw new MarvinValidationError(`${fieldName} local part cannot start or end with a dot`);
688
+ }
689
+ return trimmed;
690
+ }
691
+ var EMAIL_REGEX;
692
+ var init_validation = __esm({
693
+ "src/core/validation.ts"() {
694
+ "use strict";
695
+ init_errors();
696
+ 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])?)*$/;
697
+ }
698
+ });
699
+
437
700
  // src/core/index.ts
438
701
  var init_core = __esm({
439
702
  "src/core/index.ts"() {
@@ -443,6 +706,7 @@ var init_core = __esm({
443
706
  init_errors();
444
707
  init_pagination();
445
708
  init_cache();
709
+ init_validation();
446
710
  }
447
711
  });
448
712
 
@@ -469,5 +733,6 @@ export {
469
733
  pageToOffset,
470
734
  offsetToPage,
471
735
  MarvinCache,
736
+ validateEmail,
472
737
  init_core
473
738
  };