@sparkvault/sdk 1.23.8 → 1.24.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.
@@ -375,6 +375,153 @@ class HttpClient {
375
375
  }
376
376
  }
377
377
 
378
+ /**
379
+ * SparkVault API Base
380
+ *
381
+ * Shared lifecycle and HTTP plumbing for every internal API client in the
382
+ * SDK. Concrete clients (IdentityApi, UploadApi, ...) extend this and
383
+ * provide:
384
+ * - their own error factory via makeError()
385
+ * - their own typed endpoint methods that call rawRequest()
386
+ *
387
+ * Session lifecycle invariant: every user-facing session (a modal open, a
388
+ * verify flow, an upload widget invocation) starts with the owning Module
389
+ * calling resetSession(). close() aborts the current AbortController; the
390
+ * next resetSession() swaps in a fresh one. The Module is responsible for
391
+ * this; clients of the SDK never see any of it.
392
+ */
393
+ /** Default request timeout in milliseconds (30 seconds). */
394
+ const DEFAULT_TIMEOUT_MS = 30000;
395
+ class SparkVaultApi {
396
+ constructor(timeoutMs = DEFAULT_TIMEOUT_MS) {
397
+ /**
398
+ * Abort controller for the current session. Replaced by resetSession()
399
+ * at the start of every user-facing session — an aborted controller can
400
+ * never leak into the next session.
401
+ */
402
+ this.closeController = new AbortController();
403
+ this.timeoutMs = timeoutMs;
404
+ }
405
+ /** Abort all pending requests on this client. Called by the Module on close. */
406
+ abort() {
407
+ this.closeController.abort();
408
+ }
409
+ /** Whether the *current* session has been aborted. */
410
+ isAborted() {
411
+ return this.closeController.signal.aborted;
412
+ }
413
+ /** The current session's abort signal — for callers that need to wire it into XHR/etc. */
414
+ getAbortSignal() {
415
+ return this.closeController.signal;
416
+ }
417
+ /**
418
+ * Swap in a fresh AbortController. The Module calls this at the start of
419
+ * every user-facing session. Long-lived per-client state (e.g. config
420
+ * caches) is intentionally NOT cleared here.
421
+ */
422
+ resetSession() {
423
+ this.closeController = new AbortController();
424
+ }
425
+ /**
426
+ * Issue an HTTP request with timeout + session abort + standard error
427
+ * mapping. Subclasses build URLs and unwrap response envelopes on top.
428
+ *
429
+ * Returns the parsed JSON body (whatever shape it takes) plus the
430
+ * status code. Throws subclass-typed errors via makeError() for: aborted
431
+ * sessions, timeouts, network failures, and non-2xx responses.
432
+ */
433
+ async rawRequest(url, options) {
434
+ // Capture the current controller locally. If resetSession() swaps the
435
+ // field while this request is in flight (or in its catch block), the
436
+ // aborted-vs-timeout discrimination below stays accurate.
437
+ const localCloseController = this.closeController;
438
+ if (localCloseController.signal.aborted) {
439
+ throw this.makeError('Request cancelled', 'cancelled', 0);
440
+ }
441
+ const timeoutController = new AbortController();
442
+ const timeoutId = setTimeout(() => timeoutController.abort(), this.timeoutMs);
443
+ let combinedSignal;
444
+ let onCloseAbort = null;
445
+ if ('any' in AbortSignal) {
446
+ combinedSignal = AbortSignal.any([
447
+ timeoutController.signal,
448
+ localCloseController.signal,
449
+ ]);
450
+ }
451
+ else {
452
+ // Fallback for browsers without AbortSignal.any: link the close
453
+ // signal into the timeout controller manually. Listener is removed
454
+ // in the finally block so it doesn't leak across requests.
455
+ combinedSignal = timeoutController.signal;
456
+ onCloseAbort = () => timeoutController.abort();
457
+ localCloseController.signal.addEventListener('abort', onCloseAbort);
458
+ }
459
+ try {
460
+ // Only send Content-Type when there's actually a body — otherwise GETs
461
+ // with no payload would carry a misleading content-type header.
462
+ const headers = { 'Accept': 'application/json' };
463
+ if (options.body !== undefined)
464
+ headers['Content-Type'] = 'application/json';
465
+ if (options.headers)
466
+ Object.assign(headers, options.headers);
467
+ const response = await fetch(url, {
468
+ method: options.method,
469
+ headers,
470
+ body: options.body,
471
+ signal: combinedSignal,
472
+ });
473
+ const json = await response.json().catch(() => null);
474
+ if (!response.ok) {
475
+ const { message, code } = extractApiError(json);
476
+ throw this.makeError(message, code, response.status);
477
+ }
478
+ return { data: json, status: response.status };
479
+ }
480
+ catch (error) {
481
+ // Re-throw SparkVault errors as-is — they already carry the right code/status.
482
+ if (error instanceof SparkVaultError)
483
+ throw error;
484
+ // Distinguish session-close (cancelled) from timeout. Use the LOCAL
485
+ // controller captured at request start, not `this.closeController`.
486
+ if (error != null && typeof error === 'object' && 'name' in error && error.name === 'AbortError') {
487
+ if (localCloseController.signal.aborted) {
488
+ throw this.makeError('Request cancelled', 'cancelled', 0);
489
+ }
490
+ throw this.makeError(`Request timed out after ${this.timeoutMs}ms`, 'timeout', 0);
491
+ }
492
+ // Browser-level network failure: TypeError("Failed to fetch")
493
+ // (Chrome/Firefox) or TypeError("Load failed") (WebKit).
494
+ if (error instanceof TypeError) {
495
+ throw this.makeError('Unable to connect. Please check your connection and try again.', 'network_error', 0);
496
+ }
497
+ throw this.makeError(error instanceof Error ? error.message : 'An unexpected error occurred', 'unknown_error', 0);
498
+ }
499
+ finally {
500
+ clearTimeout(timeoutId);
501
+ if (onCloseAbort) {
502
+ localCloseController.signal.removeEventListener('abort', onCloseAbort);
503
+ }
504
+ }
505
+ }
506
+ }
507
+ /**
508
+ * Extract { message, code } from an API error body. The API wraps errors as
509
+ * `{ error: { code, message, details }, meta: {...} }`, but legacy/edge
510
+ * paths may flatten or use `{ error: 'string' }` instead.
511
+ */
512
+ function extractApiError(json) {
513
+ const root = (typeof json === 'object' && json !== null) ? json : {};
514
+ const errorObj = typeof root.error === 'object' && root.error !== null
515
+ ? root.error
516
+ : root;
517
+ const message = (typeof errorObj.message === 'string' ? errorObj.message : null) ??
518
+ (typeof errorObj.error === 'string' ? errorObj.error : null) ??
519
+ (typeof root.message === 'string' ? root.message : null) ??
520
+ 'Request failed';
521
+ const code = typeof errorObj.code === 'string' ? errorObj.code : 'api_error';
522
+ return { message, code };
523
+ }
524
+
378
525
  /**
379
526
  * CooldownTimer - Reusable countdown utility for resend buttons and rate limiting
380
527
  *
@@ -633,109 +780,30 @@ function generateState() {
633
780
  * Handles all HTTP communication with the Identity App endpoints.
634
781
  * Single responsibility: API calls only.
635
782
  */
636
- /** Default request timeout in milliseconds (30 seconds) */
637
- const DEFAULT_TIMEOUT_MS$1 = 30000;
638
- class IdentityApi {
639
- constructor(config, timeoutMs = DEFAULT_TIMEOUT_MS$1) {
783
+ class IdentityApi extends SparkVaultApi {
784
+ constructor(config, timeoutMs) {
785
+ super(timeoutMs);
640
786
  /** Cached config promise - allows preloading and deduplication */
641
787
  this.configCache = null;
642
- /** Abort controller for cancelling all pending requests on close */
643
- this.closeController = new AbortController();
644
788
  this.config = config;
645
- this.timeoutMs = timeoutMs;
646
- }
647
- /**
648
- * Abort all pending requests.
649
- * Call this when the renderer is closed to prevent orphaned requests.
650
- */
651
- abort() {
652
- this.closeController.abort();
653
- }
654
- /**
655
- * Check if the API has been aborted.
656
- */
657
- isAborted() {
658
- return this.closeController.signal.aborted;
659
789
  }
660
790
  get baseUrl() {
661
791
  return `${this.config.identityBaseUrl}/${this.config.accountId}`;
662
792
  }
793
+ makeError(message, code, statusCode) {
794
+ return new IdentityApiError(message, code, statusCode);
795
+ }
663
796
  async request(method, endpoint, body) {
664
- // Don't start new requests if already aborted (renderer closed)
665
- if (this.closeController.signal.aborted) {
666
- throw new IdentityApiError('Request cancelled', 'cancelled', 0);
667
- }
668
- const url = `${this.baseUrl}${endpoint}`;
669
- const headers = {
670
- 'Accept': 'application/json',
671
- };
672
- if (body) {
673
- headers['Content-Type'] = 'application/json';
674
- }
675
- // Create abort controller for request timeout
676
- const timeoutController = new AbortController();
677
- const timeoutId = setTimeout(() => timeoutController.abort(), this.timeoutMs);
678
- // Combine timeout and close signals - abort on either
679
- // Use AbortSignal.any if available, otherwise fall back to manual linking
680
- let combinedSignal;
681
- if ('any' in AbortSignal) {
682
- combinedSignal = AbortSignal.any([
683
- timeoutController.signal,
684
- this.closeController.signal,
685
- ]);
686
- }
687
- else {
688
- // Fallback for older browsers: link close signal to timeout controller
689
- combinedSignal = timeoutController.signal;
690
- const onClose = () => timeoutController.abort();
691
- this.closeController.signal.addEventListener('abort', onClose);
692
- }
693
- try {
694
- const response = await fetch(url, {
695
- method,
696
- headers,
697
- body: body ? JSON.stringify(body) : undefined,
698
- signal: combinedSignal,
699
- });
700
- const json = await response.json();
701
- if (!response.ok) {
702
- // API error responses have format: { error: { code, message, details }, meta: {...} }
703
- const errorData = json.error || {};
704
- const message = errorData.message || json.message || 'Request failed';
705
- const code = errorData.code || json.code || 'api_error';
706
- throw new IdentityApiError(message, code, response.status);
707
- }
708
- // API responses are wrapped in { data: ..., meta: ... }
709
- // Unwrap the data field
710
- return (json.data ?? json);
711
- }
712
- catch (error) {
713
- // Convert AbortError to a more descriptive error.
714
- // Check error.name directly (DOMException may not extend Error in all environments).
715
- if (error != null && typeof error === 'object' && 'name' in error && error.name === 'AbortError') {
716
- // Check which signal triggered the abort
717
- if (this.closeController.signal.aborted) {
718
- throw new IdentityApiError('Request cancelled', 'cancelled', 0);
719
- }
720
- throw new IdentityApiError('Request timed out', 'timeout', 0);
721
- }
722
- // Convert TypeError to a user-friendly network error.
723
- // WebKit (Safari/iOS) throws TypeError("Load failed"),
724
- // Chrome/Firefox throw TypeError("Failed to fetch")
725
- // when fetch() fails at the browser level (network error, CORS, DNS, etc.).
726
- if (error instanceof TypeError) {
727
- throw new IdentityApiError('Unable to connect. Please check your connection and try again.', 'network_error', 0);
728
- }
729
- // Re-throw IdentityApiError as-is (already has user-friendly message)
730
- if (error instanceof IdentityApiError) {
731
- throw error;
732
- }
733
- // Convert any other unexpected errors to a structured error
734
- throw new IdentityApiError(error instanceof Error ? error.message : 'An unexpected error occurred', 'unknown_error', 0);
735
- }
736
- finally {
737
- clearTimeout(timeoutId);
738
- }
797
+ const { data } = await this.rawRequest(`${this.baseUrl}${endpoint}`, {
798
+ method,
799
+ body: body ? JSON.stringify(body) : undefined,
800
+ });
801
+ // API responses are wrapped in `{ data: ..., meta: ... }`. Unwrap the
802
+ // data field if present; otherwise return the raw payload.
803
+ const unwrapped = (typeof data === 'object' && data !== null && 'data' in data)
804
+ ? data.data
805
+ : data;
806
+ return unwrapped;
739
807
  }
740
808
  /**
741
809
  * Fetch SDK configuration (branding, enabled methods).
@@ -926,12 +994,10 @@ class IdentityApi {
926
994
  });
927
995
  }
928
996
  }
929
- class IdentityApiError extends Error {
997
+ class IdentityApiError extends SparkVaultError {
930
998
  constructor(message, code, statusCode) {
931
- super(message);
999
+ super(message, code, statusCode);
932
1000
  this.name = 'IdentityApiError';
933
- this.code = code;
934
- this.statusCode = statusCode;
935
1001
  Object.setPrototypeOf(this, IdentityApiError.prototype);
936
1002
  }
937
1003
  }
@@ -6306,6 +6372,10 @@ class IdentityModule {
6306
6372
  if (this.renderer) {
6307
6373
  this.renderer.close();
6308
6374
  }
6375
+ // Reset session lifecycle before every modal open. Without this, the
6376
+ // AbortController aborted on close() would short-circuit the next
6377
+ // session's first request as "Request cancelled".
6378
+ this.api.resetSession();
6309
6379
  const isInline = !!options.target;
6310
6380
  const container = isInline
6311
6381
  ? this.createInlineContainer(options.target)
@@ -6502,23 +6572,18 @@ class IdentityModule {
6502
6572
  *
6503
6573
  * API client for vault upload operations.
6504
6574
  */
6505
- /** Default request timeout in milliseconds (30 seconds) */
6506
- const DEFAULT_TIMEOUT_MS = 30000;
6507
6575
  /**
6508
- * Error class for upload API errors.
6576
+ * Error class for upload API errors. Inherits `code` and `statusCode` from
6577
+ * SparkVaultError — no duplicate `httpStatus` field.
6509
6578
  */
6510
6579
  class UploadApiError extends SparkVaultError {
6511
- constructor(message, code, httpStatus) {
6512
- super(message, code, httpStatus);
6513
- this.httpStatus = httpStatus;
6580
+ constructor(message, code, statusCode) {
6581
+ super(message, code, statusCode);
6514
6582
  this.name = 'UploadApiError';
6515
6583
  Object.setPrototypeOf(this, UploadApiError.prototype);
6516
6584
  }
6517
6585
  }
6518
- /**
6519
- * Runtime validation for VaultUploadInfoResponse.
6520
- * Validates required fields exist and have correct types.
6521
- */
6586
+ /** Runtime validation for VaultUploadInfoResponse. */
6522
6587
  function isVaultUploadInfoResponse(data) {
6523
6588
  if (typeof data !== 'object' || data === null)
6524
6589
  return false;
@@ -6534,40 +6599,30 @@ function isVaultUploadInfoResponse(data) {
6534
6599
  typeof d.encryption.algorithm === 'string' &&
6535
6600
  (d.forge_status === 'active' || d.forge_status === 'inactive'));
6536
6601
  }
6537
- /**
6538
- * Runtime validation for InitiateUploadResponse.
6539
- */
6602
+ /** Runtime validation for InitiateUploadResponse. */
6540
6603
  function isInitiateUploadResponse(data) {
6541
6604
  if (typeof data !== 'object' || data === null)
6542
6605
  return false;
6543
6606
  const d = data;
6544
6607
  return typeof d.forge_url === 'string' && typeof d.ingot_id === 'string';
6545
6608
  }
6546
- class UploadApi {
6609
+ class UploadApi extends SparkVaultApi {
6547
6610
  constructor(config) {
6548
- /** Abort controller for cancelling all pending requests on close */
6549
- this.closeController = new AbortController();
6611
+ super(config.timeout);
6550
6612
  this.config = config;
6551
- this.timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
6552
6613
  }
6553
- /**
6554
- * Abort all pending requests.
6555
- * Call this when the renderer is closed to prevent orphaned requests.
6556
- */
6557
- abort() {
6558
- this.closeController.abort();
6559
- }
6560
- /**
6561
- * Check if the API has been aborted.
6562
- */
6563
- isAborted() {
6564
- return this.closeController.signal.aborted;
6614
+ makeError(message, code, statusCode) {
6615
+ return new UploadApiError(message, code, statusCode);
6565
6616
  }
6566
6617
  /**
6567
- * Get the abort signal for external use (e.g., XHR uploads).
6618
+ * Issue a request and unwrap the standard `{ data, meta }` envelope.
6568
6619
  */
6569
- getAbortSignal() {
6570
- return this.closeController.signal;
6620
+ async request(url, options) {
6621
+ const { data: raw, status } = await this.rawRequest(url, options);
6622
+ const data = (typeof raw === 'object' && raw !== null && 'data' in raw)
6623
+ ? raw.data
6624
+ : raw;
6625
+ return { data, status };
6571
6626
  }
6572
6627
  /**
6573
6628
  * Get vault upload info (public endpoint).
@@ -6617,85 +6672,6 @@ class UploadApi {
6617
6672
  ingotId: response.data.ingot_id,
6618
6673
  };
6619
6674
  }
6620
- /**
6621
- * Internal request method with timeout handling and error context.
6622
- */
6623
- async request(url, options) {
6624
- // Don't start new requests if already aborted (renderer closed)
6625
- if (this.closeController.signal.aborted) {
6626
- throw new UploadApiError('Request cancelled', 'cancelled', 0);
6627
- }
6628
- const timeoutController = new AbortController();
6629
- const timeoutId = setTimeout(() => timeoutController.abort(), this.timeoutMs);
6630
- // Combine timeout and close signals - abort on either
6631
- let combinedSignal;
6632
- if ('any' in AbortSignal) {
6633
- combinedSignal = AbortSignal.any([
6634
- timeoutController.signal,
6635
- this.closeController.signal,
6636
- ]);
6637
- }
6638
- else {
6639
- // Fallback for older browsers: link close signal to timeout controller
6640
- combinedSignal = timeoutController.signal;
6641
- const onClose = () => timeoutController.abort();
6642
- this.closeController.signal.addEventListener('abort', onClose);
6643
- }
6644
- try {
6645
- const response = await fetch(url, {
6646
- method: options.method,
6647
- headers: {
6648
- 'Content-Type': 'application/json',
6649
- 'Accept': 'application/json',
6650
- },
6651
- body: options.body,
6652
- signal: combinedSignal,
6653
- });
6654
- const json = await response.json();
6655
- if (!response.ok) {
6656
- // Safely extract error fields with runtime type checking
6657
- const errorData = typeof json === 'object' && json !== null ? json : {};
6658
- const errorObj = typeof errorData.error === 'object' && errorData.error !== null
6659
- ? errorData.error
6660
- : errorData;
6661
- const message = (typeof errorObj.message === 'string' ? errorObj.message : null) ??
6662
- (typeof errorObj.error === 'string' ? errorObj.error : null) ??
6663
- 'Request failed';
6664
- const code = typeof errorObj.code === 'string' ? errorObj.code : 'api_error';
6665
- throw new UploadApiError(message, code, response.status);
6666
- }
6667
- // API responses are wrapped in { data: ..., meta: ... }
6668
- const data = typeof json === 'object' && json !== null && 'data' in json
6669
- ? json.data
6670
- : json;
6671
- return { data, status: response.status };
6672
- }
6673
- catch (error) {
6674
- // Re-throw SparkVault errors as-is
6675
- if (error instanceof SparkVaultError) {
6676
- throw error;
6677
- }
6678
- // Convert AbortError to appropriate error
6679
- if (error instanceof DOMException && error.name === 'AbortError') {
6680
- // Check which signal triggered the abort
6681
- if (this.closeController.signal.aborted) {
6682
- throw new UploadApiError('Request cancelled', 'cancelled', 0);
6683
- }
6684
- throw new TimeoutError(`Request timed out after ${this.timeoutMs}ms`);
6685
- }
6686
- // Network errors with context
6687
- if (error instanceof TypeError) {
6688
- throw new NetworkError(`Network request failed: ${error.message}`, { url, method: options.method });
6689
- }
6690
- // Unknown errors - preserve message
6691
- throw new NetworkError(error instanceof Error
6692
- ? `Request failed: ${error.message}`
6693
- : 'Request failed: Unknown error');
6694
- }
6695
- finally {
6696
- clearTimeout(timeoutId);
6697
- }
6698
- }
6699
6675
  }
6700
6676
 
6701
6677
  /**
@@ -9288,25 +9264,25 @@ class UploadRenderer {
9288
9264
  title: 'Upload Widget Not Enabled',
9289
9265
  message: 'This vault has not enabled the embedded upload widget. Enable it on the vault\'s settings page in the SparkVault dashboard to start accepting uploads here.',
9290
9266
  code: 'UPLOAD_SOURCE_DISABLED',
9291
- httpStatus: error.httpStatus,
9267
+ statusCode: error.statusCode,
9292
9268
  });
9293
9269
  }
9294
- else if (error.httpStatus === 404) {
9270
+ else if (error.statusCode === 404) {
9295
9271
  this.setState({
9296
9272
  view: 'error',
9297
9273
  title: 'Upload Page Not Found',
9298
9274
  message: 'The requested upload endpoint could not be located. This may occur if the upload link has expired or was entered incorrectly.',
9299
9275
  code: 'NOT_FOUND',
9300
- httpStatus: 404,
9276
+ statusCode: 404,
9301
9277
  });
9302
9278
  }
9303
- else if (error.httpStatus === 402) {
9279
+ else if (error.statusCode === 402) {
9304
9280
  this.setState({
9305
9281
  view: 'error',
9306
9282
  title: 'Service Unavailable',
9307
9283
  message: 'This upload endpoint has been temporarily disabled due to an account billing issue. Please contact the organization\'s administrator.',
9308
9284
  code: 'PAYMENT_REQUIRED',
9309
- httpStatus: 402,
9285
+ statusCode: 402,
9310
9286
  });
9311
9287
  }
9312
9288
  else {
@@ -9315,7 +9291,7 @@ class UploadRenderer {
9315
9291
  title: 'Upload Failed',
9316
9292
  message: error.message,
9317
9293
  code: error.code,
9318
- httpStatus: error.httpStatus,
9294
+ statusCode: error.statusCode,
9319
9295
  });
9320
9296
  }
9321
9297
  this.callbacks.onError(error);
@@ -9370,6 +9346,7 @@ class VaultUploadModule {
9370
9346
  this.renderer = null;
9371
9347
  this.attachedElements = new Map();
9372
9348
  this.config = config;
9349
+ this.api = new UploadApi(config);
9373
9350
  }
9374
9351
  /**
9375
9352
  * Upload a file to a vault.
@@ -9404,12 +9381,12 @@ class VaultUploadModule {
9404
9381
  ...options,
9405
9382
  backdropBlur: options.backdropBlur ?? this.config.backdropBlur,
9406
9383
  };
9407
- // Fresh API instance per upload session: the AbortController inside the
9408
- // api is single-use, so reusing it across modal opens would make every
9409
- // request after the first close throw "Request cancelled".
9410
- const api = new UploadApi(this.config);
9384
+ // Reset session lifecycle before every modal open. Without this, the
9385
+ // AbortController aborted on close() would short-circuit the next
9386
+ // session's first request as "Request cancelled".
9387
+ this.api.resetSession();
9411
9388
  return new Promise((resolve, reject) => {
9412
- this.renderer = new UploadRenderer(container, api, mergedOptions, {
9389
+ this.renderer = new UploadRenderer(container, this.api, mergedOptions, {
9413
9390
  onSuccess: (result) => {
9414
9391
  options.onSuccess?.(result);
9415
9392
  resolve(result);