@sparkvault/sdk 1.24.1 → 1.24.2

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.
@@ -66,14 +66,25 @@ class PopupBlockedError extends SparkVaultError {
66
66
  */
67
67
  const API_URL = 'https://api.sparkvault.com';
68
68
  const IDENTITY_URL = 'https://api.sparkvault.com/v1/apps/identity';
69
+ function normalizeApiBaseUrl(url) {
70
+ const trimmed = url.replace(/\/+$/, '');
71
+ return trimmed.endsWith('/v1') ? trimmed.slice(0, -3) : trimmed;
72
+ }
73
+ function normalizeBaseUrl(url) {
74
+ return url.replace(/\/+$/, '');
75
+ }
69
76
  function resolveConfig(config) {
70
77
  return {
71
78
  accountId: config.accountId,
72
79
  timeout: config.timeout ?? 30000,
73
- apiBaseUrl: API_URL,
74
- identityBaseUrl: IDENTITY_URL,
80
+ apiBaseUrl: normalizeApiBaseUrl(config.apiBaseUrl ?? API_URL),
81
+ identityBaseUrl: normalizeBaseUrl(config.identityBaseUrl ?? IDENTITY_URL),
82
+ accessToken: config.accessToken,
83
+ getAccessToken: config.getAccessToken,
84
+ apiKey: config.apiKey,
75
85
  preloadConfig: config.preloadConfig !== false, // Default: true
76
86
  backdropBlur: config.backdropBlur !== false, // Default: true
87
+ allowedDownloadHostPatterns: config.allowedDownloadHostPatterns,
77
88
  };
78
89
  }
79
90
  function validateConfig(config) {
@@ -190,6 +201,31 @@ class HttpClient {
190
201
  constructor(config) {
191
202
  this.config = config;
192
203
  }
204
+ async getDefaultHeaders(headers, hasJsonBody) {
205
+ const requestHeaders = {
206
+ Accept: 'application/json',
207
+ ...headers,
208
+ };
209
+ if (hasJsonBody) {
210
+ requestHeaders['Content-Type'] = 'application/json';
211
+ }
212
+ if (!requestHeaders.Authorization) {
213
+ const token = await this.getAccessToken();
214
+ if (token) {
215
+ requestHeaders.Authorization = `Bearer ${token}`;
216
+ }
217
+ }
218
+ if (this.config.apiKey && !requestHeaders['X-API-Key']) {
219
+ requestHeaders['X-API-Key'] = this.config.apiKey;
220
+ }
221
+ return requestHeaders;
222
+ }
223
+ async getAccessToken() {
224
+ if (this.config.getAccessToken) {
225
+ return this.config.getAccessToken();
226
+ }
227
+ return this.config.accessToken ?? null;
228
+ }
193
229
  async request(path, options = {}) {
194
230
  const { retry } = options;
195
231
  // Per CLAUDE.md §7: Wrap with retry logic if enabled
@@ -209,11 +245,7 @@ class HttpClient {
209
245
  async executeRequest(path, options) {
210
246
  const { method = 'GET', headers = {}, body, timeout = this.config.timeout, } = options;
211
247
  const url = `${this.config.apiBaseUrl}${path}`;
212
- const requestHeaders = {
213
- 'Content-Type': 'application/json',
214
- Accept: 'application/json',
215
- ...headers,
216
- };
248
+ const requestHeaders = await this.getDefaultHeaders(headers, body !== undefined);
217
249
  const controller = new AbortController();
218
250
  const timeoutId = setTimeout(() => controller.abort(), timeout);
219
251
  try {
@@ -224,12 +256,12 @@ class HttpClient {
224
256
  signal: controller.signal,
225
257
  });
226
258
  clearTimeout(timeoutId);
227
- const data = await this.parseResponse(response);
259
+ const parsed = await this.parseResponse(response);
228
260
  if (!response.ok) {
229
- throw this.createErrorFromResponse(response.status, data);
261
+ throw this.createErrorFromResponse(response.status, parsed);
230
262
  }
231
263
  return {
232
- data,
264
+ data: this.unwrapApiResponse(parsed),
233
265
  status: response.status,
234
266
  headers: response.headers,
235
267
  };
@@ -283,13 +315,23 @@ class HttpClient {
283
315
  // Safely extract error fields with runtime type checking
284
316
  const errorData = typeof data === 'object' && data !== null ? data : {};
285
317
  const record = errorData;
286
- const message = (typeof record.message === 'string' ? record.message : null) ??
318
+ const nestedError = typeof record.error === 'object' && record.error !== null
319
+ ? record.error
320
+ : null;
321
+ const message = (typeof nestedError?.message === 'string' ? nestedError.message : null) ??
322
+ (typeof record.message === 'string' ? record.message : null) ??
287
323
  (typeof record.error === 'string' ? record.error : null) ??
288
324
  'Request failed';
289
- const code = typeof record.code === 'string' ? record.code : 'api_error';
290
- const details = typeof record.details === 'object' && record.details !== null
325
+ const code = (typeof nestedError?.code === 'string' ? nestedError.code : null) ??
326
+ (typeof record.code === 'string' ? record.code : null) ??
327
+ 'api_error';
328
+ const nestedDetails = typeof nestedError?.details === 'object' && nestedError.details !== null
329
+ ? nestedError.details
330
+ : undefined;
331
+ const topLevelDetails = typeof record.details === 'object' && record.details !== null
291
332
  ? record.details
292
333
  : undefined;
334
+ const details = nestedDetails ?? topLevelDetails;
293
335
  switch (status) {
294
336
  case 400:
295
337
  return new ValidationError(message, details);
@@ -301,6 +343,15 @@ class HttpClient {
301
343
  return new SparkVaultError(message, code, status, details);
302
344
  }
303
345
  }
346
+ unwrapApiResponse(data) {
347
+ if (typeof data === 'object' && data !== null) {
348
+ const record = data;
349
+ if ('data' in record && ('meta' in record || 'error' in record)) {
350
+ return record.data;
351
+ }
352
+ }
353
+ return data;
354
+ }
304
355
  get(path, options) {
305
356
  return this.request(path, { ...options, method: 'GET' });
306
357
  }
@@ -338,10 +389,7 @@ class HttpClient {
338
389
  async executeRequestRaw(path, options) {
339
390
  const { method = 'GET', headers = {}, body, timeout = this.config.timeout, } = options;
340
391
  const url = `${this.config.apiBaseUrl}${path}`;
341
- const requestHeaders = {
342
- 'Content-Type': 'application/json',
343
- ...headers,
344
- };
392
+ const requestHeaders = await this.getDefaultHeaders(headers, body !== undefined);
345
393
  const controller = new AbortController();
346
394
  const timeoutId = setTimeout(() => controller.abort(), timeout);
347
395
  try {
@@ -375,6 +423,63 @@ class HttpClient {
375
423
  }
376
424
  }
377
425
 
426
+ class HealthModule {
427
+ constructor(config) {
428
+ this.config = config;
429
+ }
430
+ async check(options = {}) {
431
+ const checkedAt = Math.floor(Date.now() / 1000);
432
+ const controller = new AbortController();
433
+ const timeoutId = setTimeout(() => controller.abort(), options.timeout ?? this.config.timeout);
434
+ try {
435
+ const response = await fetch(`${this.config.apiBaseUrl}/health`, {
436
+ method: 'GET',
437
+ headers: { Accept: 'application/json' },
438
+ signal: controller.signal,
439
+ });
440
+ return {
441
+ online: response.ok,
442
+ status: response.ok ? await this.readStatus(response) : 'unhealthy',
443
+ httpStatus: response.status,
444
+ checkedAt,
445
+ };
446
+ }
447
+ catch (err) {
448
+ return {
449
+ online: false,
450
+ status: 'unreachable',
451
+ checkedAt,
452
+ error: err instanceof Error ? err.message : String(err),
453
+ };
454
+ }
455
+ finally {
456
+ clearTimeout(timeoutId);
457
+ }
458
+ }
459
+ async isOnline(options = {}) {
460
+ const result = await this.check(options);
461
+ return result.online;
462
+ }
463
+ async readStatus(response) {
464
+ try {
465
+ const body = await response.json();
466
+ const data = typeof body === 'object' && body !== null && 'data' in body
467
+ ? body.data
468
+ : body;
469
+ if (typeof data === 'object' && data !== null) {
470
+ const status = data.status;
471
+ if (typeof status === 'string' && status) {
472
+ return status;
473
+ }
474
+ }
475
+ }
476
+ catch {
477
+ return response.ok ? 'healthy' : 'unhealthy';
478
+ }
479
+ return response.ok ? 'healthy' : 'unhealthy';
480
+ }
481
+ }
482
+
378
483
  /**
379
484
  * SparkVault API Base
380
485
  *
@@ -9549,6 +9654,7 @@ class VaultUploadModule {
9549
9654
  */
9550
9655
  class VaultsModule {
9551
9656
  constructor(config, http) {
9657
+ this.config = config;
9552
9658
  this.http = http;
9553
9659
  this.uploadModule = new VaultUploadModule(config);
9554
9660
  // Create callable that also has attach/close methods
@@ -9596,13 +9702,15 @@ class VaultsModule {
9596
9702
  }
9597
9703
  const cleanId = vaultId.startsWith('vlt_') ? vaultId : `vlt_${vaultId}`;
9598
9704
  const response = await this.http.post(`/v1/vaults/${cleanId}/unseal`, { vmk });
9705
+ if (!response.data.vat) {
9706
+ throw new ValidationError('Unseal response did not include a vault access token');
9707
+ }
9599
9708
  return {
9600
9709
  id: response.data.vault_id,
9601
- name: response.data.name,
9602
- vatToken: response.data.vat_token,
9710
+ vatToken: response.data.vat,
9711
+ issuedAt: response.data.issued_at,
9603
9712
  expiresAt: response.data.expires_at,
9604
- ingotCount: response.data.ingot_count,
9605
- storageBytes: response.data.storage_bytes,
9713
+ ttlSeconds: response.data.ttl_seconds,
9606
9714
  };
9607
9715
  }
9608
9716
  /**
@@ -9647,22 +9755,26 @@ class VaultsModule {
9647
9755
  */
9648
9756
  async uploadIngot(vault, options) {
9649
9757
  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';
9758
+ const requestedName = options.name ?? getBlobName(options.file);
9759
+ const requestedContentType = options.contentType ?? options.file.type ?? 'application/octet-stream';
9652
9760
  const createResponse = await this.http.post(`/v1/vaults/${vault.id}/ingots`, {
9653
- name,
9654
- content_type: contentType,
9761
+ name: requestedName,
9762
+ content_type: requestedContentType,
9655
9763
  size_bytes: options.file.size,
9656
9764
  }, {
9657
- headers: { Authorization: `Bearer ${vault.vatToken}` },
9765
+ headers: { 'X-Vault-Access-Token': vault.vatToken },
9658
9766
  });
9767
+ const created = createResponse.data;
9768
+ if (!created.forge_url) {
9769
+ throw new ValidationError('Upload endpoint did not return a Forge upload URL');
9770
+ }
9771
+ await uploadBlobWithTus(options.file, created.forge_url, created.name, created.content_type, options.onProgress);
9659
9772
  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,
9773
+ id: created.ingot_id,
9774
+ name: created.name,
9775
+ contentType: created.content_type,
9776
+ size: created.size_bytes,
9777
+ uploadExpiresAt: created.expires_at,
9666
9778
  };
9667
9779
  }
9668
9780
  /**
@@ -9674,10 +9786,14 @@ class VaultsModule {
9674
9786
  */
9675
9787
  async downloadIngot(vault, ingotId) {
9676
9788
  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}` },
9789
+ const response = await this.http.post(`/v1/vaults/${vault.id}/ingots/${cleanId}/download`, undefined, {
9790
+ headers: { 'X-Vault-Access-Token': vault.vatToken },
9680
9791
  });
9792
+ if (!response.data.download_url) {
9793
+ throw new ValidationError('Download endpoint did not return a download URL');
9794
+ }
9795
+ this.validateDownloadUrl(response.data.download_url);
9796
+ return fetchBlobWithTimeout(response.data.download_url);
9681
9797
  }
9682
9798
  /**
9683
9799
  * List all ingots in an unsealed vault.
@@ -9688,7 +9804,7 @@ class VaultsModule {
9688
9804
  */
9689
9805
  async listIngots(vault) {
9690
9806
  const response = await this.http.get(`/v1/vaults/${vault.id}/ingots`, {
9691
- headers: { Authorization: `Bearer ${vault.vatToken}` },
9807
+ headers: { 'X-Vault-Access-Token': vault.vatToken },
9692
9808
  });
9693
9809
  return response.data.ingots.map((i) => ({
9694
9810
  id: i.ingot_id,
@@ -9696,7 +9812,6 @@ class VaultsModule {
9696
9812
  contentType: i.content_type,
9697
9813
  size: i.size_bytes,
9698
9814
  createdAt: i.created_at,
9699
- type: i.ingot_type,
9700
9815
  }));
9701
9816
  }
9702
9817
  /**
@@ -9708,7 +9823,7 @@ class VaultsModule {
9708
9823
  async deleteIngot(vault, ingotId) {
9709
9824
  const cleanId = ingotId.startsWith('ing_') ? ingotId : `ing_${ingotId}`;
9710
9825
  await this.http.delete(`/v1/vaults/${vault.id}/ingots/${cleanId}`, {
9711
- headers: { Authorization: `Bearer ${vault.vatToken}` },
9826
+ headers: { 'X-Vault-Access-Token': vault.vatToken },
9712
9827
  });
9713
9828
  }
9714
9829
  validateCreateOptions(options) {
@@ -9727,6 +9842,117 @@ class VaultsModule {
9727
9842
  throw new ValidationError('File cannot be empty');
9728
9843
  }
9729
9844
  }
9845
+ validateDownloadUrl(downloadUrl) {
9846
+ let parsed;
9847
+ try {
9848
+ parsed = new URL(downloadUrl);
9849
+ }
9850
+ catch {
9851
+ throw new ValidationError('Invalid download URL from server');
9852
+ }
9853
+ if (parsed.protocol !== 'https:') {
9854
+ throw new ValidationError('Download URL must use HTTPS');
9855
+ }
9856
+ const allowedHosts = this.config.allowedDownloadHostPatterns;
9857
+ if (!allowedHosts?.length) {
9858
+ return;
9859
+ }
9860
+ if (!allowedHosts.some(pattern => pattern.test(parsed.hostname))) {
9861
+ throw new ValidationError('Invalid download URL from server');
9862
+ }
9863
+ }
9864
+ }
9865
+ function getBlobName(file) {
9866
+ if (typeof File !== 'undefined' && file instanceof File && file.name) {
9867
+ return file.name;
9868
+ }
9869
+ return 'unnamed';
9870
+ }
9871
+ function base64Encode(value) {
9872
+ return btoa(unescape(encodeURIComponent(value)));
9873
+ }
9874
+ async function uploadBlobWithTus(file, forgeUrl, filename, contentType, onProgress) {
9875
+ if (typeof XMLHttpRequest === 'undefined') {
9876
+ throw new ValidationError('XMLHttpRequest is required for browser ingot uploads');
9877
+ }
9878
+ const tusVersion = '1.0.0';
9879
+ const defaultChunkSize = 50 * 1024 * 1024;
9880
+ const url = new URL(forgeUrl);
9881
+ const istk = url.searchParams.get('istk');
9882
+ const endpoint = `${url.origin}${url.pathname}`;
9883
+ if (!istk) {
9884
+ throw new ValidationError('Forge upload URL is missing ISTK');
9885
+ }
9886
+ const createResponse = await fetch(endpoint, {
9887
+ method: 'POST',
9888
+ headers: {
9889
+ 'Tus-Resumable': tusVersion,
9890
+ 'Upload-Length': String(file.size),
9891
+ 'Upload-Metadata': `filename ${base64Encode(filename)},filetype ${base64Encode(contentType)}`,
9892
+ 'X-ISTK': istk,
9893
+ },
9894
+ });
9895
+ if (!createResponse.ok) {
9896
+ const errorText = await createResponse.text();
9897
+ throw new Error(`Failed to create upload session: ${createResponse.status} ${errorText}`);
9898
+ }
9899
+ const location = createResponse.headers.get('Location');
9900
+ const chunkSizeHeader = createResponse.headers.get('X-Chunk-Size');
9901
+ const chunkSize = chunkSizeHeader ? parseInt(chunkSizeHeader, 10) : defaultChunkSize;
9902
+ if (!location) {
9903
+ throw new Error('Server did not return upload location');
9904
+ }
9905
+ const uploadUrl = location.startsWith('http') ? location : `${url.origin}${location}`;
9906
+ let offset = 0;
9907
+ while (offset < file.size) {
9908
+ const chunkStart = offset;
9909
+ const chunkEnd = Math.min(offset + chunkSize, file.size);
9910
+ const chunk = file.slice(chunkStart, chunkEnd);
9911
+ offset = await uploadChunkWithProgress(uploadUrl, chunk, chunkStart, file.size, tusVersion, istk, onProgress);
9912
+ }
9913
+ }
9914
+ function uploadChunkWithProgress(uploadUrl, chunk, chunkStart, totalSize, tusVersion, istk, onProgress) {
9915
+ return new Promise((resolve, reject) => {
9916
+ const xhr = new XMLHttpRequest();
9917
+ xhr.upload.onprogress = (event) => {
9918
+ if (event.lengthComputable) {
9919
+ onProgress?.(chunkStart + event.loaded, totalSize);
9920
+ }
9921
+ };
9922
+ xhr.onload = () => {
9923
+ if (xhr.status >= 200 && xhr.status < 300) {
9924
+ const newOffsetHeader = xhr.getResponseHeader('Upload-Offset');
9925
+ const newOffset = newOffsetHeader ? parseInt(newOffsetHeader, 10) : chunkStart + chunk.size;
9926
+ resolve(newOffset);
9927
+ }
9928
+ else {
9929
+ reject(new Error(`Chunk upload failed with status ${xhr.status}`));
9930
+ }
9931
+ };
9932
+ xhr.onerror = () => reject(new Error('Chunk upload failed'));
9933
+ xhr.ontimeout = () => reject(new Error('Chunk upload timed out'));
9934
+ xhr.onabort = () => reject(new Error('Upload cancelled'));
9935
+ xhr.open('PATCH', uploadUrl);
9936
+ xhr.setRequestHeader('Tus-Resumable', tusVersion);
9937
+ xhr.setRequestHeader('Upload-Offset', String(chunkStart));
9938
+ xhr.setRequestHeader('Content-Type', 'application/offset+octet-stream');
9939
+ xhr.setRequestHeader('X-ISTK', istk);
9940
+ xhr.send(chunk);
9941
+ });
9942
+ }
9943
+ async function fetchBlobWithTimeout(url, timeout = 300000) {
9944
+ const controller = new AbortController();
9945
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
9946
+ try {
9947
+ const response = await fetch(url, { signal: controller.signal });
9948
+ if (!response.ok) {
9949
+ throw new Error(`Download failed with status ${response.status}`);
9950
+ }
9951
+ return response.blob();
9952
+ }
9953
+ finally {
9954
+ clearTimeout(timeoutId);
9955
+ }
9730
9956
  }
9731
9957
 
9732
9958
  /**
@@ -9913,6 +10139,7 @@ class SparkVault {
9913
10139
  const http = new HttpClient(this.config);
9914
10140
  this.identity = new IdentityModule(this.config);
9915
10141
  this.vaults = new VaultsModule(this.config, http);
10142
+ this.health = new HealthModule(this.config);
9916
10143
  }
9917
10144
  /**
9918
10145
  * Initialize the SparkVault SDK.