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