vault-sdk-prod 1.0.0 → 2.2.1

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.
package/src/Vault.js CHANGED
@@ -2,7 +2,73 @@ import crypto from "crypto";
2
2
  import axios from "axios";
3
3
  import WebSocket from "ws";
4
4
  import EventEmitter from "events";
5
- import { validator, VaultError, HTTP_ERROR_MAP } from "./utils/validationError.js";
5
+ import {
6
+ validator,
7
+ ValidationError,
8
+ VaultError,
9
+ HTTP_ERROR_MAP,
10
+ safeErrorDetails,
11
+ } from "./utils/validationError.js";
12
+ import { sanitizeFileName } from "./utils/sanitizeFileName.js";
13
+ import {
14
+ MAX_FILE_SIZE,
15
+ baseName,
16
+ contentTypeFor,
17
+ formatFileSize,
18
+ resolveFile,
19
+ } from "./utils/file.js";
20
+
21
+ const WS_CHAT_PROTOCOL = "vault-chat";
22
+
23
+ /** Storage hosts the presign step is allowed to point uploads at. */
24
+ const DEFAULT_UPLOAD_HOSTS = ["s3.filebase.com", ".s3.filebase.com"];
25
+
26
+ const MAX_PAGE_SIZE = 100;
27
+
28
+ const DEFAULT_REQUEST_TIMEOUT = 30000;
29
+ const DEFAULT_UPLOAD_CONCURRENCY = 3;
30
+ const MAX_UPLOAD_CONCURRENCY = 10;
31
+
32
+ /** Slowest upload we still wait for, used to derive a per-file timeout. */
33
+ const ASSUMED_UPLOAD_BYTES_PER_SECOND = 100 * 1024;
34
+ const MIN_UPLOAD_TIMEOUT = 60000;
35
+ const MAX_UPLOAD_TIMEOUT = 2 * 60 * 60 * 1000;
36
+
37
+ const isLocalHostname = (hostname) =>
38
+ ["localhost", "127.0.0.1", "::1", "[::1]"].includes(hostname);
39
+
40
+ const isEncryptedProtocol = (protocol) =>
41
+ protocol === "https:" || protocol === "wss:";
42
+
43
+ const REDACTED = "[redacted]";
44
+
45
+ /** Run `worker` over `items`, at most `limit` at a time, keeping input order. */
46
+ async function runWithConcurrency(items, limit, worker) {
47
+ const results = new Array(items.length);
48
+ let next = 0;
49
+
50
+ const runners = Array.from(
51
+ { length: Math.max(1, Math.min(limit, items.length)) },
52
+ async () => {
53
+ while (next < items.length) {
54
+ const index = next;
55
+ next += 1;
56
+ results[index] = await worker(items[index], index);
57
+ }
58
+ }
59
+ );
60
+
61
+ await Promise.all(runners);
62
+ return results;
63
+ }
64
+
65
+ const toBase64Url = (text) => {
66
+ const base64 =
67
+ typeof Buffer !== "undefined"
68
+ ? Buffer.from(text, "utf8").toString("base64")
69
+ : btoa(unescape(encodeURIComponent(text)));
70
+ return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
71
+ };
6
72
 
7
73
  class Vault extends EventEmitter {
8
74
  /**
@@ -14,6 +80,12 @@ class Vault extends EventEmitter {
14
80
  * @param {string} config.VAULT_CLIENT_API_KEY - Your client-specific API key
15
81
  * @param {string} config.VAULT_BASE_URL - Base URL of the Vault API (e.g. "https://api.example.com")
16
82
  * @param {string} [config.VAULT_WS_URL] - WebSocket URL for real-time events (e.g. "wss://api.example.com/ws")
83
+ * @param {boolean} [config.VAULT_ALLOW_INSECURE=false] - Allow http:// / ws:// to a non-local host (test servers only)
84
+ * @param {string} [config.VAULT_UPLOAD_ROOT] - Directory that file paths must stay inside (defaults to the working directory)
85
+ * @param {string|string[]} [config.VAULT_UPLOAD_HOSTS] - Extra storage hosts uploads may be sent to
86
+ * @param {number} [config.VAULT_TIMEOUT=30000] - Timeout in ms for API requests
87
+ * @param {number} [config.VAULT_UPLOAD_TIMEOUT] - Timeout in ms for one file upload (default: scaled to the file size)
88
+ * @param {number} [config.VAULT_UPLOAD_CONCURRENCY=3] - How many files upload at once in uploadFiles/uploadFilesToBot
17
89
  *
18
90
  * @throws {VaultError} If any required configuration parameter is missing
19
91
  *
@@ -32,6 +104,12 @@ class Vault extends EventEmitter {
32
104
  VAULT_CLIENT_API_KEY,
33
105
  VAULT_BASE_URL,
34
106
  VAULT_WS_URL,
107
+ VAULT_ALLOW_INSECURE,
108
+ VAULT_UPLOAD_ROOT,
109
+ VAULT_UPLOAD_HOSTS,
110
+ VAULT_TIMEOUT,
111
+ VAULT_UPLOAD_TIMEOUT,
112
+ VAULT_UPLOAD_CONCURRENCY,
35
113
  } = {}) {
36
114
  super();
37
115
 
@@ -49,19 +127,85 @@ class Vault extends EventEmitter {
49
127
  );
50
128
  }
51
129
 
52
- this.apiKey = VAULT_ACCESS_KEY;
53
- this.apiSecret = VAULT_SECRET_KEY;
54
- this.clientApiKey = VAULT_CLIENT_API_KEY;
130
+ // Hidden from enumeration so console.log(vault), JSON.stringify and APM
131
+ // snapshots cannot print the signing secret.
132
+ for (const [property, secret] of [
133
+ ["apiKey", VAULT_ACCESS_KEY],
134
+ ["apiSecret", VAULT_SECRET_KEY],
135
+ ["clientApiKey", VAULT_CLIENT_API_KEY],
136
+ ]) {
137
+ Object.defineProperty(this, property, {
138
+ value: secret,
139
+ enumerable: false,
140
+ writable: false,
141
+ configurable: false,
142
+ });
143
+ }
144
+
55
145
  this.baseUrl = VAULT_BASE_URL;
56
146
  this.wsUrl = VAULT_WS_URL;
57
147
  this.ws = null;
148
+ this.botChatWs = null;
58
149
 
59
- this.httpClient = axios.create({
60
- baseURL: this.baseUrl,
61
- headers: {
62
- "Content-Type": "application/json",
63
- "API-Key": this.apiKey,
64
- },
150
+ this.allowInsecure = VAULT_ALLOW_INSECURE === true;
151
+
152
+ this._assertEncryptedTransport(
153
+ this.normalizeAbsoluteUrl(this.baseUrl),
154
+ "VAULT_BASE_URL",
155
+ "constructor"
156
+ );
157
+ if (this.wsUrl) {
158
+ this._assertEncryptedTransport(
159
+ this.normalizeAbsoluteUrl(this.wsUrl, "wss:"),
160
+ "VAULT_WS_URL",
161
+ "constructor"
162
+ );
163
+ }
164
+
165
+ this.uploadRoot = VAULT_UPLOAD_ROOT || undefined;
166
+
167
+ const positive = (value, fallback) => {
168
+ const number = Number(value);
169
+ return Number.isFinite(number) && number > 0 ? number : fallback;
170
+ };
171
+
172
+ this.requestTimeout = positive(VAULT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT);
173
+ this.uploadTimeout = positive(VAULT_UPLOAD_TIMEOUT, undefined);
174
+ this.uploadConcurrency = Math.min(
175
+ positive(VAULT_UPLOAD_CONCURRENCY, DEFAULT_UPLOAD_CONCURRENCY),
176
+ MAX_UPLOAD_CONCURRENCY
177
+ );
178
+
179
+ // Uploads may only go to storage hosts we expect, so a tampered presign
180
+ // response cannot redirect a file to someone else's server.
181
+ const extraHosts = Array.isArray(VAULT_UPLOAD_HOSTS)
182
+ ? VAULT_UPLOAD_HOSTS
183
+ : String(VAULT_UPLOAD_HOSTS || "")
184
+ .split(",")
185
+ .filter(Boolean);
186
+ this.allowedUploadHosts = [
187
+ ...DEFAULT_UPLOAD_HOSTS,
188
+ ...extraHosts.map((host) => String(host).trim().toLowerCase()),
189
+ ];
190
+ try {
191
+ this.allowedUploadHosts.push(new URL(this.baseUrl).hostname.toLowerCase());
192
+ } catch {
193
+ // A base URL that will not parse fails on the first request instead.
194
+ }
195
+
196
+ // Not enumerable either: its default headers carry the access key.
197
+ Object.defineProperty(this, "httpClient", {
198
+ value: axios.create({
199
+ baseURL: this.baseUrl,
200
+ timeout: this.requestTimeout,
201
+ headers: {
202
+ "Content-Type": "application/json",
203
+ "API-Key": this.apiKey,
204
+ },
205
+ }),
206
+ enumerable: false,
207
+ writable: true,
208
+ configurable: true,
65
209
  });
66
210
  }
67
211
 
@@ -78,7 +222,7 @@ class Vault extends EventEmitter {
78
222
  */
79
223
  async request(method, endpoint, payload, options = {}) {
80
224
  const timestamp = Date.now().toString();
81
- const signature = this.sign(timestamp);
225
+ const signature = this.signRequest(method, endpoint, timestamp, payload);
82
226
 
83
227
  const headers = {
84
228
  timestamp,
@@ -112,13 +256,26 @@ class Vault extends EventEmitter {
112
256
  ? `[Vault SDK] ${operation}: ${serverMessage}`
113
257
  : `[Vault SDK] ${operation}: ${errorInfo.description}`;
114
258
 
259
+ const details = safeErrorDetails(data);
260
+ const requestId =
261
+ details?.requestId || error.response.headers?.["x-request-id"] || null;
262
+
115
263
  throw new VaultError(message, {
116
264
  status,
117
265
  code: errorInfo.code,
118
266
  operation,
119
- data,
267
+ data: details,
268
+ requestId,
120
269
  });
121
270
  } else if (error.request) {
271
+ if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
272
+ throw new VaultError(
273
+ `[Vault SDK] ${operation}: The request timed out after ${this.requestTimeout}ms. ` +
274
+ `Raise VAULT_TIMEOUT if your network needs longer.`,
275
+ { code: "REQUEST_TIMEOUT", operation }
276
+ );
277
+ }
278
+
122
279
  throw new VaultError(
123
280
  `[Vault SDK] ${operation}: No response received from the server. ` +
124
281
  `Please check your network connection and ensure VAULT_BASE_URL ("${this.baseUrl}") is correct.`,
@@ -134,19 +291,87 @@ class Vault extends EventEmitter {
134
291
  }
135
292
 
136
293
  /**
137
- * Internal: Generate HMAC-SHA256 signature for request authentication.
294
+ * Internal: Generate an HMAC-SHA256 signature bound to the full HTTP request.
138
295
  *
296
+ * @param {string} method - HTTP method
297
+ * @param {string} endpoint - API endpoint path, including query string
139
298
  * @param {string} timestamp - Current timestamp string
299
+ * @param {Object} [payload] - Request body
140
300
  * @returns {string} Hex-encoded HMAC signature
141
301
  */
142
- sign(timestamp) {
143
- const message = this.apiKey + timestamp;
302
+ signRequest(method, endpoint, timestamp, payload) {
303
+ const bodylessMethod = ["GET", "HEAD"].includes(
304
+ String(method).toUpperCase()
305
+ );
306
+ const emptyObject =
307
+ payload !== null &&
308
+ typeof payload === "object" &&
309
+ !Array.isArray(payload) &&
310
+ Object.keys(payload).length === 0;
311
+ const serializedBody =
312
+ bodylessMethod || payload == null || emptyObject
313
+ ? ""
314
+ : JSON.stringify(payload);
315
+ const bodyHash = crypto
316
+ .createHash("sha256")
317
+ .update(serializedBody)
318
+ .digest("hex");
319
+ const message = [
320
+ String(method).toUpperCase(),
321
+ endpoint,
322
+ timestamp,
323
+ this.apiKey,
324
+ bodyHash,
325
+ ].join("\n");
326
+
144
327
  return crypto
145
328
  .createHmac("sha256", this.apiSecret)
146
329
  .update(message)
147
330
  .digest("hex");
148
331
  }
149
332
 
333
+ /**
334
+ * Legacy signature used by the Twin WebSocket endpoint.
335
+ * HTTP Vault SDK requests use signRequest() above.
336
+ */
337
+ signLegacy(timestamp) {
338
+ return crypto
339
+ .createHmac("sha256", this.apiSecret)
340
+ .update(this.apiKey + timestamp)
341
+ .digest("hex");
342
+ }
343
+
344
+ /**
345
+ * Internal: For bulk-style SDK operations, surface a real SDK error when the
346
+ * server processed the request but every requested item failed.
347
+ *
348
+ * @private
349
+ */
350
+ assertNotAllItemsFailed(responseData, operation, itemLabel) {
351
+ const bulkData = responseData?.data;
352
+ const results = Array.isArray(bulkData?.results) ? bulkData.results : null;
353
+ const successCount = Number(bulkData?.successCount ?? 0);
354
+ const failureCount = Number(bulkData?.failureCount ?? 0);
355
+
356
+ if (!results || results.length === 0) {
357
+ return;
358
+ }
359
+
360
+ if (successCount > 0 || failureCount !== results.length) {
361
+ return;
362
+ }
363
+
364
+ const message =
365
+ responseData?.message ||
366
+ `All requested ${itemLabel} failed to be added to the bot`;
367
+
368
+ throw new VaultError(`[Vault SDK] ${operation}: ${message}`, {
369
+ code: "BAD_REQUEST",
370
+ operation,
371
+ data: responseData,
372
+ });
373
+ }
374
+
150
375
  // ─── WebSocket ────────────────────────────────────────────────
151
376
 
152
377
  /**
@@ -169,28 +394,71 @@ class Vault extends EventEmitter {
169
394
  );
170
395
  }
171
396
 
397
+ if (this.ws && this.ws.readyState < WebSocket.CLOSING) {
398
+ this.ws.close();
399
+ }
400
+
172
401
  const timestamp = Date.now().toString();
173
- const signature = this.sign(timestamp);
402
+ const credentials = toBase64Url(
403
+ JSON.stringify({
404
+ apikey: this.apiKey,
405
+ signature: this.signLegacy(timestamp),
406
+ timestamp,
407
+ clientApiKey: this.clientApiKey,
408
+ })
409
+ );
174
410
 
175
411
  return new Promise((resolve, reject) => {
176
- this.ws = new WebSocket(
177
- `${this.wsUrl}?apikey=${this.apiKey}&signature=${signature}&timestamp=${timestamp}&clientApiKey=${this.clientApiKey}`
178
- );
179
-
180
- this.ws.onopen = () => {
412
+ let settled = false;
413
+ // Credentials travel in the handshake, not the URL, so proxies and access
414
+ // logs never record them.
415
+ const socket = new WebSocket(new URL(this.wsUrl).toString(), [
416
+ WS_CHAT_PROTOCOL,
417
+ `vault-auth.${credentials}`,
418
+ ]);
419
+ this.ws = socket;
420
+
421
+ socket.onopen = () => {
422
+ settled = true;
181
423
  resolve();
182
424
  };
183
425
 
184
- this.ws.onmessage = this.wsOnMessage.bind(this);
185
- this.ws.onclose = () => {};
426
+ socket.onmessage = this.wsOnMessage.bind(this);
186
427
 
187
- this.ws.onerror = (error) => {
188
- reject(
189
- new VaultError(
190
- `[Vault SDK] 'connectToWebsocket': WebSocket connection failed — ${error.message || "Unknown error"}`,
191
- { code: "WEBSOCKET_ERROR", operation: "connectToWebsocket" }
192
- )
428
+ socket.onerror = (error) => {
429
+ const wrapped = new VaultError(
430
+ `[Vault SDK] 'connectToWebsocket': WebSocket connection failed — ${error.message || "Unknown error"}`,
431
+ { code: "WEBSOCKET_ERROR", operation: "connectToWebsocket" }
193
432
  );
433
+
434
+ if (!settled) {
435
+ settled = true;
436
+ reject(wrapped);
437
+ return;
438
+ }
439
+
440
+ this.wsOnError(wrapped);
441
+ };
442
+
443
+ socket.onclose = (event) => {
444
+ if (this.ws === socket) {
445
+ this.ws = null;
446
+ }
447
+
448
+ this.emit("websocket_close", {
449
+ code: event?.code,
450
+ reason: event?.reason,
451
+ });
452
+
453
+ if (!settled) {
454
+ settled = true;
455
+ reject(
456
+ new VaultError(
457
+ "[Vault SDK] 'connectToWebsocket': WebSocket closed before the connection was established.",
458
+ { code: "WEBSOCKET_CLOSED", operation: "connectToWebsocket" }
459
+ )
460
+ );
461
+ }
194
462
  };
195
463
  });
196
464
  }
@@ -221,741 +489,2373 @@ class Vault extends EventEmitter {
221
489
  return error;
222
490
  }
223
491
 
224
- // ─── File Upload ──────────────────────────────────────────────
225
-
226
492
  /**
227
- * Upload a single file to the vault.
228
- *
229
- * The upload is a 3-step process:
230
- * 1. Compute SHA-256 hash of the file content
231
- * 2. Get a presigned S3 URL from the server
232
- * 3. Upload directly to S3, then register the upload
233
- *
234
- * @param {Object} file - File object to upload
235
- * @param {Buffer|Uint8Array} file.buffer - File content as a buffer
236
- * @param {string} file.name - File name (e.g. "document.pdf")
237
- * @param {string} [file.type] - MIME type (e.g. "application/pdf"). Defaults to "application/octet-stream"
238
- * @param {string} vaultId - The vault ID to upload to
239
- * @param {string} [parentId] - Parent folder ID (omit or null for root)
240
- * @returns {Promise<Object>} Upload result with file details
241
- *
242
- * @throws {VaultError} If upload fails at any step
243
- * @throws {ValidationError} If required parameters are missing/invalid
244
- *
245
- * @example
246
- * import fs from "fs";
247
- * const fileBuffer = fs.readFileSync("./photo.jpg");
248
- * const result = await vault.uploadFile(
249
- * { buffer: fileBuffer, name: "photo.jpg", type: "image/jpeg" },
250
- * "your-vault-id"
251
- * );
493
+ * Internal: Resolve the payload body from the standard API response wrapper.
494
+ * @private
252
495
  */
253
- async uploadFile(file, vaultId, parentId) {
254
- validator.validate(
255
- {
256
- file: {
257
- value: file,
258
- type: "object",
259
- message:
260
- "[Vault SDK] 'uploadFile': The 'file' parameter must be an object with { buffer, name } properties.",
261
- },
262
- vaultId: { value: vaultId, type: "string" },
263
- },
264
- "uploadFile"
265
- );
496
+ getResponseData(responseData) {
497
+ if (responseData && typeof responseData === "object" && "data" in responseData) {
498
+ return responseData.data;
499
+ }
500
+ return responseData;
501
+ }
266
502
 
267
- if (!file.buffer) {
503
+ /**
504
+ * Internal: Normalize a user-provided base URL into an absolute URL object.
505
+ * Accepts http(s), ws(s), protocol-relative, root-relative, and bare host forms.
506
+ * @private
507
+ */
508
+ normalizeAbsoluteUrl(rawUrl, fallbackProtocol = "https:") {
509
+ const value = typeof rawUrl === "string" ? rawUrl.trim() : "";
510
+ if (!value) {
268
511
  throw new VaultError(
269
- "[Vault SDK] 'uploadFile': file.buffer is required. Provide the file content as a Buffer or Uint8Array.",
270
- { code: "INVALID_PARAMETER", operation: "uploadFile" }
512
+ "[Vault SDK] Invalid URL configuration. Expected an absolute base URL.",
513
+ { code: "INVALID_PARAMETER", operation: "normalizeAbsoluteUrl" }
271
514
  );
272
515
  }
273
516
 
274
- if (!file.name || typeof file.name !== "string" || !file.name.trim()) {
275
- throw new VaultError(
276
- "[Vault SDK] 'uploadFile': file.name is required. Provide the file name as a non-empty string (e.g. 'document.pdf').",
277
- { code: "INVALID_PARAMETER", operation: "uploadFile" }
278
- );
517
+ if (/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value)) {
518
+ return new URL(value);
519
+ }
520
+
521
+ if (value.startsWith("//")) {
522
+ return new URL(`${fallbackProtocol}${value}`);
279
523
  }
280
524
 
281
- const { buffer, name, type } = file;
282
- const size = buffer.length;
525
+ if (value.startsWith("/")) {
526
+ if (typeof window !== "undefined" && window.location?.origin) {
527
+ return new URL(value, window.location.origin);
528
+ }
283
529
 
284
- if (size === 0) {
285
530
  throw new VaultError(
286
- "[Vault SDK] 'uploadFile': file.buffer is empty. Cannot upload a zero-byte file.",
287
- { code: "INVALID_PARAMETER", operation: "uploadFile" }
531
+ `[Vault SDK] Cannot resolve relative URL "${value}" without a browser origin.`,
532
+ { code: "INVALID_PARAMETER", operation: "normalizeAbsoluteUrl" }
288
533
  );
289
534
  }
290
535
 
291
- // Step 1: Calculate SHA-256 hash
292
- const hash = crypto.createHash("sha256").update(buffer).digest("hex");
536
+ return new URL(`${fallbackProtocol}//${value}`);
537
+ }
293
538
 
294
- // Step 2: Get presigned URL
295
- let presignedRes;
296
- try {
297
- presignedRes = await this.getPresignedUrl({
298
- vaultId,
299
- fileName: name,
300
- fileType: type || "application/octet-stream",
301
- fileSize: size,
302
- contentHash: hash,
303
- folderId: parentId,
304
- });
305
- } catch (error) {
306
- if (error instanceof VaultError) throw error;
539
+ /**
540
+ * Internal: Build the bot chat WebSocket URL.
541
+ * @private
542
+ */
543
+ getBotChatWebSocketUrl(overrideUrl) {
544
+ const base = overrideUrl || this.wsUrl || this.baseUrl;
545
+ if (!base) {
307
546
  throw new VaultError(
308
- `[Vault SDK] 'uploadFile': Failed to get upload URL for "${name}" — ${error.message}`,
309
- { code: "PRESIGN_FAILED", operation: "uploadFile" }
547
+ "[Vault SDK] 'connectToBotChat': VAULT_BASE_URL or VAULT_WS_URL is required to build the bot chat WebSocket URL.",
548
+ { code: "MISSING_CONFIG", operation: "connectToBotChat" }
310
549
  );
311
550
  }
312
551
 
313
- const { url, key, contentType, sanitizedName } = presignedRes;
552
+ const normalizedBase = this.normalizeAbsoluteUrl(
553
+ base,
554
+ typeof base === "string" && base.trim().startsWith("ws") ? "wss:" : "https:"
555
+ );
314
556
 
315
- // Step 3: Upload to S3
316
- try {
317
- await axios.put(url, buffer, {
318
- headers: {
319
- "Content-Type": contentType,
320
- "x-amz-meta-original-filename": sanitizedName || name,
321
- "x-amz-meta-content-hash": hash,
322
- "x-amz-meta-vault-id": vaultId,
323
- "x-amz-meta-folder-id": parentId || "root",
324
- "x-amz-meta-file-size": size.toString(),
325
- },
326
- });
327
- } catch (error) {
328
- const status = error.response?.status;
329
- let detail = error.message;
330
- if (status === 403)
331
- detail =
332
- "The presigned URL has expired or required signing headers are missing. Please try uploading again.";
333
- if (status === 413)
334
- detail = `File "${name}" exceeds the maximum allowed upload size.`;
557
+ const url = new URL(normalizedBase.toString());
558
+ const path = url.pathname.replace(/\/+$/, "");
335
559
 
336
- throw new VaultError(
337
- `[Vault SDK] 'uploadFile': Failed to upload "${name}" to storage — ${detail}`,
338
- {
339
- status,
340
- code: "STORAGE_UPLOAD_FAILED",
341
- operation: "uploadFile",
342
- }
343
- );
560
+ if (path !== "/ws/bot-chat") {
561
+ url.pathname = "/ws/bot-chat";
344
562
  }
345
563
 
346
- // Step 4: Register the upload
347
- try {
348
- return await this.registerUpload({
349
- vaultId,
350
- fileName: name,
351
- filebaseKey: key,
352
- fileSize: size,
353
- contentHash: hash,
354
- folderId: parentId,
355
- });
356
- } catch (error) {
357
- if (error instanceof VaultError) throw error;
358
- throw new VaultError(
359
- `[Vault SDK] 'uploadFile': File "${name}" was uploaded to storage but failed to register. ` +
360
- `Please contact support if this persists — ${error.message}`,
361
- { code: "REGISTER_FAILED", operation: "uploadFile" }
362
- );
564
+ if (url.protocol === "https:") {
565
+ url.protocol = "wss:";
566
+ } else if (url.protocol === "http:") {
567
+ url.protocol = "ws:";
568
+ } else if (url.protocol !== "ws:" && url.protocol !== "wss:") {
569
+ url.protocol = "wss:";
363
570
  }
571
+
572
+ this._assertEncryptedTransport(url, "The bot chat WebSocket URL", "connectToBotChat");
573
+
574
+ return url.toString();
364
575
  }
365
576
 
366
577
  /**
367
- * Upload multiple files to the vault in parallel.
368
- *
369
- * Each file is uploaded independently. Failed uploads do not block others.
370
- *
371
- * @param {Array<Object>} files - Array of file objects, each with { buffer, name, type? }
372
- * @param {string} vaultId - The vault ID to upload to
373
- * @param {string} [parentId] - Parent folder ID (omit or null for root)
374
- * @returns {Promise<Array<Object>>} Array of results, each with status "success" or "failed"
578
+ * Create a short-lived launch token that can be redeemed into a vault JWT.
375
579
  *
376
- * @example
377
- * const results = await vault.uploadFiles(
378
- * [
379
- * { buffer: buf1, name: "file1.pdf", type: "application/pdf" },
380
- * { buffer: buf2, name: "file2.jpg", type: "image/jpeg" },
381
- * ],
382
- * "your-vault-id"
383
- * );
580
+ * @param {string} vaultId - The vault ID to create the token for
581
+ * @param {Object} [options] - Optional launch context
582
+ * @returns {Promise<Object>} Standard API response containing launchToken and launchUrl
384
583
  */
385
- async uploadFiles(files, vaultId, parentId = null) {
584
+ async createVaultLaunchToken(vaultId, options = {}) {
386
585
  validator.validate(
387
586
  {
388
- files: {
389
- value: files,
390
- type: "array",
391
- message:
392
- "[Vault SDK] 'uploadFiles': 'files' must be a non-empty array of file objects ({ buffer, name }).",
393
- },
394
587
  vaultId: { value: vaultId, type: "string" },
588
+ options: { value: options, type: "object", required: false },
395
589
  },
396
- "uploadFiles"
590
+ "createVaultLaunchToken"
397
591
  );
398
592
 
399
- const uploadPromises = files.map(async (file, index) => {
400
- try {
401
- if (!file || typeof file !== "object") {
402
- throw new VaultError(
403
- `File at index ${index} is not a valid object. Each file must have { buffer, name }.`,
404
- { code: "INVALID_PARAMETER", operation: "uploadFiles" }
405
- );
593
+ if (options.returnTo !== undefined && options.returnTo !== null) {
594
+ if (typeof options.returnTo !== "string" || !options.returnTo.trim()) {
595
+ throw new ValidationError(
596
+ "createVaultLaunchToken",
597
+ "options.returnTo",
598
+ "string",
599
+ "[Vault SDK] 'createVaultLaunchToken': options.returnTo must be a non-empty string when provided."
600
+ );
601
+ }
602
+
603
+ // Browsers drop tabs and newlines while parsing a URL, so a path that
604
+ // hides them can turn into "//evil.com". Paths must resolve back to the
605
+ // same origin; anything else must be a plain http(s) URL.
606
+ const returnTo = options.returnTo.trim();
607
+ const hasUnsafeChar = Array.from(returnTo).some((char) => {
608
+ const code = char.charCodeAt(0);
609
+ return code < 32 || code === 127 || code === 92;
610
+ });
611
+ const lowered = returnTo.toLowerCase();
612
+ let isSafe = false;
613
+ if (!hasUnsafeChar) {
614
+ try {
615
+ const base = "https://vault.invalid";
616
+ isSafe = returnTo.startsWith("/")
617
+ ? new URL(returnTo, base).origin === base
618
+ : (lowered.startsWith("http://") || lowered.startsWith("https://")) &&
619
+ Boolean(new URL(returnTo));
620
+ } catch {
621
+ isSafe = false;
406
622
  }
407
- const result = await this.uploadFile(file, vaultId, parentId);
408
- return { ...result, status: "success", fileName: file.name };
409
- } catch (error) {
410
- return {
411
- status: "failed",
412
- fileName: file?.name || `file[${index}]`,
413
- error: error.message,
414
- code: error.code || "UPLOAD_FAILED",
415
- };
416
623
  }
417
- });
624
+ if (!isSafe) {
625
+ throw new ValidationError(
626
+ "createVaultLaunchToken",
627
+ "options.returnTo",
628
+ "safe URL",
629
+ "[Vault SDK] 'createVaultLaunchToken': options.returnTo must be an internal path or an http(s) URL allowed by the Vault server."
630
+ );
631
+ }
632
+ }
418
633
 
419
- return await Promise.all(uploadPromises);
420
- }
634
+ const payload = { vaultId };
635
+ for (const key of ["returnTo", "clientId", "adminId", "sourceUserId"]) {
636
+ if (typeof options[key] === "string" && options[key].trim()) {
637
+ payload[key] = options[key].trim();
638
+ }
639
+ }
421
640
 
422
- // ─── File Retrieval ───────────────────────────────────────────
641
+ const response = await this.request(
642
+ "POST",
643
+ "/v1/vault-sdk/launch-token",
644
+ payload,
645
+ { operation: "createVaultLaunchToken" }
646
+ );
647
+ return response.data;
648
+ }
423
649
 
424
650
  /**
425
- * Search for files in the vault by name or query.
426
- *
427
- * @param {string} vaultId - The vault ID to search in
428
- * @param {string} [query=""] - Search query to filter files by name
429
- * @returns {Promise<Object>} Matching files
651
+ * Redeem a launch token into a normal vault access token.
430
652
  *
431
- * @example
432
- * const files = await vault.getFiles("your-vault-id", "report");
653
+ * @param {string} launchToken - One-time launch token from createVaultLaunchToken()
654
+ * @returns {Promise<Object>} Standard API response containing user.accessToken
433
655
  */
434
- async getFiles(vaultId, query = "") {
656
+ async redeemVaultLaunchToken(launchToken) {
435
657
  validator.validate(
436
658
  {
437
- vaultId: { value: vaultId, type: "string" },
438
- query: { value: query, type: "string", required: false },
659
+ launchToken: { value: launchToken, type: "string" },
439
660
  },
440
- "getFiles"
661
+ "redeemVaultLaunchToken"
441
662
  );
442
663
 
443
- const queryString = `?vaultId=${encodeURIComponent(vaultId)}&query=${encodeURIComponent(query)}`;
444
664
  const response = await this.request(
445
- "GET",
446
- `/v1/vault-sdk/get-files${queryString}`,
447
- undefined,
448
- { operation: "getFiles" }
665
+ "POST",
666
+ "/v1/vault-sdk/launch/redeem",
667
+ { token: launchToken.trim() },
668
+ { operation: "redeemVaultLaunchToken" }
449
669
  );
450
670
  return response.data;
451
671
  }
452
672
 
453
673
  /**
454
- * Get all files in the vault.
455
- *
456
- * @param {string} vaultId - The vault ID
457
- * @returns {Promise<Object>} All files in the vault
674
+ * Create and redeem a launch token into the JWT required by the bot chat socket.
458
675
  *
459
- * @example
460
- * const allFiles = await vault.getAllFiles("your-vault-id");
676
+ * @param {string} vaultId - The vault ID to authenticate for chat
677
+ * @param {Object} [options] - Optional launch context
678
+ * @returns {Promise<string>} Vault access token for bot chat
461
679
  */
462
- async getAllFiles(vaultId) {
463
- validator.validate(
464
- {
465
- vaultId: { value: vaultId, type: "string" },
466
- },
467
- "getAllFiles"
468
- );
680
+ async createBotChatAccessToken(vaultId, options = {}) {
681
+ const launchResponse = await this.createVaultLaunchToken(vaultId, options);
682
+ const launchData = this.getResponseData(launchResponse);
683
+ const launchToken = launchData?.launchToken;
469
684
 
470
- const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
471
- const response = await this.request(
472
- "GET",
473
- `/v1/vault-sdk/all-files${queryString}`,
474
- undefined,
475
- { operation: "getAllFiles" }
476
- );
477
- return response.data;
478
- }
685
+ if (!launchToken) {
686
+ throw new VaultError(
687
+ "[Vault SDK] 'createBotChatAccessToken': Launch token was not returned by the server.",
688
+ { code: "BAD_RESPONSE", operation: "createBotChatAccessToken", data: launchResponse }
689
+ );
690
+ }
479
691
 
480
- // ─── Storage & Plans ──────────────────────────────────────────
692
+ const redeemResponse = await this.redeemVaultLaunchToken(launchToken);
693
+ const redeemData = this.getResponseData(redeemResponse);
694
+ const accessToken = redeemData?.user?.accessToken;
695
+
696
+ if (!accessToken) {
697
+ throw new VaultError(
698
+ "[Vault SDK] 'createBotChatAccessToken': Access token was not returned by the server.",
699
+ { code: "BAD_RESPONSE", operation: "createBotChatAccessToken", data: redeemResponse }
700
+ );
701
+ }
702
+
703
+ return accessToken;
704
+ }
481
705
 
482
706
  /**
483
- * Get storage usage details for the vault (used space, total space, etc.).
707
+ * Open a WebSocket connection to the live bot chat service.
484
708
  *
485
- * @param {string} vaultId - The vault ID
486
- * @returns {Promise<Object>} Storage usage information
709
+ * If `token` is omitted, the SDK will mint one from the vault SDK auth flow.
710
+ * Pass `botId` to automatically join a bot chat once the socket opens.
487
711
  *
488
- * @example
489
- * const storage = await vault.getStorageDetails("your-vault-id");
712
+ * @param {string} vaultId - The vault ID to authenticate for chat
713
+ * @param {Object} [options]
714
+ * @param {string} [options.token] - Existing vault access token
715
+ * @param {string} [options.launchToken] - Existing launch token to redeem
716
+ * @param {string} [options.botId] - Bot ID to auto-join after connect
717
+ * @param {string} [options.sessionId] - Existing chat session ID to resume
718
+ * @param {string} [options.wsUrl] - Optional explicit WebSocket base URL
719
+ * @returns {Promise<Object>} Connection metadata
490
720
  */
491
- async getStorageDetails(vaultId) {
721
+ async connectToBotChat(vaultId, options = {}) {
492
722
  validator.validate(
493
723
  {
494
724
  vaultId: { value: vaultId, type: "string" },
725
+ options: { value: options, type: "object", required: false },
495
726
  },
496
- "getStorageDetails"
727
+ "connectToBotChat"
497
728
  );
498
729
 
499
- const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
500
- const response = await this.request(
501
- "GET",
502
- `/v1/vault-sdk/storage-details${queryString}`,
503
- undefined,
504
- { operation: "getStorageDetails" }
505
- );
506
- return response.data;
507
- }
730
+ const {
731
+ token,
732
+ launchToken,
733
+ botId,
734
+ sessionId,
735
+ wsUrl,
736
+ ...launchOptions
737
+ } = options;
738
+
739
+ let accessToken = typeof token === "string" && token.trim() ? token.trim() : null;
740
+
741
+ if (!accessToken && typeof launchToken === "string" && launchToken.trim()) {
742
+ const redeemResponse = await this.redeemVaultLaunchToken(launchToken.trim());
743
+ const redeemData = this.getResponseData(redeemResponse);
744
+ accessToken = redeemData?.user?.accessToken || null;
745
+ }
508
746
 
509
- /**
510
- * Get all available storage plans.
511
- *
512
- * @param {string} vaultId - The vault ID
513
- * @returns {Promise<Object>} Available storage plans
514
- *
747
+ if (!accessToken) {
748
+ accessToken = await this.createBotChatAccessToken(vaultId, launchOptions);
749
+ }
750
+
751
+ if (this.botChatWs && this.botChatWs.readyState < WebSocket.CLOSING) {
752
+ this.botChatWs.close();
753
+ }
754
+
755
+ const socketUrl = this.getBotChatWebSocketUrl(wsUrl);
756
+
757
+ return new Promise((resolve, reject) => {
758
+ let settled = false;
759
+ const socket = new WebSocket(socketUrl, [
760
+ WS_CHAT_PROTOCOL,
761
+ `vault-token.${accessToken}`,
762
+ ]);
763
+ this.botChatWs = socket;
764
+
765
+ socket.onopen = () => {
766
+ this.emit("bot_chat_open");
767
+
768
+ if (botId) {
769
+ this.joinBotChat(botId, sessionId);
770
+ }
771
+
772
+ settled = true;
773
+ resolve({
774
+ url: socketUrl,
775
+ botId: botId || null,
776
+ sessionId: sessionId || null,
777
+ });
778
+ };
779
+
780
+ socket.onmessage = this.botChatOnMessage.bind(this);
781
+
782
+ socket.onclose = (event) => {
783
+ if (this.botChatWs === socket) {
784
+ this.botChatWs = null;
785
+ }
786
+
787
+ this.emit("bot_chat_close", event);
788
+
789
+ if (!settled) {
790
+ reject(
791
+ new VaultError(
792
+ "[Vault SDK] 'connectToBotChat': Bot chat socket closed before the connection was established.",
793
+ { code: "WEBSOCKET_CLOSED", operation: "connectToBotChat" }
794
+ )
795
+ );
796
+ }
797
+ };
798
+
799
+ socket.onerror = (error) => {
800
+ const wrapped = new VaultError(
801
+ `[Vault SDK] 'connectToBotChat': WebSocket connection failed — ${error.message || "Unknown error"}`,
802
+ { code: "WEBSOCKET_ERROR", operation: "connectToBotChat" }
803
+ );
804
+
805
+ this.emit("bot_chat_stream_error", wrapped);
806
+
807
+ if (!settled) {
808
+ settled = true;
809
+ reject(wrapped);
810
+ }
811
+ };
812
+ });
813
+ }
814
+
815
+ /**
816
+ * Internal: Parse and emit bot chat messages.
817
+ * @private
818
+ */
819
+ botChatOnMessage(event) {
820
+ try {
821
+ const response = JSON.parse(event.data);
822
+ this.emit("bot_chat_message", response);
823
+
824
+ if (response?.type) {
825
+ this.emit(`bot_chat_${response.type}`, response.payload);
826
+ }
827
+
828
+ return response;
829
+ } catch (error) {
830
+ this.emit(
831
+ "bot_chat_stream_error",
832
+ new VaultError(
833
+ `[Vault SDK] Failed to parse bot chat WebSocket message: ${error.message}`,
834
+ { code: "BAD_RESPONSE", operation: "botChatOnMessage" }
835
+ )
836
+ );
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Internal: Send an event through the bot chat socket.
842
+ * @private
843
+ */
844
+ sendBotChatEvent(type, payload = {}) {
845
+ if (!this.botChatWs || this.botChatWs.readyState !== WebSocket.OPEN) {
846
+ throw new VaultError(
847
+ `[Vault SDK] '${type}': Bot chat socket is not connected.`,
848
+ { code: "WEBSOCKET_NOT_CONNECTED", operation: type }
849
+ );
850
+ }
851
+
852
+ this.botChatWs.send(JSON.stringify({ type, payload }));
853
+ }
854
+
855
+ /**
856
+ * Join a bot's live chat stream.
857
+ *
858
+ * @param {string} botId - Target bot ID
859
+ * @param {string} [sessionId] - Optional existing session to resume
860
+ */
861
+ joinBotChat(botId, sessionId = null) {
862
+ validator.validate(
863
+ {
864
+ botId: { value: botId, type: "string" },
865
+ sessionId: { value: sessionId, type: "string", required: false },
866
+ },
867
+ "joinBotChat"
868
+ );
869
+
870
+ const payload = { botId: botId.trim() };
871
+ if (typeof sessionId === "string" && sessionId.trim()) {
872
+ payload.sessionId = sessionId.trim();
873
+ }
874
+
875
+ this.sendBotChatEvent("join_chat", payload);
876
+ }
877
+
878
+ /**
879
+ * Send a chat message to the currently joined bot.
880
+ *
881
+ * @param {string} message - User message content
882
+ * @param {Array<{role: string, content: string}>} [history] - Ignored. The server
883
+ * rebuilds the conversation from the stored session; kept so existing calls still work.
884
+ */
885
+ sendBotChatMessage(message, history = []) {
886
+ validator.validate(
887
+ {
888
+ message: { value: message, type: "string" },
889
+ },
890
+ "sendBotChatMessage"
891
+ );
892
+
893
+ if (!Array.isArray(history)) {
894
+ throw new VaultError(
895
+ "[Vault SDK] 'sendBotChatMessage': history must be an array when provided.",
896
+ { code: "INVALID_PARAMETER", operation: "sendBotChatMessage" }
897
+ );
898
+ }
899
+
900
+ const trimmedMessage = message.trim();
901
+ if (!trimmedMessage) {
902
+ throw new VaultError(
903
+ "[Vault SDK] 'sendBotChatMessage': Message cannot be empty.",
904
+ { code: "INVALID_PARAMETER", operation: "sendBotChatMessage" }
905
+ );
906
+ }
907
+
908
+ if (history.length && !this.warnedHistoryIgnored) {
909
+ this.warnedHistoryIgnored = true;
910
+ console.warn(
911
+ "[Vault SDK] 'sendBotChatMessage': the history argument is ignored. " +
912
+ "The server rebuilds the conversation from the stored session."
913
+ );
914
+ }
915
+
916
+ this.sendBotChatEvent("send_message", { message: trimmedMessage });
917
+ }
918
+
919
+ /**
920
+ * Broadcast a typing indicator to the user's other active chat tabs.
921
+ */
922
+ sendBotChatTyping() {
923
+ this.sendBotChatEvent("typing", { isTyping: true });
924
+ }
925
+
926
+ /**
927
+ * Close the bot chat socket if it is open.
928
+ *
929
+ * @param {number} [code=1000] - WebSocket close code
930
+ * @param {string} [reason="Bot chat closed by client"] - Close reason
931
+ */
932
+ disconnectBotChat(code = 1000, reason = "Bot chat closed by client") {
933
+ if (!this.botChatWs) {
934
+ return;
935
+ }
936
+
937
+ this.botChatWs.close(code, reason);
938
+ this.botChatWs = null;
939
+ }
940
+
941
+ // ─── File Upload ──────────────────────────────────────────────
942
+
943
+ /**
944
+ * Upload a file to the vault.
945
+ *
946
+ * @param {string|Object|Blob} file - A path on disk, a File/Blob, or an
947
+ * object with the content — { buffer, name } / { path } / { data, name }.
948
+ * `type` (or `mimeType`/`contentType`) is optional; it is derived from the
949
+ * file extension when omitted.
950
+ * @param {string} vaultId - The vault ID to upload to
951
+ * @param {string} [parentId] - Parent folder ID (omit or null for root)
952
+ * @returns {Promise<Object>} Registration response with the stored file details
953
+ *
954
+ * @throws {VaultError} If the file is unreadable, invalid, or the upload fails at any step
955
+ * @throws {ValidationError} If required parameters are missing/invalid
956
+ */
957
+ /**
958
+ * Internal: turn a caller-supplied page number or page size into a usable
959
+ * integer, rejecting values that are not numbers and clamping the rest.
960
+ *
961
+ * @param {*} value - Raw value from the caller
962
+ * @param {string} field - Field name, used in the message
963
+ * @param {string} operation - Calling method name
964
+ * @param {number} max - Largest value allowed
965
+ * @returns {number} An integer between 1 and max
966
+ * @throws {ValidationError} If the value is not a number
967
+ */
968
+ _pagingValue(value, field, operation, max) {
969
+ const numeric =
970
+ typeof value === "string" && value.trim() !== "" ? Number(value) : value;
971
+
972
+ if (typeof numeric !== "number" || !Number.isFinite(numeric)) {
973
+ throw new ValidationError(
974
+ operation,
975
+ field,
976
+ "number",
977
+ `[Vault SDK] '${operation}': '${field}' must be a number. Received: ${typeof value}.`
978
+ );
979
+ }
980
+
981
+ return Math.min(Math.max(Math.trunc(numeric), 1), max);
982
+ }
983
+
984
+ /**
985
+ * Internal: refuse a URL that would carry credentials in the clear.
986
+ *
987
+ * @param {URL} parsed - The URL to check
988
+ * @param {string} label - What the URL is, used in the message
989
+ * @param {string} operation - Calling method name
990
+ * @returns {URL} The same URL, when it is acceptable
991
+ * @throws {VaultError} If the URL is unencrypted, non-local and not explicitly allowed
992
+ */
993
+ _assertEncryptedTransport(parsed, label, operation) {
994
+ if (
995
+ isEncryptedProtocol(parsed.protocol) ||
996
+ this.allowInsecure ||
997
+ isLocalHostname(parsed.hostname.toLowerCase())
998
+ ) {
999
+ return parsed;
1000
+ }
1001
+
1002
+ throw new VaultError(
1003
+ `[Vault SDK] ${operation}: ${label} uses "${parsed.protocol}//", so API keys, signatures and file contents would travel unencrypted. ` +
1004
+ `Use https:// (or wss://), or set VAULT_ALLOW_INSECURE: true for a local test server.`,
1005
+ { code: "INSECURE_TRANSPORT", operation }
1006
+ );
1007
+ }
1008
+
1009
+ /**
1010
+ * A redacted view of this client, used by JSON.stringify and console.log.
1011
+ *
1012
+ * @returns {Object} Configuration with the secrets replaced
1013
+ */
1014
+ toJSON() {
1015
+ return {
1016
+ baseUrl: this.baseUrl,
1017
+ wsUrl: this.wsUrl,
1018
+ apiKey: REDACTED,
1019
+ apiSecret: REDACTED,
1020
+ clientApiKey: REDACTED,
1021
+ };
1022
+ }
1023
+
1024
+ [Symbol.for("nodejs.util.inspect.custom")]() {
1025
+ return `Vault ${JSON.stringify(this.toJSON())}`;
1026
+ }
1027
+
1028
+ /**
1029
+ * Internal: check a presigned upload URL before sending any bytes to it.
1030
+ *
1031
+ * @param {string} rawUrl - URL returned by the presign step
1032
+ * @param {string} operation - Calling method name
1033
+ * @param {string} fileName - File name, used in error messages
1034
+ * @returns {string} The URL to upload to
1035
+ * @throws {VaultError} If the URL is malformed, not HTTPS, or on an unexpected host
1036
+ */
1037
+ _checkUploadUrl(rawUrl, operation, fileName) {
1038
+ let parsed;
1039
+ try {
1040
+ parsed = new URL(String(rawUrl));
1041
+ } catch {
1042
+ throw new VaultError(
1043
+ `[Vault SDK] '${operation}': The upload URL returned for "${fileName}" is not a valid URL.`,
1044
+ { code: "UPLOAD_URL_REJECTED", operation }
1045
+ );
1046
+ }
1047
+
1048
+ const hostname = parsed.hostname.toLowerCase();
1049
+
1050
+ if (parsed.protocol !== "https:" && !isLocalHostname(hostname)) {
1051
+ throw new VaultError(
1052
+ `[Vault SDK] '${operation}': The upload URL returned for "${fileName}" is not HTTPS. ` +
1053
+ `Refusing to send the file over an unencrypted connection.`,
1054
+ { code: "UPLOAD_URL_REJECTED", operation }
1055
+ );
1056
+ }
1057
+
1058
+ const allowed = this.allowedUploadHosts.some((entry) =>
1059
+ entry.startsWith(".") ? hostname.endsWith(entry) : hostname === entry
1060
+ );
1061
+
1062
+ if (!allowed) {
1063
+ throw new VaultError(
1064
+ `[Vault SDK] '${operation}': The server returned an upload URL on an unexpected host ("${hostname}") for "${fileName}". ` +
1065
+ `Add it to VAULT_UPLOAD_HOSTS if your deployment stores files there.`,
1066
+ { code: "UPLOAD_URL_REJECTED", operation }
1067
+ );
1068
+ }
1069
+
1070
+ return parsed.toString();
1071
+ }
1072
+
1073
+ /**
1074
+ * Internal: how long to wait for one upload, scaled to the file size.
1075
+ *
1076
+ * @param {number} bytes - File size in bytes
1077
+ * @returns {number} Timeout in milliseconds
1078
+ */
1079
+ _uploadTimeoutFor(bytes) {
1080
+ if (this.uploadTimeout !== undefined) return this.uploadTimeout;
1081
+
1082
+ const scaled =
1083
+ Math.ceil((Number(bytes) || 0) / ASSUMED_UPLOAD_BYTES_PER_SECOND) * 1000;
1084
+ return Math.min(Math.max(MIN_UPLOAD_TIMEOUT, scaled), MAX_UPLOAD_TIMEOUT);
1085
+ }
1086
+
1087
+ async uploadFile(file, vaultId, parentId = null) {
1088
+ validator.validate(
1089
+ {
1090
+ vaultId: { value: vaultId, type: "string" },
1091
+ },
1092
+ "uploadFile"
1093
+ );
1094
+
1095
+ // Read the file and derive everything the upload needs from it.
1096
+ const { buffer, name, type } = await resolveFile(file, "uploadFile", {
1097
+ uploadRoot: this.uploadRoot,
1098
+ });
1099
+ const fileSize = buffer.length;
1100
+
1101
+ if (fileSize === 0) {
1102
+ throw new VaultError(
1103
+ `[Vault SDK] 'uploadFile': "${name}" is empty. Cannot upload a zero-byte file.`,
1104
+ { code: "INVALID_PARAMETER", operation: "uploadFile" }
1105
+ );
1106
+ }
1107
+
1108
+ if (fileSize > MAX_FILE_SIZE) {
1109
+ throw new VaultError(
1110
+ `[Vault SDK] 'uploadFile': "${name}" is ${formatFileSize(fileSize)}, ` +
1111
+ `which exceeds the maximum upload size of ${formatFileSize(MAX_FILE_SIZE)}.`,
1112
+ { code: "FILE_TOO_LARGE", operation: "uploadFile" }
1113
+ );
1114
+ }
1115
+
1116
+ const fileName = sanitizeFileName(name);
1117
+ const fileType = type || contentTypeFor(fileName);
1118
+ const contentHash = crypto
1119
+ .createHash("sha256")
1120
+ .update(buffer)
1121
+ .digest("hex");
1122
+
1123
+ // Step 1: Get the presigned storage URL
1124
+ let presign;
1125
+ try {
1126
+ const response = await this.request(
1127
+ "POST",
1128
+ "/v1/vault-sdk/get-presigned-url",
1129
+ {
1130
+ vaultId,
1131
+ fileName,
1132
+ fileType,
1133
+ fileSize,
1134
+ contentHash,
1135
+ folderId: parentId,
1136
+ },
1137
+ { operation: "uploadFile" }
1138
+ );
1139
+ presign = response.data?.data ?? response.data;
1140
+ } catch (error) {
1141
+ if (error instanceof VaultError) throw error;
1142
+ throw new VaultError(
1143
+ `[Vault SDK] 'uploadFile': Failed to get an upload URL for "${fileName}" — ${error.message}`,
1144
+ { code: "PRESIGN_FAILED", operation: "uploadFile" }
1145
+ );
1146
+ }
1147
+
1148
+ const { url, key, contentType, sanitizedName, userId, metadata } =
1149
+ presign || {};
1150
+
1151
+ if (!url || !key) {
1152
+ throw new VaultError(
1153
+ `[Vault SDK] 'uploadFile': The server did not return an upload URL for "${fileName}".`,
1154
+ {
1155
+ code: "PRESIGN_FAILED",
1156
+ operation: "uploadFile",
1157
+ data: safeErrorDetails(presign),
1158
+ }
1159
+ );
1160
+ }
1161
+
1162
+ // Step 2: Upload the bytes to storage.
1163
+ const metaHeaders = metadata
1164
+ ? Object.fromEntries(
1165
+ Object.entries(metadata).map(([metaKey, value]) => [
1166
+ `x-amz-meta-${metaKey}`,
1167
+ String(value),
1168
+ ])
1169
+ )
1170
+ : {
1171
+ "x-amz-meta-original-filename": sanitizedName || fileName,
1172
+ "x-amz-meta-content-hash": contentHash,
1173
+ "x-amz-meta-user-id": String(userId ?? ""),
1174
+ "x-amz-meta-folder-id": parentId || "root",
1175
+ "x-amz-meta-file-size": fileSize.toString(),
1176
+ };
1177
+
1178
+ const uploadUrl = this._checkUploadUrl(url, "uploadFile", fileName);
1179
+
1180
+ try {
1181
+ await axios.put(uploadUrl, buffer, {
1182
+ headers: {
1183
+ "Content-Type": contentType || fileType,
1184
+ ...metaHeaders,
1185
+ },
1186
+ maxBodyLength: Infinity,
1187
+ maxContentLength: Infinity,
1188
+ maxRedirects: 0,
1189
+ timeout: this._uploadTimeoutFor(fileSize),
1190
+ });
1191
+ } catch (error) {
1192
+ const status = error.response?.status;
1193
+ let detail = error.message;
1194
+ if (status === 403)
1195
+ detail =
1196
+ "The presigned URL has expired or required signing headers are missing. Please try uploading again.";
1197
+ if (status === 413)
1198
+ detail = `File "${fileName}" exceeds the maximum allowed upload size.`;
1199
+
1200
+ throw new VaultError(
1201
+ `[Vault SDK] 'uploadFile': Failed to upload "${fileName}" to storage — ${detail}`,
1202
+ {
1203
+ status,
1204
+ code: "STORAGE_UPLOAD_FAILED",
1205
+ operation: "uploadFile",
1206
+ }
1207
+ );
1208
+ }
1209
+
1210
+ // Step 3: Register the upload
1211
+ try {
1212
+ const response = await this.request(
1213
+ "POST",
1214
+ "/v1/vault-sdk/register-upload",
1215
+ {
1216
+ vaultId,
1217
+ fileName: sanitizedName || fileName,
1218
+ filebaseKey: key,
1219
+ fileSize,
1220
+ contentHash,
1221
+ folderId: parentId,
1222
+ },
1223
+ { operation: "uploadFile" }
1224
+ );
1225
+ return response.data;
1226
+ } catch (error) {
1227
+ if (error instanceof VaultError) throw error;
1228
+ throw new VaultError(
1229
+ `[Vault SDK] 'uploadFile': File "${fileName}" was uploaded to storage but failed to register. ` +
1230
+ `Please contact support if this persists — ${error.message}`,
1231
+ { code: "REGISTER_FAILED", operation: "uploadFile" }
1232
+ );
1233
+ }
1234
+ }
1235
+
1236
+ /**
1237
+ * Upload multiple files to the vault in parallel.
1238
+ *
1239
+ * Files upload a few at a time (VAULT_UPLOAD_CONCURRENCY, 3 by default) and
1240
+ * each one is independent, so a single failure does not block the others.
1241
+ * If every file fails, the call throws instead of returning an all-failed list.
1242
+ *
1243
+ * @param {Array<string|Object|Blob>} files - Array of files, in any form uploadFile() accepts
1244
+ * @param {string} vaultId - The vault ID to upload to
1245
+ * @param {string} [parentId] - Parent folder ID (omit or null for root)
1246
+ * @returns {Promise<Array<Object>>} Array of results, each with status "success" or "failed"
1247
+ *
1248
+ * @example
1249
+ * const results = await vault.uploadFiles(
1250
+ * ["./file1.pdf", { buffer: buf2, name: "file2.jpg" }],
1251
+ * "your-vault-id"
1252
+ * );
1253
+ */
1254
+ async uploadFiles(files, vaultId, parentId = null) {
1255
+ validator.validate(
1256
+ {
1257
+ files: {
1258
+ value: files,
1259
+ type: "array",
1260
+ message:
1261
+ "[Vault SDK] 'uploadFiles': 'files' must be a non-empty array of file objects ({ buffer, name }).",
1262
+ },
1263
+ vaultId: { value: vaultId, type: "string" },
1264
+ },
1265
+ "uploadFiles"
1266
+ );
1267
+
1268
+ const results = await runWithConcurrency(
1269
+ files,
1270
+ this.uploadConcurrency,
1271
+ async (file, index) => {
1272
+ const label =
1273
+ baseName(
1274
+ typeof file === "string" ? file : file?.name || file?.path || ""
1275
+ ) || `file[${index}]`;
1276
+
1277
+ try {
1278
+ const result = await this.uploadFile(file, vaultId, parentId);
1279
+ return { ...result, status: "success", fileName: label };
1280
+ } catch (error) {
1281
+ return {
1282
+ status: "failed",
1283
+ fileName: label,
1284
+ error: error.message,
1285
+ code: error.code || "UPLOAD_FAILED",
1286
+ };
1287
+ }
1288
+ }
1289
+ );
1290
+
1291
+ if (results.length && results.every((result) => result.status === "failed")) {
1292
+ throw new VaultError(
1293
+ `[Vault SDK] 'uploadFiles': All ${results.length} file${results.length === 1 ? "" : "s"} failed to upload.`,
1294
+ { code: "UPLOAD_FAILED", operation: "uploadFiles", data: { results } }
1295
+ );
1296
+ }
1297
+
1298
+ return results;
1299
+ }
1300
+
1301
+ // ─── File Retrieval ───────────────────────────────────────────
1302
+
1303
+ /**
1304
+ * Search for files in the vault by name or query.
1305
+ *
1306
+ * @param {string} vaultId - The vault ID to search in
1307
+ * @param {string} [query=""] - Search query to filter files by name
1308
+ * @returns {Promise<Object>} Matching files
1309
+ *
1310
+ * @example
1311
+ * const files = await vault.getFiles("your-vault-id", "report");
1312
+ */
1313
+ async getFiles(vaultId, query = "") {
1314
+ validator.validate(
1315
+ {
1316
+ vaultId: { value: vaultId, type: "string" },
1317
+ query: { value: query, type: "string", required: false },
1318
+ },
1319
+ "getFiles"
1320
+ );
1321
+
1322
+ const queryString = `?vaultId=${encodeURIComponent(vaultId)}&query=${encodeURIComponent(query)}`;
1323
+ const response = await this.request(
1324
+ "GET",
1325
+ `/v1/vault-sdk/get-files${queryString}`,
1326
+ undefined,
1327
+ { operation: "getFiles" }
1328
+ );
1329
+ return response.data;
1330
+ }
1331
+
1332
+ /**
1333
+ * Get all files in the vault.
1334
+ *
1335
+ * @param {string} vaultId - The vault ID
1336
+ * @returns {Promise<Object>} All files in the vault
1337
+ *
1338
+ * @example
1339
+ * const allFiles = await vault.getAllFiles("your-vault-id");
1340
+ */
1341
+ async getAllFiles(vaultId) {
1342
+ validator.validate(
1343
+ {
1344
+ vaultId: { value: vaultId, type: "string" },
1345
+ },
1346
+ "getAllFiles"
1347
+ );
1348
+
1349
+ const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
1350
+ const response = await this.request(
1351
+ "GET",
1352
+ `/v1/vault-sdk/all-files${queryString}`,
1353
+ undefined,
1354
+ { operation: "getAllFiles" }
1355
+ );
1356
+ return response.data;
1357
+ }
1358
+
1359
+ // ─── Storage & Plans ──────────────────────────────────────────
1360
+
1361
+ /**
1362
+ * Get storage usage details for the vault (used space, total space, etc.).
1363
+ *
1364
+ * @param {string} vaultId - The vault ID
1365
+ * @returns {Promise<Object>} Storage usage information
1366
+ *
1367
+ * @example
1368
+ * const storage = await vault.getStorageDetails("your-vault-id");
1369
+ */
1370
+ async getStorageDetails(vaultId) {
1371
+ validator.validate(
1372
+ {
1373
+ vaultId: { value: vaultId, type: "string" },
1374
+ },
1375
+ "getStorageDetails"
1376
+ );
1377
+
1378
+ const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
1379
+ const response = await this.request(
1380
+ "GET",
1381
+ `/v1/vault-sdk/storage-details${queryString}`,
1382
+ undefined,
1383
+ { operation: "getStorageDetails" }
1384
+ );
1385
+ return response.data;
1386
+ }
1387
+
1388
+ /**
1389
+ * Get all available storage plans.
1390
+ *
1391
+ * @param {string} vaultId - The vault ID
1392
+ * @returns {Promise<Object>} Available storage plans
1393
+ *
1394
+ * @example
1395
+ * const plans = await vault.getAllPlans("your-vault-id");
1396
+ */
1397
+ async getAllPlans(vaultId) {
1398
+ validator.validate(
1399
+ {
1400
+ vaultId: { value: vaultId, type: "string" },
1401
+ },
1402
+ "getAllPlans"
1403
+ );
1404
+
1405
+ const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
1406
+ const response = await this.request(
1407
+ "GET",
1408
+ `/v1/vault-sdk/all-plans${queryString}`,
1409
+ undefined,
1410
+ { operation: "getAllPlans" }
1411
+ );
1412
+ return response.data;
1413
+ }
1414
+
1415
+ /**
1416
+ * Purchase a storage plan.
1417
+ *
1418
+ * @param {string} vaultId - The vault ID
1419
+ * @param {string} priceId - The price ID of the plan to purchase
1420
+ * @returns {Promise<Object>} Purchase confirmation
1421
+ *
1422
+ * @example
1423
+ * const purchase = await vault.buyPlan("your-vault-id", "price-id");
1424
+ */
1425
+ async buyPlan(vaultId, priceId) {
1426
+ validator.validate(
1427
+ {
1428
+ vaultId: { value: vaultId, type: "string" },
1429
+ priceId: { value: priceId, type: "string" },
1430
+ },
1431
+ "buyPlan"
1432
+ );
1433
+
1434
+ const response = await this.request(
1435
+ "POST",
1436
+ "/v1/vault-sdk/buy-plan",
1437
+ { vaultId, priceId },
1438
+ { operation: "buyPlan" }
1439
+ );
1440
+ return response.data;
1441
+ }
1442
+
1443
+ /**
1444
+ * Cancel the active subscription at period end.
1445
+ *
1446
+ * @param {string} vaultId - The vault ID
1447
+ * @returns {Promise<Object>} Cancellation scheduling details
1448
+ *
1449
+ * @example
1450
+ * const result = await vault.cancelSubscription("your-vault-id");
1451
+ */
1452
+ async cancelSubscription(vaultId) {
1453
+ validator.validate(
1454
+ {
1455
+ vaultId: { value: vaultId, type: "string" },
1456
+ },
1457
+ "cancelSubscription"
1458
+ );
1459
+
1460
+ const response = await this.request(
1461
+ "POST",
1462
+ "/v1/vault-sdk/cancel-subscription",
1463
+ { vaultId },
1464
+ { operation: "cancelSubscription" }
1465
+ );
1466
+ return response.data;
1467
+ }
1468
+
1469
+ /**
1470
+ * Schedule an upcoming plan to start after current plan expiry.
1471
+ *
1472
+ * @param {string} vaultId - The vault ID
1473
+ * @param {string} priceId - Stripe price ID for the upcoming plan
1474
+ * @returns {Promise<Object>} Upcoming plan scheduling result
1475
+ *
1476
+ * @example
1477
+ * const result = await vault.createUpcomingPlan("your-vault-id", "price-id");
1478
+ */
1479
+ async createUpcomingPlan(vaultId, priceId) {
1480
+ validator.validate(
1481
+ {
1482
+ vaultId: { value: vaultId, type: "string" },
1483
+ priceId: { value: priceId, type: "string" },
1484
+ },
1485
+ "createUpcomingPlan"
1486
+ );
1487
+
1488
+ const response = await this.request(
1489
+ "POST",
1490
+ "/v1/vault-sdk/upcoming",
1491
+ { vaultId, priceId },
1492
+ { operation: "createUpcomingPlan" }
1493
+ );
1494
+ return response.data;
1495
+ }
1496
+
1497
+ /**
1498
+ * Cancel auto-renewal for a pending upcoming plan.
1499
+ *
1500
+ * @param {string} vaultId - The vault ID
1501
+ * @returns {Promise<Object>} Upcoming plan cancellation result
1502
+ *
1503
+ * @example
1504
+ * const result = await vault.cancelUpcomingPlan("your-vault-id");
1505
+ */
1506
+ async cancelUpcomingPlan(vaultId) {
1507
+ validator.validate(
1508
+ {
1509
+ vaultId: { value: vaultId, type: "string" },
1510
+ },
1511
+ "cancelUpcomingPlan"
1512
+ );
1513
+
1514
+ const response = await this.request(
1515
+ "POST",
1516
+ "/v1/vault-sdk/upcoming/cancel",
1517
+ { vaultId },
1518
+ { operation: "cancelUpcomingPlan" }
1519
+ );
1520
+ return response.data;
1521
+ }
1522
+
1523
+ /**
1524
+ * Get active subscriptions for the vault.
1525
+ *
1526
+ * @param {string} vaultId - The vault ID
1527
+ * @returns {Promise<Object>} Active subscriptions
1528
+ *
1529
+ * @example
1530
+ * const subs = await vault.getSubscriptions("your-vault-id");
1531
+ */
1532
+ async getSubscriptions(vaultId) {
1533
+ validator.validate(
1534
+ {
1535
+ vaultId: { value: vaultId, type: "string" },
1536
+ },
1537
+ "getSubscriptions"
1538
+ );
1539
+
1540
+ const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
1541
+ const response = await this.request(
1542
+ "GET",
1543
+ `/v1/vault-sdk/subscriptions${queryString}`,
1544
+ undefined,
1545
+ { operation: "getSubscriptions" }
1546
+ );
1547
+ return response.data;
1548
+ }
1549
+
1550
+ /**
1551
+ * Get the wallet summary for the authenticated vault user.
1552
+ *
1553
+ * @param {string} vaultId - The vault ID
1554
+ * @returns {Promise<Object>} Wallet summary including points and status
1555
+ *
1556
+ * @example
1557
+ * const wallet = await vault.getWalletInfo("your-vault-id");
1558
+ */
1559
+ async getWalletInfo(vaultId) {
1560
+ validator.validate(
1561
+ {
1562
+ vaultId: { value: vaultId, type: "string" },
1563
+ },
1564
+ "getWalletInfo"
1565
+ );
1566
+
1567
+ const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
1568
+ const response = await this.request(
1569
+ "GET",
1570
+ `/v1/vault-sdk/wallet/info${queryString}`,
1571
+ undefined,
1572
+ { operation: "getWalletInfo" }
1573
+ );
1574
+ return response.data;
1575
+ }
1576
+
1577
+ /**
1578
+ * Get paginated wallet transaction history for the authenticated vault user.
1579
+ *
1580
+ * @param {string} vaultId - The vault ID
1581
+ * @param {Object} [query]
1582
+ * @param {number} [query.page] - Page number (defaults to 1)
1583
+ * @param {number} [query.limit] - Page size (defaults to 20)
1584
+ * @param {string} [query.category] - Optional transaction category filter
1585
+ * @returns {Promise<Object>} Transaction history and pagination metadata
1586
+ *
1587
+ * @example
1588
+ * const history = await vault.getTransactionHistory("your-vault-id");
1589
+ * const filtered = await vault.getTransactionHistory("your-vault-id", {
1590
+ * page: 2,
1591
+ * limit: 10,
1592
+ * category: "credit",
1593
+ * });
1594
+ */
1595
+ async getTransactionHistory(vaultId, query = {}) {
1596
+ validator.validate(
1597
+ {
1598
+ vaultId: { value: vaultId, type: "string" },
1599
+ query: { value: query, type: "object", required: false },
1600
+ },
1601
+ "getTransactionHistory"
1602
+ );
1603
+
1604
+ const params = new URLSearchParams({ vaultId });
1605
+
1606
+ if (query.page !== undefined && query.page !== null) {
1607
+ params.set(
1608
+ "page",
1609
+ String(
1610
+ this._pagingValue(query.page, "page", "getTransactionHistory", Number.MAX_SAFE_INTEGER)
1611
+ )
1612
+ );
1613
+ }
1614
+ if (query.limit !== undefined && query.limit !== null) {
1615
+ params.set(
1616
+ "limit",
1617
+ String(this._pagingValue(query.limit, "limit", "getTransactionHistory", MAX_PAGE_SIZE))
1618
+ );
1619
+ }
1620
+ if (typeof query.category === "string" && query.category.trim()) {
1621
+ params.set("category", query.category.trim());
1622
+ }
1623
+
1624
+ const response = await this.request(
1625
+ "GET",
1626
+ `/v1/vault-sdk/wallet/transactions?${params.toString()}`,
1627
+ undefined,
1628
+ { operation: "getTransactionHistory" }
1629
+ );
1630
+ return response.data;
1631
+ }
1632
+
1633
+ // ─── Folder Operations ────────────────────────────────────────
1634
+
1635
+ /**
1636
+ * Create a new folder in the vault.
1637
+ *
1638
+ * @param {string} vaultId - The vault ID
1639
+ * @param {string} folderName - Name for the new folder
1640
+ * @param {string} [parentId] - Parent folder ID for nested folders (omit for root)
1641
+ * @returns {Promise<Object>} Created folder details
1642
+ *
1643
+ * @example
1644
+ * await vault.createFolder("your-vault-id", "Documents");
1645
+ * await vault.createFolder("your-vault-id", "Invoices", "parent-folder-id");
1646
+ */
1647
+ async createFolder(vaultId, folderName, parentId = null) {
1648
+ validator.validate(
1649
+ {
1650
+ vaultId: { value: vaultId, type: "string" },
1651
+ folderName: { value: folderName, type: "string" },
1652
+ },
1653
+ "createFolder"
1654
+ );
1655
+
1656
+ const response = await this.request(
1657
+ "POST",
1658
+ "/v1/vault-sdk/create-folder",
1659
+ { vaultId, folderName, parentId },
1660
+ { operation: "createFolder" }
1661
+ );
1662
+ return response.data;
1663
+ }
1664
+
1665
+ /**
1666
+ * Rename a file or folder in the vault.
1667
+ *
1668
+ * @param {string} vaultId - The vault ID
1669
+ * @param {string} itemId - The ID of the file or folder to rename
1670
+ * @param {string} newName - The new name
1671
+ * @returns {Promise<Object>} Updated item details
1672
+ *
1673
+ * @example
1674
+ * await vault.renameItem("your-vault-id", "item-id", "New Name.pdf");
1675
+ */
1676
+ async renameItem(vaultId, itemId, newName) {
1677
+ validator.validate(
1678
+ {
1679
+ vaultId: { value: vaultId, type: "string" },
1680
+ itemId: { value: itemId, type: "string" },
1681
+ newName: { value: newName, type: "string" },
1682
+ },
1683
+ "renameItem"
1684
+ );
1685
+
1686
+ const response = await this.request(
1687
+ "POST",
1688
+ "/v1/vault-sdk/rename",
1689
+ { vaultId, itemId, newName },
1690
+ { operation: "renameItem" }
1691
+ );
1692
+ return response.data;
1693
+ }
1694
+
1695
+ /**
1696
+ * Delete a folder from the vault.
1697
+ *
1698
+ * @param {string} vaultId - The vault ID
1699
+ * @param {string} folderId - The ID of the folder to delete
1700
+ * @returns {Promise<Object>} Deletion confirmation
1701
+ *
1702
+ * @example
1703
+ * await vault.deleteFolder("your-vault-id", "folder-id");
1704
+ */
1705
+ async deleteFolder(vaultId, folderId) {
1706
+ validator.validate(
1707
+ {
1708
+ vaultId: { value: vaultId, type: "string" },
1709
+ folderId: { value: folderId, type: "string" },
1710
+ },
1711
+ "deleteFolder"
1712
+ );
1713
+
1714
+ const response = await this.request(
1715
+ "DELETE",
1716
+ "/v1/vault-sdk/delete-folder",
1717
+ { vaultId, folderId },
1718
+ { operation: "deleteFolder" }
1719
+ );
1720
+ return response.data;
1721
+ }
1722
+
1723
+ /**
1724
+ * Delete a file from the vault.
1725
+ *
1726
+ * @param {string} vaultId - The vault ID
1727
+ * @param {string} fileId - The ID of the file to delete
1728
+ * @returns {Promise<Object>} Deletion confirmation
1729
+ *
1730
+ * @example
1731
+ * await vault.deleteFile("your-vault-id", "file-id");
1732
+ */
1733
+ async deleteFile(vaultId, fileId) {
1734
+ validator.validate(
1735
+ {
1736
+ vaultId: { value: vaultId, type: "string" },
1737
+ fileId: { value: fileId, type: "string" },
1738
+ },
1739
+ "deleteFile"
1740
+ );
1741
+
1742
+ const response = await this.request(
1743
+ "DELETE",
1744
+ "/v1/vault-sdk/delete-file",
1745
+ { vaultId, fileId },
1746
+ { operation: "deleteFile" }
1747
+ );
1748
+ return response.data;
1749
+ }
1750
+
1751
+ // ─── Starred Files ────────────────────────────────────────────
1752
+
1753
+ /**
1754
+ * Mark or unmark a file as starred.
1755
+ *
1756
+ * @param {string} vaultId - The vault ID
1757
+ * @param {string} fileId - The file ID to star/unstar
1758
+ * @param {boolean} isStarred - true to star, false to unstar
1759
+ * @returns {Promise<Object>} Updated file details
1760
+ *
1761
+ * @example
1762
+ * await vault.addToStarred("your-vault-id", "file-id", true);
1763
+ */
1764
+ async addToStarred(vaultId, fileId, isStarred) {
1765
+ validator.validate(
1766
+ {
1767
+ vaultId: { value: vaultId, type: "string" },
1768
+ fileId: { value: fileId, type: "string" },
1769
+ isStarred: { value: isStarred, type: "boolean" },
1770
+ },
1771
+ "addToStarred"
1772
+ );
1773
+
1774
+ const response = await this.request(
1775
+ "POST",
1776
+ "/v1/vault-sdk/favorites",
1777
+ { vaultId, assetId: fileId, isFavorite: isStarred },
1778
+ { operation: "addToStarred" }
1779
+ );
1780
+ return response.data;
1781
+ }
1782
+
1783
+ /**
1784
+ * Get all starred files in the vault.
1785
+ *
1786
+ * @param {string} vaultId - The vault ID
1787
+ * @returns {Promise<Object>} Starred files
1788
+ *
1789
+ * @example
1790
+ * const starred = await vault.getStarredFiles("your-vault-id");
1791
+ */
1792
+ async getStarredFiles(vaultId) {
1793
+ validator.validate(
1794
+ {
1795
+ vaultId: { value: vaultId, type: "string" },
1796
+ },
1797
+ "getStarredFiles"
1798
+ );
1799
+
1800
+ const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
1801
+ const response = await this.request(
1802
+ "GET",
1803
+ `/v1/vault-sdk/favorites${queryString}`,
1804
+ undefined,
1805
+ { operation: "getStarredFiles" }
1806
+ );
1807
+ return response.data;
1808
+ }
1809
+
1810
+ // ─── Platform Operations ──────────────────────────────────────
1811
+
1812
+ /**
1813
+ * Create a vault for a user, or link an existing one to your client.
1814
+ *
1815
+ * Idempotent: if a user already exists for the email, they are linked to your
1816
+ * client API key and returned instead of erroring.
1817
+ *
1818
+ * @param {string} email - User's email address
1819
+ * @param {string} [platformId] - Optional platform ID to associate the user with
1820
+ * @returns {Promise<Object>} Created (or existing) user details, including vaultId
1821
+ *
515
1822
  * @example
516
- * const plans = await vault.getAllPlans("your-vault-id");
1823
+ * const user = await vault.createVault("user@example.com", "platform-id");
1824
+ * const sdkUser = await vault.createVault("user@example.com"); // platform-less SDK user link
517
1825
  */
518
- async getAllPlans(vaultId) {
1826
+ async createVault(email, platformId) {
1827
+ const normalizedEmail =
1828
+ typeof email === "string" ? email.trim() : email;
1829
+ const normalizedPlatformId =
1830
+ typeof platformId === "string" ? platformId.trim() : platformId;
1831
+
519
1832
  validator.validate(
520
1833
  {
521
- vaultId: { value: vaultId, type: "string" },
1834
+ email: {
1835
+ value: normalizedEmail,
1836
+ type: "string",
1837
+ message:
1838
+ "[Vault SDK] 'createVault' requires 'email' to be a non-empty string.",
1839
+ },
1840
+ platformId: {
1841
+ value: normalizedPlatformId || undefined,
1842
+ type: "string",
1843
+ required: false,
1844
+ },
522
1845
  },
523
- "getAllPlans"
1846
+ "createVault"
524
1847
  );
525
1848
 
526
- const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
1849
+ const payload = { email: normalizedEmail };
1850
+ if (normalizedPlatformId) {
1851
+ payload.platformId = normalizedPlatformId;
1852
+ }
1853
+
527
1854
  const response = await this.request(
528
- "GET",
529
- `/v1/vault-sdk/all-plans${queryString}`,
530
- undefined,
531
- { operation: "getAllPlans" }
1855
+ "POST",
1856
+ "/v1/vault-sdk/create-user",
1857
+ payload,
1858
+ { operation: "createVault" }
532
1859
  );
533
1860
  return response.data;
534
1861
  }
535
1862
 
536
1863
  /**
537
- * Purchase a storage plan.
1864
+ * Import an existing vault into a platform.
538
1865
  *
539
- * @param {string} vaultId - The vault ID
540
- * @param {string} priceId - The price ID of the plan to purchase
541
- * @returns {Promise<Object>} Purchase confirmation
1866
+ * @param {string} vaultId - The vault ID to import
1867
+ * @param {string} [platformId] - Optional target platform ID
1868
+ * @returns {Promise<Object>} Import result
542
1869
  *
543
1870
  * @example
544
- * const purchase = await vault.buyPlan("your-vault-id", "price-id");
1871
+ * const result = await vault.importVault("vault-id", "platform-id");
1872
+ * const result = await vault.importVault("vault-id"); // link client + enable SDK access without a platform
545
1873
  */
546
- async buyPlan(vaultId, priceId) {
1874
+ async importVault(vaultId, platformId) {
1875
+ const normalizedPlatformId =
1876
+ typeof platformId === "string" ? platformId.trim() : platformId;
1877
+
547
1878
  validator.validate(
548
1879
  {
549
1880
  vaultId: { value: vaultId, type: "string" },
550
- priceId: { value: priceId, type: "string" },
1881
+ platformId: {
1882
+ value: normalizedPlatformId || undefined,
1883
+ type: "string",
1884
+ required: false,
1885
+ },
551
1886
  },
552
- "buyPlan"
1887
+ "importVault"
553
1888
  );
554
1889
 
1890
+ const payload = { vaultId };
1891
+ if (normalizedPlatformId) {
1892
+ payload.platformId = normalizedPlatformId;
1893
+ }
1894
+
555
1895
  const response = await this.request(
556
1896
  "POST",
557
- "/v1/vault-sdk/buy-plan",
558
- { vaultId, priceId },
559
- { operation: "buyPlan" }
1897
+ "/v1/vault-sdk/import-vault",
1898
+ payload,
1899
+ { operation: "importVault" }
560
1900
  );
561
1901
  return response.data;
562
1902
  }
563
1903
 
564
1904
  /**
565
- * Cancel the active subscription at period end.
1905
+ * Create a bot for the given vault.
566
1906
  *
567
- * @param {string} vaultId - The vault ID
568
- * @returns {Promise<Object>} Cancellation scheduling details
1907
+ * @param {string} vaultId - The vault ID that owns the bot
1908
+ * @param {Object} bot
1909
+ * @param {string} bot.name - Bot display name
1910
+ * @param {string} [bot.description] - Optional bot personality/description
1911
+ * @param {string} [bot.profession] - Optional profession label
1912
+ * @returns {Promise<Object>} Created bot details, including its dedicated folder
569
1913
  *
570
1914
  * @example
571
- * const result = await vault.cancelSubscription("your-vault-id");
1915
+ * const bot = await vault.createBot("your-vault-id", {
1916
+ * name: "Support Bot",
1917
+ * description: "Answers customer questions clearly",
1918
+ * profession: "Customer Support",
1919
+ * });
572
1920
  */
573
- async cancelSubscription(vaultId) {
1921
+ async createBot(vaultId, bot) {
1922
+ const normalizedVaultId =
1923
+ typeof vaultId === "string" ? vaultId.trim() : vaultId;
1924
+
1925
+ const normalizedBot = {
1926
+ ...bot,
1927
+ description: bot?.description?.trim() || undefined,
1928
+ profession: bot?.profession?.trim() || undefined,
1929
+ };
1930
+
574
1931
  validator.validate(
575
1932
  {
576
- vaultId: { value: vaultId, type: "string" },
1933
+ vaultId: {
1934
+ value: normalizedVaultId,
1935
+ type: "string",
1936
+ message:
1937
+ "[Vault SDK] 'createBot' requires 'vaultId' to be a non-empty string.",
1938
+ },
1939
+ bot: { value: normalizedBot, type: "object" },
1940
+ name: {
1941
+ value: normalizedBot.name,
1942
+ type: "string",
1943
+ message:
1944
+ "[Vault SDK] 'createBot' requires bot's name to be a non-empty string.",
1945
+ },
1946
+ description: {
1947
+ value: normalizedBot.description,
1948
+ type: "string",
1949
+ required: false,
1950
+ },
1951
+ profession: {
1952
+ value: normalizedBot.profession,
1953
+ type: "string",
1954
+ required: false,
1955
+ },
577
1956
  },
578
- "cancelSubscription"
1957
+ "createBot"
579
1958
  );
580
1959
 
1960
+ const payload = {
1961
+ vaultId: normalizedVaultId,
1962
+ name: bot.name.trim(),
1963
+ };
1964
+
1965
+ if (typeof bot.description === "string" && bot.description.trim()) {
1966
+ payload.description = bot.description.trim();
1967
+ }
1968
+
1969
+ if (typeof bot.profession === "string" && bot.profession.trim()) {
1970
+ payload.profession = bot.profession.trim();
1971
+ }
1972
+
581
1973
  const response = await this.request(
582
1974
  "POST",
583
- "/v1/vault-sdk/cancel-subscription",
584
- { vaultId },
585
- { operation: "cancelSubscription" }
1975
+ "/v1/vault-sdk/bots",
1976
+ payload,
1977
+ { operation: "createBot" }
586
1978
  );
587
1979
  return response.data;
588
1980
  }
589
1981
 
590
1982
  /**
591
- * Schedule an upcoming plan to start after current plan expiry.
1983
+ * Update a bot through the Vault SDK.
592
1984
  *
593
- * @param {string} vaultId - The vault ID
594
- * @param {string} priceId - Stripe price ID for the upcoming plan
595
- * @returns {Promise<Object>} Upcoming plan scheduling result
1985
+ * Any supported field may be omitted for a partial update.
596
1986
  *
597
- * @example
598
- * const result = await vault.createUpcomingPlan("your-vault-id", "price-id");
1987
+ * @param {string} vaultId - The vault ID that owns the bot
1988
+ * @param {string} botId - The bot ID to update
1989
+ * @param {Object} updates
1990
+ * @returns {Promise<Object>} Updated bot response
599
1991
  */
600
- async createUpcomingPlan(vaultId, priceId) {
1992
+ async updateBot(vaultId, botId, updates) {
601
1993
  validator.validate(
602
1994
  {
603
1995
  vaultId: { value: vaultId, type: "string" },
604
- priceId: { value: priceId, type: "string" },
1996
+ botId: { value: botId, type: "string" },
1997
+ updates: { value: updates, type: "object" },
1998
+ name: { value: updates?.name, type: "string", required: false, rejectNull: true },
1999
+ description: { value: updates?.description, type: "string", required: false, rejectNull: true },
2000
+ profession: { value: updates?.profession, type: "string", required: false, rejectNull: true },
2001
+ useLLMFallback: { value: updates?.useLLMFallback, type: "boolean", required: false, rejectNull: true },
2002
+ wordLimit: { value: updates?.wordLimit, type: "integer", required: false, rejectNull: true },
605
2003
  },
606
- "createUpcomingPlan"
2004
+ "updateBot"
607
2005
  );
608
2006
 
2007
+ const payload = { vaultId };
2008
+
2009
+ if (typeof updates?.name === "string") payload.name = updates.name.trim();
2010
+ if (typeof updates?.description === "string") {
2011
+ payload.description = updates.description;
2012
+ }
2013
+ if (typeof updates?.profession === "string") {
2014
+ payload.profession = updates.profession;
2015
+ }
2016
+ if (typeof updates?.useLLMFallback === "boolean") {
2017
+ payload.useLLMFallback = updates.useLLMFallback;
2018
+ }
2019
+ if (typeof updates?.wordLimit === "number") payload.wordLimit = updates.wordLimit;
2020
+
609
2021
  const response = await this.request(
610
- "POST",
611
- "/v1/vault-sdk/upcoming",
612
- { vaultId, priceId },
613
- { operation: "createUpcomingPlan" }
2022
+ "PATCH",
2023
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}`,
2024
+ payload,
2025
+ { operation: "updateBot" }
614
2026
  );
615
2027
  return response.data;
616
2028
  }
617
2029
 
618
2030
  /**
619
- * Cancel auto-renewal for a pending upcoming plan.
2031
+ * Delete a bot owned by the authenticated vault user.
620
2032
  *
621
- * @param {string} vaultId - The vault ID
622
- * @returns {Promise<Object>} Upcoming plan cancellation result
2033
+ * This mirrors the Twin Vault backend `DELETE /bots/:botId` behavior.
2034
+ *
2035
+ * @param {string} botId - The bot ID to delete
2036
+ * @param {string} vaultId - The vault ID that owns the bot
2037
+ * @returns {Promise<Object>} Standard API response from the backend
623
2038
  *
624
2039
  * @example
625
- * const result = await vault.cancelUpcomingPlan("your-vault-id");
2040
+ * await vault.deleteBot("bot-id", "your-vault-id");
626
2041
  */
627
- async cancelUpcomingPlan(vaultId) {
2042
+ async deleteBot(botId, vaultId) {
628
2043
  validator.validate(
629
2044
  {
2045
+ botId: { value: botId, type: "string" },
630
2046
  vaultId: { value: vaultId, type: "string" },
631
2047
  },
632
- "cancelUpcomingPlan"
2048
+ "deleteBot"
633
2049
  );
634
2050
 
635
2051
  const response = await this.request(
636
- "POST",
637
- "/v1/vault-sdk/upcoming/cancel",
638
- { vaultId },
639
- { operation: "cancelUpcomingPlan" }
2052
+ "DELETE",
2053
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}?vaultId=${encodeURIComponent(vaultId)}`,
2054
+ undefined,
2055
+ { operation: "deleteBot" }
640
2056
  );
2057
+
641
2058
  return response.data;
642
2059
  }
643
2060
 
644
2061
  /**
645
- * Get active subscriptions for the vault.
2062
+ * Get the extracted text content for a bot file.
646
2063
  *
647
- * @param {string} vaultId - The vault ID
648
- * @returns {Promise<Object>} Active subscriptions
2064
+ * This mirrors the Twin Vault backend `GET /bots/:botId/files/:fileId/text`
2065
+ * behavior through the Vault SDK route chain.
2066
+ *
2067
+ * @param {string} vaultId - The vault ID that owns the bot
2068
+ * @param {string} botId - The bot ID
2069
+ * @param {string} fileId - The bot file ID
2070
+ * @returns {Promise<string>} Extracted text content for the file
649
2071
  *
650
2072
  * @example
651
- * const subs = await vault.getSubscriptions("your-vault-id");
2073
+ * const text = await vault.getBotFileText("your-vault-id", "bot-id", "file-id");
652
2074
  */
653
- async getSubscriptions(vaultId) {
2075
+ async getBotFileText(vaultId, botId, fileId) {
654
2076
  validator.validate(
655
2077
  {
656
- vaultId: { value: vaultId, type: "string" },
2078
+ vaultId: {
2079
+ value: vaultId,
2080
+ type: "string",
2081
+ message: "[Vault SDK] 'getBotFileText' requires 'vaultId' to be a non-empty string.",
2082
+ },
2083
+ botId: {
2084
+ value: botId,
2085
+ type: "string",
2086
+ message: "[Vault SDK] 'getBotFileText' requires 'botId' to be a non-empty string.",
2087
+ },
2088
+ fileId: {
2089
+ value: fileId,
2090
+ type: "string",
2091
+ message: "[Vault SDK] 'getBotFileText' requires 'fileId' to be a non-empty string.",
2092
+ },
657
2093
  },
658
- "getSubscriptions"
2094
+ "getBotFileText"
659
2095
  );
660
2096
 
661
- const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
662
2097
  const response = await this.request(
663
2098
  "GET",
664
- `/v1/vault-sdk/subscriptions${queryString}`,
2099
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}/files/${encodeURIComponent(fileId)}/text?vaultId=${encodeURIComponent(vaultId)}`,
665
2100
  undefined,
666
- { operation: "getSubscriptions" }
2101
+ { operation: "getBotFileText" }
667
2102
  );
2103
+
668
2104
  return response.data;
669
2105
  }
670
2106
 
671
- // ─── Folder Operations ────────────────────────────────────────
2107
+ /**
2108
+ * Internal helper for bot file actions that share the same route shape.
2109
+ *
2110
+ * @private
2111
+ */
2112
+ async updateBotFileAction(vaultId, botId, fileId, action) {
2113
+ validator.validate(
2114
+ {
2115
+ vaultId: { value: vaultId, type: "string" },
2116
+ botId: { value: botId, type: "string" },
2117
+ fileId: { value: fileId, type: "string" },
2118
+ action: { value: action, type: "string" },
2119
+ },
2120
+ "updateBotFileAction"
2121
+ );
2122
+
2123
+ if (action !== "cancel" && action !== "retry") {
2124
+ throw new VaultError(
2125
+ `[Vault SDK] 'updateBotFileAction': Unsupported action "${action}".`,
2126
+ { code: "INVALID_PARAMETER", operation: "updateBotFileAction" }
2127
+ );
2128
+ }
2129
+
2130
+ const response = await this.request(
2131
+ "POST",
2132
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}/files/${encodeURIComponent(fileId)}/${action}?vaultId=${encodeURIComponent(vaultId)}`,
2133
+ undefined,
2134
+ { operation: action === "cancel" ? "cancelBotFile" : "retryBotFile" }
2135
+ );
2136
+
2137
+ return response.data;
2138
+ }
672
2139
 
673
2140
  /**
674
- * Create a new folder in the vault.
2141
+ * Cancel a processing bot file.
675
2142
  *
676
- * @param {string} vaultId - The vault ID
677
- * @param {string} folderName - Name for the new folder
678
- * @param {string} [parentId] - Parent folder ID for nested folders (omit for root)
679
- * @returns {Promise<Object>} Created folder details
2143
+ * @param {string} vaultId - The vault ID that owns the bot
2144
+ * @param {string} botId - The bot ID
2145
+ * @param {string} fileId - The bot file ID
2146
+ * @returns {Promise<Object>} Standard API response from the backend
2147
+ */
2148
+ async cancelBotFile(vaultId, botId, fileId) {
2149
+ return this.updateBotFileAction(vaultId, botId, fileId, "cancel");
2150
+ }
2151
+
2152
+ /**
2153
+ * Retry a failed bot file.
680
2154
  *
681
- * @example
682
- * await vault.createFolder("your-vault-id", "Documents");
683
- * await vault.createFolder("your-vault-id", "Invoices", "parent-folder-id");
2155
+ * @param {string} vaultId - The vault ID that owns the bot
2156
+ * @param {string} botId - The bot ID
2157
+ * @param {string} fileId - The bot file ID
2158
+ * @returns {Promise<Object>} Standard API response from the backend
684
2159
  */
685
- async createFolder(vaultId, folderName, parentId = null) {
2160
+ async retryBotFile(vaultId, botId, fileId) {
2161
+ return this.updateBotFileAction(vaultId, botId, fileId, "retry");
2162
+ }
2163
+
2164
+ /**
2165
+ * Quote the Twin Points cost of transcribing media before uploading or linking it.
2166
+ *
2167
+ * Pass direct file metadata in `payload.files`, folder IDs in `payload.folderIds`,
2168
+ * or both. Each file accepts `{ name, size, fileId?, durationSeconds? }`.
2169
+ *
2170
+ * @param {string} vaultId - The vault ID that owns the bot
2171
+ * @param {string} botId - The target bot ID
2172
+ * @param {Object} [payload]
2173
+ * @param {Array<Object>} [payload.files] - Files to quote
2174
+ * @param {string[]} [payload.folderIds] - Existing storage folders to inspect
2175
+ * @returns {Promise<Object>} Quote response from the bot endpoint
2176
+ */
2177
+ async quoteTranscription(vaultId, botId, payload = {}) {
686
2178
  validator.validate(
687
2179
  {
688
2180
  vaultId: { value: vaultId, type: "string" },
689
- folderName: { value: folderName, type: "string" },
2181
+ botId: { value: botId, type: "string" },
2182
+ payload: { value: payload, type: "object", required: false },
690
2183
  },
691
- "createFolder"
2184
+ "quoteTranscription"
692
2185
  );
693
2186
 
2187
+ const files = Array.isArray(payload.files)
2188
+ ? payload.files
2189
+ .filter((file) => file && typeof file === "object")
2190
+ .map((file) => ({
2191
+ name: typeof file.name === "string" ? file.name.trim() : "",
2192
+ size: Number(file.size || 0),
2193
+ ...(typeof file.fileId === "string" && file.fileId.trim()
2194
+ ? { fileId: file.fileId.trim() }
2195
+ : {}),
2196
+ ...(Number.isFinite(Number(file.durationSeconds))
2197
+ ? { durationSeconds: Number(file.durationSeconds) }
2198
+ : {}),
2199
+ }))
2200
+ .filter((file) => file.name)
2201
+ : [];
2202
+
2203
+ const folderIds = Array.isArray(payload.folderIds)
2204
+ ? [...new Set(payload.folderIds.map((id) => (typeof id === "string" ? id.trim() : "")).filter(Boolean))]
2205
+ : [];
2206
+
2207
+ if (!files.length && !folderIds.length) {
2208
+ throw new VaultError(
2209
+ "[Vault SDK] 'quoteTranscription': At least one file or folder ID is required.",
2210
+ { code: "INVALID_PARAMETER", operation: "quoteTranscription" }
2211
+ );
2212
+ }
2213
+
694
2214
  const response = await this.request(
695
2215
  "POST",
696
- "/v1/vault-sdk/create-folder",
697
- { vaultId, folderName, parentId },
698
- { operation: "createFolder" }
2216
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}/files/quote`,
2217
+ {
2218
+ vaultId,
2219
+ files,
2220
+ folderIds,
2221
+ },
2222
+ { operation: "quoteTranscription" }
699
2223
  );
2224
+
700
2225
  return response.data;
701
2226
  }
702
2227
 
703
2228
  /**
704
- * Rename a file or folder in the vault.
2229
+ * Internal helper for bot uploads that now use the same presign -> upload
2230
+ * -> register flow as regular drive uploads.
2231
+ *
2232
+ * @private
2233
+ */
2234
+ async uploadSingleFileToBot(file, vaultId, botId) {
2235
+ const resolved = await resolveFile(file, "uploadFilesToBot", {
2236
+ uploadRoot: this.uploadRoot,
2237
+ });
2238
+ const fileSize = resolved.buffer.length;
2239
+
2240
+ if (fileSize > MAX_FILE_SIZE) {
2241
+ throw new VaultError(
2242
+ `[Vault SDK] 'uploadFilesToBot': "${resolved.name}" is ${formatFileSize(fileSize)}, ` +
2243
+ `which exceeds the maximum upload size of ${formatFileSize(MAX_FILE_SIZE)}.`,
2244
+ { code: "FILE_TOO_LARGE", operation: "uploadFilesToBot" }
2245
+ );
2246
+ }
2247
+
2248
+ const fileName = sanitizeFileName(resolved.name);
2249
+ const fileType = resolved.type || contentTypeFor(fileName);
2250
+ const contentHash = crypto
2251
+ .createHash("sha256")
2252
+ .update(resolved.buffer)
2253
+ .digest("hex");
2254
+
2255
+ let presign;
2256
+ try {
2257
+ const response = await this.request(
2258
+ "POST",
2259
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}/files/presign`,
2260
+ {
2261
+ vaultId,
2262
+ fileName,
2263
+ fileType,
2264
+ fileSize,
2265
+ contentHash,
2266
+ },
2267
+ { operation: "uploadFilesToBot" }
2268
+ );
2269
+ presign = response.data?.data ?? response.data;
2270
+ } catch (error) {
2271
+ if (error instanceof VaultError) throw error;
2272
+ throw new VaultError(
2273
+ `[Vault SDK] 'uploadFilesToBot': Failed to get an upload URL for "${fileName}" — ${error.message}`,
2274
+ { code: "PRESIGN_FAILED", operation: "uploadFilesToBot" }
2275
+ );
2276
+ }
2277
+
2278
+ const { url, key, contentType, sanitizedName, userId, metadata } = presign || {};
2279
+
2280
+ if (!url || !key) {
2281
+ throw new VaultError(
2282
+ `[Vault SDK] 'uploadFilesToBot': The server did not return an upload URL for "${fileName}".`,
2283
+ {
2284
+ code: "PRESIGN_FAILED",
2285
+ operation: "uploadFilesToBot",
2286
+ data: safeErrorDetails(presign),
2287
+ }
2288
+ );
2289
+ }
2290
+
2291
+ const metaHeaders = metadata
2292
+ ? Object.fromEntries(
2293
+ Object.entries(metadata).map(([metaKey, value]) => [
2294
+ `x-amz-meta-${metaKey}`,
2295
+ String(value),
2296
+ ])
2297
+ )
2298
+ : {
2299
+ "x-amz-meta-original-filename": sanitizedName || fileName,
2300
+ "x-amz-meta-content-hash": contentHash,
2301
+ "x-amz-meta-user-id": String(userId ?? ""),
2302
+ "x-amz-meta-file-size": fileSize.toString(),
2303
+ };
2304
+
2305
+ const uploadUrl = this._checkUploadUrl(url, "uploadFilesToBot", fileName);
2306
+
2307
+ try {
2308
+ await axios.put(uploadUrl, resolved.buffer, {
2309
+ headers: {
2310
+ "Content-Type": contentType || fileType,
2311
+ ...metaHeaders,
2312
+ },
2313
+ maxBodyLength: Infinity,
2314
+ maxContentLength: Infinity,
2315
+ maxRedirects: 0,
2316
+ timeout: this._uploadTimeoutFor(fileSize),
2317
+ });
2318
+ } catch (error) {
2319
+ const status = error.response?.status;
2320
+ let detail = error.message;
2321
+ if (status === 403) {
2322
+ detail =
2323
+ "The presigned URL has expired or required signing headers are missing. Please try uploading again.";
2324
+ }
2325
+ if (status === 413) {
2326
+ detail = `File "${fileName}" exceeds the maximum allowed upload size.`;
2327
+ }
2328
+
2329
+ throw new VaultError(
2330
+ `[Vault SDK] 'uploadFilesToBot': Failed to upload "${fileName}" to storage — ${detail}`,
2331
+ {
2332
+ status,
2333
+ code: "STORAGE_UPLOAD_FAILED",
2334
+ operation: "uploadFilesToBot",
2335
+ }
2336
+ );
2337
+ }
2338
+
2339
+ const rawDurationSeconds =
2340
+ typeof file === "object" && file !== null ? file.durationSeconds : undefined;
2341
+ const durationSeconds = Number(rawDurationSeconds);
2342
+
2343
+ try {
2344
+ const response = await this.request(
2345
+ "POST",
2346
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}/files/register`,
2347
+ {
2348
+ vaultId,
2349
+ fileName: sanitizedName || fileName,
2350
+ filebaseKey: key,
2351
+ fileSize,
2352
+ contentHash,
2353
+ ...(Number.isFinite(durationSeconds) && durationSeconds >= 0
2354
+ ? { durationSeconds }
2355
+ : {}),
2356
+ },
2357
+ { operation: "uploadFilesToBot" }
2358
+ );
2359
+ return response.data;
2360
+ } catch (error) {
2361
+ if (error instanceof VaultError) throw error;
2362
+ throw new VaultError(
2363
+ `[Vault SDK] 'uploadFilesToBot': File "${fileName}" was uploaded to storage but failed to register. ` +
2364
+ `Please contact support if this persists — ${error.message}`,
2365
+ { code: "REGISTER_FAILED", operation: "uploadFilesToBot" }
2366
+ );
2367
+ }
2368
+ }
2369
+
2370
+ /**
2371
+ * Delete one or more bot chat sessions through the bulk-delete route.
705
2372
  *
706
- * @param {string} vaultId - The vault ID
707
- * @param {string} itemId - The ID of the file or folder to rename
708
- * @param {string} newName - The new name
709
- * @returns {Promise<Object>} Updated item details
2373
+ * A single session ID is accepted and normalized into a one-item array.
2374
+ *
2375
+ * @param {string} vaultId - The vault ID that owns the bot
2376
+ * @param {string} botId - The bot ID
2377
+ * @param {string|string[]} sessionIds - One session ID or multiple session IDs
2378
+ * @returns {Promise<Object>} Standard API response from the backend
710
2379
  *
711
2380
  * @example
712
- * await vault.renameItem("your-vault-id", "item-id", "New Name.pdf");
2381
+ * await vault.deleteBotSessions("your-vault-id", "bot-id", "session-id");
2382
+ * await vault.deleteBotSessions("your-vault-id", "bot-id", ["session-a", "session-b"]);
713
2383
  */
714
- async renameItem(vaultId, itemId, newName) {
2384
+ async deleteBotSessions(vaultId, botId, sessionIds) {
715
2385
  validator.validate(
716
2386
  {
717
2387
  vaultId: { value: vaultId, type: "string" },
718
- itemId: { value: itemId, type: "string" },
719
- newName: { value: newName, type: "string" },
2388
+ botId: { value: botId, type: "string" },
720
2389
  },
721
- "renameItem"
2390
+ "deleteBotSessions"
722
2391
  );
723
2392
 
2393
+ const normalizedSessionIds = Array.isArray(sessionIds)
2394
+ ? [...new Set(sessionIds.map((id) => (typeof id === "string" ? id.trim() : "")).filter(Boolean))]
2395
+ : typeof sessionIds === "string" && sessionIds.trim()
2396
+ ? [sessionIds.trim()]
2397
+ : [];
2398
+
2399
+ if (!normalizedSessionIds.length) {
2400
+ throw new VaultError(
2401
+ "[Vault SDK] 'deleteBotSessions': At least one session ID is required.",
2402
+ { code: "INVALID_PARAMETER", operation: "deleteBotSessions" }
2403
+ );
2404
+ }
2405
+
724
2406
  const response = await this.request(
725
2407
  "POST",
726
- "/v1/vault-sdk/rename",
727
- { vaultId, itemId, newName },
728
- { operation: "renameItem" }
2408
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}/sessions/bulk-delete`,
2409
+ {
2410
+ vaultId,
2411
+ sessionIds: normalizedSessionIds,
2412
+ },
2413
+ { operation: "deleteBotSessions" }
729
2414
  );
2415
+
730
2416
  return response.data;
731
2417
  }
732
2418
 
733
2419
  /**
734
- * Delete a folder from the vault.
2420
+ * Export one or more bot chat sessions through the bulk-export route.
735
2421
  *
736
- * @param {string} vaultId - The vault ID
737
- * @param {string} folderId - The ID of the folder to delete
738
- * @returns {Promise<Object>} Deletion confirmation
2422
+ * A single session ID is accepted and normalized into a one-item array.
2423
+ *
2424
+ * @param {string} vaultId - The vault ID that owns the bot
2425
+ * @param {string} botId - The bot ID
2426
+ * @param {string|string[]} sessionIds - One session ID or multiple session IDs
2427
+ * @param {"drive"|"brain"} saveOption - Where to export the session data
2428
+ * @param {string} [targetBotId] - Optional target bot ID when exporting to brain
2429
+ * @returns {Promise<Object>} Standard API response from the backend
739
2430
  *
740
2431
  * @example
741
- * await vault.deleteFolder("your-vault-id", "folder-id");
2432
+ * await vault.exportBotSessions("your-vault-id", "bot-id", "session-id", "drive");
2433
+ * await vault.exportBotSessions("your-vault-id", "bot-id", ["session-a", "session-b"], "brain", "target-bot-id");
742
2434
  */
743
- async deleteFolder(vaultId, folderId) {
2435
+ async exportBotSessions(vaultId, botId, sessionIds, saveOption, targetBotId) {
744
2436
  validator.validate(
745
2437
  {
746
2438
  vaultId: { value: vaultId, type: "string" },
747
- folderId: { value: folderId, type: "string" },
2439
+ botId: { value: botId, type: "string" },
2440
+ saveOption: { value: saveOption, type: "string" },
2441
+ targetBotId: { value: targetBotId, type: "string", required: false },
748
2442
  },
749
- "deleteFolder"
2443
+ "exportBotSessions"
750
2444
  );
751
2445
 
2446
+ const normalizedSessionIds = Array.isArray(sessionIds)
2447
+ ? [...new Set(sessionIds.map((id) => (typeof id === "string" ? id.trim() : "")).filter(Boolean))]
2448
+ : typeof sessionIds === "string" && sessionIds.trim()
2449
+ ? [sessionIds.trim()]
2450
+ : [];
2451
+
2452
+ if (!normalizedSessionIds.length) {
2453
+ throw new VaultError(
2454
+ "[Vault SDK] 'exportBotSessions': At least one session ID is required.",
2455
+ { code: "INVALID_PARAMETER", operation: "exportBotSessions" }
2456
+ );
2457
+ }
2458
+
2459
+ if (saveOption !== "drive" && saveOption !== "brain") {
2460
+ throw new VaultError(
2461
+ "[Vault SDK] 'exportBotSessions': saveOption must be either \"drive\" or \"brain\".",
2462
+ { code: "INVALID_PARAMETER", operation: "exportBotSessions" }
2463
+ );
2464
+ }
2465
+
2466
+ const payload = {
2467
+ vaultId,
2468
+ sessionIds: normalizedSessionIds,
2469
+ saveOption,
2470
+ };
2471
+
2472
+ if (typeof targetBotId === "string" && targetBotId.trim()) {
2473
+ payload.targetBotId = targetBotId.trim();
2474
+ }
2475
+
752
2476
  const response = await this.request(
753
- "DELETE",
754
- "/v1/vault-sdk/delete-folder",
755
- { vaultId, folderId },
756
- { operation: "deleteFolder" }
2477
+ "POST",
2478
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}/sessions/bulk-export`,
2479
+ payload,
2480
+ { operation: "exportBotSessions" }
757
2481
  );
2482
+
758
2483
  return response.data;
759
2484
  }
760
2485
 
761
2486
  /**
762
- * Delete a file from the vault.
2487
+ * Remove either a bot file or a linked storage folder from a bot.
763
2488
  *
764
- * @param {string} vaultId - The vault ID
765
- * @param {string} fileId - The ID of the file to delete
766
- * @returns {Promise<Object>} Deletion confirmation
2489
+ * @param {string} vaultId - The vault ID that owns the bot
2490
+ * @param {string} botId - The bot ID
2491
+ * @param {"file"|"folder"} assetType - The asset type to remove
2492
+ * @param {string} assetId - The file ID or folder ID to remove
2493
+ * @param {{ permanent?: boolean, keepTranscript?: boolean }} [options] - Optional file-removal flags
2494
+ * @returns {Promise<Object>} Standard API response from the backend
767
2495
  *
768
2496
  * @example
769
- * await vault.deleteFile("your-vault-id", "file-id");
2497
+ * await vault.removeBotAsset("your-vault-id", "bot-id", "file", "file-id", {
2498
+ * permanent: true,
2499
+ * keepTranscript: false,
2500
+ * });
2501
+ * await vault.removeBotAsset("your-vault-id", "bot-id", "folder", "folder-id");
770
2502
  */
771
- async deleteFile(vaultId, fileId) {
2503
+ async removeBotAsset(vaultId, botId, assetType, assetId, options = {}) {
772
2504
  validator.validate(
773
2505
  {
774
2506
  vaultId: { value: vaultId, type: "string" },
775
- fileId: { value: fileId, type: "string" },
2507
+ botId: { value: botId, type: "string" },
2508
+ assetType: { value: assetType, type: "string" },
2509
+ assetId: { value: assetId, type: "string" },
2510
+ options: { value: options, type: "object", required: false },
776
2511
  },
777
- "deleteFile"
2512
+ "removeBotAsset"
778
2513
  );
779
2514
 
2515
+ const normalizedType =
2516
+ typeof assetType === "string" ? assetType.trim().toLowerCase() : "";
2517
+
2518
+ if (normalizedType !== "file" && normalizedType !== "folder") {
2519
+ throw new VaultError(
2520
+ "[Vault SDK] 'removeBotAsset': assetType must be either \"file\" or \"folder\".",
2521
+ { code: "INVALID_PARAMETER", operation: "removeBotAsset" }
2522
+ );
2523
+ }
2524
+
2525
+ const { permanent, keepTranscript } = options || {};
2526
+ const params = new URLSearchParams({
2527
+ vaultId: vaultId.trim(),
2528
+ });
2529
+
2530
+ if (normalizedType === "folder" && (permanent !== undefined || keepTranscript !== undefined)) {
2531
+ throw new VaultError(
2532
+ "[Vault SDK] 'removeBotAsset': permanent and keepTranscript are supported only for assetType \"file\".",
2533
+ { code: "INVALID_PARAMETER", operation: "removeBotAsset" }
2534
+ );
2535
+ }
2536
+
2537
+ if (permanent !== undefined) {
2538
+ if (typeof permanent !== "boolean") {
2539
+ throw new VaultError(
2540
+ "[Vault SDK] 'removeBotAsset': options.permanent must be a boolean when provided.",
2541
+ { code: "INVALID_PARAMETER", operation: "removeBotAsset" }
2542
+ );
2543
+ }
2544
+ params.set("permanent", String(permanent));
2545
+ }
2546
+
2547
+ if (keepTranscript !== undefined) {
2548
+ if (typeof keepTranscript !== "boolean") {
2549
+ throw new VaultError(
2550
+ "[Vault SDK] 'removeBotAsset': options.keepTranscript must be a boolean when provided.",
2551
+ { code: "INVALID_PARAMETER", operation: "removeBotAsset" }
2552
+ );
2553
+ }
2554
+ params.set("keepTranscript", String(keepTranscript));
2555
+ }
2556
+
780
2557
  const response = await this.request(
781
2558
  "DELETE",
782
- "/v1/vault-sdk/delete-file",
783
- { vaultId, fileId },
784
- { operation: "deleteFile" }
2559
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}/assets/${encodeURIComponent(
2560
+ normalizedType
2561
+ )}/${encodeURIComponent(assetId)}?${params.toString()}`,
2562
+ undefined,
2563
+ { operation: "removeBotAsset" }
785
2564
  );
2565
+
786
2566
  return response.data;
787
2567
  }
788
2568
 
789
- // ─── Starred Files ────────────────────────────────────────────
790
-
791
2569
  /**
792
- * Mark or unmark a file as starred.
2570
+ * Fetch one bot's full details, or all bots with their associated files and folders.
793
2571
  *
794
- * @param {string} vaultId - The vault ID
795
- * @param {string} fileId - The file ID to star/unstar
796
- * @param {boolean} isStarred - true to star, false to unstar
797
- * @returns {Promise<Object>} Updated file details
2572
+ * When `botId` is omitted, the SDK returns the detailed view for every bot
2573
+ * owned by the vault user.
2574
+ *
2575
+ * @param {string} vaultId - The vault ID that owns the bot(s)
2576
+ * @param {string} [botId] - Optional bot ID
2577
+ * @returns {Promise<Object|Object[]>} One detailed bot or an array of detailed bots
798
2578
  *
799
2579
  * @example
800
- * await vault.addToStarred("your-vault-id", "file-id", true);
2580
+ * const oneBot = await vault.getBotDetails("your-vault-id", "bot-id");
2581
+ * const allBots = await vault.getBotDetails("your-vault-id");
801
2582
  */
802
- async addToStarred(vaultId, fileId, isStarred) {
2583
+ async getBotDetails(vaultId, botId) {
803
2584
  validator.validate(
804
2585
  {
805
2586
  vaultId: { value: vaultId, type: "string" },
806
- fileId: { value: fileId, type: "string" },
807
- isStarred: { value: isStarred, type: "boolean" },
2587
+ botId: { value: botId, type: "string", required: false },
808
2588
  },
809
- "addToStarred"
2589
+ "getBotDetails"
810
2590
  );
811
2591
 
812
- const response = await this.request(
813
- "POST",
814
- "/v1/vault-sdk/add-to-starred",
815
- { vaultId, fileId, isStarred },
816
- { operation: "addToStarred" }
817
- );
2592
+ const encodedVaultId = encodeURIComponent(vaultId);
2593
+ const endpoint = botId
2594
+ ? `/v1/vault-sdk/bots/${encodeURIComponent(botId)}?vaultId=${encodedVaultId}`
2595
+ : `/v1/vault-sdk/bots?vaultId=${encodedVaultId}`;
2596
+
2597
+ const response = await this.request("GET", endpoint, undefined, {
2598
+ operation: "getBotDetails",
2599
+ });
818
2600
  return response.data;
819
2601
  }
820
2602
 
821
2603
  /**
822
- * Get all starred files in the vault.
2604
+ * Fetch all chat sessions for a bot, or all messages for one session.
823
2605
  *
824
- * @param {string} vaultId - The vault ID
825
- * @returns {Promise<Object>} Starred files
2606
+ * When `sessionId` is omitted, the SDK returns the bot's session list.
2607
+ * When `sessionId` is provided, it returns that session's full message history.
2608
+ *
2609
+ * @param {string} vaultId - The vault ID that owns the bot
2610
+ * @param {string} botId - The bot ID
2611
+ * @param {string} [sessionId] - Optional session ID
2612
+ * @returns {Promise<Object[]|Object>} Session list or session messages response
826
2613
  *
827
2614
  * @example
828
- * const starred = await vault.getStarredFiles("your-vault-id");
2615
+ * const sessions = await vault.getBotSessions("your-vault-id", "bot-id");
2616
+ * const messages = await vault.getBotSessions("your-vault-id", "bot-id", "session-id");
829
2617
  */
830
- async getStarredFiles(vaultId) {
2618
+ async getBotSessions(vaultId, botId, sessionId) {
831
2619
  validator.validate(
832
2620
  {
833
2621
  vaultId: { value: vaultId, type: "string" },
2622
+ botId: { value: botId, type: "string" },
2623
+ sessionId: { value: sessionId, type: "string", required: false },
834
2624
  },
835
- "getStarredFiles"
2625
+ "getBotSessions"
836
2626
  );
837
2627
 
838
- const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
839
- const response = await this.request(
840
- "GET",
841
- `/v1/vault-sdk/get-starred-files${queryString}`,
842
- undefined,
843
- { operation: "getStarredFiles" }
844
- );
2628
+ const encodedVaultId = encodeURIComponent(vaultId);
2629
+ const normalizedBotId = botId.trim();
2630
+ const normalizedSessionId =
2631
+ typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : null;
2632
+
2633
+ const endpoint = normalizedSessionId
2634
+ ? `/v1/vault-sdk/bots/${encodeURIComponent(normalizedBotId)}/sessions/${encodeURIComponent(
2635
+ normalizedSessionId
2636
+ )}/messages?vaultId=${encodedVaultId}`
2637
+ : `/v1/vault-sdk/bots/${encodeURIComponent(normalizedBotId)}/sessions?vaultId=${encodedVaultId}`;
2638
+
2639
+ const response = await this.request("GET", endpoint, undefined, {
2640
+ operation: "getBotSessions",
2641
+ });
845
2642
  return response.data;
846
2643
  }
847
2644
 
848
- // ─── Platform Operations ──────────────────────────────────────
849
-
850
2645
  /**
851
- * Create a new platform user.
2646
+ * Attach an existing storage file to a bot without re-uploading it.
852
2647
  *
853
- * @param {string} email - User's email address
854
- * @param {string} [platformId] - Optional platform ID to create the user in
855
- * @returns {Promise<Object>} Created user details
2648
+ * The file stays in storage and is linked into the bot's knowledge set.
2649
+ *
2650
+ * @param {string} vaultId - The vault ID that owns the bot
2651
+ * @param {string} botId - The target bot ID
2652
+ * @param {string|string[]} fileIds - One file ID or multiple file IDs
2653
+ * @returns {Promise<Object>} Bulk link result from the bot endpoint
856
2654
  *
857
2655
  * @example
858
- * const user = await vault.createPlatformUser("user@example.com", "platform-id");
859
- * const sdkUser = await vault.createPlatformUser("user@example.com"); // platform-less SDK user link
2656
+ * await vault.addDriveFilesToBot("your-vault-id", "bot-id", "file-id");
2657
+ * await vault.addDriveFilesToBot("your-vault-id", "bot-id", ["file-a", "file-b"]);
860
2658
  */
861
- async createPlatformUser(email, platformId) {
862
- const normalizedPlatformId =
863
- typeof platformId === "string" ? platformId.trim() : platformId;
864
-
2659
+ async addDriveFilesToBot(vaultId, botId, fileIds) {
865
2660
  validator.validate(
866
2661
  {
867
- email: { value: email, type: "string" },
868
- platformId: {
869
- value: normalizedPlatformId || undefined,
870
- type: "string",
871
- required: false,
872
- },
2662
+ vaultId: { value: vaultId, type: "string" },
2663
+ botId: { value: botId, type: "string" },
873
2664
  },
874
- "createPlatformUser"
2665
+ "addDriveFilesToBot"
875
2666
  );
876
2667
 
877
- const payload = { email };
878
- if (normalizedPlatformId) {
879
- payload.platformId = normalizedPlatformId;
2668
+ const normalizedFileIds = Array.isArray(fileIds)
2669
+ ? [...new Set(fileIds.map((id) => (typeof id === "string" ? id.trim() : "")).filter(Boolean))]
2670
+ : typeof fileIds === "string" && fileIds.trim()
2671
+ ? [fileIds.trim()]
2672
+ : [];
2673
+
2674
+ if (!normalizedFileIds.length) {
2675
+ throw new VaultError(
2676
+ "[Vault SDK] 'addDriveFilesToBot': At least one file ID is required.",
2677
+ { code: "INVALID_PARAMETER", operation: "addDriveFilesToBot" }
2678
+ );
880
2679
  }
881
2680
 
882
2681
  const response = await this.request(
883
2682
  "POST",
884
- "/v1/vault-sdk/create-user",
885
- payload,
886
- { operation: "createPlatformUser" }
2683
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}/add-drive-files`,
2684
+ {
2685
+ vaultId,
2686
+ fileIds: normalizedFileIds,
2687
+ },
2688
+ { operation: "addDriveFilesToBot" }
887
2689
  );
2690
+ this.assertNotAllItemsFailed(response.data, "addDriveFilesToBot", "files");
888
2691
  return response.data;
889
2692
  }
890
2693
 
891
2694
  /**
892
- * Import an existing vault into a platform.
2695
+ * Attach one or more existing storage folders to a bot without moving them.
893
2696
  *
894
- * @param {string} vaultId - The vault ID to import
895
- * @param {string} [platformId] - Optional target platform ID
896
- * @returns {Promise<Object>} Import result
2697
+ * You can pass a single folder ID or an array of folder IDs. The SDK
2698
+ * normalizes the input and uses the bulk folder-link route.
2699
+ *
2700
+ * @param {string} vaultId - The vault ID that owns the bot
2701
+ * @param {string} botId - The target bot ID
2702
+ * @param {string|string[]} folderIds - One folder ID or multiple folder IDs
2703
+ * @returns {Promise<Object>} Bulk link result from the bot endpoint
897
2704
  *
898
2705
  * @example
899
- * const result = await vault.importVault("vault-id", "platform-id");
900
- * const result = await vault.importVault("vault-id"); // link client + enable SDK access without a platform
2706
+ * await vault.addDriveFoldersToBot("your-vault-id", "bot-id", "folder-id");
2707
+ * await vault.addDriveFoldersToBot("your-vault-id", "bot-id", ["folder-a", "folder-b"]);
901
2708
  */
902
- async importVault(vaultId, platformId) {
903
- const normalizedPlatformId =
904
- typeof platformId === "string" ? platformId.trim() : platformId;
905
-
2709
+ async addDriveFoldersToBot(vaultId, botId, folderIds) {
906
2710
  validator.validate(
907
2711
  {
908
2712
  vaultId: { value: vaultId, type: "string" },
909
- platformId: {
910
- value: normalizedPlatformId || undefined,
911
- type: "string",
912
- required: false,
913
- },
2713
+ botId: { value: botId, type: "string" },
914
2714
  },
915
- "importVault"
2715
+ "addDriveFoldersToBot"
916
2716
  );
917
2717
 
918
- const payload = { vaultId };
919
- if (normalizedPlatformId) {
920
- payload.platformId = normalizedPlatformId;
2718
+ const normalizedFolderIds = Array.isArray(folderIds)
2719
+ ? [...new Set(folderIds.map((id) => (typeof id === "string" ? id.trim() : "")).filter(Boolean))]
2720
+ : typeof folderIds === "string" && folderIds.trim()
2721
+ ? [folderIds.trim()]
2722
+ : [];
2723
+
2724
+ if (!normalizedFolderIds.length) {
2725
+ throw new VaultError(
2726
+ "[Vault SDK] 'addDriveFoldersToBot': At least one folder ID is required.",
2727
+ { code: "INVALID_PARAMETER", operation: "addDriveFoldersToBot" }
2728
+ );
921
2729
  }
922
2730
 
923
2731
  const response = await this.request(
924
2732
  "POST",
925
- "/v1/vault-sdk/import-vault",
926
- payload,
927
- { operation: "importVault" }
2733
+ `/v1/vault-sdk/bots/${encodeURIComponent(botId)}/add-drive-folders`,
2734
+ {
2735
+ vaultId,
2736
+ folderIds: normalizedFolderIds,
2737
+ },
2738
+ { operation: "addDriveFoldersToBot" }
2739
+ );
2740
+ this.assertNotAllItemsFailed(
2741
+ response.data,
2742
+ "addDriveFoldersToBot",
2743
+ "folders"
928
2744
  );
929
2745
  return response.data;
930
2746
  }
931
2747
 
932
- // ─── Media ────────────────────────────────────────────────────
933
-
934
2748
  /**
935
- * Fetch media associated with a vault ID.
2749
+ * Upload one or more files directly to a bot for ingestion.
936
2750
  *
937
- * @param {string} vaultId - The vault ID
938
- * @returns {Promise<Object>} Media data
2751
+ * This stores the files in the bot's dedicated folder and starts bot
2752
+ * knowledge processing in the background.
2753
+ *
2754
+ * @param {string|Object|Blob|Array<string|Object|Blob>} files
2755
+ * @param {string} vaultId - The vault ID that owns the bot
2756
+ * @param {string} botId - The target bot ID
2757
+ * @returns {Promise<Object>} Upload result from the bot ingestion endpoint
939
2758
  *
940
2759
  * @example
941
- * const media = await vault.getMedia("your-vault-id");
2760
+ * await vault.uploadFilesToBot("./faq.pdf", "your-vault-id", "bot-id");
2761
+ * await vault.uploadFilesToBot(
2762
+ * ["./faq.pdf", { buffer: audioBuffer, name: "call.mp3" }],
2763
+ * "your-vault-id",
2764
+ * "bot-id"
2765
+ * );
942
2766
  */
943
- async getMedia(vaultId) {
2767
+ async uploadFilesToBot(files, vaultId, botId) {
944
2768
  validator.validate(
945
2769
  {
946
2770
  vaultId: { value: vaultId, type: "string" },
2771
+ botId: { value: botId, type: "string" },
947
2772
  },
948
- "getMedia"
2773
+ "uploadFilesToBot"
949
2774
  );
950
2775
 
951
- const queryString = `?vaultId=${encodeURIComponent(vaultId)}`;
952
- const response = await this.request(
953
- "GET",
954
- `/v1/vault-sdk/get-media${queryString}`,
955
- undefined,
956
- { operation: "getMedia" }
2776
+ const normalizedFiles = Array.isArray(files) ? files : [files];
2777
+ if (!normalizedFiles.length) {
2778
+ throw new VaultError(
2779
+ "[Vault SDK] 'uploadFilesToBot': At least one file is required.",
2780
+ { code: "INVALID_PARAMETER", operation: "uploadFilesToBot" }
2781
+ );
2782
+ }
2783
+
2784
+ const results = await runWithConcurrency(
2785
+ normalizedFiles,
2786
+ this.uploadConcurrency,
2787
+ async (file, index) => {
2788
+ const label =
2789
+ baseName(
2790
+ typeof file === "string" ? file : file?.name || file?.path || ""
2791
+ ) || `file[${index}]`;
2792
+
2793
+ try {
2794
+ const response = await this.uploadSingleFileToBot(file, vaultId, botId);
2795
+ return { status: "success", fileName: label, response };
2796
+ } catch (error) {
2797
+ return {
2798
+ status: "failed",
2799
+ fileName: label,
2800
+ error: error.message,
2801
+ code: error.code || "UPLOAD_FAILED",
2802
+ };
2803
+ }
2804
+ }
957
2805
  );
958
- return response.data;
2806
+
2807
+ const successful = results.filter((result) => result.status === "success");
2808
+ if (!successful.length) {
2809
+ throw new VaultError(
2810
+ "[Vault SDK] 'uploadFilesToBot': All files failed to upload.",
2811
+ { code: "UPLOAD_FAILED", operation: "uploadFilesToBot", data: { results } }
2812
+ );
2813
+ }
2814
+
2815
+ const filesOut = [];
2816
+ const skipped = [];
2817
+
2818
+ for (const result of successful) {
2819
+ const payload = result.response?.data || {};
2820
+ if (payload.file) filesOut.push(payload.file);
2821
+ if (Array.isArray(payload.skipped)) skipped.push(...payload.skipped);
2822
+ }
2823
+
2824
+ for (const result of results) {
2825
+ if (result.status === "failed") {
2826
+ skipped.push({
2827
+ name: result.fileName,
2828
+ reason: result.error,
2829
+ code: result.code,
2830
+ });
2831
+ }
2832
+ }
2833
+
2834
+ const successCount = successful.length;
2835
+ const failureCount = results.length - successCount;
2836
+ const messageParts = [];
2837
+ if (filesOut.length) {
2838
+ messageParts.push(
2839
+ `${filesOut.length} file${filesOut.length === 1 ? "" : "s"} sent for ingestion`
2840
+ );
2841
+ }
2842
+ if (skipped.length) {
2843
+ messageParts.push(`${skipped.length} skipped`);
2844
+ }
2845
+
2846
+ return {
2847
+ success: true,
2848
+ message: messageParts.length
2849
+ ? `${messageParts.join(", ")}. Processing happens in the background.`
2850
+ : "Nothing to upload.",
2851
+ data: {
2852
+ files: filesOut,
2853
+ skipped,
2854
+ results,
2855
+ successCount,
2856
+ failureCount,
2857
+ },
2858
+ };
959
2859
  }
960
2860
 
961
2861
  /**
@@ -969,84 +2869,6 @@ class Vault extends EventEmitter {
969
2869
  return this.renameItem(vaultId, itemId, newName);
970
2870
  }
971
2871
 
972
- // ─── Internal Helpers ─────────────────────────────────────────
973
-
974
- /**
975
- * Internal: Get a presigned S3 URL for file upload.
976
- * @private
977
- */
978
- async getPresignedUrl({
979
- vaultId,
980
- fileName,
981
- fileType,
982
- fileSize,
983
- contentHash,
984
- folderId,
985
- }) {
986
- validator.validate(
987
- {
988
- vaultId: { value: vaultId, type: "string" },
989
- fileName: { value: fileName, type: "string" },
990
- fileSize: { value: fileSize, type: "number" },
991
- contentHash: { value: contentHash, type: "string" },
992
- },
993
- "getPresignedUrl"
994
- );
995
-
996
- const response = await this.request(
997
- "POST",
998
- "/v1/vault-sdk/get-presigned-url",
999
- {
1000
- vaultId,
1001
- fileName,
1002
- fileType: fileType || "application/octet-stream",
1003
- fileSize,
1004
- contentHash,
1005
- folderId,
1006
- },
1007
- { operation: "getPresignedUrl" }
1008
- );
1009
- return response.data;
1010
- }
1011
-
1012
- /**
1013
- * Internal: Register a completed file upload with the backend.
1014
- * @private
1015
- */
1016
- async registerUpload({
1017
- vaultId,
1018
- fileName,
1019
- filebaseKey,
1020
- fileSize,
1021
- contentHash,
1022
- folderId,
1023
- }) {
1024
- validator.validate(
1025
- {
1026
- vaultId: { value: vaultId, type: "string" },
1027
- fileName: { value: fileName, type: "string" },
1028
- filebaseKey: { value: filebaseKey, type: "string" },
1029
- fileSize: { value: fileSize, type: "number" },
1030
- contentHash: { value: contentHash, type: "string" },
1031
- },
1032
- "registerUpload"
1033
- );
1034
-
1035
- const response = await this.request(
1036
- "POST",
1037
- "/v1/vault-sdk/register-upload",
1038
- {
1039
- vaultId,
1040
- fileName,
1041
- filebaseKey,
1042
- fileSize,
1043
- contentHash,
1044
- folderId,
1045
- },
1046
- { operation: "registerUpload" }
1047
- );
1048
- return response.data;
1049
- }
1050
2872
  }
1051
2873
 
1052
2874
  export default Vault;