@inneropen/marvin-sdk 2.0.0-next.14 → 2.0.0-next.16

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,7 +342,8 @@ var init_HttpClient = __esm({
239
342
  }
240
343
  const data = await response.json();
241
344
  if (this.debug) {
242
- this.logger.log("[Marvin] Response:", JSON.stringify(data, null, 2));
345
+ const sanitized = this.sanitizeForLogging(data);
346
+ this.logger.log("[Marvin] Response:", JSON.stringify(sanitized, null, 2));
243
347
  }
244
348
  return data;
245
349
  } catch (error) {
@@ -248,6 +352,17 @@ var init_HttpClient = __esm({
248
352
  throw error;
249
353
  }
250
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
+ }
251
366
  if (error.name === "AbortError") {
252
367
  throw new MarvinNetworkError(
253
368
  `Request timeout after ${this.timeout}ms`,
@@ -328,10 +443,27 @@ var init_AuthStrategy = __esm({
328
443
  }
329
444
  sessionToken;
330
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
+ }
331
460
  injectAuth(headers) {
332
461
  if (this.sessionToken) {
333
462
  headers.set("Authorization", `Bearer ${this.sessionToken}`);
334
463
  }
464
+ if (this.csrfToken) {
465
+ headers.set("X-CSRF-Token", this.csrfToken);
466
+ }
335
467
  }
336
468
  async handleUnauthorized() {
337
469
  if (this.onUnauthorized) {
@@ -339,7 +471,9 @@ var init_AuthStrategy = __esm({
339
471
  }
340
472
  }
341
473
  getDescription() {
342
- 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;
343
477
  }
344
478
  };
345
479
  NoAuth = class {
@@ -399,11 +533,17 @@ var init_cache = __esm({
399
533
  "src/core/cache.ts"() {
400
534
  "use strict";
401
535
  MarvinCache = class {
402
- constructor(duration = 5 * 60 * 1e3) {
536
+ constructor(duration = 5 * 60 * 1e3, maxEntries = 1e3) {
403
537
  this.duration = duration;
538
+ this.maxEntries = maxEntries;
404
539
  }
405
540
  duration;
541
+ maxEntries;
406
542
  cache = /* @__PURE__ */ new Map();
543
+ /**
544
+ * Get cached data by key
545
+ * Returns undefined if not found or expired
546
+ */
407
547
  get(key) {
408
548
  const entry = this.cache.get(key);
409
549
  if (!entry) return void 0;
@@ -414,25 +554,149 @@ var init_cache = __esm({
414
554
  }
415
555
  return entry.data;
416
556
  }
417
- 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
+ }
418
567
  this.cache.set(key, {
419
568
  data,
420
- timestamp: Date.now()
569
+ timestamp: Date.now(),
570
+ tags
421
571
  });
422
572
  }
573
+ /**
574
+ * Check if key exists and is not expired
575
+ */
423
576
  has(key) {
424
577
  return this.get(key) !== void 0;
425
578
  }
579
+ /**
580
+ * Clear all cached entries
581
+ */
426
582
  clear() {
427
583
  this.cache.clear();
428
584
  }
585
+ /**
586
+ * Delete a specific cache entry
587
+ */
429
588
  delete(key) {
430
589
  this.cache.delete(key);
431
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
+ }
432
652
  };
433
653
  }
434
654
  });
435
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
+
436
700
  // src/core/index.ts
437
701
  var init_core = __esm({
438
702
  "src/core/index.ts"() {
@@ -442,6 +706,7 @@ var init_core = __esm({
442
706
  init_errors();
443
707
  init_pagination();
444
708
  init_cache();
709
+ init_validation();
445
710
  }
446
711
  });
447
712
 
@@ -468,5 +733,6 @@ export {
468
733
  pageToOffset,
469
734
  offsetToPage,
470
735
  MarvinCache,
736
+ validateEmail,
471
737
  init_core
472
738
  };