@sparkvault/sdk 1.24.1 → 1.24.3

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.
@@ -64,16 +64,32 @@ class PopupBlockedError extends SparkVaultError {
64
64
  /**
65
65
  * SparkVault SDK Configuration
66
66
  */
67
+ const DEFAULT_ALLOWED_DOWNLOAD_HOST_PATTERNS = [
68
+ /\.sparkvault\.(com|io)$/i,
69
+ /\.amazonaws\.com$/i,
70
+ /\.cloudfront\.net$/i,
71
+ ];
67
72
  const API_URL = 'https://api.sparkvault.com';
68
73
  const IDENTITY_URL = 'https://api.sparkvault.com/v1/apps/identity';
74
+ function normalizeApiBaseUrl(url) {
75
+ const trimmed = url.replace(/\/+$/, '');
76
+ return trimmed.endsWith('/v1') ? trimmed.slice(0, -3) : trimmed;
77
+ }
78
+ function normalizeBaseUrl(url) {
79
+ return url.replace(/\/+$/, '');
80
+ }
69
81
  function resolveConfig(config) {
70
82
  return {
71
83
  accountId: config.accountId,
72
84
  timeout: config.timeout ?? 30000,
73
- apiBaseUrl: API_URL,
74
- identityBaseUrl: IDENTITY_URL,
85
+ apiBaseUrl: normalizeApiBaseUrl(config.apiBaseUrl ?? API_URL),
86
+ identityBaseUrl: normalizeBaseUrl(config.identityBaseUrl ?? IDENTITY_URL),
87
+ accessToken: config.accessToken,
88
+ getAccessToken: config.getAccessToken,
89
+ apiKey: config.apiKey,
75
90
  preloadConfig: config.preloadConfig !== false, // Default: true
76
91
  backdropBlur: config.backdropBlur !== false, // Default: true
92
+ allowedDownloadHostPatterns: config.allowedDownloadHostPatterns ?? DEFAULT_ALLOWED_DOWNLOAD_HOST_PATTERNS,
77
93
  };
78
94
  }
79
95
  function validateConfig(config) {
@@ -190,6 +206,31 @@ class HttpClient {
190
206
  constructor(config) {
191
207
  this.config = config;
192
208
  }
209
+ async getDefaultHeaders(headers, hasJsonBody) {
210
+ const requestHeaders = {
211
+ Accept: 'application/json',
212
+ ...headers,
213
+ };
214
+ if (hasJsonBody) {
215
+ requestHeaders['Content-Type'] = 'application/json';
216
+ }
217
+ if (!requestHeaders.Authorization) {
218
+ const token = await this.getAccessToken();
219
+ if (token) {
220
+ requestHeaders.Authorization = `Bearer ${token}`;
221
+ }
222
+ }
223
+ if (this.config.apiKey && !requestHeaders['X-API-Key']) {
224
+ requestHeaders['X-API-Key'] = this.config.apiKey;
225
+ }
226
+ return requestHeaders;
227
+ }
228
+ async getAccessToken() {
229
+ if (this.config.getAccessToken) {
230
+ return this.config.getAccessToken();
231
+ }
232
+ return this.config.accessToken ?? null;
233
+ }
193
234
  async request(path, options = {}) {
194
235
  const { retry } = options;
195
236
  // Per CLAUDE.md §7: Wrap with retry logic if enabled
@@ -209,11 +250,7 @@ class HttpClient {
209
250
  async executeRequest(path, options) {
210
251
  const { method = 'GET', headers = {}, body, timeout = this.config.timeout, } = options;
211
252
  const url = `${this.config.apiBaseUrl}${path}`;
212
- const requestHeaders = {
213
- 'Content-Type': 'application/json',
214
- Accept: 'application/json',
215
- ...headers,
216
- };
253
+ const requestHeaders = await this.getDefaultHeaders(headers, body !== undefined);
217
254
  const controller = new AbortController();
218
255
  const timeoutId = setTimeout(() => controller.abort(), timeout);
219
256
  try {
@@ -224,12 +261,12 @@ class HttpClient {
224
261
  signal: controller.signal,
225
262
  });
226
263
  clearTimeout(timeoutId);
227
- const data = await this.parseResponse(response);
264
+ const parsed = await this.parseResponse(response);
228
265
  if (!response.ok) {
229
- throw this.createErrorFromResponse(response.status, data);
266
+ throw this.createErrorFromResponse(response.status, parsed);
230
267
  }
231
268
  return {
232
- data,
269
+ data: this.unwrapApiResponse(parsed),
233
270
  status: response.status,
234
271
  headers: response.headers,
235
272
  };
@@ -283,13 +320,23 @@ class HttpClient {
283
320
  // Safely extract error fields with runtime type checking
284
321
  const errorData = typeof data === 'object' && data !== null ? data : {};
285
322
  const record = errorData;
286
- const message = (typeof record.message === 'string' ? record.message : null) ??
323
+ const nestedError = typeof record.error === 'object' && record.error !== null
324
+ ? record.error
325
+ : null;
326
+ const message = (typeof nestedError?.message === 'string' ? nestedError.message : null) ??
327
+ (typeof record.message === 'string' ? record.message : null) ??
287
328
  (typeof record.error === 'string' ? record.error : null) ??
288
329
  'Request failed';
289
- const code = typeof record.code === 'string' ? record.code : 'api_error';
290
- const details = typeof record.details === 'object' && record.details !== null
330
+ const code = (typeof nestedError?.code === 'string' ? nestedError.code : null) ??
331
+ (typeof record.code === 'string' ? record.code : null) ??
332
+ 'api_error';
333
+ const nestedDetails = typeof nestedError?.details === 'object' && nestedError.details !== null
334
+ ? nestedError.details
335
+ : undefined;
336
+ const topLevelDetails = typeof record.details === 'object' && record.details !== null
291
337
  ? record.details
292
338
  : undefined;
339
+ const details = nestedDetails ?? topLevelDetails;
293
340
  switch (status) {
294
341
  case 400:
295
342
  return new ValidationError(message, details);
@@ -301,6 +348,15 @@ class HttpClient {
301
348
  return new SparkVaultError(message, code, status, details);
302
349
  }
303
350
  }
351
+ unwrapApiResponse(data) {
352
+ if (typeof data === 'object' && data !== null) {
353
+ const record = data;
354
+ if ('data' in record && ('meta' in record || 'error' in record)) {
355
+ return record.data;
356
+ }
357
+ }
358
+ return data;
359
+ }
304
360
  get(path, options) {
305
361
  return this.request(path, { ...options, method: 'GET' });
306
362
  }
@@ -338,10 +394,7 @@ class HttpClient {
338
394
  async executeRequestRaw(path, options) {
339
395
  const { method = 'GET', headers = {}, body, timeout = this.config.timeout, } = options;
340
396
  const url = `${this.config.apiBaseUrl}${path}`;
341
- const requestHeaders = {
342
- 'Content-Type': 'application/json',
343
- ...headers,
344
- };
397
+ const requestHeaders = await this.getDefaultHeaders(headers, body !== undefined);
345
398
  const controller = new AbortController();
346
399
  const timeoutId = setTimeout(() => controller.abort(), timeout);
347
400
  try {
@@ -375,6 +428,63 @@ class HttpClient {
375
428
  }
376
429
  }
377
430
 
431
+ class HealthModule {
432
+ constructor(config) {
433
+ this.config = config;
434
+ }
435
+ async check(options = {}) {
436
+ const checkedAt = Math.floor(Date.now() / 1000);
437
+ const controller = new AbortController();
438
+ const timeoutId = setTimeout(() => controller.abort(), options.timeout ?? this.config.timeout);
439
+ try {
440
+ const response = await fetch(`${this.config.apiBaseUrl}/health`, {
441
+ method: 'GET',
442
+ headers: { Accept: 'application/json' },
443
+ signal: controller.signal,
444
+ });
445
+ return {
446
+ online: response.ok,
447
+ status: response.ok ? await this.readStatus(response) : 'unhealthy',
448
+ httpStatus: response.status,
449
+ checkedAt,
450
+ };
451
+ }
452
+ catch (err) {
453
+ return {
454
+ online: false,
455
+ status: 'unreachable',
456
+ checkedAt,
457
+ error: err instanceof Error ? err.message : String(err),
458
+ };
459
+ }
460
+ finally {
461
+ clearTimeout(timeoutId);
462
+ }
463
+ }
464
+ async isOnline(options = {}) {
465
+ const result = await this.check(options);
466
+ return result.online;
467
+ }
468
+ async readStatus(response) {
469
+ try {
470
+ const body = await response.json();
471
+ const data = typeof body === 'object' && body !== null && 'data' in body
472
+ ? body.data
473
+ : body;
474
+ if (typeof data === 'object' && data !== null) {
475
+ const status = data.status;
476
+ if (typeof status === 'string' && status) {
477
+ return status;
478
+ }
479
+ }
480
+ }
481
+ catch {
482
+ return response.ok ? 'healthy' : 'unhealthy';
483
+ }
484
+ return response.ok ? 'healthy' : 'unhealthy';
485
+ }
486
+ }
487
+
378
488
  /**
379
489
  * SparkVault API Base
380
490
  *
@@ -9549,6 +9659,7 @@ class VaultUploadModule {
9549
9659
  */
9550
9660
  class VaultsModule {
9551
9661
  constructor(config, http) {
9662
+ this.config = config;
9552
9663
  this.http = http;
9553
9664
  this.uploadModule = new VaultUploadModule(config);
9554
9665
  // Create callable that also has attach/close methods
@@ -9596,13 +9707,15 @@ class VaultsModule {
9596
9707
  }
9597
9708
  const cleanId = vaultId.startsWith('vlt_') ? vaultId : `vlt_${vaultId}`;
9598
9709
  const response = await this.http.post(`/v1/vaults/${cleanId}/unseal`, { vmk });
9710
+ if (!response.data.vat) {
9711
+ throw new ValidationError('Unseal response did not include a vault access token');
9712
+ }
9599
9713
  return {
9600
9714
  id: response.data.vault_id,
9601
- name: response.data.name,
9602
- vatToken: response.data.vat_token,
9715
+ vatToken: response.data.vat,
9716
+ issuedAt: response.data.issued_at,
9603
9717
  expiresAt: response.data.expires_at,
9604
- ingotCount: response.data.ingot_count,
9605
- storageBytes: response.data.storage_bytes,
9718
+ ttlSeconds: response.data.ttl_seconds,
9606
9719
  };
9607
9720
  }
9608
9721
  /**
@@ -9647,22 +9760,26 @@ class VaultsModule {
9647
9760
  */
9648
9761
  async uploadIngot(vault, options) {
9649
9762
  this.validateUploadOptions(options);
9650
- const name = options.name ?? (options.file instanceof File ? options.file.name : 'unnamed');
9651
- const contentType = options.contentType ?? options.file.type ?? 'application/octet-stream';
9763
+ const requestedName = options.name ?? getBlobName(options.file);
9764
+ const requestedContentType = options.contentType ?? options.file.type ?? 'application/octet-stream';
9652
9765
  const createResponse = await this.http.post(`/v1/vaults/${vault.id}/ingots`, {
9653
- name,
9654
- content_type: contentType,
9766
+ name: requestedName,
9767
+ content_type: requestedContentType,
9655
9768
  size_bytes: options.file.size,
9656
9769
  }, {
9657
- headers: { Authorization: `Bearer ${vault.vatToken}` },
9770
+ headers: { 'X-Vault-Access-Token': vault.vatToken },
9658
9771
  });
9772
+ const created = createResponse.data;
9773
+ if (!created.forge_url) {
9774
+ throw new ValidationError('Upload endpoint did not return a Forge upload URL');
9775
+ }
9776
+ await uploadBlobWithTus(options.file, created.forge_url, created.name, created.content_type, options.onProgress);
9659
9777
  return {
9660
- id: createResponse.data.ingot_id,
9661
- name: createResponse.data.name,
9662
- contentType: createResponse.data.content_type,
9663
- size: createResponse.data.size_bytes,
9664
- createdAt: createResponse.data.created_at,
9665
- type: createResponse.data.ingot_type,
9778
+ id: created.ingot_id,
9779
+ name: created.name,
9780
+ contentType: created.content_type,
9781
+ size: created.size_bytes,
9782
+ uploadExpiresAt: created.expires_at,
9666
9783
  };
9667
9784
  }
9668
9785
  /**
@@ -9674,10 +9791,27 @@ class VaultsModule {
9674
9791
  */
9675
9792
  async downloadIngot(vault, ingotId) {
9676
9793
  const cleanId = ingotId.startsWith('ing_') ? ingotId : `ing_${ingotId}`;
9677
- return this.http.requestRaw(`/v1/vaults/${vault.id}/ingots/${cleanId}/download`, {
9678
- method: 'POST',
9679
- headers: { Authorization: `Bearer ${vault.vatToken}` },
9680
- });
9794
+ let lastError = null;
9795
+ for (let attempt = 1; attempt <= DOWNLOAD_MAX_ATTEMPTS; attempt++) {
9796
+ try {
9797
+ const response = await this.http.post(`/v1/vaults/${vault.id}/ingots/${cleanId}/download`, undefined, {
9798
+ headers: { 'X-Vault-Access-Token': vault.vatToken },
9799
+ });
9800
+ if (!response.data.download_url) {
9801
+ throw new ValidationError('Download endpoint did not return a download URL');
9802
+ }
9803
+ this.validateDownloadUrl(response.data.download_url);
9804
+ return await fetchBlobWithTimeout(response.data.download_url);
9805
+ }
9806
+ catch (err) {
9807
+ lastError = err instanceof Error ? err : new Error(String(err));
9808
+ if (!isRetryableDownloadError(lastError) || attempt === DOWNLOAD_MAX_ATTEMPTS) {
9809
+ throw lastError;
9810
+ }
9811
+ await delayExponential(attempt);
9812
+ }
9813
+ }
9814
+ throw lastError ?? new Error('Download failed');
9681
9815
  }
9682
9816
  /**
9683
9817
  * List all ingots in an unsealed vault.
@@ -9688,7 +9822,7 @@ class VaultsModule {
9688
9822
  */
9689
9823
  async listIngots(vault) {
9690
9824
  const response = await this.http.get(`/v1/vaults/${vault.id}/ingots`, {
9691
- headers: { Authorization: `Bearer ${vault.vatToken}` },
9825
+ headers: { 'X-Vault-Access-Token': vault.vatToken },
9692
9826
  });
9693
9827
  return response.data.ingots.map((i) => ({
9694
9828
  id: i.ingot_id,
@@ -9696,7 +9830,6 @@ class VaultsModule {
9696
9830
  contentType: i.content_type,
9697
9831
  size: i.size_bytes,
9698
9832
  createdAt: i.created_at,
9699
- type: i.ingot_type,
9700
9833
  }));
9701
9834
  }
9702
9835
  /**
@@ -9708,7 +9841,7 @@ class VaultsModule {
9708
9841
  async deleteIngot(vault, ingotId) {
9709
9842
  const cleanId = ingotId.startsWith('ing_') ? ingotId : `ing_${ingotId}`;
9710
9843
  await this.http.delete(`/v1/vaults/${vault.id}/ingots/${cleanId}`, {
9711
- headers: { Authorization: `Bearer ${vault.vatToken}` },
9844
+ headers: { 'X-Vault-Access-Token': vault.vatToken },
9712
9845
  });
9713
9846
  }
9714
9847
  validateCreateOptions(options) {
@@ -9727,6 +9860,133 @@ class VaultsModule {
9727
9860
  throw new ValidationError('File cannot be empty');
9728
9861
  }
9729
9862
  }
9863
+ validateDownloadUrl(downloadUrl) {
9864
+ let parsed;
9865
+ try {
9866
+ parsed = new URL(downloadUrl);
9867
+ }
9868
+ catch {
9869
+ throw new ValidationError('Invalid download URL from server');
9870
+ }
9871
+ if (parsed.protocol !== 'https:') {
9872
+ throw new ValidationError('Download URL must use HTTPS');
9873
+ }
9874
+ const allowedHosts = this.config.allowedDownloadHostPatterns;
9875
+ if (!allowedHosts.some(pattern => pattern.test(parsed.hostname))) {
9876
+ throw new ValidationError('Invalid download URL from server');
9877
+ }
9878
+ }
9879
+ }
9880
+ function getBlobName(file) {
9881
+ if (typeof File !== 'undefined' && file instanceof File && file.name) {
9882
+ return file.name;
9883
+ }
9884
+ return 'unnamed';
9885
+ }
9886
+ function base64Encode(value) {
9887
+ return btoa(unescape(encodeURIComponent(value)));
9888
+ }
9889
+ async function uploadBlobWithTus(file, forgeUrl, filename, contentType, onProgress) {
9890
+ if (typeof XMLHttpRequest === 'undefined') {
9891
+ throw new ValidationError('XMLHttpRequest is required for browser ingot uploads');
9892
+ }
9893
+ const tusVersion = '1.0.0';
9894
+ const defaultChunkSize = 50 * 1024 * 1024;
9895
+ const url = new URL(forgeUrl);
9896
+ const istk = url.searchParams.get('istk');
9897
+ const endpoint = `${url.origin}${url.pathname}`;
9898
+ if (!istk) {
9899
+ throw new ValidationError('Forge upload URL is missing ISTK');
9900
+ }
9901
+ const createResponse = await fetch(endpoint, {
9902
+ method: 'POST',
9903
+ headers: {
9904
+ 'Tus-Resumable': tusVersion,
9905
+ 'Upload-Length': String(file.size),
9906
+ 'Upload-Metadata': `filename ${base64Encode(filename)},filetype ${base64Encode(contentType)}`,
9907
+ 'X-ISTK': istk,
9908
+ },
9909
+ });
9910
+ if (!createResponse.ok) {
9911
+ const errorText = await createResponse.text();
9912
+ throw new Error(`Failed to create upload session: ${createResponse.status} ${errorText}`);
9913
+ }
9914
+ const location = createResponse.headers.get('Location');
9915
+ const chunkSizeHeader = createResponse.headers.get('X-Chunk-Size');
9916
+ const chunkSize = chunkSizeHeader ? parseInt(chunkSizeHeader, 10) : defaultChunkSize;
9917
+ if (!location) {
9918
+ throw new Error('Server did not return upload location');
9919
+ }
9920
+ const uploadUrl = location.startsWith('http') ? location : `${url.origin}${location}`;
9921
+ let offset = 0;
9922
+ while (offset < file.size) {
9923
+ const chunkStart = offset;
9924
+ const chunkEnd = Math.min(offset + chunkSize, file.size);
9925
+ const chunk = file.slice(chunkStart, chunkEnd);
9926
+ offset = await uploadChunkWithProgress(uploadUrl, chunk, chunkStart, file.size, tusVersion, istk, onProgress);
9927
+ }
9928
+ }
9929
+ function uploadChunkWithProgress(uploadUrl, chunk, chunkStart, totalSize, tusVersion, istk, onProgress) {
9930
+ return new Promise((resolve, reject) => {
9931
+ const xhr = new XMLHttpRequest();
9932
+ xhr.upload.onprogress = (event) => {
9933
+ if (event.lengthComputable) {
9934
+ onProgress?.(chunkStart + event.loaded, totalSize);
9935
+ }
9936
+ };
9937
+ xhr.onload = () => {
9938
+ if (xhr.status >= 200 && xhr.status < 300) {
9939
+ const newOffsetHeader = xhr.getResponseHeader('Upload-Offset');
9940
+ const newOffset = newOffsetHeader ? parseInt(newOffsetHeader, 10) : chunkStart + chunk.size;
9941
+ resolve(newOffset);
9942
+ }
9943
+ else {
9944
+ reject(new Error(`Chunk upload failed with status ${xhr.status}`));
9945
+ }
9946
+ };
9947
+ xhr.onerror = () => reject(new Error('Chunk upload failed'));
9948
+ xhr.ontimeout = () => reject(new Error('Chunk upload timed out'));
9949
+ xhr.onabort = () => reject(new Error('Upload cancelled'));
9950
+ xhr.open('PATCH', uploadUrl);
9951
+ xhr.setRequestHeader('Tus-Resumable', tusVersion);
9952
+ xhr.setRequestHeader('Upload-Offset', String(chunkStart));
9953
+ xhr.setRequestHeader('Content-Type', 'application/offset+octet-stream');
9954
+ xhr.setRequestHeader('X-ISTK', istk);
9955
+ xhr.send(chunk);
9956
+ });
9957
+ }
9958
+ const DOWNLOAD_MAX_ATTEMPTS = 3;
9959
+ const DOWNLOAD_RETRY_BASE_MS = 500;
9960
+ function delayExponential(attempt) {
9961
+ return new Promise(resolve => setTimeout(resolve, DOWNLOAD_RETRY_BASE_MS * 2 ** (attempt - 1)));
9962
+ }
9963
+ function isRetryableDownloadError(err) {
9964
+ // Server contract / client-side validation errors aren't fixed by retrying.
9965
+ if (err instanceof ValidationError)
9966
+ return false;
9967
+ // Network errors carry a status code only when the SDK HTTP client attached
9968
+ // one; treat any explicitly-4xx response as a permanent failure.
9969
+ const status = err.statusCode;
9970
+ if (typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && status !== 429) {
9971
+ return false;
9972
+ }
9973
+ return true;
9974
+ }
9975
+ async function fetchBlobWithTimeout(url, timeout = 300000) {
9976
+ const controller = new AbortController();
9977
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
9978
+ try {
9979
+ const response = await fetch(url, { signal: controller.signal });
9980
+ if (!response.ok) {
9981
+ const error = new Error(`Download failed with status ${response.status}`);
9982
+ error.statusCode = response.status;
9983
+ throw error;
9984
+ }
9985
+ return response.blob();
9986
+ }
9987
+ finally {
9988
+ clearTimeout(timeoutId);
9989
+ }
9730
9990
  }
9731
9991
 
9732
9992
  /**
@@ -9913,6 +10173,7 @@ class SparkVault {
9913
10173
  const http = new HttpClient(this.config);
9914
10174
  this.identity = new IdentityModule(this.config);
9915
10175
  this.vaults = new VaultsModule(this.config, http);
10176
+ this.health = new HealthModule(this.config);
9916
10177
  }
9917
10178
  /**
9918
10179
  * Initialize the SparkVault SDK.