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